diff --git a/.gitignore b/.gitignore index c596dd3b..c96dfb20 100644 --- a/.gitignore +++ b/.gitignore @@ -61,7 +61,7 @@ tests/e2e/reports/ # BitFun sandbox data - auto managed .bitfun/ - +.cursor .cursor/rules/no-cargo.mdc ASSETS_LICENSES.md \ No newline at end of file diff --git a/src/apps/relay-server/Cargo.toml b/src/apps/relay-server/Cargo.toml index 40d4b262..807d2486 100644 --- a/src/apps/relay-server/Cargo.toml +++ b/src/apps/relay-server/Cargo.toml @@ -5,6 +5,10 @@ authors.workspace = true edition.workspace = true description = "BitFun Relay Server - WebSocket relay for Remote Connect" +[lib] +name = "bitfun_relay_server" +path = "src/lib.rs" + [[bin]] name = "bitfun-relay-server" path = "src/main.rs" diff --git a/src/apps/relay-server/Dockerfile b/src/apps/relay-server/Dockerfile index c91ac4cc..c18ce086 100644 --- a/src/apps/relay-server/Dockerfile +++ b/src/apps/relay-server/Dockerfile @@ -6,10 +6,13 @@ WORKDIR /build # Install build dependencies RUN apt-get update && apt-get install -y pkg-config libssl-dev && rm -rf /var/lib/apt/lists/* -# Copy workspace files +# 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 @@ -20,12 +23,16 @@ 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 "fn main() {}" > src/apps/relay-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 @@ -44,8 +51,7 @@ RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/ WORKDIR /app COPY --from=builder /build/target/release/bitfun-relay-server /app/bitfun-relay-server -# Optional: copy mobile-web static files -# COPY src/mobile-web/dist /app/static +COPY src/mobile-web/dist /app/static ENV RELAY_PORT=9700 ENV RELAY_STATIC_DIR=/app/static diff --git a/src/apps/relay-server/deploy/Cargo.toml b/src/apps/relay-server/deploy/Cargo.toml deleted file mode 100644 index b5212ad6..00000000 --- a/src/apps/relay-server/deploy/Cargo.toml +++ /dev/null @@ -1,33 +0,0 @@ -[package] -name = "bitfun-relay-server" -version = "0.1.1" -authors = ["BitFun Team"] -edition = "2021" -description = "BitFun Relay Server - WebSocket relay for Remote Connect" - -[[bin]] -name = "bitfun-relay-server" -path = "src/main.rs" - -[dependencies] -axum = { version = "0.7", features = ["json", "ws"] } -tower-http = { version = "0.6", features = ["cors", "fs"] } -tokio = { version = "1.0", features = ["full"] } -futures-util = "0.3" -serde = { version = "1", features = ["derive"] } -serde_json = "1" -anyhow = "1.0" -tracing = "0.1" -tracing-subscriber = { version = "0.3", features = ["env-filter"] } -uuid = { version = "1.0", features = ["v4", "serde"] } -chrono = { version = "0.4", features = ["serde", "clock"] } -dashmap = "5.5" -rand = "0.8" -base64 = "0.21" -sha2 = "0.10" - -[profile.release] -opt-level = 3 -lto = true -codegen-units = 1 -strip = true diff --git a/src/apps/relay-server/deploy/Dockerfile b/src/apps/relay-server/deploy/Dockerfile deleted file mode 100644 index b8d743c7..00000000 --- a/src/apps/relay-server/deploy/Dockerfile +++ /dev/null @@ -1,29 +0,0 @@ -FROM rust:1.85-slim AS builder - -WORKDIR /build - -RUN apt-get update && apt-get install -y pkg-config libssl-dev && rm -rf /var/lib/apt/lists/* - -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 -RUN mkdir -p /app/static - -ENV RELAY_PORT=9700 -ENV RELAY_STATIC_DIR=/app/static -EXPOSE 9700 - -CMD ["/app/bitfun-relay-server"] diff --git a/src/apps/relay-server/deploy/docker-compose.yml b/src/apps/relay-server/deploy/docker-compose.yml deleted file mode 100644 index 6ce5a4cf..00000000 --- a/src/apps/relay-server/deploy/docker-compose.yml +++ /dev/null @@ -1,20 +0,0 @@ -services: - relay-server: - build: - context: . - dockerfile: Dockerfile - container_name: bitfun-relay - restart: unless-stopped - ports: - - "9700:9700" - environment: - - RELAY_PORT=9700 - - RELAY_STATIC_DIR=/app/static - - RELAY_ROOM_WEB_DIR=/app/room-web - - RELAY_ROOM_TTL=3600 - volumes: - - ./static:/app/static:ro - - room-web:/app/room-web - -volumes: - room-web: diff --git a/src/apps/relay-server/deploy/src/config.rs b/src/apps/relay-server/deploy/src/config.rs deleted file mode 100644 index 626d1009..00000000 --- a/src/apps/relay-server/deploy/src/config.rs +++ /dev/null @@ -1,53 +0,0 @@ -//! Relay server configuration. - -use std::net::SocketAddr; - -#[derive(Debug, Clone)] -#[allow(dead_code)] -pub struct RelayConfig { - pub listen_addr: SocketAddr, - pub room_ttl_secs: u64, - pub heartbeat_interval_secs: u64, - pub heartbeat_timeout_secs: u64, - pub static_dir: Option, - /// Directory where per-room uploaded mobile-web files are stored. - pub room_web_dir: String, - pub cors_allow_origins: Vec, -} - -impl Default for RelayConfig { - fn default() -> Self { - Self { - listen_addr: ([0, 0, 0, 0], 9700).into(), - room_ttl_secs: 3600, - heartbeat_interval_secs: 30, - heartbeat_timeout_secs: 90, - static_dir: None, - room_web_dir: "/tmp/bitfun-room-web".to_string(), - cors_allow_origins: vec!["*".to_string()], - } - } -} - -impl RelayConfig { - pub fn from_env() -> Self { - let mut cfg = Self::default(); - if let Ok(port) = std::env::var("RELAY_PORT") { - if let Ok(p) = port.parse::() { - cfg.listen_addr = ([0, 0, 0, 0], p).into(); - } - } - if let Ok(dir) = std::env::var("RELAY_STATIC_DIR") { - cfg.static_dir = Some(dir); - } - if let Ok(dir) = std::env::var("RELAY_ROOM_WEB_DIR") { - cfg.room_web_dir = dir; - } - if let Ok(ttl) = std::env::var("RELAY_ROOM_TTL") { - if let Ok(t) = ttl.parse() { - cfg.room_ttl_secs = t; - } - } - cfg - } -} diff --git a/src/apps/relay-server/deploy/src/main.rs b/src/apps/relay-server/deploy/src/main.rs deleted file mode 100644 index a7f80237..00000000 --- a/src/apps/relay-server/deploy/src/main.rs +++ /dev/null @@ -1,98 +0,0 @@ -//! BitFun Relay Server -//! -//! WebSocket relay for Remote Connect. Manages rooms and forwards E2E encrypted -//! messages between desktop and mobile clients. Also serves mobile web static files. - -use axum::extract::DefaultBodyLimit; -use axum::routing::{get, post}; -use axum::Router; -use tower_http::cors::CorsLayer; -use tracing::info; - -mod config; -mod relay; -mod routes; - -use config::RelayConfig; -use relay::RoomManager; -use routes::api::{self, AppState}; -use routes::websocket; - -#[tokio::main] -async fn main() -> anyhow::Result<()> { - tracing_subscriber::fmt() - .with_max_level(tracing::Level::DEBUG) - .init(); - - let cfg = RelayConfig::from_env(); - info!("BitFun Relay Server v{}", env!("CARGO_PKG_VERSION")); - - let room_manager = RoomManager::new(); - - let cleanup_rm = room_manager.clone(); - let cleanup_ttl = cfg.room_ttl_secs; - let cleanup_room_web_dir = cfg.room_web_dir.clone(); - tokio::spawn(async move { - loop { - tokio::time::sleep(std::time::Duration::from_secs(60)).await; - let stale_ids = cleanup_rm.cleanup_stale_rooms(cleanup_ttl); - for room_id in &stale_ids { - api::cleanup_room_web(&cleanup_room_web_dir, room_id); - } - } - }); - - let store_dir = std::path::PathBuf::from(&cfg.room_web_dir).join("_store"); - let _ = std::fs::create_dir_all(&store_dir); - let content_store = std::sync::Arc::new(api::ContentStore::new(&store_dir)); - - let state = AppState { - room_manager, - start_time: std::time::Instant::now(), - room_web_dir: cfg.room_web_dir.clone(), - content_store, - }; - - let mut app = Router::new() - .route("/health", get(api::health_check)) - .route("/api/info", get(api::server_info)) - .route("/api/rooms/:room_id/join", post(api::join_room)) - .route("/api/rooms/:room_id/message", post(api::relay_message)) - .route("/api/rooms/:room_id/poll", get(api::poll_messages)) - .route("/api/rooms/:room_id/ack", post(api::ack_messages)) - .route( - "/api/rooms/:room_id/upload-web", - post(api::upload_web).layer(DefaultBodyLimit::max(10 * 1024 * 1024)), - ) - .route( - "/api/rooms/:room_id/check-web-files", - post(api::check_web_files), - ) - .route( - "/api/rooms/:room_id/upload-web-files", - post(api::upload_web_files).layer(DefaultBodyLimit::max(10 * 1024 * 1024)), - ) - .route("/r/*rest", get(api::serve_room_web_catchall)) - .route("/ws", get(websocket::websocket_handler)) - .layer(CorsLayer::permissive()) - .with_state(state); - - // Serve mobile web static files as a fallback for requests that - // don't match any API or WebSocket route. - if let Some(static_dir) = &cfg.static_dir { - info!("Serving static files from: {static_dir}"); - app = app.fallback_service( - tower_http::services::ServeDir::new(static_dir) - .append_index_html_on_directories(true), - ); - } - - info!("Room web upload dir: {}", cfg.room_web_dir); - - let listener = tokio::net::TcpListener::bind(cfg.listen_addr).await?; - info!("Relay server listening on {}", cfg.listen_addr); - info!("WebSocket endpoint: ws://{}/ws", cfg.listen_addr); - - axum::serve(listener, app).await?; - Ok(()) -} diff --git a/src/apps/relay-server/deploy/src/relay/mod.rs b/src/apps/relay-server/deploy/src/relay/mod.rs deleted file mode 100644 index c24d4265..00000000 --- a/src/apps/relay-server/deploy/src/relay/mod.rs +++ /dev/null @@ -1,5 +0,0 @@ -//! Core relay logic: room management and message routing. - -pub mod room; - -pub use room::RoomManager; diff --git a/src/apps/relay-server/deploy/src/relay/room.rs b/src/apps/relay-server/deploy/src/relay/room.rs deleted file mode 100644 index 8b336d85..00000000 --- a/src/apps/relay-server/deploy/src/relay/room.rs +++ /dev/null @@ -1,497 +0,0 @@ -//! Room management for the relay server. -//! -//! Each room holds at most 2 participants (desktop + mobile). -//! Messages are relayed without decryption (E2E encrypted between clients). -//! Desktop→mobile messages are buffered so that the mobile client can poll -//! for missed messages via the HTTP API. - -use chrono::Utc; -use dashmap::DashMap; -use serde::{Deserialize, Serialize}; -use std::sync::Arc; -use tokio::sync::mpsc; -use tracing::{debug, info, warn}; - -pub type ConnId = u64; - -#[derive(Debug, Clone)] -pub struct OutboundMessage { - pub text: String, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum MessageDirection { - ToMobile, - ToDesktop, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct BufferedMessage { - pub seq: u64, - pub timestamp: i64, - pub direction: MessageDirection, - pub encrypted_data: String, - pub nonce: String, -} - -#[derive(Debug)] -pub struct Participant { - pub conn_id: ConnId, - pub device_id: String, - pub device_type: String, - pub public_key: String, - pub tx: Option>, - #[allow(dead_code)] - pub joined_at: i64, - pub last_heartbeat: i64, -} - -#[derive(Debug)] -pub struct RelayRoom { - pub room_id: String, - #[allow(dead_code)] - pub created_at: i64, - pub last_activity: i64, - pub participants: Vec, - pub message_store: Vec, - pub next_seq: u64, -} - -impl RelayRoom { - pub fn new(room_id: String) -> Self { - let now = Utc::now().timestamp(); - Self { - room_id, - created_at: now, - last_activity: now, - participants: Vec::with_capacity(2), - message_store: Vec::new(), - next_seq: 1, - } - } - - pub fn add_participant(&mut self, participant: Participant) -> bool { - if self.participants.len() >= 2 { - return false; - } - self.participants.push(participant); - self.touch(); - true - } - - pub fn remove_participant(&mut self, conn_id: ConnId) -> Option { - if let Some(idx) = self.participants.iter().position(|p| p.conn_id == conn_id) { - Some(self.participants.remove(idx)) - } else { - None - } - } - - pub fn relay_to_peer(&self, sender_conn_id: ConnId, message: &str) -> bool { - for p in &self.participants { - if p.conn_id != sender_conn_id { - if let Some(ref tx) = p.tx { - let _ = tx.send(OutboundMessage { - text: message.to_string(), - }); - } - return true; - } - } - false - } - - #[allow(dead_code)] - pub fn send_to(&self, conn_id: ConnId, message: &str) { - for p in &self.participants { - if p.conn_id == conn_id { - if let Some(ref tx) = p.tx { - let _ = tx.send(OutboundMessage { - text: message.to_string(), - }); - } - return; - } - } - } - - pub fn broadcast(&self, message: &str) { - for p in &self.participants { - if let Some(ref tx) = p.tx { - let _ = tx.send(OutboundMessage { - text: message.to_string(), - }); - } - } - } - - pub fn is_empty(&self) -> bool { - self.participants.is_empty() - } - - #[allow(dead_code)] - pub fn participant_count(&self) -> usize { - self.participants.len() - } - - pub fn update_heartbeat(&mut self, conn_id: ConnId) { - let now = Utc::now().timestamp(); - for p in &mut self.participants { - if p.conn_id == conn_id { - p.last_heartbeat = now; - break; - } - } - // Do not update room's last_activity here, so that if the other peer is inactive, - // we can still detect it. - } - - fn touch(&mut self) { - self.last_activity = Utc::now().timestamp(); - } - - /// Buffer an encrypted message for later polling by the target device. - pub fn buffer_message( - &mut self, - direction: MessageDirection, - encrypted_data: String, - nonce: String, - ) -> u64 { - let seq = self.next_seq; - self.next_seq += 1; - self.message_store.push(BufferedMessage { - seq, - timestamp: Utc::now().timestamp(), - direction, - encrypted_data, - nonce, - }); - self.touch(); - seq - } - - /// Return buffered messages for a given direction with seq > since_seq. - pub fn poll_messages( - &self, - direction: MessageDirection, - since_seq: u64, - ) -> Vec { - self.message_store - .iter() - .filter(|m| m.direction == direction && m.seq > since_seq) - .cloned() - .collect() - } - - /// Remove buffered messages with seq <= ack_seq for a given direction. - pub fn ack_messages(&mut self, direction: MessageDirection, ack_seq: u64) { - self.message_store - .retain(|m| !(m.direction == direction && m.seq <= ack_seq)); - } - - /// Get the device_type of the sender identified by conn_id. - pub fn sender_device_type(&self, conn_id: ConnId) -> Option<&str> { - self.participants - .iter() - .find(|p| p.conn_id == conn_id) - .map(|p| p.device_type.as_str()) - } -} - -pub struct RoomManager { - rooms: DashMap, - conn_to_room: DashMap, - next_conn_id: std::sync::atomic::AtomicU64, -} - -impl RoomManager { - pub fn new() -> Arc { - Arc::new(Self { - rooms: DashMap::new(), - conn_to_room: DashMap::new(), - next_conn_id: std::sync::atomic::AtomicU64::new(1), - }) - } - - pub fn next_conn_id(&self) -> ConnId { - self.next_conn_id - .fetch_add(1, std::sync::atomic::Ordering::Relaxed) - } - - /// If conn_id is already in a room, remove it from that room first. - fn leave_current_room(&self, conn_id: ConnId) { - if let Some((_, old_room_id)) = self.conn_to_room.remove(&conn_id) { - let mut should_remove = false; - if let Some(mut room) = self.rooms.get_mut(&old_room_id) { - room.remove_participant(conn_id); - should_remove = room.is_empty(); - } - if should_remove { - self.rooms.remove(&old_room_id); - debug!("Cleaned up old room {old_room_id} after conn moved"); - } - } - } - - pub fn create_room( - &self, - room_id: &str, - conn_id: ConnId, - device_id: &str, - device_type: &str, - public_key: &str, - tx: Option>, - ) -> bool { - if self.rooms.contains_key(room_id) { - warn!("Room {room_id} already exists"); - return false; - } - - self.leave_current_room(conn_id); - - let now = Utc::now().timestamp(); - let mut room = RelayRoom::new(room_id.to_string()); - room.add_participant(Participant { - conn_id, - device_id: device_id.to_string(), - device_type: device_type.to_string(), - public_key: public_key.to_string(), - tx, - joined_at: now, - last_heartbeat: now, - }); - - self.rooms.insert(room_id.to_string(), room); - self.conn_to_room.insert(conn_id, room_id.to_string()); - - info!("Room {room_id} created by {device_id} ({device_type})"); - true - } - - pub fn join_room( - &self, - room_id: &str, - conn_id: ConnId, - device_id: &str, - device_type: &str, - public_key: &str, - tx: Option>, - ) -> bool { - self.leave_current_room(conn_id); - - let mut room_ref = match self.rooms.get_mut(room_id) { - Some(r) => r, - None => { - warn!("Room {room_id} not found"); - return false; - } - }; - - let now = Utc::now().timestamp(); - let ok = room_ref.add_participant(Participant { - conn_id, - device_id: device_id.to_string(), - device_type: device_type.to_string(), - public_key: public_key.to_string(), - tx, - joined_at: now, - last_heartbeat: now, - }); - - if ok { - drop(room_ref); - self.conn_to_room.insert(conn_id, room_id.to_string()); - info!("Device {device_id} ({device_type}) joined room {room_id}"); - } else { - warn!("Room {room_id} is full"); - } - - ok - } - - /// Relay a message to the peer. If the sender is desktop, also buffer for mobile polling. - pub fn relay_message(&self, conn_id: ConnId, encrypted_data: &str, nonce: &str) -> bool { - if let Some(room_id) = self.conn_to_room.get(&conn_id) { - if let Some(mut room) = self.rooms.get_mut(room_id.value()) { - let sender_type = room - .sender_device_type(conn_id) - .unwrap_or("unknown") - .to_string(); - - let direction = if sender_type == "desktop" { - MessageDirection::ToMobile - } else { - MessageDirection::ToDesktop - }; - room.buffer_message( - direction, - encrypted_data.to_string(), - nonce.to_string(), - ); - - let relay_json = serde_json::json!({ - "type": "relay", - "room_id": room_id.value(), - "encrypted_data": encrypted_data, - "nonce": nonce, - }) - .to_string(); - - return room.relay_to_peer(conn_id, &relay_json); - } - } - false - } - - pub fn on_disconnect(&self, conn_id: ConnId) { - if let Some((_, room_id)) = self.conn_to_room.remove(&conn_id) { - let mut should_remove = false; - - if let Some(mut room) = self.rooms.get_mut(&room_id) { - if let Some(removed) = room.remove_participant(conn_id) { - info!( - "Device {} disconnected from room {}", - removed.device_id, room_id - ); - - let notification = serde_json::json!({ - "type": "peer_disconnected", - "device_id": removed.device_id, - }) - .to_string(); - room.broadcast(¬ification); - } - should_remove = room.is_empty(); - } - - if should_remove { - self.rooms.remove(&room_id); - debug!("Empty room {room_id} removed"); - } - } - } - - pub fn heartbeat(&self, conn_id: ConnId) -> bool { - if let Some(room_id) = self.conn_to_room.get(&conn_id) { - if let Some(mut room) = self.rooms.get_mut(room_id.value()) { - room.update_heartbeat(conn_id); - return true; - } - } - false - } - - /// Returns (device_id, device_type, public_key) of the peer. - pub fn get_peer_info( - &self, - room_id: &str, - conn_id: ConnId, - ) -> Option<(String, String, String)> { - if let Some(room) = self.rooms.get(room_id) { - for p in &room.participants { - if p.conn_id != conn_id { - return Some(( - p.device_id.clone(), - p.device_type.clone(), - p.public_key.clone(), - )); - } - } - } - None - } - - /// Find conn_id by device_id in a specific room - pub fn get_conn_id_by_device(&self, room_id: &str, device_id: &str) -> Option { - if let Some(room) = self.rooms.get(room_id) { - for p in &room.participants { - if p.device_id == device_id { - return Some(p.conn_id); - } - } - } - None - } - - /// Check if the room has a peer of the opposite device type - pub fn has_peer(&self, room_id: &str, my_device_type: &str) -> bool { - if let Some(room) = self.rooms.get(room_id) { - room.participants.iter().any(|p| p.device_type != my_device_type) - } else { - false - } - } - - /// Clean up stale rooms based on last_activity rather than created_at. - /// Returns the list of room IDs that were removed. - pub fn cleanup_stale_rooms(&self, ttl_secs: u64) -> Vec { - let now = Utc::now().timestamp(); - let stale_ids: Vec = self - .rooms - .iter() - .filter(|r| (now - r.last_activity) as u64 > ttl_secs) - .map(|r| r.room_id.clone()) - .collect(); - - for room_id in &stale_ids { - if let Some((_, room)) = self.rooms.remove(room_id) { - for p in &room.participants { - self.conn_to_room.remove(&p.conn_id); - } - info!("Stale room {room_id} cleaned up"); - } - } - - stale_ids - } - - pub fn send_to_others_in_room(&self, room_id: &str, exclude_conn_id: ConnId, message: &str) { - if let Some(room) = self.rooms.get(room_id) { - for p in &room.participants { - if p.conn_id != exclude_conn_id { - if let Some(ref tx) = p.tx { - let _ = tx.send(OutboundMessage { - text: message.to_string(), - }); - } - } - } - } - } - - /// Poll buffered messages for a specific room and direction. - pub fn poll_messages( - &self, - room_id: &str, - direction: MessageDirection, - since_seq: u64, - ) -> Vec { - if let Some(mut room) = self.rooms.get_mut(room_id) { - room.last_activity = Utc::now().timestamp(); - room.poll_messages(direction, since_seq) - } else { - Vec::new() - } - } - - /// Acknowledge receipt of messages up to ack_seq. - pub fn ack_messages(&self, room_id: &str, direction: MessageDirection, ack_seq: u64) { - if let Some(mut room) = self.rooms.get_mut(room_id) { - room.last_activity = Utc::now().timestamp(); - room.ack_messages(direction, ack_seq); - } - } - - pub fn room_exists(&self, room_id: &str) -> bool { - self.rooms.contains_key(room_id) - } - - pub fn room_count(&self) -> usize { - self.rooms.len() - } - - pub fn connection_count(&self) -> usize { - self.conn_to_room.len() - } -} diff --git a/src/apps/relay-server/deploy/src/routes/api.rs b/src/apps/relay-server/deploy/src/routes/api.rs deleted file mode 100644 index e21d9862..00000000 --- a/src/apps/relay-server/deploy/src/routes/api.rs +++ /dev/null @@ -1,538 +0,0 @@ -//! REST API routes for the relay server. - -use axum::extract::{Path, Query, State}; -use axum::http::StatusCode; -use axum::Json; -use dashmap::DashMap; -use serde::{Deserialize, Serialize}; -use sha2::{Digest, Sha256}; -use std::collections::HashMap; -use std::sync::Arc; - -use crate::relay::room::{BufferedMessage, MessageDirection}; -use crate::relay::RoomManager; - -#[derive(Clone)] -pub struct AppState { - pub room_manager: Arc, - pub start_time: std::time::Instant, - /// Base directory for per-room uploaded mobile-web files. - pub room_web_dir: String, - /// Global content-addressed file store: sha256 hex -> stored on disk at `{room_web_dir}/_store/{hash}`. - pub content_store: Arc, -} - -/// Tracks which SHA-256 hashes are already persisted in the `_store/` directory. -pub struct ContentStore { - known_hashes: DashMap, -} - -impl ContentStore { - pub fn new(store_dir: &std::path::Path) -> Self { - let known: DashMap = DashMap::new(); - if store_dir.is_dir() { - if let Ok(entries) = std::fs::read_dir(store_dir) { - for entry in entries.flatten() { - if let Ok(meta) = entry.metadata() { - if meta.is_file() { - if let Some(name) = entry.file_name().to_str() { - known.insert(name.to_string(), meta.len()); - } - } - } - } - } - } - tracing::info!("Content store initialized with {} entries", known.len()); - Self { known_hashes: known } - } - - pub fn contains(&self, hash: &str) -> bool { - self.known_hashes.contains_key(hash) - } - - pub fn insert(&self, hash: String, size: u64) { - self.known_hashes.insert(hash, size); - } -} - -#[derive(Serialize)] -pub struct HealthResponse { - pub status: String, - pub version: String, - pub uptime_seconds: u64, - pub rooms: usize, - pub connections: usize, -} - -pub async fn health_check(State(state): State) -> Json { - Json(HealthResponse { - status: "healthy".to_string(), - version: env!("CARGO_PKG_VERSION").to_string(), - uptime_seconds: state.start_time.elapsed().as_secs(), - rooms: state.room_manager.room_count(), - connections: state.room_manager.connection_count(), - }) -} - -#[derive(Serialize)] -pub struct ServerInfo { - pub name: String, - pub version: String, - pub protocol_version: u8, -} - -pub async fn server_info() -> Json { - Json(ServerInfo { - name: "BitFun Relay Server".to_string(), - version: env!("CARGO_PKG_VERSION").to_string(), - protocol_version: 1, - }) -} - -#[derive(Deserialize)] -pub struct JoinRoomRequest { - pub device_id: String, - pub device_type: String, - pub public_key: String, -} - -/// `POST /api/rooms/:room_id/join` -pub async fn join_room( - State(state): State, - Path(room_id): Path, - Json(body): Json, -) -> Result, StatusCode> { - let conn_id = state.room_manager.next_conn_id(); - let existing_peer = state.room_manager.get_peer_info(&room_id, conn_id); - - let ok = state.room_manager.join_room( - &room_id, - conn_id, - &body.device_id, - &body.device_type, - &body.public_key, - None, // HTTP client, no websocket tx - ); - - if ok { - let joiner_notification = serde_json::to_string(&crate::routes::websocket::OutboundProtocol::PeerJoined { - device_id: body.device_id.clone(), - device_type: body.device_type.clone(), - public_key: body.public_key.clone(), - }).unwrap_or_default(); - state.room_manager.send_to_others_in_room(&room_id, conn_id, &joiner_notification); - - if let Some((peer_did, peer_dt, peer_pk)) = existing_peer { - Ok(Json(serde_json::json!({ - "status": "joined", - "peer": { - "device_id": peer_did, - "device_type": peer_dt, - "public_key": peer_pk - } - }))) - } else { - Ok(Json(serde_json::json!({ - "status": "joined", - "peer": null - }))) - } - } else { - Err(StatusCode::BAD_REQUEST) - } -} - -#[derive(Deserialize)] -pub struct RelayMessageRequest { - pub device_id: String, - pub encrypted_data: String, - pub nonce: String, -} - -/// `POST /api/rooms/:room_id/message` -pub async fn relay_message( - State(state): State, - Path(room_id): Path, - Json(body): Json, -) -> StatusCode { - // Find conn_id by device_id in the room - if let Some(conn_id) = state.room_manager.get_conn_id_by_device(&room_id, &body.device_id) { - if state.room_manager.relay_message(conn_id, &body.encrypted_data, &body.nonce) { - StatusCode::OK - } else { - StatusCode::NOT_FOUND - } - } else { - StatusCode::UNAUTHORIZED - } -} - -#[derive(Deserialize)] -pub struct PollQuery { - pub since_seq: Option, - pub device_type: Option, -} - -#[derive(Serialize)] -pub struct PollResponse { - pub messages: Vec, - pub peer_connected: bool, -} - -/// `GET /api/rooms/:room_id/poll?since_seq=0&device_type=mobile` -pub async fn poll_messages( - State(state): State, - Path(room_id): Path, - Query(query): Query, -) -> Result, StatusCode> { - let since = query.since_seq.unwrap_or(0); - let direction = match query.device_type.as_deref() { - Some("desktop") => MessageDirection::ToDesktop, - _ => MessageDirection::ToMobile, - }; - - let peer_connected = state.room_manager.has_peer(&room_id, query.device_type.as_deref().unwrap_or("mobile")); - let messages = state.room_manager.poll_messages(&room_id, direction, since); - - Ok(Json(PollResponse { messages, peer_connected })) -} - -#[derive(Deserialize)] -pub struct AckRequest { - pub ack_seq: u64, - pub device_type: Option, -} - -/// `POST /api/rooms/:room_id/ack` -pub async fn ack_messages( - State(state): State, - Path(room_id): Path, - Json(body): Json, -) -> StatusCode { - let direction = match body.device_type.as_deref() { - Some("desktop") => MessageDirection::ToDesktop, - _ => MessageDirection::ToMobile, - }; - state - .room_manager - .ack_messages(&room_id, direction, body.ack_seq); - StatusCode::OK -} - -// ── Per-room mobile-web upload & serving ─────────────────────────────────── - -#[derive(Deserialize)] -pub struct UploadWebRequest { - pub files: HashMap, -} - -/// `POST /api/rooms/:room_id/upload-web` -/// -/// Desktop uploads mobile-web dist files (base64-encoded) so the mobile -/// browser can load the exact same version the desktop is running. -/// Now uses the global content store + symlinks to avoid storing duplicates. -pub async fn upload_web( - State(state): State, - Path(room_id): Path, - Json(body): Json, -) -> Result, StatusCode> { - use base64::{engine::general_purpose::STANDARD as B64, Engine}; - - if !state.room_manager.room_exists(&room_id) { - return Err(StatusCode::NOT_FOUND); - } - - let store_dir = std::path::PathBuf::from(&state.room_web_dir).join("_store"); - let _ = std::fs::create_dir_all(&store_dir); - - let room_dir = std::path::PathBuf::from(&state.room_web_dir).join(&room_id); - if let Err(e) = std::fs::create_dir_all(&room_dir) { - tracing::error!("Failed to create room web dir {}: {e}", room_dir.display()); - return Err(StatusCode::INTERNAL_SERVER_ERROR); - } - - let mut written = 0usize; - let mut reused = 0usize; - for (rel_path, b64_content) in &body.files { - if rel_path.contains("..") { - continue; - } - let decoded = B64.decode(b64_content).map_err(|_| StatusCode::BAD_REQUEST)?; - let hash = hex_sha256(&decoded); - - let store_path = store_dir.join(&hash); - if !store_path.exists() { - std::fs::write(&store_path, &decoded) - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - state.content_store.insert(hash.clone(), decoded.len() as u64); - written += 1; - } else { - reused += 1; - } - - let dest = room_dir.join(rel_path); - if let Some(parent) = dest.parent() { - let _ = std::fs::create_dir_all(parent); - } - let _ = std::fs::remove_file(&dest); - create_link(&store_path, &dest) - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - } - - tracing::info!( - "Room {room_id}: upload-web complete (new={written}, reused={reused})" - ); - Ok(Json(serde_json::json!({ - "status": "ok", - "files_written": written, - "files_reused": reused - }))) -} - -// ── Incremental upload protocol ──────────────────────────────────────────── - -#[derive(Deserialize)] -pub struct FileManifestEntry { - pub path: String, - pub hash: String, - #[allow(dead_code)] - pub size: u64, -} - -#[derive(Deserialize)] -pub struct CheckWebFilesRequest { - pub files: Vec, -} - -#[derive(Serialize)] -pub struct CheckWebFilesResponse { - pub needed: Vec, - pub existing_count: usize, - pub total_count: usize, -} - -/// `POST /api/rooms/:room_id/check-web-files` -/// -/// Accepts a manifest of file metadata (path, sha256, size). Registers the -/// room's file manifest and returns which files the server still needs. Files -/// whose hash already exists in the global content store are skipped. -pub async fn check_web_files( - State(state): State, - Path(room_id): Path, - Json(body): Json, -) -> Result, StatusCode> { - if !state.room_manager.room_exists(&room_id) { - return Err(StatusCode::NOT_FOUND); - } - - let store_dir = std::path::PathBuf::from(&state.room_web_dir).join("_store"); - let _ = std::fs::create_dir_all(&store_dir); - - let room_dir = std::path::PathBuf::from(&state.room_web_dir).join(&room_id); - let _ = std::fs::create_dir_all(&room_dir); - - let mut needed = Vec::new(); - let mut existing_count = 0usize; - let total_count = body.files.len(); - - for entry in &body.files { - if entry.path.contains("..") { - continue; - } - if state.content_store.contains(&entry.hash) { - existing_count += 1; - let store_path = store_dir.join(&entry.hash); - let dest = room_dir.join(&entry.path); - if let Some(parent) = dest.parent() { - let _ = std::fs::create_dir_all(parent); - } - let _ = std::fs::remove_file(&dest); - let _ = create_link(&store_path, &dest); - } else { - needed.push(entry.path.clone()); - } - } - - tracing::info!( - "Room {room_id}: check-web-files total={total_count}, existing={existing_count}, needed={}", - needed.len() - ); - - Ok(Json(CheckWebFilesResponse { - needed, - existing_count, - total_count, - })) -} - -#[derive(Deserialize)] -pub struct UploadWebFilesEntry { - pub content: String, - pub hash: String, -} - -#[derive(Deserialize)] -pub struct UploadWebFilesRequest { - pub files: HashMap, -} - -/// `POST /api/rooms/:room_id/upload-web-files` -/// -/// Upload only the files that the server requested via `check-web-files`. -/// Each entry includes the base64 content and its expected sha256 hash. -/// Files are stored in the global content store and symlinked into the room. -pub async fn upload_web_files( - State(state): State, - Path(room_id): Path, - Json(body): Json, -) -> Result, StatusCode> { - use base64::{engine::general_purpose::STANDARD as B64, Engine}; - - if !state.room_manager.room_exists(&room_id) { - return Err(StatusCode::NOT_FOUND); - } - - let store_dir = std::path::PathBuf::from(&state.room_web_dir).join("_store"); - let _ = std::fs::create_dir_all(&store_dir); - - let room_dir = std::path::PathBuf::from(&state.room_web_dir).join(&room_id); - let _ = std::fs::create_dir_all(&room_dir); - - let mut stored = 0usize; - for (rel_path, entry) in &body.files { - if rel_path.contains("..") { - continue; - } - let decoded = B64.decode(&entry.content).map_err(|_| StatusCode::BAD_REQUEST)?; - let actual_hash = hex_sha256(&decoded); - if actual_hash != entry.hash { - tracing::warn!( - "Room {room_id}: hash mismatch for {rel_path} (expected={}, actual={actual_hash})", - entry.hash - ); - return Err(StatusCode::BAD_REQUEST); - } - - let store_path = store_dir.join(&actual_hash); - if !store_path.exists() { - std::fs::write(&store_path, &decoded) - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - state - .content_store - .insert(actual_hash.clone(), decoded.len() as u64); - } - - let dest = room_dir.join(rel_path); - if let Some(parent) = dest.parent() { - let _ = std::fs::create_dir_all(parent); - } - let _ = std::fs::remove_file(&dest); - create_link(&store_path, &dest) - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - stored += 1; - } - - tracing::info!("Room {room_id}: upload-web-files stored {stored} new files"); - Ok(Json(serde_json::json!({ "status": "ok", "files_stored": stored }))) -} - -fn hex_sha256(data: &[u8]) -> String { - let mut hasher = Sha256::new(); - hasher.update(data); - format!("{:x}", hasher.finalize()) -} - -/// Create a symlink (Unix) or hard link fallback (Windows). -fn create_link( - original: &std::path::Path, - link: &std::path::Path, -) -> std::io::Result<()> { - #[cfg(unix)] - { - std::os::unix::fs::symlink(original, link) - } - #[cfg(not(unix))] - { - std::fs::hard_link(original, link) - .or_else(|_| std::fs::copy(original, link).map(|_| ())) - } -} - -/// `GET /r/{*rest}` — serve per-room mobile-web static files. -/// -/// The `rest` path is expected to be `room_id` or `room_id/file/path`. -/// Falls back to `index.html` for SPA routing. -pub async fn serve_room_web_catchall( - State(state): State, - Path(rest): Path, -) -> Result { - use axum::body::Body; - use axum::http::header; - use axum::response::IntoResponse; - - let rest = rest.trim_start_matches('/'); - let (room_id, file_path) = match rest.find('/') { - Some(idx) => (&rest[..idx], &rest[idx + 1..]), - None => (rest, ""), - }; - - if room_id.is_empty() { - return Err(StatusCode::NOT_FOUND); - } - - let room_dir = std::path::PathBuf::from(&state.room_web_dir).join(room_id); - if !room_dir.exists() { - return Err(StatusCode::NOT_FOUND); - } - - let target = if file_path.is_empty() { - room_dir.join("index.html") - } else { - room_dir.join(file_path) - }; - - let file = if target.is_file() { - target - } else { - room_dir.join("index.html") - }; - - if !file.is_file() { - return Err(StatusCode::NOT_FOUND); - } - - let content = std::fs::read(&file).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - let mime = mime_from_path(&file); - - Ok(([(header::CONTENT_TYPE, mime)], Body::from(content)).into_response()) -} - -fn mime_from_path(p: &std::path::Path) -> &'static str { - match p.extension().and_then(|e| e.to_str()) { - Some("html") => "text/html; charset=utf-8", - Some("js") => "application/javascript; charset=utf-8", - Some("css") => "text/css; charset=utf-8", - Some("json") => "application/json", - Some("png") => "image/png", - Some("svg") => "image/svg+xml", - Some("ico") => "image/x-icon", - Some("woff2") => "font/woff2", - Some("woff") => "font/woff", - Some("ttf") => "font/ttf", - Some("wasm") => "application/wasm", - _ => "application/octet-stream", - } -} - -/// Remove the per-room web directory (called on room cleanup). -pub fn cleanup_room_web(room_web_dir: &str, room_id: &str) { - let dir = std::path::PathBuf::from(room_web_dir).join(room_id); - if dir.exists() { - if let Err(e) = std::fs::remove_dir_all(&dir) { - tracing::warn!("Failed to clean up room web dir {}: {e}", dir.display()); - } else { - tracing::info!("Cleaned up room web dir for {room_id}"); - } - } -} diff --git a/src/apps/relay-server/deploy/src/routes/mod.rs b/src/apps/relay-server/deploy/src/routes/mod.rs deleted file mode 100644 index ae5fb74d..00000000 --- a/src/apps/relay-server/deploy/src/routes/mod.rs +++ /dev/null @@ -1,4 +0,0 @@ -//! HTTP and WebSocket routes for the relay server. - -pub mod api; -pub mod websocket; diff --git a/src/apps/relay-server/deploy/src/routes/websocket.rs b/src/apps/relay-server/deploy/src/routes/websocket.rs deleted file mode 100644 index bfc80e70..00000000 --- a/src/apps/relay-server/deploy/src/routes/websocket.rs +++ /dev/null @@ -1,218 +0,0 @@ -//! WebSocket handler for the relay server. -//! -//! Each connected client sends/receives JSON messages following the relay protocol. -//! The server never decrypts application data — it only handles room management -//! and forwards encrypted payloads between paired devices. -//! Desktop→mobile messages are also buffered for later polling. - -use axum::{ - extract::{ - ws::{Message, WebSocket, WebSocketUpgrade}, - State, - }, - response::Response, -}; -use futures_util::{SinkExt, StreamExt}; -use serde::{Deserialize, Serialize}; -use std::sync::Arc; -use tokio::sync::mpsc; -use tracing::{debug, error, info, warn}; - -use crate::relay::room::{ConnId, OutboundMessage, RoomManager}; -use crate::routes::api::AppState; - -#[derive(Debug, Deserialize)] -#[serde(tag = "type", rename_all = "snake_case")] -#[allow(dead_code)] -pub enum InboundMessage { - CreateRoom { - room_id: Option, - device_id: String, - device_type: String, - public_key: String, - }, - JoinRoom { - room_id: String, - device_id: String, - device_type: String, - public_key: String, - }, - Relay { - room_id: String, - encrypted_data: String, - nonce: String, - }, - Heartbeat, -} - -#[derive(Debug, Serialize)] -#[serde(tag = "type", rename_all = "snake_case")] -#[allow(dead_code)] -pub enum OutboundProtocol { - RoomCreated { room_id: String }, - PeerJoined { device_id: String, device_type: String, public_key: String }, - Relay { room_id: String, encrypted_data: String, nonce: String }, - HeartbeatAck, - PeerDisconnected { device_id: String }, - Error { message: String }, -} - -pub async fn websocket_handler( - ws: WebSocketUpgrade, - State(state): State, -) -> Response { - ws.on_upgrade(move |socket| handle_socket(socket, state)) -} - -async fn handle_socket(socket: WebSocket, state: AppState) { - let (mut ws_sender, mut ws_receiver) = socket.split(); - let (out_tx, mut out_rx) = mpsc::unbounded_channel::(); - - let conn_id = state.room_manager.next_conn_id(); - info!("WebSocket connected: conn_id={conn_id}"); - - let write_task = tokio::spawn(async move { - while let Some(msg) = out_rx.recv().await { - if !msg.text.is_empty() { - if ws_sender.send(Message::Text(msg.text)).await.is_err() { - break; - } - } - } - }); - - while let Some(msg_result) = ws_receiver.next().await { - match msg_result { - Ok(Message::Text(text)) => { - handle_text_message(&text, conn_id, &state.room_manager, &out_tx); - } - Ok(Message::Ping(_)) => { - // Axum auto-replies Pong for Ping frames - } - Ok(Message::Close(_)) => { - info!("WebSocket close from conn_id={conn_id}"); - break; - } - Err(e) => { - error!("WebSocket error conn_id={conn_id}: {e}"); - break; - } - _ => {} - } - } - - state.room_manager.on_disconnect(conn_id); - drop(out_tx); - let _ = write_task.await; - info!("WebSocket disconnected: conn_id={conn_id}"); -} - -fn handle_text_message( - text: &str, - conn_id: ConnId, - room_manager: &Arc, - out_tx: &mpsc::UnboundedSender, -) { - debug!("Received from conn_id={conn_id}: {}", &text[..text.len().min(200)]); - let msg: InboundMessage = match serde_json::from_str(text) { - Ok(m) => m, - Err(e) => { - warn!("Invalid message from conn_id={conn_id}: {e}"); - send_json(out_tx, &OutboundProtocol::Error { - message: format!("invalid message format: {e}"), - }); - return; - } - }; - - match msg { - InboundMessage::CreateRoom { - room_id, - device_id, - device_type, - public_key, - } => { - let room_id = room_id.unwrap_or_else(generate_room_id); - let ok = room_manager.create_room( - &room_id, conn_id, &device_id, &device_type, &public_key, Some(out_tx.clone()), - ); - if ok { - send_json(out_tx, &OutboundProtocol::RoomCreated { room_id }); - } else { - send_json(out_tx, &OutboundProtocol::Error { - message: "failed to create room".into(), - }); - } - } - - InboundMessage::JoinRoom { - room_id, - device_id, - device_type, - public_key, - } => { - let existing_peer = room_manager.get_peer_info(&room_id, conn_id); - - let ok = room_manager.join_room( - &room_id, conn_id, &device_id, &device_type, &public_key, Some(out_tx.clone()), - ); - - if ok { - let joiner_notification = serde_json::to_string(&OutboundProtocol::PeerJoined { - device_id: device_id.clone(), - device_type: device_type.clone(), - public_key: public_key.clone(), - }).unwrap_or_default(); - room_manager.send_to_others_in_room(&room_id, conn_id, &joiner_notification); - - if let Some((peer_did, peer_dt, peer_pk)) = existing_peer { - send_json(out_tx, &OutboundProtocol::PeerJoined { - device_id: peer_did, - device_type: peer_dt, - public_key: peer_pk, - }); - } else { - warn!("No existing peer found for room {room_id} to send back to joiner"); - } - } else { - send_json(out_tx, &OutboundProtocol::Error { - message: format!("failed to join room {room_id}"), - }); - } - } - - InboundMessage::Relay { - room_id: _, - encrypted_data, - nonce, - } => { - debug!("Relay message from conn_id={conn_id} data_len={}", encrypted_data.len()); - if room_manager.relay_message(conn_id, &encrypted_data, &nonce) { - debug!("Relay message forwarded from conn_id={conn_id}"); - } else { - warn!("Relay failed for conn_id={conn_id}: no peer found"); - } - } - - InboundMessage::Heartbeat => { - if room_manager.heartbeat(conn_id) { - send_json(out_tx, &OutboundProtocol::HeartbeatAck); - } else { - send_json(out_tx, &OutboundProtocol::Error { - message: "Room not found or expired".into(), - }); - } - } - } -} - -fn send_json(tx: &mpsc::UnboundedSender, msg: &T) { - if let Ok(json) = serde_json::to_string(msg) { - let _ = tx.send(OutboundMessage { text: json }); - } -} - -fn generate_room_id() -> String { - let bytes: [u8; 6] = rand::random(); - bytes.iter().map(|b| format!("{b:02x}")).collect() -} diff --git a/src/apps/relay-server/src/lib.rs b/src/apps/relay-server/src/lib.rs new file mode 100644 index 00000000..e133f5b5 --- /dev/null +++ b/src/apps/relay-server/src/lib.rs @@ -0,0 +1,267 @@ +//! BitFun Relay Server Library +//! +//! Shared relay logic used by both the standalone relay-server binary and +//! the embedded relay running inside the desktop process. +//! +//! The relay is a stateless HTTP-to-WebSocket bridge: +//! - Desktop clients connect via WebSocket +//! - Mobile clients interact via HTTP POST +//! - The relay forwards encrypted payloads without inspection +//! - Per-room mobile-web static files are managed via `WebAssetStore` + +pub mod relay; +pub mod routes; + +pub use relay::room::{RoomManager, ResponsePayload}; +pub use routes::api::AppState; + +use axum::extract::DefaultBodyLimit; +use axum::routing::{get, post}; +use axum::Router; +use dashmap::DashMap; +use std::collections::HashMap; +use std::sync::Arc; + +// ── WebAssetStore trait ─────────────────────────────────────────────── + +/// Abstract storage for per-room mobile-web static assets. +/// +/// The standalone relay uses `DiskAssetStore` (filesystem-backed), while +/// the embedded relay uses `MemoryAssetStore` (in-memory DashMap-backed). +pub trait WebAssetStore: Send + Sync + 'static { + /// Check if content with this SHA-256 hash exists in the store. + fn has_content(&self, hash: &str) -> bool; + + /// Store content by its SHA-256 hash. No-op if already present. + fn store_content(&self, hash: &str, data: Vec) -> Result<(), String>; + + /// Associate a relative file path within a room to a stored content hash. + fn map_to_room(&self, room_id: &str, rel_path: &str, hash: &str) -> Result<(), String>; + + /// Retrieve file content for serving. Falls back to `index.html` if the + /// requested path doesn't exist (SPA routing). + fn get_file(&self, room_id: &str, path: &str) -> Option>; + + /// Check if any web files have been uploaded for this room. + fn has_room_files(&self, room_id: &str) -> bool; + + /// Remove all uploaded web files for a room. + fn cleanup_room(&self, room_id: &str); +} + +// ── MemoryAssetStore ────────────────────────────────────────────────── + +/// In-memory asset store backed by DashMap. Used by the embedded relay. +pub struct MemoryAssetStore { + content_store: DashMap>>, + room_manifests: DashMap>, +} + +impl MemoryAssetStore { + pub fn new() -> Self { + Self { + content_store: DashMap::new(), + room_manifests: DashMap::new(), + } + } +} + +impl Default for MemoryAssetStore { + fn default() -> Self { + Self::new() + } +} + +impl WebAssetStore for MemoryAssetStore { + fn has_content(&self, hash: &str) -> bool { + self.content_store.contains_key(hash) + } + + fn store_content(&self, hash: &str, data: Vec) -> Result<(), String> { + self.content_store + .entry(hash.to_string()) + .or_insert_with(|| Arc::new(data)); + Ok(()) + } + + fn map_to_room(&self, room_id: &str, rel_path: &str, hash: &str) -> Result<(), String> { + self.room_manifests + .entry(room_id.to_string()) + .or_default() + .insert(rel_path.to_string(), hash.to_string()); + Ok(()) + } + + fn get_file(&self, room_id: &str, path: &str) -> Option> { + let manifest = self.room_manifests.get(room_id)?; + let hash = manifest + .get(path) + .or_else(|| manifest.get("index.html"))?; + let content = self.content_store.get(hash)?; + Some(content.value().as_ref().clone()) + } + + fn has_room_files(&self, room_id: &str) -> bool { + self.room_manifests.contains_key(room_id) + } + + fn cleanup_room(&self, room_id: &str) { + self.room_manifests.remove(room_id); + } +} + +// ── DiskAssetStore ──────────────────────────────────────────────────── + +/// Filesystem-backed asset store. Used by the standalone relay server. +/// +/// Content is stored in `{base_dir}/_store/{hash}` and symlinked into +/// per-room directories `{base_dir}/{room_id}/{path}`. +pub struct DiskAssetStore { + base_dir: String, + known_hashes: DashMap, +} + +impl DiskAssetStore { + pub fn new(base_dir: &str) -> Self { + let store_dir = std::path::PathBuf::from(base_dir).join("_store"); + let _ = std::fs::create_dir_all(&store_dir); + + let known: DashMap = DashMap::new(); + if store_dir.is_dir() { + if let Ok(entries) = std::fs::read_dir(&store_dir) { + for entry in entries.flatten() { + if let Ok(meta) = entry.metadata() { + if meta.is_file() { + if let Some(name) = entry.file_name().to_str() { + known.insert(name.to_string(), meta.len()); + } + } + } + } + } + } + tracing::info!( + "DiskAssetStore initialized with {} entries from {base_dir}", + known.len() + ); + Self { + base_dir: base_dir.to_string(), + known_hashes: known, + } + } + + fn store_dir(&self) -> std::path::PathBuf { + std::path::PathBuf::from(&self.base_dir).join("_store") + } + + fn room_dir(&self, room_id: &str) -> std::path::PathBuf { + std::path::PathBuf::from(&self.base_dir).join(room_id) + } +} + +impl WebAssetStore for DiskAssetStore { + fn has_content(&self, hash: &str) -> bool { + self.known_hashes.contains_key(hash) + } + + fn store_content(&self, hash: &str, data: Vec) -> Result<(), String> { + let store_path = self.store_dir().join(hash); + if !store_path.exists() { + std::fs::write(&store_path, &data).map_err(|e| e.to_string())?; + self.known_hashes + .insert(hash.to_string(), data.len() as u64); + } + Ok(()) + } + + fn map_to_room(&self, room_id: &str, rel_path: &str, hash: &str) -> Result<(), String> { + let store_path = self.store_dir().join(hash); + let dest = self.room_dir(room_id).join(rel_path); + if let Some(parent) = dest.parent() { + let _ = std::fs::create_dir_all(parent); + } + let _ = std::fs::remove_file(&dest); + create_link(&store_path, &dest).map_err(|e| e.to_string()) + } + + fn get_file(&self, room_id: &str, path: &str) -> Option> { + let room_dir = self.room_dir(room_id); + let target = room_dir.join(path); + let file = if target.is_file() { + target + } else { + room_dir.join("index.html") + }; + if file.is_file() { + std::fs::read(&file).ok() + } else { + None + } + } + + fn has_room_files(&self, room_id: &str) -> bool { + self.room_dir(room_id).exists() + } + + fn cleanup_room(&self, room_id: &str) { + let dir = self.room_dir(room_id); + if dir.exists() { + if let Err(e) = std::fs::remove_dir_all(&dir) { + tracing::warn!("Failed to clean up room web dir {}: {e}", dir.display()); + } else { + tracing::info!("Cleaned up room web dir for {room_id}"); + } + } + } +} + +fn create_link(original: &std::path::Path, link: &std::path::Path) -> std::io::Result<()> { + #[cfg(unix)] + { + std::os::unix::fs::symlink(original, link) + } + #[cfg(not(unix))] + { + std::fs::hard_link(original, link).or_else(|_| std::fs::copy(original, link).map(|_| ())) + } +} + +// ── Router builder ──────────────────────────────────────────────────── + +/// Build the relay router with all API, WebSocket, and static-file routes. +/// +/// Both the standalone binary and the embedded relay call this function, +/// passing their own `WebAssetStore` implementation. +pub fn build_relay_router( + room_manager: Arc, + asset_store: Arc, + start_time: std::time::Instant, +) -> Router { + let state = AppState { + room_manager, + start_time, + asset_store, + }; + + Router::new() + .route("/health", get(routes::api::health_check)) + .route("/api/info", get(routes::api::server_info)) + .route("/api/rooms/:room_id/pair", post(routes::api::pair)) + .route("/api/rooms/:room_id/command", post(routes::api::command)) + .route( + "/api/rooms/:room_id/upload-web", + post(routes::api::upload_web).layer(DefaultBodyLimit::max(10 * 1024 * 1024)), + ) + .route( + "/api/rooms/:room_id/check-web-files", + post(routes::api::check_web_files), + ) + .route( + "/api/rooms/:room_id/upload-web-files", + post(routes::api::upload_web_files).layer(DefaultBodyLimit::max(10 * 1024 * 1024)), + ) + .route("/r/*rest", get(routes::api::serve_room_web_catchall)) + .route("/ws", get(routes::websocket::websocket_handler)) + .layer(tower_http::cors::CorsLayer::permissive()) + .with_state(state) +} diff --git a/src/apps/relay-server/src/main.rs b/src/apps/relay-server/src/main.rs index a7f80237..29d3a9db 100644 --- a/src/apps/relay-server/src/main.rs +++ b/src/apps/relay-server/src/main.rs @@ -1,22 +1,15 @@ //! BitFun Relay Server //! -//! WebSocket relay for Remote Connect. Manages rooms and forwards E2E encrypted -//! messages between desktop and mobile clients. Also serves mobile web static files. +//! Standalone binary that runs the relay as a network service. +//! Uses `DiskAssetStore` for filesystem-backed mobile-web file storage. -use axum::extract::DefaultBodyLimit; -use axum::routing::{get, post}; -use axum::Router; -use tower_http::cors::CorsLayer; +use std::sync::Arc; use tracing::info; mod config; -mod relay; -mod routes; +use bitfun_relay_server::{build_relay_router, DiskAssetStore, RoomManager, WebAssetStore}; use config::RelayConfig; -use relay::RoomManager; -use routes::api::{self, AppState}; -use routes::websocket; #[tokio::main] async fn main() -> anyhow::Result<()> { @@ -28,57 +21,24 @@ async fn main() -> anyhow::Result<()> { info!("BitFun Relay Server v{}", env!("CARGO_PKG_VERSION")); let room_manager = RoomManager::new(); + let asset_store = Arc::new(DiskAssetStore::new(&cfg.room_web_dir)); let cleanup_rm = room_manager.clone(); let cleanup_ttl = cfg.room_ttl_secs; - let cleanup_room_web_dir = cfg.room_web_dir.clone(); + let cleanup_store = asset_store.clone(); tokio::spawn(async move { loop { tokio::time::sleep(std::time::Duration::from_secs(60)).await; let stale_ids = cleanup_rm.cleanup_stale_rooms(cleanup_ttl); for room_id in &stale_ids { - api::cleanup_room_web(&cleanup_room_web_dir, room_id); + cleanup_store.cleanup_room(room_id); } } }); - let store_dir = std::path::PathBuf::from(&cfg.room_web_dir).join("_store"); - let _ = std::fs::create_dir_all(&store_dir); - let content_store = std::sync::Arc::new(api::ContentStore::new(&store_dir)); + let start_time = std::time::Instant::now(); + let mut app = build_relay_router(room_manager, asset_store, start_time); - let state = AppState { - room_manager, - start_time: std::time::Instant::now(), - room_web_dir: cfg.room_web_dir.clone(), - content_store, - }; - - let mut app = Router::new() - .route("/health", get(api::health_check)) - .route("/api/info", get(api::server_info)) - .route("/api/rooms/:room_id/join", post(api::join_room)) - .route("/api/rooms/:room_id/message", post(api::relay_message)) - .route("/api/rooms/:room_id/poll", get(api::poll_messages)) - .route("/api/rooms/:room_id/ack", post(api::ack_messages)) - .route( - "/api/rooms/:room_id/upload-web", - post(api::upload_web).layer(DefaultBodyLimit::max(10 * 1024 * 1024)), - ) - .route( - "/api/rooms/:room_id/check-web-files", - post(api::check_web_files), - ) - .route( - "/api/rooms/:room_id/upload-web-files", - post(api::upload_web_files).layer(DefaultBodyLimit::max(10 * 1024 * 1024)), - ) - .route("/r/*rest", get(api::serve_room_web_catchall)) - .route("/ws", get(websocket::websocket_handler)) - .layer(CorsLayer::permissive()) - .with_state(state); - - // Serve mobile web static files as a fallback for requests that - // don't match any API or WebSocket route. if let Some(static_dir) = &cfg.static_dir { info!("Serving static files from: {static_dir}"); app = app.fallback_service( diff --git a/src/apps/relay-server/src/relay/room.rs b/src/apps/relay-server/src/relay/room.rs index 8b336d85..9122f86c 100644 --- a/src/apps/relay-server/src/relay/room.rs +++ b/src/apps/relay-server/src/relay/room.rs @@ -1,15 +1,14 @@ //! Room management for the relay server. //! -//! Each room holds at most 2 participants (desktop + mobile). -//! Messages are relayed without decryption (E2E encrypted between clients). -//! Desktop→mobile messages are buffered so that the mobile client can poll -//! for missed messages via the HTTP API. +//! Each room holds a single desktop participant connected via WebSocket. +//! Mobile clients interact through HTTP requests that the relay bridges +//! to the desktop via the WebSocket connection. The relay stores no +//! business data — it only routes messages. use chrono::Utc; use dashmap::DashMap; -use serde::{Deserialize, Serialize}; use std::sync::Arc; -use tokio::sync::mpsc; +use tokio::sync::{mpsc, oneshot}; use tracing::{debug, info, warn}; pub type ConnId = u64; @@ -19,29 +18,21 @@ pub struct OutboundMessage { pub text: String, } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum MessageDirection { - ToMobile, - ToDesktop, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct BufferedMessage { - pub seq: u64, - pub timestamp: i64, - pub direction: MessageDirection, +/// Payload returned by the desktop in response to a bridged HTTP request. +#[derive(Debug, Clone)] +pub struct ResponsePayload { pub encrypted_data: String, pub nonce: String, } #[derive(Debug)] -pub struct Participant { +pub struct DesktopConnection { pub conn_id: ConnId, + #[allow(dead_code)] pub device_id: String, - pub device_type: String, + #[allow(dead_code)] pub public_key: String, - pub tx: Option>, + pub tx: mpsc::UnboundedSender, #[allow(dead_code)] pub joined_at: i64, pub last_heartbeat: i64, @@ -53,9 +44,7 @@ pub struct RelayRoom { #[allow(dead_code)] pub created_at: i64, pub last_activity: i64, - pub participants: Vec, - pub message_store: Vec, - pub next_seq: u64, + pub desktop: Option, } impl RelayRoom { @@ -65,137 +54,27 @@ impl RelayRoom { room_id, created_at: now, last_activity: now, - participants: Vec::with_capacity(2), - message_store: Vec::new(), - next_seq: 1, - } - } - - pub fn add_participant(&mut self, participant: Participant) -> bool { - if self.participants.len() >= 2 { - return false; - } - self.participants.push(participant); - self.touch(); - true - } - - pub fn remove_participant(&mut self, conn_id: ConnId) -> Option { - if let Some(idx) = self.participants.iter().position(|p| p.conn_id == conn_id) { - Some(self.participants.remove(idx)) - } else { - None - } - } - - pub fn relay_to_peer(&self, sender_conn_id: ConnId, message: &str) -> bool { - for p in &self.participants { - if p.conn_id != sender_conn_id { - if let Some(ref tx) = p.tx { - let _ = tx.send(OutboundMessage { - text: message.to_string(), - }); - } - return true; - } - } - false - } - - #[allow(dead_code)] - pub fn send_to(&self, conn_id: ConnId, message: &str) { - for p in &self.participants { - if p.conn_id == conn_id { - if let Some(ref tx) = p.tx { - let _ = tx.send(OutboundMessage { - text: message.to_string(), - }); - } - return; - } - } - } - - pub fn broadcast(&self, message: &str) { - for p in &self.participants { - if let Some(ref tx) = p.tx { - let _ = tx.send(OutboundMessage { - text: message.to_string(), - }); - } + desktop: None, } } pub fn is_empty(&self) -> bool { - self.participants.is_empty() - } - - #[allow(dead_code)] - pub fn participant_count(&self) -> usize { - self.participants.len() + self.desktop.is_none() } - pub fn update_heartbeat(&mut self, conn_id: ConnId) { - let now = Utc::now().timestamp(); - for p in &mut self.participants { - if p.conn_id == conn_id { - p.last_heartbeat = now; - break; - } - } - // Do not update room's last_activity here, so that if the other peer is inactive, - // we can still detect it. - } - - fn touch(&mut self) { + pub fn touch(&mut self) { self.last_activity = Utc::now().timestamp(); } - /// Buffer an encrypted message for later polling by the target device. - pub fn buffer_message( - &mut self, - direction: MessageDirection, - encrypted_data: String, - nonce: String, - ) -> u64 { - let seq = self.next_seq; - self.next_seq += 1; - self.message_store.push(BufferedMessage { - seq, - timestamp: Utc::now().timestamp(), - direction, - encrypted_data, - nonce, - }); - self.touch(); - seq - } - - /// Return buffered messages for a given direction with seq > since_seq. - pub fn poll_messages( - &self, - direction: MessageDirection, - since_seq: u64, - ) -> Vec { - self.message_store - .iter() - .filter(|m| m.direction == direction && m.seq > since_seq) - .cloned() - .collect() - } - - /// Remove buffered messages with seq <= ack_seq for a given direction. - pub fn ack_messages(&mut self, direction: MessageDirection, ack_seq: u64) { - self.message_store - .retain(|m| !(m.direction == direction && m.seq <= ack_seq)); - } - - /// Get the device_type of the sender identified by conn_id. - pub fn sender_device_type(&self, conn_id: ConnId) -> Option<&str> { - self.participants - .iter() - .find(|p| p.conn_id == conn_id) - .map(|p| p.device_type.as_str()) + pub fn send_to_desktop(&self, message: &str) -> bool { + if let Some(ref desktop) = self.desktop { + let _ = desktop.tx.send(OutboundMessage { + text: message.to_string(), + }); + true + } else { + false + } } } @@ -203,6 +82,7 @@ pub struct RoomManager { rooms: DashMap, conn_to_room: DashMap, next_conn_id: std::sync::atomic::AtomicU64, + pending_requests: DashMap>, } impl RoomManager { @@ -211,6 +91,7 @@ impl RoomManager { rooms: DashMap::new(), conn_to_room: DashMap::new(), next_conn_id: std::sync::atomic::AtomicU64::new(1), + pending_requests: DashMap::new(), }) } @@ -219,43 +100,33 @@ impl RoomManager { .fetch_add(1, std::sync::atomic::Ordering::Relaxed) } - /// If conn_id is already in a room, remove it from that room first. - fn leave_current_room(&self, conn_id: ConnId) { - if let Some((_, old_room_id)) = self.conn_to_room.remove(&conn_id) { - let mut should_remove = false; - if let Some(mut room) = self.rooms.get_mut(&old_room_id) { - room.remove_participant(conn_id); - should_remove = room.is_empty(); - } - if should_remove { - self.rooms.remove(&old_room_id); - debug!("Cleaned up old room {old_room_id} after conn moved"); - } - } - } - pub fn create_room( &self, room_id: &str, conn_id: ConnId, device_id: &str, - device_type: &str, public_key: &str, - tx: Option>, + tx: mpsc::UnboundedSender, ) -> bool { - if self.rooms.contains_key(room_id) { - warn!("Room {room_id} already exists"); - return false; + if let Some((_, old_room_id)) = self.conn_to_room.remove(&conn_id) { + let should_remove = if let Some(mut room) = self.rooms.get_mut(&old_room_id) { + room.desktop = None; + room.is_empty() + } else { + false + }; + if should_remove { + self.rooms.remove(&old_room_id); + } } - self.leave_current_room(conn_id); + self.rooms.remove(room_id); let now = Utc::now().timestamp(); let mut room = RelayRoom::new(room_id.to_string()); - room.add_participant(Participant { + room.desktop = Some(DesktopConnection { conn_id, device_id: device_id.to_string(), - device_type: device_type.to_string(), public_key: public_key.to_string(), tx, joined_at: now, @@ -265,106 +136,63 @@ impl RoomManager { self.rooms.insert(room_id.to_string(), room); self.conn_to_room.insert(conn_id, room_id.to_string()); - info!("Room {room_id} created by {device_id} ({device_type})"); + info!("Room {room_id} created by desktop {device_id}"); true } - pub fn join_room( - &self, - room_id: &str, - conn_id: ConnId, - device_id: &str, - device_type: &str, - public_key: &str, - tx: Option>, - ) -> bool { - self.leave_current_room(conn_id); + pub fn send_to_desktop(&self, room_id: &str, message: &str) -> bool { + if let Some(mut room) = self.rooms.get_mut(room_id) { + room.touch(); + room.send_to_desktop(message) + } else { + false + } + } - let mut room_ref = match self.rooms.get_mut(room_id) { - Some(r) => r, - None => { - warn!("Room {room_id} not found"); - return false; - } - }; + #[allow(dead_code)] + pub fn get_desktop_public_key(&self, room_id: &str) -> Option { + self.rooms + .get(room_id) + .and_then(|r| r.desktop.as_ref().map(|d| d.public_key.clone())) + } - let now = Utc::now().timestamp(); - let ok = room_ref.add_participant(Participant { - conn_id, - device_id: device_id.to_string(), - device_type: device_type.to_string(), - public_key: public_key.to_string(), - tx, - joined_at: now, - last_heartbeat: now, - }); + pub fn register_pending( + &self, + correlation_id: String, + ) -> oneshot::Receiver { + let (tx, rx) = oneshot::channel(); + self.pending_requests.insert(correlation_id, tx); + rx + } - if ok { - drop(room_ref); - self.conn_to_room.insert(conn_id, room_id.to_string()); - info!("Device {device_id} ({device_type}) joined room {room_id}"); + pub fn resolve_pending(&self, correlation_id: &str, payload: ResponsePayload) -> bool { + if let Some((_, tx)) = self.pending_requests.remove(correlation_id) { + tx.send(payload).is_ok() } else { - warn!("Room {room_id} is full"); + warn!("No pending request for correlation_id={correlation_id}"); + false } - - ok } - /// Relay a message to the peer. If the sender is desktop, also buffer for mobile polling. - pub fn relay_message(&self, conn_id: ConnId, encrypted_data: &str, nonce: &str) -> bool { - if let Some(room_id) = self.conn_to_room.get(&conn_id) { - if let Some(mut room) = self.rooms.get_mut(room_id.value()) { - let sender_type = room - .sender_device_type(conn_id) - .unwrap_or("unknown") - .to_string(); - - let direction = if sender_type == "desktop" { - MessageDirection::ToMobile - } else { - MessageDirection::ToDesktop - }; - room.buffer_message( - direction, - encrypted_data.to_string(), - nonce.to_string(), - ); - - let relay_json = serde_json::json!({ - "type": "relay", - "room_id": room_id.value(), - "encrypted_data": encrypted_data, - "nonce": nonce, - }) - .to_string(); - - return room.relay_to_peer(conn_id, &relay_json); - } - } - false + pub fn cancel_pending(&self, correlation_id: &str) { + self.pending_requests.remove(correlation_id); } pub fn on_disconnect(&self, conn_id: ConnId) { if let Some((_, room_id)) = self.conn_to_room.remove(&conn_id) { - let mut should_remove = false; - - if let Some(mut room) = self.rooms.get_mut(&room_id) { - if let Some(removed) = room.remove_participant(conn_id) { - info!( - "Device {} disconnected from room {}", - removed.device_id, room_id - ); - - let notification = serde_json::json!({ - "type": "peer_disconnected", - "device_id": removed.device_id, - }) - .to_string(); - room.broadcast(¬ification); + let should_remove = if let Some(mut room) = self.rooms.get_mut(&room_id) { + if room + .desktop + .as_ref() + .map_or(false, |d| d.conn_id == conn_id) + { + info!("Desktop disconnected from room {room_id}"); + room.desktop = None; } - should_remove = room.is_empty(); - } - + room.is_empty() + } else { + false + }; if should_remove { self.rooms.remove(&room_id); debug!("Empty room {room_id} removed"); @@ -375,56 +203,17 @@ impl RoomManager { pub fn heartbeat(&self, conn_id: ConnId) -> bool { if let Some(room_id) = self.conn_to_room.get(&conn_id) { if let Some(mut room) = self.rooms.get_mut(room_id.value()) { - room.update_heartbeat(conn_id); - return true; - } - } - false - } - - /// Returns (device_id, device_type, public_key) of the peer. - pub fn get_peer_info( - &self, - room_id: &str, - conn_id: ConnId, - ) -> Option<(String, String, String)> { - if let Some(room) = self.rooms.get(room_id) { - for p in &room.participants { - if p.conn_id != conn_id { - return Some(( - p.device_id.clone(), - p.device_type.clone(), - p.public_key.clone(), - )); - } - } - } - None - } - - /// Find conn_id by device_id in a specific room - pub fn get_conn_id_by_device(&self, room_id: &str, device_id: &str) -> Option { - if let Some(room) = self.rooms.get(room_id) { - for p in &room.participants { - if p.device_id == device_id { - return Some(p.conn_id); + if let Some(ref mut desktop) = room.desktop { + if desktop.conn_id == conn_id { + desktop.last_heartbeat = Utc::now().timestamp(); + return true; + } } } } - None - } - - /// Check if the room has a peer of the opposite device type - pub fn has_peer(&self, room_id: &str, my_device_type: &str) -> bool { - if let Some(room) = self.rooms.get(room_id) { - room.participants.iter().any(|p| p.device_type != my_device_type) - } else { - false - } + false } - /// Clean up stale rooms based on last_activity rather than created_at. - /// Returns the list of room IDs that were removed. pub fn cleanup_stale_rooms(&self, ttl_secs: u64) -> Vec { let now = Utc::now().timestamp(); let stale_ids: Vec = self @@ -436,8 +225,8 @@ impl RoomManager { for room_id in &stale_ids { if let Some((_, room)) = self.rooms.remove(room_id) { - for p in &room.participants { - self.conn_to_room.remove(&p.conn_id); + if let Some(ref desktop) = room.desktop { + self.conn_to_room.remove(&desktop.conn_id); } info!("Stale room {room_id} cleaned up"); } @@ -446,47 +235,16 @@ impl RoomManager { stale_ids } - pub fn send_to_others_in_room(&self, room_id: &str, exclude_conn_id: ConnId, message: &str) { - if let Some(room) = self.rooms.get(room_id) { - for p in &room.participants { - if p.conn_id != exclude_conn_id { - if let Some(ref tx) = p.tx { - let _ = tx.send(OutboundMessage { - text: message.to_string(), - }); - } - } - } - } - } - - /// Poll buffered messages for a specific room and direction. - pub fn poll_messages( - &self, - room_id: &str, - direction: MessageDirection, - since_seq: u64, - ) -> Vec { - if let Some(mut room) = self.rooms.get_mut(room_id) { - room.last_activity = Utc::now().timestamp(); - room.poll_messages(direction, since_seq) - } else { - Vec::new() - } - } - - /// Acknowledge receipt of messages up to ack_seq. - pub fn ack_messages(&self, room_id: &str, direction: MessageDirection, ack_seq: u64) { - if let Some(mut room) = self.rooms.get_mut(room_id) { - room.last_activity = Utc::now().timestamp(); - room.ack_messages(direction, ack_seq); - } - } - pub fn room_exists(&self, room_id: &str) -> bool { self.rooms.contains_key(room_id) } + pub fn has_desktop(&self, room_id: &str) -> bool { + self.rooms + .get(room_id) + .map_or(false, |r| r.desktop.is_some()) + } + pub fn room_count(&self) -> usize { self.rooms.len() } diff --git a/src/apps/relay-server/src/routes/api.rs b/src/apps/relay-server/src/routes/api.rs index e21d9862..b046c95b 100644 --- a/src/apps/relay-server/src/routes/api.rs +++ b/src/apps/relay-server/src/routes/api.rs @@ -1,60 +1,36 @@ //! REST API routes for the relay server. - -use axum::extract::{Path, Query, State}; +//! +//! Provides two HTTP endpoints for mobile clients: +//! - POST /api/rooms/:room_id/pair — initiate pairing +//! - POST /api/rooms/:room_id/command — send encrypted commands +//! +//! Both endpoints bridge the HTTP request to the desktop via WebSocket +//! using correlation-based request-response matching. +//! +//! File-serving and upload endpoints use the `WebAssetStore` trait, +//! so the same handlers work for both disk-backed and memory-backed stores. + +use axum::extract::{Path, State}; use axum::http::StatusCode; use axum::Json; -use dashmap::DashMap; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use std::collections::HashMap; use std::sync::Arc; +use std::time::Duration; -use crate::relay::room::{BufferedMessage, MessageDirection}; use crate::relay::RoomManager; +use crate::routes::websocket::OutboundProtocol; +use crate::WebAssetStore; #[derive(Clone)] pub struct AppState { pub room_manager: Arc, pub start_time: std::time::Instant, - /// Base directory for per-room uploaded mobile-web files. - pub room_web_dir: String, - /// Global content-addressed file store: sha256 hex -> stored on disk at `{room_web_dir}/_store/{hash}`. - pub content_store: Arc, -} - -/// Tracks which SHA-256 hashes are already persisted in the `_store/` directory. -pub struct ContentStore { - known_hashes: DashMap, + pub asset_store: Arc, } -impl ContentStore { - pub fn new(store_dir: &std::path::Path) -> Self { - let known: DashMap = DashMap::new(); - if store_dir.is_dir() { - if let Ok(entries) = std::fs::read_dir(store_dir) { - for entry in entries.flatten() { - if let Ok(meta) = entry.metadata() { - if meta.is_file() { - if let Some(name) = entry.file_name().to_str() { - known.insert(name.to_string(), meta.len()); - } - } - } - } - } - } - tracing::info!("Content store initialized with {} entries", known.len()); - Self { known_hashes: known } - } - - pub fn contains(&self, hash: &str) -> bool { - self.known_hashes.contains_key(hash) - } - - pub fn insert(&self, hash: String, size: u64) { - self.known_hashes.insert(hash, size); - } -} +// ── Health & Info ────────────────────────────────────────────────────────── #[derive(Serialize)] pub struct HealthResponse { @@ -86,152 +62,137 @@ pub async fn server_info() -> Json { Json(ServerInfo { name: "BitFun Relay Server".to_string(), version: env!("CARGO_PKG_VERSION").to_string(), - protocol_version: 1, + protocol_version: 2, }) } +// ── Pair & Command (HTTP-to-WS bridge) ──────────────────────────────────── + #[derive(Deserialize)] -pub struct JoinRoomRequest { - pub device_id: String, - pub device_type: String, +pub struct PairRequest { pub public_key: String, + pub device_id: String, + pub device_name: String, } -/// `POST /api/rooms/:room_id/join` -pub async fn join_room( - State(state): State, - Path(room_id): Path, - Json(body): Json, -) -> Result, StatusCode> { - let conn_id = state.room_manager.next_conn_id(); - let existing_peer = state.room_manager.get_peer_info(&room_id, conn_id); - - let ok = state.room_manager.join_room( - &room_id, - conn_id, - &body.device_id, - &body.device_type, - &body.public_key, - None, // HTTP client, no websocket tx - ); - - if ok { - let joiner_notification = serde_json::to_string(&crate::routes::websocket::OutboundProtocol::PeerJoined { - device_id: body.device_id.clone(), - device_type: body.device_type.clone(), - public_key: body.public_key.clone(), - }).unwrap_or_default(); - state.room_manager.send_to_others_in_room(&room_id, conn_id, &joiner_notification); - - if let Some((peer_did, peer_dt, peer_pk)) = existing_peer { - Ok(Json(serde_json::json!({ - "status": "joined", - "peer": { - "device_id": peer_did, - "device_type": peer_dt, - "public_key": peer_pk - } - }))) - } else { - Ok(Json(serde_json::json!({ - "status": "joined", - "peer": null - }))) - } - } else { - Err(StatusCode::BAD_REQUEST) - } -} - -#[derive(Deserialize)] -pub struct RelayMessageRequest { - pub device_id: String, +#[derive(Serialize)] +pub struct PairResponse { pub encrypted_data: String, pub nonce: String, } -/// `POST /api/rooms/:room_id/message` -pub async fn relay_message( +/// `POST /api/rooms/:room_id/pair` +/// +/// Mobile sends its public key to initiate pairing. The relay forwards this +/// to the desktop via WebSocket and waits for the encrypted challenge response. +pub async fn pair( State(state): State, Path(room_id): Path, - Json(body): Json, -) -> StatusCode { - // Find conn_id by device_id in the room - if let Some(conn_id) = state.room_manager.get_conn_id_by_device(&room_id, &body.device_id) { - if state.room_manager.relay_message(conn_id, &body.encrypted_data, &body.nonce) { - StatusCode::OK - } else { - StatusCode::NOT_FOUND + Json(body): Json, +) -> Result, StatusCode> { + if !state.room_manager.has_desktop(&room_id) { + return Err(StatusCode::NOT_FOUND); + } + + let correlation_id = generate_correlation_id(); + let rx = state.room_manager.register_pending(correlation_id.clone()); + + let ws_msg = serde_json::to_string(&OutboundProtocol::PairRequest { + correlation_id: correlation_id.clone(), + public_key: body.public_key, + device_id: body.device_id, + device_name: body.device_name, + }) + .unwrap_or_default(); + + if !state.room_manager.send_to_desktop(&room_id, &ws_msg) { + state.room_manager.cancel_pending(&correlation_id); + return Err(StatusCode::SERVICE_UNAVAILABLE); + } + + match tokio::time::timeout(Duration::from_secs(30), rx).await { + Ok(Ok(payload)) => Ok(Json(PairResponse { + encrypted_data: payload.encrypted_data, + nonce: payload.nonce, + })), + _ => { + state.room_manager.cancel_pending(&correlation_id); + Err(StatusCode::GATEWAY_TIMEOUT) } - } else { - StatusCode::UNAUTHORIZED } } #[derive(Deserialize)] -pub struct PollQuery { - pub since_seq: Option, - pub device_type: Option, +pub struct CommandRequest { + pub encrypted_data: String, + pub nonce: String, } #[derive(Serialize)] -pub struct PollResponse { - pub messages: Vec, - pub peer_connected: bool, +pub struct CommandResponse { + pub encrypted_data: String, + pub nonce: String, } -/// `GET /api/rooms/:room_id/poll?since_seq=0&device_type=mobile` -pub async fn poll_messages( +/// `POST /api/rooms/:room_id/command` +/// +/// Mobile sends an encrypted command. The relay forwards it to the desktop +/// via WebSocket, waits for the encrypted response, and returns it. +pub async fn command( State(state): State, Path(room_id): Path, - Query(query): Query, -) -> Result, StatusCode> { - let since = query.since_seq.unwrap_or(0); - let direction = match query.device_type.as_deref() { - Some("desktop") => MessageDirection::ToDesktop, - _ => MessageDirection::ToMobile, - }; - - let peer_connected = state.room_manager.has_peer(&room_id, query.device_type.as_deref().unwrap_or("mobile")); - let messages = state.room_manager.poll_messages(&room_id, direction, since); - - Ok(Json(PollResponse { messages, peer_connected })) -} + Json(body): Json, +) -> Result, StatusCode> { + if !state.room_manager.has_desktop(&room_id) { + return Err(StatusCode::NOT_FOUND); + } -#[derive(Deserialize)] -pub struct AckRequest { - pub ack_seq: u64, - pub device_type: Option, + let correlation_id = generate_correlation_id(); + let rx = state.room_manager.register_pending(correlation_id.clone()); + + let ws_msg = serde_json::to_string(&OutboundProtocol::Command { + correlation_id: correlation_id.clone(), + encrypted_data: body.encrypted_data, + nonce: body.nonce, + }) + .unwrap_or_default(); + + if !state.room_manager.send_to_desktop(&room_id, &ws_msg) { + state.room_manager.cancel_pending(&correlation_id); + return Err(StatusCode::SERVICE_UNAVAILABLE); + } + + match tokio::time::timeout(Duration::from_secs(60), rx).await { + Ok(Ok(payload)) => Ok(Json(CommandResponse { + encrypted_data: payload.encrypted_data, + nonce: payload.nonce, + })), + _ => { + state.room_manager.cancel_pending(&correlation_id); + Err(StatusCode::GATEWAY_TIMEOUT) + } + } } -/// `POST /api/rooms/:room_id/ack` -pub async fn ack_messages( - State(state): State, - Path(room_id): Path, - Json(body): Json, -) -> StatusCode { - let direction = match body.device_type.as_deref() { - Some("desktop") => MessageDirection::ToDesktop, - _ => MessageDirection::ToMobile, - }; - state - .room_manager - .ack_messages(&room_id, direction, body.ack_seq); - StatusCode::OK +fn generate_correlation_id() -> String { + let bytes: [u8; 16] = rand::random(); + bytes.iter().map(|b| format!("{b:02x}")).collect() } // ── Per-room mobile-web upload & serving ─────────────────────────────────── +fn hex_sha256(data: &[u8]) -> String { + let mut hasher = Sha256::new(); + hasher.update(data); + format!("{:x}", hasher.finalize()) +} + #[derive(Deserialize)] pub struct UploadWebRequest { pub files: HashMap, } /// `POST /api/rooms/:room_id/upload-web` -/// -/// Desktop uploads mobile-web dist files (base64-encoded) so the mobile -/// browser can load the exact same version the desktop is running. -/// Now uses the global content store + symlinks to avoid storing duplicates. pub async fn upload_web( State(state): State, Path(room_id): Path, @@ -243,15 +204,6 @@ pub async fn upload_web( return Err(StatusCode::NOT_FOUND); } - let store_dir = std::path::PathBuf::from(&state.room_web_dir).join("_store"); - let _ = std::fs::create_dir_all(&store_dir); - - let room_dir = std::path::PathBuf::from(&state.room_web_dir).join(&room_id); - if let Err(e) = std::fs::create_dir_all(&room_dir) { - tracing::error!("Failed to create room web dir {}: {e}", room_dir.display()); - return Err(StatusCode::INTERNAL_SERVER_ERROR); - } - let mut written = 0usize; let mut reused = 0usize; for (rel_path, b64_content) in &body.files { @@ -261,28 +213,23 @@ pub async fn upload_web( let decoded = B64.decode(b64_content).map_err(|_| StatusCode::BAD_REQUEST)?; let hash = hex_sha256(&decoded); - let store_path = store_dir.join(&hash); - if !store_path.exists() { - std::fs::write(&store_path, &decoded) + if !state.asset_store.has_content(&hash) { + state + .asset_store + .store_content(&hash, decoded) .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - state.content_store.insert(hash.clone(), decoded.len() as u64); written += 1; } else { reused += 1; } - let dest = room_dir.join(rel_path); - if let Some(parent) = dest.parent() { - let _ = std::fs::create_dir_all(parent); - } - let _ = std::fs::remove_file(&dest); - create_link(&store_path, &dest) + state + .asset_store + .map_to_room(&room_id, rel_path, &hash) .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; } - tracing::info!( - "Room {room_id}: upload-web complete (new={written}, reused={reused})" - ); + tracing::info!("Room {room_id}: upload-web complete (new={written}, reused={reused})"); Ok(Json(serde_json::json!({ "status": "ok", "files_written": written, @@ -313,10 +260,6 @@ pub struct CheckWebFilesResponse { } /// `POST /api/rooms/:room_id/check-web-files` -/// -/// Accepts a manifest of file metadata (path, sha256, size). Registers the -/// room's file manifest and returns which files the server still needs. Files -/// whose hash already exists in the global content store are skipped. pub async fn check_web_files( State(state): State, Path(room_id): Path, @@ -326,12 +269,6 @@ pub async fn check_web_files( return Err(StatusCode::NOT_FOUND); } - let store_dir = std::path::PathBuf::from(&state.room_web_dir).join("_store"); - let _ = std::fs::create_dir_all(&store_dir); - - let room_dir = std::path::PathBuf::from(&state.room_web_dir).join(&room_id); - let _ = std::fs::create_dir_all(&room_dir); - let mut needed = Vec::new(); let mut existing_count = 0usize; let total_count = body.files.len(); @@ -340,15 +277,11 @@ pub async fn check_web_files( if entry.path.contains("..") { continue; } - if state.content_store.contains(&entry.hash) { + if state.asset_store.has_content(&entry.hash) { existing_count += 1; - let store_path = store_dir.join(&entry.hash); - let dest = room_dir.join(&entry.path); - if let Some(parent) = dest.parent() { - let _ = std::fs::create_dir_all(parent); - } - let _ = std::fs::remove_file(&dest); - let _ = create_link(&store_path, &dest); + let _ = state + .asset_store + .map_to_room(&room_id, &entry.path, &entry.hash); } else { needed.push(entry.path.clone()); } @@ -378,10 +311,6 @@ pub struct UploadWebFilesRequest { } /// `POST /api/rooms/:room_id/upload-web-files` -/// -/// Upload only the files that the server requested via `check-web-files`. -/// Each entry includes the base64 content and its expected sha256 hash. -/// Files are stored in the global content store and symlinked into the room. pub async fn upload_web_files( State(state): State, Path(room_id): Path, @@ -393,12 +322,6 @@ pub async fn upload_web_files( return Err(StatusCode::NOT_FOUND); } - let store_dir = std::path::PathBuf::from(&state.room_web_dir).join("_store"); - let _ = std::fs::create_dir_all(&store_dir); - - let room_dir = std::path::PathBuf::from(&state.room_web_dir).join(&room_id); - let _ = std::fs::create_dir_all(&room_dir); - let mut stored = 0usize; for (rel_path, entry) in &body.files { if rel_path.contains("..") { @@ -414,55 +337,25 @@ pub async fn upload_web_files( return Err(StatusCode::BAD_REQUEST); } - let store_path = store_dir.join(&actual_hash); - if !store_path.exists() { - std::fs::write(&store_path, &decoded) - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + if !state.asset_store.has_content(&actual_hash) { state - .content_store - .insert(actual_hash.clone(), decoded.len() as u64); + .asset_store + .store_content(&actual_hash, decoded) + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + stored += 1; } - let dest = room_dir.join(rel_path); - if let Some(parent) = dest.parent() { - let _ = std::fs::create_dir_all(parent); - } - let _ = std::fs::remove_file(&dest); - create_link(&store_path, &dest) + state + .asset_store + .map_to_room(&room_id, rel_path, &actual_hash) .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - stored += 1; } tracing::info!("Room {room_id}: upload-web-files stored {stored} new files"); Ok(Json(serde_json::json!({ "status": "ok", "files_stored": stored }))) } -fn hex_sha256(data: &[u8]) -> String { - let mut hasher = Sha256::new(); - hasher.update(data); - format!("{:x}", hasher.finalize()) -} - -/// Create a symlink (Unix) or hard link fallback (Windows). -fn create_link( - original: &std::path::Path, - link: &std::path::Path, -) -> std::io::Result<()> { - #[cfg(unix)] - { - std::os::unix::fs::symlink(original, link) - } - #[cfg(not(unix))] - { - std::fs::hard_link(original, link) - .or_else(|_| std::fs::copy(original, link).map(|_| ())) - } -} - /// `GET /r/{*rest}` — serve per-room mobile-web static files. -/// -/// The `rest` path is expected to be `room_id` or `room_id/file/path`. -/// Falls back to `index.html` for SPA routing. pub async fn serve_room_web_catchall( State(state): State, Path(rest): Path, @@ -481,35 +374,23 @@ pub async fn serve_room_web_catchall( return Err(StatusCode::NOT_FOUND); } - let room_dir = std::path::PathBuf::from(&state.room_web_dir).join(room_id); - if !room_dir.exists() { - return Err(StatusCode::NOT_FOUND); - } - - let target = if file_path.is_empty() { - room_dir.join("index.html") + let lookup_path = if file_path.is_empty() { + "index.html" } else { - room_dir.join(file_path) + file_path }; - let file = if target.is_file() { - target - } else { - room_dir.join("index.html") - }; - - if !file.is_file() { - return Err(StatusCode::NOT_FOUND); - } - - let content = std::fs::read(&file).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - let mime = mime_from_path(&file); + let content = state + .asset_store + .get_file(room_id, lookup_path) + .ok_or(StatusCode::NOT_FOUND)?; + let mime = mime_from_path(lookup_path); Ok(([(header::CONTENT_TYPE, mime)], Body::from(content)).into_response()) } -fn mime_from_path(p: &std::path::Path) -> &'static str { - match p.extension().and_then(|e| e.to_str()) { +fn mime_from_path(p: &str) -> &'static str { + match p.rsplit('.').next() { Some("html") => "text/html; charset=utf-8", Some("js") => "application/javascript; charset=utf-8", Some("css") => "text/css; charset=utf-8", @@ -524,15 +405,3 @@ fn mime_from_path(p: &std::path::Path) -> &'static str { _ => "application/octet-stream", } } - -/// Remove the per-room web directory (called on room cleanup). -pub fn cleanup_room_web(room_web_dir: &str, room_id: &str) { - let dir = std::path::PathBuf::from(room_web_dir).join(room_id); - if dir.exists() { - if let Err(e) = std::fs::remove_dir_all(&dir) { - tracing::warn!("Failed to clean up room web dir {}: {e}", dir.display()); - } else { - tracing::info!("Cleaned up room web dir for {room_id}"); - } - } -} diff --git a/src/apps/relay-server/src/routes/websocket.rs b/src/apps/relay-server/src/routes/websocket.rs index bfc80e70..f323213c 100644 --- a/src/apps/relay-server/src/routes/websocket.rs +++ b/src/apps/relay-server/src/routes/websocket.rs @@ -1,9 +1,8 @@ //! WebSocket handler for the relay server. //! -//! Each connected client sends/receives JSON messages following the relay protocol. -//! The server never decrypts application data — it only handles room management -//! and forwards encrypted payloads between paired devices. -//! Desktop→mobile messages are also buffered for later polling. +//! Only desktop clients connect via WebSocket. Mobile clients use HTTP. +//! The relay bridges HTTP requests to the desktop via WebSocket using +//! correlation IDs for request-response matching. use axum::{ extract::{ @@ -18,43 +17,53 @@ use std::sync::Arc; use tokio::sync::mpsc; use tracing::{debug, error, info, warn}; -use crate::relay::room::{ConnId, OutboundMessage, RoomManager}; +use crate::relay::room::{ConnId, OutboundMessage, ResponsePayload, RoomManager}; use crate::routes::api::AppState; +/// Messages received from the desktop via WebSocket. #[derive(Debug, Deserialize)] #[serde(tag = "type", rename_all = "snake_case")] -#[allow(dead_code)] pub enum InboundMessage { CreateRoom { room_id: Option, device_id: String, + #[allow(dead_code)] device_type: String, public_key: String, }, - JoinRoom { - room_id: String, - device_id: String, - device_type: String, - public_key: String, - }, - Relay { - room_id: String, + /// Desktop responds to a bridged HTTP request. + RelayResponse { + correlation_id: String, encrypted_data: String, nonce: String, }, Heartbeat, } +/// Messages sent to the desktop via WebSocket. #[derive(Debug, Serialize)] #[serde(tag = "type", rename_all = "snake_case")] -#[allow(dead_code)] pub enum OutboundProtocol { - RoomCreated { room_id: String }, - PeerJoined { device_id: String, device_type: String, public_key: String }, - Relay { room_id: String, encrypted_data: String, nonce: String }, + RoomCreated { + room_id: String, + }, + /// Mobile pairing request forwarded to desktop. + PairRequest { + correlation_id: String, + public_key: String, + device_id: String, + device_name: String, + }, + /// Encrypted command from mobile forwarded to desktop. + Command { + correlation_id: String, + encrypted_data: String, + nonce: String, + }, HeartbeatAck, - PeerDisconnected { device_id: String }, - Error { message: String }, + Error { + message: String, + }, } pub async fn websocket_handler( @@ -86,9 +95,7 @@ async fn handle_socket(socket: WebSocket, state: AppState) { Ok(Message::Text(text)) => { handle_text_message(&text, conn_id, &state.room_manager, &out_tx); } - Ok(Message::Ping(_)) => { - // Axum auto-replies Pong for Ping frames - } + Ok(Message::Ping(_)) => {} Ok(Message::Close(_)) => { info!("WebSocket close from conn_id={conn_id}"); break; @@ -113,14 +120,20 @@ fn handle_text_message( room_manager: &Arc, out_tx: &mpsc::UnboundedSender, ) { - debug!("Received from conn_id={conn_id}: {}", &text[..text.len().min(200)]); + debug!( + "Received from conn_id={conn_id}: {}", + &text[..text.len().min(200)] + ); let msg: InboundMessage = match serde_json::from_str(text) { Ok(m) => m, Err(e) => { warn!("Invalid message from conn_id={conn_id}: {e}"); - send_json(out_tx, &OutboundProtocol::Error { - message: format!("invalid message format: {e}"), - }); + send_json( + out_tx, + &OutboundProtocol::Error { + message: format!("invalid message format: {e}"), + }, + ); return; } }; @@ -129,78 +142,54 @@ fn handle_text_message( InboundMessage::CreateRoom { room_id, device_id, - device_type, + device_type: _, public_key, } => { let room_id = room_id.unwrap_or_else(generate_room_id); let ok = room_manager.create_room( - &room_id, conn_id, &device_id, &device_type, &public_key, Some(out_tx.clone()), + &room_id, + conn_id, + &device_id, + &public_key, + out_tx.clone(), ); if ok { send_json(out_tx, &OutboundProtocol::RoomCreated { room_id }); } else { - send_json(out_tx, &OutboundProtocol::Error { - message: "failed to create room".into(), - }); + send_json( + out_tx, + &OutboundProtocol::Error { + message: "failed to create room".into(), + }, + ); } } - InboundMessage::JoinRoom { - room_id, - device_id, - device_type, - public_key, - } => { - let existing_peer = room_manager.get_peer_info(&room_id, conn_id); - - let ok = room_manager.join_room( - &room_id, conn_id, &device_id, &device_type, &public_key, Some(out_tx.clone()), - ); - - if ok { - let joiner_notification = serde_json::to_string(&OutboundProtocol::PeerJoined { - device_id: device_id.clone(), - device_type: device_type.clone(), - public_key: public_key.clone(), - }).unwrap_or_default(); - room_manager.send_to_others_in_room(&room_id, conn_id, &joiner_notification); - - if let Some((peer_did, peer_dt, peer_pk)) = existing_peer { - send_json(out_tx, &OutboundProtocol::PeerJoined { - device_id: peer_did, - device_type: peer_dt, - public_key: peer_pk, - }); - } else { - warn!("No existing peer found for room {room_id} to send back to joiner"); - } - } else { - send_json(out_tx, &OutboundProtocol::Error { - message: format!("failed to join room {room_id}"), - }); - } - } - - InboundMessage::Relay { - room_id: _, + InboundMessage::RelayResponse { + correlation_id, encrypted_data, nonce, } => { - debug!("Relay message from conn_id={conn_id} data_len={}", encrypted_data.len()); - if room_manager.relay_message(conn_id, &encrypted_data, &nonce) { - debug!("Relay message forwarded from conn_id={conn_id}"); - } else { - warn!("Relay failed for conn_id={conn_id}: no peer found"); - } + debug!("RelayResponse from desktop conn_id={conn_id} corr={correlation_id}"); + room_manager.resolve_pending( + &correlation_id, + ResponsePayload { + encrypted_data, + nonce, + }, + ); } InboundMessage::Heartbeat => { if room_manager.heartbeat(conn_id) { send_json(out_tx, &OutboundProtocol::HeartbeatAck); } else { - send_json(out_tx, &OutboundProtocol::Error { - message: "Room not found or expired".into(), - }); + send_json( + out_tx, + &OutboundProtocol::Error { + message: "Room not found or expired".into(), + }, + ); } } } diff --git a/src/crates/core/Cargo.toml b/src/crates/core/Cargo.toml index 86e44465..d0078efe 100644 --- a/src/crates/core/Cargo.toml +++ b/src/crates/core/Cargo.toml @@ -110,6 +110,9 @@ image = { workspace = true } # WebSocket client tokio-tungstenite = { workspace = true } +# Relay server shared library (embedded relay reuses standalone relay logic) +bitfun-relay-server = { path = "../../apps/relay-server" } + # Event layer dependency (lowest layer) bitfun-events = { path = "../events" } 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 0d645bd0..0c7c0270 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 @@ -572,30 +572,15 @@ async fn select_session( ) -> HandleResult { use crate::agentic::coordination::get_global_coordinator; - let coordinator = match get_global_coordinator() { - Some(c) => c, - None => { - state.current_session_id = Some(session_id.to_string()); - info!("Bot resumed session: {session_id}"); - return HandleResult { - reply: format!( - "Resumed session: {session_name}\n\n\ - You can now send messages to interact with the AI agent." - ), - forward_to_session: None, - }; - } - }; + if let Some(coordinator) = get_global_coordinator() { + let _ = coordinator.restore_session(session_id).await; + } - let _ = coordinator.restore_session(session_id).await; state.current_session_id = Some(session_id.to_string()); info!("Bot resumed session: {session_id}"); - let last_pair = coordinator - .get_messages(session_id) - .await - .ok() - .and_then(|msgs| extract_last_dialog_pair(&msgs)); + let last_pair = + load_last_dialog_pair_from_turns(state.current_workspace.as_deref(), session_id).await; let mut reply = format!("Resumed session: {session_name}\n\n"); if let Some((user_text, assistant_text)) = last_pair { @@ -610,45 +595,52 @@ async fn select_session( HandleResult { reply, forward_to_session: None } } -fn extract_last_dialog_pair( - messages: &[crate::agentic::core::Message], +/// Load the last user/assistant dialog pair from ConversationPersistenceManager, +/// the same data source the desktop frontend uses. +async fn load_last_dialog_pair_from_turns( + workspace_path: Option<&str>, + session_id: &str, ) -> Option<(String, String)> { - use crate::agentic::core::MessageRole; + use crate::infrastructure::PathManager; + use crate::service::conversation::ConversationPersistenceManager; const MAX_USER_LEN: usize = 200; const MAX_AI_LEN: usize = 400; - // Find the index of the last assistant message with readable text. - let assistant_idx = messages.iter().rposition(|m| { - m.role == MessageRole::Assistant && message_text(m).is_some() - })?; + let wp = std::path::PathBuf::from(workspace_path?); + let pm = std::sync::Arc::new(PathManager::new().ok()?); + let conv_mgr = ConversationPersistenceManager::new(pm, wp).await.ok()?; + let turns = conv_mgr.load_session_turns(session_id).await.ok()?; + let turn = turns.last()?; - // Find the last user message that appears before the assistant message. - let user_idx = messages[..assistant_idx].iter().rposition(|m| { - m.role == MessageRole::User && message_text(m).is_some() - })?; + let user_text = strip_user_message_tags(&turn.user_message.content); + if user_text.is_empty() { + return None; + } - let user_text = truncate_text(&message_text(&messages[user_idx])?, MAX_USER_LEN); - let assistant_text = truncate_text(&message_text(&messages[assistant_idx])?, MAX_AI_LEN); + let mut ai_text = String::new(); + for round in &turn.model_rounds { + for t in &round.text_items { + if t.is_subagent_item.unwrap_or(false) { + continue; + } + if !t.content.is_empty() { + if !ai_text.is_empty() { + ai_text.push('\n'); + } + ai_text.push_str(&t.content); + } + } + } - Some((user_text, assistant_text)) -} + if ai_text.is_empty() { + return None; + } -fn message_text(msg: &crate::agentic::core::Message) -> Option { - use crate::agentic::core::{MessageContent, MessageRole}; - let raw = match &msg.content { - MessageContent::Text(t) if !t.trim().is_empty() => t.as_str(), - MessageContent::Mixed { text, .. } if !text.trim().is_empty() => text.as_str(), - _ => return None, - }; - // User messages in agentic mode are wrapped with and may contain - // a trailing block — extract the visible portion only. - let cleaned = if msg.role == MessageRole::User { - strip_user_message_tags(raw) - } else { - raw.trim().to_string() - }; - if cleaned.is_empty() { None } else { Some(cleaned) } + Some(( + truncate_text(&user_text, MAX_USER_LEN), + truncate_text(&ai_text, MAX_AI_LEN), + )) } /// Strip XML wrapper tags injected by wrap_user_input before storing the message: diff --git a/src/crates/core/src/service/remote_connect/embedded_relay.rs b/src/crates/core/src/service/remote_connect/embedded_relay.rs index 7b80f2cd..71ce2640 100644 --- a/src/crates/core/src/service/remote_connect/embedded_relay.rs +++ b/src/crates/core/src/service/remote_connect/embedded_relay.rs @@ -1,184 +1,40 @@ //! Embedded mini relay server for LAN / ngrok modes. //! -//! Runs inside the desktop process using axum + WebSocket. -//! Supports the same protocol as the standalone relay-server. +//! Runs inside the desktop process, reusing the same relay logic as the +//! standalone relay-server binary. Uses `MemoryAssetStore` for in-memory +//! mobile-web file storage (no disk I/O for uploaded assets). -use axum::{ - extract::{ - ws::{Message, WebSocket, WebSocketUpgrade}, - State, - }, - response::{IntoResponse, Response}, - routing::get, - Json, Router, -}; -use dashmap::DashMap; -use futures_util::{SinkExt, StreamExt}; -use log::{debug, info}; -use serde::{Deserialize, Serialize}; -use std::sync::{ - atomic::{AtomicU64, Ordering}, - Arc, -}; -use tokio::sync::mpsc; - -type ConnId = u64; - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum MessageDirection { - ToMobile, - ToDesktop, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct BufferedMessage { - pub seq: u64, - pub timestamp: i64, - pub direction: MessageDirection, - pub encrypted_data: String, - pub nonce: String, -} - -struct Participant { - conn_id: ConnId, - device_id: String, - device_type: String, - public_key: String, - tx: Option>, - last_activity: i64, -} - -struct Room { - participants: Vec, - message_store: Vec, - next_seq: u64, -} - -impl Room { - fn buffer_message(&mut self, direction: MessageDirection, encrypted_data: String, nonce: String) -> u64 { - let seq = self.next_seq; - self.next_seq += 1; - self.message_store.push(BufferedMessage { - seq, - timestamp: chrono::Utc::now().timestamp(), - direction, - encrypted_data, - nonce, - }); - seq - } -} - -struct RelayState { - rooms: DashMap, - conn_to_room: DashMap, - next_id: AtomicU64, - /// Global content-addressed store: sha256 hex -> file bytes. - content_store: DashMap>>, - /// Per-room file manifests: room_id -> (relative_path -> sha256 hex). - room_manifests: DashMap>, -} - -impl RelayState { - fn new() -> Arc { - Arc::new(Self { - rooms: DashMap::new(), - conn_to_room: DashMap::new(), - next_id: AtomicU64::new(1), - content_store: DashMap::new(), - room_manifests: DashMap::new(), - }) - } -} - -#[derive(Deserialize)] -#[serde(tag = "type", rename_all = "snake_case")] -enum Inbound { - CreateRoom { - room_id: Option, - device_id: String, - device_type: String, - public_key: String, - }, - JoinRoom { - room_id: String, - device_id: String, - device_type: String, - public_key: String, - }, - Relay { - #[allow(dead_code)] - room_id: String, - encrypted_data: String, - nonce: String, - }, - Heartbeat, -} - -#[derive(Serialize)] -#[serde(tag = "type", rename_all = "snake_case")] -enum Outbound { - RoomCreated { room_id: String }, - PeerJoined { device_id: String, device_type: String, public_key: String }, - Relay { room_id: String, encrypted_data: String, nonce: String }, - PeerDisconnected { device_id: String }, - HeartbeatAck, - Error { message: String }, -} +use bitfun_relay_server::{build_relay_router, MemoryAssetStore, RoomManager}; +use log::info; +use std::sync::Arc; /// Start the embedded relay and return a shutdown handle. -/// The server listens on `0.0.0.0:{port}`. /// /// If `static_dir` is provided, the server also serves mobile-web static files /// as a fallback for requests that don't match any API or WebSocket route. -pub async fn start_embedded_relay(port: u16, static_dir: Option<&str>) -> anyhow::Result { - let state = RelayState::new(); - let app_state = state.clone(); - - let cleanup_state = state.clone(); +pub async fn start_embedded_relay( + port: u16, + static_dir: Option<&str>, +) -> anyhow::Result { + let room_manager = RoomManager::new(); + let asset_store = Arc::new(MemoryAssetStore::new()); + let start_time = std::time::Instant::now(); + + let cleanup_rm = room_manager.clone(); tokio::spawn(async move { loop { tokio::time::sleep(std::time::Duration::from_secs(60)).await; - let now = chrono::Utc::now().timestamp(); - let stale_ids: Vec = cleanup_state.rooms - .iter() - .filter(|r| (now - r.participants.iter().map(|p| p.last_activity).max().unwrap_or(now)) > 300) - .map(|r| r.key().clone()) - .collect(); - - for id in stale_ids { - if let Some((_, room)) = cleanup_state.rooms.remove(&id) { - for p in room.participants { - cleanup_state.conn_to_room.remove(&p.conn_id); - } - } - } + cleanup_rm.cleanup_stale_rooms(300); } }); - let mut app = Router::new() - .route("/ws", get(ws_handler)) - .route("/health", get(health)) - .route("/api/rooms/:room_id/join", axum::routing::post(join_room_http)) - .route("/api/rooms/:room_id/message", axum::routing::post(relay_message_http)) - .route("/api/rooms/:room_id/poll", get(poll_messages_http)) - .route("/api/rooms/:room_id/ack", axum::routing::post(ack_messages_http)) - .route("/api/rooms/:room_id/upload-web", axum::routing::post(upload_web_http)) - .route("/api/rooms/:room_id/check-web-files", axum::routing::post(check_web_files_http)) - .route("/api/rooms/:room_id/upload-web-files", axum::routing::post(upload_web_files_http)) - .route("/r/*path", get(serve_room_web_http)) - .layer(tower_http::cors::CorsLayer::permissive()) - .with_state(app_state); + let mut app = build_relay_router(room_manager, asset_store, start_time); if let Some(dir) = static_dir { info!("Embedded relay: serving static files from {dir}"); let serve_dir = tower_http::services::ServeDir::new(dir) .append_index_html_on_directories(true); - // Wrap with cache-control middleware: - // - HTML: no-cache (always fetch fresh to pick up new asset hashes) - // - Hashed assets: immutable long-cache (filename contains content hash) - let static_app = Router::<()>::new() + let static_app = axum::Router::<()>::new() .fallback_service(serve_dir) .layer(axum::middleware::from_fn(static_cache_headers)); app = app.fallback_service(static_app); @@ -193,23 +49,24 @@ pub async fn start_embedded_relay(port: u16, static_dir: Option<&str>) -> anyhow let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>(); tokio::spawn(async move { axum::serve(listener, app) - .with_graceful_shutdown(async { let _ = shutdown_rx.await; }) + .with_graceful_shutdown(async { + let _ = shutdown_rx.await; + }) .await .ok(); }); tokio::time::sleep(std::time::Duration::from_millis(200)).await; - Ok(EmbeddedRelayHandle { _shutdown: Some(shutdown_tx) }) + Ok(EmbeddedRelayHandle { + _shutdown: Some(shutdown_tx), + }) } -/// Middleware that sets Cache-Control headers for static file responses. -/// HTML files get `no-cache` so the browser always checks for updates, -/// while hashed asset files (JS/CSS in /assets/) get long-term caching. async fn static_cache_headers( request: axum::extract::Request, next: axum::middleware::Next, -) -> Response { +) -> axum::response::Response { let path = request.uri().path().to_string(); let mut response = next.run(request).await; let headers = response.headers_mut(); @@ -249,589 +106,3 @@ impl Drop for EmbeddedRelayHandle { self.stop(); } } - -async fn health() -> impl IntoResponse { - Json(serde_json::json!({"status": "healthy"})) -} - -async fn ws_handler(ws: WebSocketUpgrade, State(state): State>) -> Response { - ws.on_upgrade(move |socket| handle_socket(socket, state)) -} - -async fn handle_socket(socket: WebSocket, state: Arc) { - let (mut ws_tx, mut ws_rx) = socket.split(); - let (out_tx, mut out_rx) = mpsc::unbounded_channel::(); - - let conn_id = state.next_id.fetch_add(1, Ordering::Relaxed); - - let write_task = tokio::spawn(async move { - while let Some(text) = out_rx.recv().await { - if ws_tx.send(Message::Text(text)).await.is_err() { - break; - } - } - }); - - while let Some(Ok(msg)) = ws_rx.next().await { - if let Message::Text(text) = msg { - handle_msg(&text, conn_id, &state, &out_tx); - } - } - - on_disconnect(conn_id, &state); - drop(out_tx); - let _ = write_task.await; - debug!("Embedded relay: conn {conn_id} closed"); -} - -fn handle_msg( - text: &str, - conn_id: ConnId, - state: &Arc, - out_tx: &mpsc::UnboundedSender, -) { - let msg: Inbound = match serde_json::from_str(text) { - Ok(m) => m, - Err(e) => { - send(&Some(out_tx.clone()), &Outbound::Error { message: format!("bad message: {e}") }); - return; - } - }; - - match msg { - Inbound::CreateRoom { room_id, device_id, device_type, public_key } => { - let room_id = room_id.unwrap_or_else(gen_room_id); - let mut room = Room { - participants: Vec::with_capacity(2), - message_store: Vec::new(), - next_seq: 1, - }; - room.participants.push(Participant { - conn_id, device_id, device_type, public_key, tx: Some(out_tx.clone()), last_activity: chrono::Utc::now().timestamp(), - }); - state.rooms.insert(room_id.clone(), room); - state.conn_to_room.insert(conn_id, room_id.clone()); - send(&Some(out_tx.clone()), &Outbound::RoomCreated { room_id }); - } - - Inbound::JoinRoom { room_id, device_id, device_type, public_key } => { - let existing_peer = state.rooms.get(&room_id).and_then(|r| { - r.participants.first().map(|p| (p.device_id.clone(), p.device_type.clone(), p.public_key.clone())) - }); - - let ok = if let Some(mut room) = state.rooms.get_mut(&room_id) { - if room.participants.len() < 2 { - room.participants.push(Participant { - conn_id, - device_id: device_id.clone(), - device_type: device_type.clone(), - public_key: public_key.clone(), - tx: Some(out_tx.clone()), - last_activity: chrono::Utc::now().timestamp(), - }); - state.conn_to_room.insert(conn_id, room_id.clone()); - true - } else { - false - } - } else { - false - }; - - if ok { - if let Some(room) = state.rooms.get(&room_id) { - for p in &room.participants { - if p.conn_id != conn_id { - send(&p.tx, &Outbound::PeerJoined { - device_id: device_id.clone(), - device_type: device_type.clone(), - public_key: public_key.clone(), - }); - } - } - } - if let Some((pdid, pdt, ppk)) = existing_peer { - send(&Some(out_tx.clone()), &Outbound::PeerJoined { - device_id: pdid, device_type: pdt, public_key: ppk, - }); - } - } else { - send(&Some(out_tx.clone()), &Outbound::Error { message: format!("cannot join room {room_id}") }); - } - } - - Inbound::Relay { room_id, encrypted_data, nonce } => { - if let Some(rid) = state.conn_to_room.get(&conn_id) { - if let Some(mut room) = state.rooms.get_mut(rid.value()) { - let sender_type = room.participants.iter() - .find(|p| p.conn_id == conn_id) - .map(|p| p.device_type.clone()) - .unwrap_or_default(); - - let direction = if sender_type == "desktop" { - MessageDirection::ToMobile - } else { - MessageDirection::ToDesktop - }; - - room.buffer_message(direction, encrypted_data.clone(), nonce.clone()); - - let relay_json = serde_json::to_string(&Outbound::Relay { - room_id: room_id.clone(), encrypted_data, nonce, - }).unwrap_or_default(); - for p in &room.participants { - if p.conn_id != conn_id { - if let Some(ref tx) = p.tx { - let _ = tx.send(relay_json.clone()); - } - } - } - } - } - } - - Inbound::Heartbeat => { - if let Some(room_id) = state.conn_to_room.get(&conn_id) { - if let Some(mut room) = state.rooms.get_mut(room_id.value()) { - if let Some(p) = room.participants.iter_mut().find(|p| p.conn_id == conn_id) { - p.last_activity = chrono::Utc::now().timestamp(); - } - } - send(&Some(out_tx.clone()), &Outbound::HeartbeatAck); - } else { - send(&Some(out_tx.clone()), &Outbound::Error { message: "Room not found".into() }); - } - } - } -} - -fn on_disconnect(conn_id: ConnId, state: &Arc) { - if let Some((_, room_id)) = state.conn_to_room.remove(&conn_id) { - let mut should_remove = false; - if let Some(mut room) = state.rooms.get_mut(&room_id) { - let removed = room.participants.iter().position(|p| p.conn_id == conn_id); - if let Some(idx) = removed { - let p = room.participants.remove(idx); - let notif = serde_json::to_string(&Outbound::PeerDisconnected { - device_id: p.device_id, - }).unwrap_or_default(); - for other in &room.participants { - if let Some(ref tx) = other.tx { - let _ = tx.send(notif.clone()); - } - } - } - should_remove = room.participants.is_empty(); - } - if should_remove { - state.rooms.remove(&room_id); - } - } -} - -fn send(tx: &Option>, msg: &Outbound) { - if let Some(tx) = tx { - if let Ok(json) = serde_json::to_string(msg) { - let _ = tx.send(json); - } - } -} - -fn gen_room_id() -> String { - let bytes: [u8; 6] = rand::random(); - bytes.iter().map(|b| format!("{b:02x}")).collect() -} - -// ── HTTP Handlers ─────────────────────────────────────────────────── - -#[derive(Deserialize)] -struct JoinRoomRequest { - device_id: String, - device_type: String, - public_key: String, -} - -async fn join_room_http( - State(state): State>, - axum::extract::Path(room_id): axum::extract::Path, - Json(body): Json, -) -> Result, axum::http::StatusCode> { - let conn_id = state.next_id.fetch_add(1, Ordering::Relaxed); - - let existing_peer = state.rooms.get(&room_id).and_then(|r| { - r.participants.first().map(|p| (p.device_id.clone(), p.device_type.clone(), p.public_key.clone())) - }); - - let ok = if let Some(mut room) = state.rooms.get_mut(&room_id) { - if room.participants.len() < 2 { - room.participants.push(Participant { - conn_id, - device_id: body.device_id.clone(), - device_type: body.device_type.clone(), - public_key: body.public_key.clone(), - tx: None, // HTTP client - last_activity: chrono::Utc::now().timestamp(), - }); - state.conn_to_room.insert(conn_id, room_id.clone()); - true - } else { - false - } - } else { - false - }; - - if ok { - if let Some(room) = state.rooms.get(&room_id) { - for p in &room.participants { - if p.conn_id != conn_id { - send(&p.tx, &Outbound::PeerJoined { - device_id: body.device_id.clone(), - device_type: body.device_type.clone(), - public_key: body.public_key.clone(), - }); - } - } - } - - if let Some((pdid, pdt, ppk)) = existing_peer { - Ok(Json(serde_json::json!({ - "status": "joined", - "peer": { - "device_id": pdid, - "device_type": pdt, - "public_key": ppk - } - }))) - } else { - Ok(Json(serde_json::json!({ - "status": "joined", - "peer": null - }))) - } - } else { - Err(axum::http::StatusCode::BAD_REQUEST) - } -} - -#[derive(Deserialize)] -struct RelayMessageRequest { - device_id: String, - encrypted_data: String, - nonce: String, -} - -async fn relay_message_http( - State(state): State>, - axum::extract::Path(room_id): axum::extract::Path, - Json(body): Json, -) -> axum::http::StatusCode { - if let Some(mut room) = state.rooms.get_mut(&room_id) { - let sender_conn_id = room.participants.iter() - .find(|p| p.device_id == body.device_id) - .map(|p| p.conn_id); - - if let Some(conn_id) = sender_conn_id { - let sender_type = room.participants.iter() - .find(|p| p.conn_id == conn_id) - .map(|p| p.device_type.clone()) - .unwrap_or_default(); - - let direction = if sender_type == "desktop" { - MessageDirection::ToMobile - } else { - MessageDirection::ToDesktop - }; - - room.buffer_message(direction, body.encrypted_data.clone(), body.nonce.clone()); - - let relay_json = serde_json::to_string(&Outbound::Relay { - room_id: room_id.clone(), - encrypted_data: body.encrypted_data, - nonce: body.nonce, - }).unwrap_or_default(); - - for p in &room.participants { - if p.conn_id != conn_id { - if let Some(ref tx) = p.tx { - let _ = tx.send(relay_json.clone()); - } - } - } - return axum::http::StatusCode::OK; - } - } - axum::http::StatusCode::NOT_FOUND -} - -#[derive(Deserialize)] -struct PollQuery { - since_seq: Option, - device_type: Option, -} - -#[derive(Serialize)] -struct PollResponse { - messages: Vec, - peer_connected: bool, -} - -async fn poll_messages_http( - State(state): State>, - axum::extract::Path(room_id): axum::extract::Path, - axum::extract::Query(query): axum::extract::Query, -) -> Result, axum::http::StatusCode> { - let since = query.since_seq.unwrap_or(0); - let direction_str = query.device_type.as_deref().unwrap_or("mobile"); - let direction = match direction_str { - "desktop" => MessageDirection::ToDesktop, - _ => MessageDirection::ToMobile, - }; - - if let Some(mut room) = state.rooms.get_mut(&room_id) { - if let Some(p) = room.participants.iter_mut().find(|p| p.device_type == direction_str) { - p.last_activity = chrono::Utc::now().timestamp(); - } - let peer_connected = room.participants.iter().any(|p| p.device_type != direction_str); - let messages = room.message_store - .iter() - .filter(|m| m.direction == direction && m.seq > since) - .cloned() - .collect(); - Ok(Json(PollResponse { messages, peer_connected })) - } else { - Ok(Json(PollResponse { messages: vec![], peer_connected: false })) - } -} - -#[derive(Deserialize)] -struct AckRequest { - ack_seq: u64, - device_type: Option, -} - -async fn ack_messages_http( - State(state): State>, - axum::extract::Path(room_id): axum::extract::Path, - Json(body): Json, -) -> axum::http::StatusCode { - let direction_str = body.device_type.as_deref().unwrap_or("mobile"); - let direction = match direction_str { - "desktop" => MessageDirection::ToDesktop, - _ => MessageDirection::ToMobile, - }; - - if let Some(mut room) = state.rooms.get_mut(&room_id) { - if let Some(p) = room.participants.iter_mut().find(|p| p.device_type == direction_str) { - p.last_activity = chrono::Utc::now().timestamp(); - } - room.message_store.retain(|m| !(m.direction == direction && m.seq <= body.ack_seq)); - } - axum::http::StatusCode::OK -} - -// ── Mobile-web upload & serving (content-addressed in-memory store) ───── - -fn hex_sha256(data: &[u8]) -> String { - use sha2::{Digest, Sha256}; - let mut hasher = Sha256::new(); - hasher.update(data); - format!("{:x}", hasher.finalize()) -} - -#[derive(Deserialize)] -struct UploadWebRequest { - files: std::collections::HashMap, -} - -async fn upload_web_http( - State(state): State>, - axum::extract::Path(room_id): axum::extract::Path, - Json(body): Json, -) -> Result, axum::http::StatusCode> { - use base64::{engine::general_purpose::STANDARD as B64, Engine}; - - if !state.rooms.contains_key(&room_id) { - return Err(axum::http::StatusCode::NOT_FOUND); - } - - let mut manifest = std::collections::HashMap::new(); - let mut written = 0usize; - let mut reused = 0usize; - - for (rel_path, b64_content) in &body.files { - if rel_path.contains("..") { - continue; - } - let decoded = B64.decode(b64_content).map_err(|_| axum::http::StatusCode::BAD_REQUEST)?; - let hash = hex_sha256(&decoded); - - if !state.content_store.contains_key(&hash) { - state.content_store.insert(hash.clone(), Arc::new(decoded)); - written += 1; - } else { - reused += 1; - } - manifest.insert(rel_path.clone(), hash); - } - - state.room_manifests.insert(room_id.clone(), manifest); - info!("Room {room_id}: upload-web complete (new={written}, reused={reused})"); - Ok(Json(serde_json::json!({ - "status": "ok", - "files_written": written, - "files_reused": reused - }))) -} - -#[derive(Deserialize)] -struct FileManifestEntry { - path: String, - hash: String, - #[allow(dead_code)] - size: u64, -} - -#[derive(Deserialize)] -struct CheckWebFilesRequest { - files: Vec, -} - -async fn check_web_files_http( - State(state): State>, - axum::extract::Path(room_id): axum::extract::Path, - Json(body): Json, -) -> Result, axum::http::StatusCode> { - if !state.rooms.contains_key(&room_id) { - return Err(axum::http::StatusCode::NOT_FOUND); - } - - let mut manifest = std::collections::HashMap::new(); - let mut needed = Vec::new(); - let mut existing_count = 0usize; - - for entry in &body.files { - if entry.path.contains("..") { - continue; - } - manifest.insert(entry.path.clone(), entry.hash.clone()); - if state.content_store.contains_key(&entry.hash) { - existing_count += 1; - } else { - needed.push(entry.path.clone()); - } - } - - state.room_manifests.insert(room_id.clone(), manifest); - - info!( - "Room {room_id}: check-web-files total={}, existing={existing_count}, needed={}", - body.files.len(), - needed.len() - ); - - Ok(Json(serde_json::json!({ - "needed": needed, - "existing_count": existing_count, - "total_count": body.files.len() - }))) -} - -#[derive(Deserialize)] -struct UploadWebFilesEntry { - content: String, - hash: String, -} - -#[derive(Deserialize)] -struct UploadWebFilesRequest { - files: std::collections::HashMap, -} - -async fn upload_web_files_http( - State(state): State>, - axum::extract::Path(room_id): axum::extract::Path, - Json(body): Json, -) -> Result, axum::http::StatusCode> { - use base64::{engine::general_purpose::STANDARD as B64, Engine}; - - if !state.rooms.contains_key(&room_id) { - return Err(axum::http::StatusCode::NOT_FOUND); - } - - let mut stored = 0usize; - for (rel_path, entry) in &body.files { - if rel_path.contains("..") { - continue; - } - let decoded = B64.decode(&entry.content).map_err(|_| axum::http::StatusCode::BAD_REQUEST)?; - let actual_hash = hex_sha256(&decoded); - if actual_hash != entry.hash { - return Err(axum::http::StatusCode::BAD_REQUEST); - } - - if !state.content_store.contains_key(&actual_hash) { - state.content_store.insert(actual_hash.clone(), Arc::new(decoded)); - stored += 1; - } - - if let Some(mut manifest) = state.room_manifests.get_mut(&room_id) { - manifest.insert(rel_path.clone(), actual_hash); - } - } - - info!("Room {room_id}: upload-web-files stored {stored} new files"); - Ok(Json(serde_json::json!({ "status": "ok", "files_stored": stored }))) -} - -async fn serve_room_web_http( - State(state): State>, - axum::extract::Path(path): axum::extract::Path, -) -> Result { - use axum::body::Body; - use axum::http::header; - use axum::response::IntoResponse; - - let path = path.trim_start_matches('/'); - let (room_id, file_path) = match path.find('/') { - Some(idx) => (&path[..idx], &path[idx + 1..]), - None => (path, ""), - }; - let file_path = if file_path.is_empty() { "index.html" } else { file_path }; - let room_id = room_id.to_string(); - - let manifest = state - .room_manifests - .get(&room_id) - .ok_or(axum::http::StatusCode::NOT_FOUND)?; - - let hash = manifest - .get(file_path) - .or_else(|| manifest.get("index.html")) - .ok_or(axum::http::StatusCode::NOT_FOUND)?; - - let content = state - .content_store - .get(hash) - .ok_or(axum::http::StatusCode::NOT_FOUND)?; - - let mime = mime_from_ext(file_path); - Ok(([(header::CONTENT_TYPE, mime)], Body::from(content.value().as_ref().clone())).into_response()) -} - -fn mime_from_ext(path: &str) -> &'static str { - match path.rsplit('.').next() { - Some("html") => "text/html; charset=utf-8", - Some("js") => "application/javascript; charset=utf-8", - Some("css") => "text/css; charset=utf-8", - Some("json") => "application/json", - Some("png") => "image/png", - Some("svg") => "image/svg+xml", - Some("ico") => "image/x-icon", - Some("woff2") => "font/woff2", - Some("woff") => "font/woff", - Some("ttf") => "font/ttf", - Some("wasm") => "application/wasm", - _ => "application/octet-stream", - } -} diff --git a/src/crates/core/src/service/remote_connect/mod.rs b/src/crates/core/src/service/remote_connect/mod.rs index 034a071c..3cd6821a 100644 --- a/src/crates/core/src/service/remote_connect/mod.rs +++ b/src/crates/core/src/service/remote_connect/mod.rs @@ -315,11 +315,17 @@ impl RemoteConnectService { tokio::spawn(async move { while let Some(event) = event_rx.recv().await { match event { - relay_client::RelayEvent::PeerJoined { + relay_client::RelayEvent::PairRequest { + correlation_id, public_key, device_id, + device_name: _, } => { - info!("Peer joined: {device_id}"); + info!("PairRequest from {device_id}"); + // Allow re-pairing: clear existing server so the + // subsequent challenge-echo enters the pairing + // verification branch instead of the command branch. + *server_arc.write().await = None; let mut p = pairing_arc.write().await; match p.on_peer_joined(&public_key).await { Ok(challenge) => { @@ -330,21 +336,22 @@ impl RemoteConnectService { encryption::encrypt_to_base64(secret, &challenge_json) { if let Some(ref client) = *relay_arc.read().await { - if let Some(room) = p.room_id() { - let _ = client - .send_encrypted(room, &enc, &nonce) - .await; - } + let _ = client + .send_relay_response( + &correlation_id, &enc, &nonce, + ) + .await; } } } } Err(e) => { - error!("Pairing error on peer_joined: {e}"); + error!("Pairing error on pair_request: {e}"); } } } - relay_client::RelayEvent::MessageReceived { + relay_client::RelayEvent::CommandReceived { + correlation_id, encrypted_data, nonce, } => { @@ -361,15 +368,16 @@ impl RemoteConnectService { .encrypt_response(&response, request_id.as_deref()) { Ok((enc, resp_nonce)) => { - if let Some(ref client) = *relay_arc.read().await { - let p = pairing_arc.read().await; - if let Some(room) = p.room_id() { - let _ = client - .send_encrypted( - room, &enc, &resp_nonce, - ) - .await; - } + if let Some(ref client) = + *relay_arc.read().await + { + let _ = client + .send_relay_response( + &correlation_id, + &enc, + &resp_nonce, + ) + .await; } } Err(e) => { @@ -397,50 +405,24 @@ impl RemoteConnectService { Ok(true) => { info!("Pairing verified successfully"); if let Some(s) = pw.shared_secret() { - let (stream_tx, mut stream_rx) = tokio::sync::mpsc::unbounded_channel::(); - - let relay_for_stream = relay_arc.clone(); - let pairing_for_stream = pairing_arc.clone(); - tokio::spawn(async move { - while let Some((enc, nonce)) = - stream_rx.recv().await - { - if let Some(ref client) = - *relay_for_stream.read().await - { - let p = pairing_for_stream - .read() - .await; - if let Some(room) = p.room_id() { - let _ = client - .send_encrypted( - room, &enc, &nonce, - ) - .await; - } - } - } - }); - - let server = - RemoteServer::new(*s, stream_tx); + let server = RemoteServer::new(*s); let initial_sync = server.generate_initial_sync().await; - if let Ok((enc, nonce)) = server + if let Ok((enc, resp_nonce)) = server .encrypt_response(&initial_sync, None) { if let Some(ref client) = *relay_arc.read().await { - if let Some(room) = pw.room_id() { - info!("Sending initial sync to mobile after pairing"); - let _ = client - .send_encrypted( - room, &enc, &nonce, - ) - .await; - } + info!("Sending initial sync to mobile after pairing"); + let _ = client + .send_relay_response( + &correlation_id, + &enc, + &resp_nonce, + ) + .await; } } @@ -459,18 +441,13 @@ impl RemoteConnectService { } } } - relay_client::RelayEvent::PeerDisconnected { device_id } => { - info!("Peer disconnected: {device_id}"); - pairing_arc.write().await.disconnect().await; - *server_arc.write().await = None; - } relay_client::RelayEvent::Reconnected => { - info!("Relay reconnected, resetting pairing state"); - pairing_arc.write().await.disconnect().await; - *server_arc.write().await = None; + info!("Relay reconnected — pairing + server preserved for mobile polling"); } relay_client::RelayEvent::Disconnected => { info!("Relay disconnected"); + pairing_arc.write().await.disconnect().await; + *server_arc.write().await = None; } relay_client::RelayEvent::Error { message } => { error!("Relay error: {message}"); 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 450c5280..bcbe5c83 100644 --- a/src/crates/core/src/service/remote_connect/relay_client.rs +++ b/src/crates/core/src/service/remote_connect/relay_client.rs @@ -1,11 +1,12 @@ //! WebSocket client for connecting to the Relay Server. //! -//! Manages the desktop-side WebSocket connection, sends/receives relay protocol messages, -//! and dispatches events to the pairing and session bridge layers. +//! Manages the desktop-side WebSocket connection. In the new architecture the +//! relay bridges HTTP requests from mobile to the desktop via WebSocket. +//! The desktop receives `PairRequest` and `Command` messages (with correlation +//! IDs) and responds with `RelayResponse`. //! -//! Supports automatic reconnect: when the connection drops, it retries with exponential -//! backoff and re-creates the same room (same room_id + public_key) so that in-flight -//! QR codes remain valid. +//! Supports automatic reconnect with exponential backoff and room re-creation +//! so that in-flight QR codes remain valid. use anyhow::{anyhow, Result}; use futures_util::{SinkExt, StreamExt}; @@ -23,36 +24,39 @@ type WsStream = tokio_tungstenite::WebSocketStream< #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "snake_case")] pub enum RelayMessage { + // ── Outbound (desktop → relay) ────────────────────────────────── CreateRoom { room_id: Option, device_id: String, device_type: String, public_key: String, }, + /// Respond to a bridged HTTP request identified by `correlation_id`. + RelayResponse { + correlation_id: String, + encrypted_data: String, + nonce: String, + }, + Heartbeat, + + // ── Inbound (relay → desktop) ─────────────────────────────────── RoomCreated { room_id: String, }, - JoinRoom { - room_id: String, - device_id: String, - device_type: String, + /// Mobile pairing request forwarded by the relay. + PairRequest { + correlation_id: String, public_key: String, - }, - PeerJoined { device_id: String, - device_type: String, - public_key: String, + device_name: String, }, - Relay { - room_id: String, + /// Encrypted command from mobile forwarded by the relay. + Command { + correlation_id: String, encrypted_data: String, nonce: String, }, - Heartbeat, HeartbeatAck, - PeerDisconnected { - device_id: String, - }, Error { message: String, }, @@ -62,14 +66,27 @@ pub enum RelayMessage { #[derive(Debug, Clone)] pub enum RelayEvent { Connected, - RoomCreated { room_id: String }, - PeerJoined { public_key: String, device_id: String }, - MessageReceived { encrypted_data: String, nonce: String }, - PeerDisconnected { device_id: String }, - /// Emitted after a successful automatic reconnect + room recreation. + RoomCreated { + room_id: String, + }, + /// Mobile wants to pair. + PairRequest { + correlation_id: String, + public_key: String, + device_id: String, + device_name: String, + }, + /// Mobile sent an encrypted command. + CommandReceived { + correlation_id: String, + encrypted_data: String, + nonce: String, + }, Reconnected, Disconnected, - Error { message: String }, + Error { + message: String, + }, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -80,7 +97,6 @@ pub enum ConnectionState { Reconnecting, } -/// Information kept to rebuild the room after a reconnect. #[derive(Debug, Clone, Default)] struct ReconnectCtx { ws_url: String, @@ -114,7 +130,6 @@ impl RelayClient { self.state.read().await.clone() } - /// Connect to the relay server WebSocket endpoint and start background tasks. pub async fn connect(&self, ws_url: &str) -> Result<()> { *self.state.write().await = ConnectionState::Connecting; @@ -123,7 +138,6 @@ impl RelayClient { info!("Connected to relay server at {ws_url}"); *self.state.write().await = ConnectionState::Connected; - // Record the ws_url for future reconnect *self.reconnect_ctx.write().await = Some(ReconnectCtx { ws_url: ws_url.to_string(), ..Default::default() @@ -134,22 +148,19 @@ impl RelayClient { Ok(()) } - /// Wire up read / write / heartbeat tasks for a live stream. async fn launch_tasks(&self, ws_stream: WsStream) { let (mut ws_write, ws_read) = ws_stream.split(); let (cmd_tx, mut cmd_rx) = mpsc::unbounded_channel::(); - // Share cmd_tx so `send()` / `create_room()` / heartbeat can enqueue messages let cmd_tx_arc = self.cmd_tx.clone(); let state_arc = self.state.clone(); let room_id_arc = self.room_id.clone(); let event_tx = self.event_tx.clone(); let reconnect_arc = self.reconnect_ctx.clone(); - // Store cmd_tx immediately (before spawning tasks, to avoid race with create_room) *cmd_tx_arc.write().await = Some(cmd_tx); - // ── Write task ──────────────────────────────────────────────────────── + // ── Write task ────────────────────────────────────────────────────── tokio::spawn(async move { while let Some(msg) = cmd_rx.recv().await { if let Ok(json) = serde_json::to_string(&msg) { @@ -161,11 +172,10 @@ impl RelayClient { debug!("Write task exited"); }); - // ── Read task with reconnect loop ───────────────────────────────────── + // ── Read task with reconnect loop ─────────────────────────────────── let mut ws_read = ws_read; tokio::spawn(async move { 'outer: loop { - // Read messages until connection drops while let Some(res) = ws_read.next().await { match res { Ok(Message::Text(text)) => { @@ -191,7 +201,6 @@ impl RelayClient { } } - // Drop detected — enter reconnect loop *state_arc.write().await = ConnectionState::Reconnecting; info!("Relay connection dropped; will attempt reconnect"); @@ -224,19 +233,16 @@ impl RelayClient { mpsc::unbounded_channel::(); *cmd_tx_arc.write().await = Some(new_cmd_tx.clone()); - // New write task tokio::spawn(async move { while let Some(msg) = new_cmd_rx.recv().await { if let Ok(json) = serde_json::to_string(&msg) { - if new_write.send(Message::Text(json)).await.is_err() - { + if new_write.send(Message::Text(json)).await.is_err() { break; } } } }); - // Re-create the room so existing QR codes remain valid if !ctx.room_id.is_empty() { let recreate = RelayMessage::CreateRoom { room_id: Some(ctx.room_id.clone()), @@ -264,7 +270,7 @@ impl RelayClient { let _ = event_tx.send(RelayEvent::Disconnected); }); - // ── Heartbeat task ──────────────────────────────────────────────────── + // ── Heartbeat task ────────────────────────────────────────────────── let hb_state = self.state.clone(); let hb_cmd = self.cmd_tx.clone(); tokio::spawn(async move { @@ -275,7 +281,7 @@ impl RelayClient { break; } if st != ConnectionState::Connected { - continue; // Don't heartbeat while reconnecting + continue; } if let Some(tx) = hb_cmd.read().await.as_ref() { let _ = tx.send(RelayMessage::Heartbeat); @@ -295,16 +301,31 @@ impl RelayClient { *room_id_store.write().await = Some(room_id.clone()); let _ = event_tx.send(RelayEvent::RoomCreated { room_id }); } - RelayMessage::PeerJoined { device_id, public_key, .. } => { - info!("Peer joined: {device_id}"); - let _ = event_tx.send(RelayEvent::PeerJoined { public_key, device_id }); - } - RelayMessage::Relay { encrypted_data, nonce, .. } => { - let _ = event_tx.send(RelayEvent::MessageReceived { encrypted_data, nonce }); + RelayMessage::PairRequest { + correlation_id, + public_key, + device_id, + device_name, + } => { + info!("PairRequest from {device_id}"); + let _ = event_tx.send(RelayEvent::PairRequest { + correlation_id, + public_key, + device_id, + device_name, + }); } - RelayMessage::PeerDisconnected { device_id } => { - info!("Peer disconnected: {device_id}"); - let _ = event_tx.send(RelayEvent::PeerDisconnected { device_id }); + RelayMessage::Command { + correlation_id, + encrypted_data, + nonce, + } => { + debug!("Command received, corr={correlation_id}"); + let _ = event_tx.send(RelayEvent::CommandReceived { + correlation_id, + encrypted_data, + nonce, + }); } RelayMessage::HeartbeatAck => { debug!("Heartbeat acknowledged"); @@ -317,7 +338,6 @@ impl RelayClient { } } - /// Send a protocol message to the relay server. pub async fn send(&self, msg: RelayMessage) -> Result<()> { let guard = self.cmd_tx.read().await; let tx = guard.as_ref().ok_or_else(|| anyhow!("not connected"))?; @@ -325,17 +345,12 @@ impl RelayClient { Ok(()) } - /// Create a room on the relay server. - /// - /// Also records the device_id / room_id / public_key in the reconnect context - /// so the room is automatically recreated after a transient disconnect. pub async fn create_room( &self, device_id: &str, public_key: &str, room_id: Option<&str>, ) -> Result<()> { - // Update reconnect context with room params if let Some(rid) = room_id { let mut guard = self.reconnect_ctx.write().await; if let Some(ref mut ctx) = *guard { @@ -354,15 +369,15 @@ impl RelayClient { .await } - /// Send an E2E-encrypted relay message. - pub async fn send_encrypted( + /// Send a relay response back to the relay server for a bridged HTTP request. + pub async fn send_relay_response( &self, - room_id: &str, + correlation_id: &str, encrypted_data: &str, nonce: &str, ) -> Result<()> { - self.send(RelayMessage::Relay { - room_id: room_id.to_string(), + self.send(RelayMessage::RelayResponse { + correlation_id: correlation_id.to_string(), encrypted_data: encrypted_data.to_string(), nonce: nonce.to_string(), }) @@ -370,7 +385,6 @@ impl RelayClient { } pub async fn disconnect(&self) { - // Signal reconnect loop to stop by clearing the context and setting state *self.state.write().await = ConnectionState::Disconnected; *self.reconnect_ctx.write().await = None; *self.cmd_tx.write().await = None; @@ -382,7 +396,6 @@ impl RelayClient { } } -/// Open a plain WebSocket connection (no TLS negotiation needed — nginx handles TLS). async fn dial(ws_url: &str) -> Result { let (stream, _) = tokio_tungstenite::connect_async(ws_url) .await 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 b7c6764e..aa218424 100644 --- a/src/crates/core/src/service/remote_connect/remote_server.rs +++ b/src/crates/core/src/service/remote_connect/remote_server.rs @@ -1,18 +1,20 @@ //! Session bridge: translates remote commands into local session operations. //! -//! The mobile client sends encrypted commands (list sessions, send message, etc.) -//! which are decrypted and dispatched to the local SessionManager via the global -//! ConversationCoordinator. +//! Mobile clients send encrypted commands via the relay (HTTP → WS bridge). +//! The desktop decrypts, dispatches, and returns encrypted responses. //! -//! After a SendMessage command, a `RemoteEventForwarder` is registered as an -//! internal event subscriber so that streaming progress (text chunks, tool events, -//! turn completion, etc.) is encrypted and relayed back to the mobile client. +//! Instead of streaming events to the mobile, the desktop maintains an +//! in-memory `RemoteSessionStateTracker` per session. The mobile polls +//! for state changes using the `PollSession` command, receiving only +//! incremental updates (new messages + current active turn snapshot). use anyhow::{anyhow, Result}; +use dashmap::DashMap; use log::{debug, error, info}; use serde::{Deserialize, Serialize}; use serde_json::Value; -use tokio::sync::mpsc; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, RwLock}; use super::encryption; @@ -33,18 +35,13 @@ pub enum RemoteCommand { path: String, }, ListSessions { - /// Filter by workspace path. If omitted, falls back to the desktop's current workspace. workspace_path: Option, - /// Max sessions to return per page (default 30, max 100). limit: Option, - /// Zero-based offset for pagination. offset: Option, }, CreateSession { agent_type: Option, session_name: Option, - /// Workspace to bind the new session to. Falls back to the desktop's - /// current workspace when not provided. workspace_path: Option, }, GetSessionMessages { @@ -55,10 +52,7 @@ pub enum RemoteCommand { SendMessage { session_id: String, content: String, - /// When provided, overrides the session's current agent type for this - /// turn (e.g. "agentic", "Plan", "debug"). agent_type: Option, - /// Images attached by the mobile user (base64 data-URL). images: Option>, }, CancelTask { @@ -67,11 +61,16 @@ pub enum RemoteCommand { DeleteSession { session_id: String, }, - SubscribeSession { - session_id: String, + /// Submit answers for an AskUserQuestion tool. + AnswerQuestion { + tool_id: String, + answers: serde_json::Value, }, - UnsubscribeSession { + /// Incremental poll — returns only what changed since `since_version`. + PollSession { session_id: String, + since_version: u64, + known_msg_count: usize, }, Ping, } @@ -97,7 +96,6 @@ pub enum RemoteResponse { }, SessionList { sessions: Vec, - /// Whether more sessions exist beyond this page. has_more: bool, }, SessionCreated { @@ -112,26 +110,13 @@ pub enum RemoteResponse { session_id: String, turn_id: String, }, - StreamEvent { - session_id: String, - event_type: String, - payload: serde_json::Value, - }, TaskCancelled { session_id: String, }, SessionDeleted { session_id: String, }, - SessionSubscribed { - session_id: String, - }, - SessionUnsubscribed { - session_id: String, - }, - /// Pushed to mobile immediately after pairing – contains the desktop's - /// current workspace info and session list so the mobile can display the - /// same data as the desktop without extra round-trips. + /// Pushed to mobile immediately after pairing. InitialSync { has_workspace: bool, #[serde(skip_serializing_if = "Option::is_none")] @@ -143,6 +128,22 @@ pub enum RemoteResponse { sessions: Vec, has_more_sessions: bool, }, + /// Incremental poll response. + SessionPoll { + version: u64, + changed: bool, + #[serde(skip_serializing_if = "Option::is_none")] + session_state: Option, + #[serde(skip_serializing_if = "Option::is_none")] + title: Option, + #[serde(skip_serializing_if = "Option::is_none")] + new_messages: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + total_msg_count: Option, + #[serde(skip_serializing_if = "Option::is_none")] + active_turn: Option, + }, + AnswerAccepted, Pong, Error { message: String, @@ -157,10 +158,8 @@ pub struct SessionInfo { pub created_at: String, pub updated_at: String, pub message_count: usize, - /// Workspace path this session belongs to #[serde(skip_serializing_if = "Option::is_none")] pub workspace_path: Option, - /// Workspace display name (last path component) #[serde(skip_serializing_if = "Option::is_none")] pub workspace_name: Option, } @@ -172,6 +171,23 @@ pub struct ChatMessage { pub content: String, pub timestamp: String, pub metadata: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub tools: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub thinking: Option, + /// Ordered items preserving the interleaved display order from the desktop. + #[serde(skip_serializing_if = "Option::is_none")] + pub items: Option>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ChatMessageItem { + #[serde(rename = "type")] + pub item_type: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub content: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub tool: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -181,29 +197,196 @@ pub struct RecentWorkspaceEntry { pub last_opened: String, } -/// An encrypted (data, nonce) pair ready to be sent over the relay. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ActiveTurnSnapshot { + pub turn_id: String, + pub status: String, + pub text: String, + pub thinking: String, + pub tools: Vec, + pub round_index: usize, + #[serde(skip_serializing_if = "Option::is_none")] + pub items: Option>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RemoteToolStatus { + pub id: String, + pub name: String, + pub status: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub duration_ms: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub start_ms: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub input_preview: Option, + /// Full tool input for interactive tools (e.g. AskUserQuestion). + #[serde(skip_serializing_if = "Option::is_none")] + pub tool_input: Option, +} + pub type EncryptedPayload = (String, String); -/// Strip XML wrapper tags that the agent system adds to user input before storage. -/// e.g. "\nHello\n\n..." -/// → "Hello" +/// Convert ConversationPersistenceManager turns into mobile ChatMessages. +/// This is the same data source the desktop frontend uses. +fn turns_to_chat_messages( + turns: &[crate::service::conversation::DialogTurnData], +) -> Vec { + let mut result = Vec::new(); + + for turn in turns { + result.push(ChatMessage { + id: turn.user_message.id.clone(), + role: "user".to_string(), + content: strip_user_input_tags(&turn.user_message.content), + timestamp: (turn.user_message.timestamp / 1000).to_string(), + metadata: None, + tools: None, + thinking: None, + items: None, + }); + + // Collect ordered items across all rounds, preserving interleaved order + struct OrderedEntry { + order_index: usize, + item: ChatMessageItem, + } + let mut ordered: Vec = Vec::new(); + let mut tools_flat = Vec::new(); + let mut thinking_parts = Vec::new(); + let mut text_parts = Vec::new(); + + for round in &turn.model_rounds { + for t in &round.text_items { + if t.is_subagent_item.unwrap_or(false) { + continue; + } + if !t.content.is_empty() { + text_parts.push(t.content.clone()); + ordered.push(OrderedEntry { + order_index: t.order_index.unwrap_or(usize::MAX), + item: ChatMessageItem { + item_type: "text".to_string(), + content: Some(t.content.clone()), + tool: None, + }, + }); + } + } + for t in &round.thinking_items { + if t.is_subagent_item.unwrap_or(false) { + continue; + } + if !t.content.is_empty() { + thinking_parts.push(t.content.clone()); + ordered.push(OrderedEntry { + order_index: t.order_index.unwrap_or(usize::MAX), + item: ChatMessageItem { + item_type: "thinking".to_string(), + content: Some(t.content.clone()), + tool: None, + }, + }); + } + } + for t in &round.tool_items { + if t.is_subagent_item.unwrap_or(false) { + continue; + } + let status_str = t.status.as_deref().unwrap_or( + if t.tool_result.is_some() { + "completed" + } else { + "running" + }, + ); + let tool_status = RemoteToolStatus { + id: t.id.clone(), + name: t.tool_name.clone(), + status: status_str.to_string(), + duration_ms: t.duration_ms, + start_ms: Some(t.start_time), + input_preview: None, + tool_input: None, + }; + tools_flat.push(tool_status.clone()); + ordered.push(OrderedEntry { + order_index: t.order_index.unwrap_or(usize::MAX), + item: ChatMessageItem { + item_type: "tool".to_string(), + content: None, + tool: Some(tool_status), + }, + }); + } + } + + ordered.sort_by_key(|e| e.order_index); + let items: Vec = ordered.into_iter().map(|e| e.item).collect(); + + let ts = turn + .model_rounds + .last() + .map(|r| r.end_time.unwrap_or(r.start_time)) + .unwrap_or(turn.start_time); + + result.push(ChatMessage { + id: format!("{}_assistant", turn.turn_id), + role: "assistant".to_string(), + content: text_parts.join("\n\n"), + timestamp: (ts / 1000).to_string(), + metadata: None, + tools: if tools_flat.is_empty() { None } else { Some(tools_flat) }, + thinking: if thinking_parts.is_empty() { + None + } else { + Some(thinking_parts.join("\n\n")) + }, + items: if items.is_empty() { None } else { Some(items) }, + }); + } + + result +} + +/// Load historical chat messages from ConversationPersistenceManager. +/// Uses the same data source as the desktop frontend. +async fn load_chat_messages_from_conversation_persistence( + session_id: &str, +) -> (Vec, bool) { + use crate::infrastructure::{get_workspace_path, PathManager}; + use crate::service::conversation::ConversationPersistenceManager; + + let Some(wp) = get_workspace_path() else { + return (vec![], false); + }; + let Ok(pm) = PathManager::new() else { + return (vec![], false); + }; + let pm = std::sync::Arc::new(pm); + let Ok(conv_mgr) = ConversationPersistenceManager::new(pm, wp).await else { + return (vec![], false); + }; + let Ok(turns) = conv_mgr.load_session_turns(session_id).await else { + return (vec![], false); + }; + (turns_to_chat_messages(&turns), false) +} + fn strip_user_input_tags(content: &str) -> String { let s = content.trim(); - // Extract inner content of ... if s.starts_with("") { if let Some(end) = s.find("") { let inner = s["".len()..end].trim(); return inner.to_string(); } } - // Drop section (can appear without wrapper) if let Some(pos) = s.find("") { return s[..pos].trim().to_string(); } s.to_string() } -/// Map mobile-friendly agent type names to the actual agent registry IDs. fn resolve_agent_type(mobile_type: Option<&str>) -> &'static str { match mobile_type { Some("code") | Some("agentic") | Some("Agentic") => "agentic", @@ -214,8 +397,6 @@ fn resolve_agent_type(mobile_type: Option<&str>) -> &'static str { } } -/// Decode a `data:image/...;base64,...` URL and save it as a file. -/// Returns the absolute path to the saved image on success. fn save_data_url_image( dir: &std::path::Path, name: &str, @@ -248,29 +429,348 @@ fn save_data_url_image( Some(path) } +// ── RemoteSessionStateTracker ────────────────────────────────────── + +/// Mutable state snapshot updated by the event subscriber. +#[derive(Debug)] +struct TrackerState { + session_state: String, + title: String, + turn_id: Option, + turn_status: String, + accumulated_text: String, + accumulated_thinking: String, + active_tools: Vec, + round_index: usize, + /// Ordered items preserving the interleaved arrival order for real-time display. + active_items: Vec, +} + +/// Tracks the real-time state of a session for polling by the mobile client. +/// Subscribes to `AgenticEvent` and updates an in-memory snapshot. +pub struct RemoteSessionStateTracker { + target_session_id: String, + version: AtomicU64, + state: RwLock, +} + +impl RemoteSessionStateTracker { + pub fn new(session_id: String) -> Self { + Self { + target_session_id: session_id, + version: AtomicU64::new(0), + state: RwLock::new(TrackerState { + session_state: "idle".to_string(), + title: String::new(), + turn_id: None, + turn_status: String::new(), + accumulated_text: String::new(), + accumulated_thinking: String::new(), + active_tools: Vec::new(), + round_index: 0, + active_items: Vec::new(), + }), + } + } + + pub fn version(&self) -> u64 { + self.version.load(Ordering::Relaxed) + } + + fn bump_version(&self) { + self.version.fetch_add(1, Ordering::Relaxed); + } + + pub fn snapshot_active_turn(&self) -> Option { + let s = self.state.read().unwrap(); + s.turn_id.as_ref().map(|tid| ActiveTurnSnapshot { + turn_id: tid.clone(), + status: s.turn_status.clone(), + text: s.accumulated_text.clone(), + thinking: s.accumulated_thinking.clone(), + tools: s.active_tools.clone(), + round_index: s.round_index, + items: if s.active_items.is_empty() { None } else { Some(s.active_items.clone()) }, + }) + } + + pub fn session_state(&self) -> String { + self.state.read().unwrap().session_state.clone() + } + + pub fn title(&self) -> String { + self.state.read().unwrap().title.clone() + } + + fn handle_event(&self, event: &crate::agentic::events::AgenticEvent) { + use bitfun_events::AgenticEvent as AE; + + let is_direct = event.session_id() == Some(self.target_session_id.as_str()); + let is_subagent = if !is_direct { + match event { + AE::TextChunk { subagent_parent_info, .. } + | AE::ThinkingChunk { subagent_parent_info, .. } + | AE::ToolEvent { subagent_parent_info, .. } => subagent_parent_info + .as_ref() + .map_or(false, |p| p.session_id == self.target_session_id), + _ => false, + } + } else { + false + }; + + if !is_direct && !is_subagent { + return; + } + + match event { + AE::TextChunk { text, .. } => { + let mut s = self.state.write().unwrap(); + s.accumulated_text.push_str(text); + if let Some(last) = s.active_items.last_mut() { + if last.item_type == "text" { + let c = last.content.get_or_insert_with(String::new); + c.push_str(text); + } else { + s.active_items.push(ChatMessageItem { + item_type: "text".to_string(), + content: Some(text.clone()), + tool: None, + }); + } + } else { + s.active_items.push(ChatMessageItem { + item_type: "text".to_string(), + content: Some(text.clone()), + tool: None, + }); + } + drop(s); + self.bump_version(); + } + AE::ThinkingChunk { content, .. } => { + let clean = content + .replace("", "") + .replace("", "") + .replace("", ""); + let mut s = self.state.write().unwrap(); + s.accumulated_thinking.push_str(&clean); + if let Some(last) = s.active_items.last_mut() { + if last.item_type == "thinking" { + let c = last.content.get_or_insert_with(String::new); + c.push_str(&clean); + } else { + s.active_items.push(ChatMessageItem { + item_type: "thinking".to_string(), + content: Some(clean), + tool: None, + }); + } + } else { + s.active_items.push(ChatMessageItem { + item_type: "thinking".to_string(), + content: Some(clean), + tool: None, + }); + } + drop(s); + self.bump_version(); + } + AE::ToolEvent { tool_event, .. } => { + if let Ok(val) = serde_json::to_value(tool_event) { + let event_type = val + .get("event_type") + .and_then(|v| v.as_str()) + .unwrap_or(""); + let tool_id = val + .get("tool_id") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let tool_name = val + .get("tool_name") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + + let mut s = self.state.write().unwrap(); + match event_type { + "Started" => { + let input_preview = val + .get("input") + .and_then(|v| v.as_str()) + .map(|s| s.chars().take(100).collect()); + let tool_input = if tool_name == "AskUserQuestion" { + val.get("params").cloned() + } else { + None + }; + let tool_count = s.active_tools.len(); + let resolved_id = if tool_id.is_empty() { + format!("{}-{}", tool_name, tool_count) + } else { + tool_id + }; + let tool_status = RemoteToolStatus { + id: resolved_id, + name: tool_name, + status: "running".to_string(), + duration_ms: None, + start_ms: Some( + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64, + ), + input_preview, + tool_input, + }; + s.active_items.push(ChatMessageItem { + item_type: "tool".to_string(), + content: None, + tool: Some(tool_status.clone()), + }); + s.active_tools.push(tool_status); + } + "Completed" | "Succeeded" => { + let duration = val + .get("duration_ms") + .and_then(|v| v.as_u64()); + if let Some(t) = s.active_tools.iter_mut().rev().find(|t| { + (t.id == tool_id || t.name == tool_name) && t.status == "running" + }) { + t.status = "completed".to_string(); + t.duration_ms = duration; + } + if let Some(item) = s.active_items.iter_mut().rev().find(|i| { + i.item_type == "tool" && i.tool.as_ref().map_or(false, |t| (t.id == tool_id || t.name == tool_name) && t.status == "running") + }) { + if let Some(t) = item.tool.as_mut() { + t.status = "completed".to_string(); + t.duration_ms = duration; + } + } + } + "Failed" => { + if let Some(t) = s.active_tools.iter_mut().rev().find(|t| { + (t.id == tool_id || t.name == tool_name) && t.status == "running" + }) { + t.status = "failed".to_string(); + } + if let Some(item) = s.active_items.iter_mut().rev().find(|i| { + i.item_type == "tool" && i.tool.as_ref().map_or(false, |t| (t.id == tool_id || t.name == tool_name) && t.status == "running") + }) { + if let Some(t) = item.tool.as_mut() { + t.status = "failed".to_string(); + } + } + } + _ => {} + } + drop(s); + self.bump_version(); + } + } + AE::DialogTurnStarted { turn_id, .. } if is_direct => { + let mut s = self.state.write().unwrap(); + s.turn_id = Some(turn_id.clone()); + s.turn_status = "active".to_string(); + s.accumulated_text.clear(); + s.accumulated_thinking.clear(); + s.active_tools.clear(); + s.active_items.clear(); + s.round_index = 0; + s.session_state = "running".to_string(); + drop(s); + self.bump_version(); + } + AE::DialogTurnCompleted { .. } if is_direct => { + let mut s = self.state.write().unwrap(); + s.turn_status = "completed".to_string(); + s.turn_id = None; + s.accumulated_text.clear(); + s.accumulated_thinking.clear(); + s.active_tools.clear(); + s.active_items.clear(); + s.session_state = "idle".to_string(); + drop(s); + self.bump_version(); + } + AE::DialogTurnFailed { .. } if is_direct => { + let mut s = self.state.write().unwrap(); + s.turn_status = "failed".to_string(); + s.turn_id = None; + s.session_state = "idle".to_string(); + drop(s); + self.bump_version(); + } + AE::DialogTurnCancelled { .. } if is_direct => { + let mut s = self.state.write().unwrap(); + s.turn_status = "cancelled".to_string(); + s.turn_id = None; + s.session_state = "idle".to_string(); + drop(s); + self.bump_version(); + } + AE::ModelRoundStarted { round_index, .. } if is_direct => { + let mut s = self.state.write().unwrap(); + s.round_index = *round_index; + drop(s); + self.bump_version(); + } + AE::SessionStateChanged { new_state, .. } if is_direct => { + let mut s = self.state.write().unwrap(); + s.session_state = new_state.clone(); + drop(s); + self.bump_version(); + } + AE::SessionTitleGenerated { title, .. } if is_direct => { + let mut s = self.state.write().unwrap(); + s.title = title.clone(); + drop(s); + self.bump_version(); + } + _ => {} + } + } +} + +#[async_trait::async_trait] +impl crate::agentic::events::EventSubscriber for Arc { + async fn on_event( + &self, + event: &crate::agentic::events::AgenticEvent, + ) -> crate::util::errors::BitFunResult<()> { + self.handle_event(event); + Ok(()) + } +} + +// ── RemoteServer ─────────────────────────────────────────────────── + /// Bridges remote commands to local session operations. pub struct RemoteServer { shared_secret: [u8; 32], - active_subscriptions: std::sync::Mutex>, - stream_tx: mpsc::UnboundedSender, + state_trackers: Arc>>, } impl Drop for RemoteServer { fn drop(&mut self) { - if let Ok(subs) = self.active_subscriptions.lock() { - for sub_id in subs.iter() { - unregister_stream_forwarder(sub_id); + use crate::agentic::coordination::get_global_coordinator; + if let Some(coordinator) = get_global_coordinator() { + for entry in self.state_trackers.iter() { + let sub_id = format!("remote_tracker_{}", entry.key()); + coordinator.unsubscribe_internal(&sub_id); } } } } impl RemoteServer { - pub fn new(shared_secret: [u8; 32], stream_tx: mpsc::UnboundedSender) -> Self { + pub fn new(shared_secret: [u8; 32]) -> Self { Self { shared_secret, - active_subscriptions: std::sync::Mutex::new(std::collections::HashSet::new()), - stream_tx, + state_trackers: Arc::new(DashMap::new()), } } @@ -311,35 +811,45 @@ impl RemoteServer { pub async fn dispatch(&self, cmd: &RemoteCommand) -> RemoteResponse { match cmd { RemoteCommand::Ping => RemoteResponse::Pong, - - RemoteCommand::GetWorkspaceInfo | - RemoteCommand::ListRecentWorkspaces | - RemoteCommand::SetWorkspace { .. } => { - self.handle_workspace_command(cmd).await - } - RemoteCommand::ListSessions { .. } | - RemoteCommand::CreateSession { .. } | - RemoteCommand::GetSessionMessages { .. } | - RemoteCommand::DeleteSession { .. } => { - self.handle_session_command(cmd).await - } + RemoteCommand::GetWorkspaceInfo + | RemoteCommand::ListRecentWorkspaces + | RemoteCommand::SetWorkspace { .. } => self.handle_workspace_command(cmd).await, + + RemoteCommand::ListSessions { .. } + | RemoteCommand::CreateSession { .. } + | RemoteCommand::GetSessionMessages { .. } + | RemoteCommand::DeleteSession { .. } => self.handle_session_command(cmd).await, - RemoteCommand::SendMessage { .. } | - RemoteCommand::CancelTask { .. } => { + RemoteCommand::SendMessage { .. } + | RemoteCommand::CancelTask { .. } + | RemoteCommand::AnswerQuestion { .. } => { self.handle_execution_command(cmd).await } - RemoteCommand::SubscribeSession { .. } | - RemoteCommand::UnsubscribeSession { .. } => { - self.handle_subscription_command(cmd).await - } + RemoteCommand::PollSession { .. } => self.handle_poll_command(cmd).await, } } - /// Build the initial sync payload that is pushed to the mobile right after - /// pairing completes. This reads the same disk source as the desktop UI's - /// `get_conversation_sessions` so the session lists are guaranteed consistent. + /// Ensure a state tracker exists for the given session and return it. + fn ensure_tracker(&self, session_id: &str) -> Arc { + if let Some(tracker) = self.state_trackers.get(session_id) { + return tracker.clone(); + } + + let tracker = Arc::new(RemoteSessionStateTracker::new(session_id.to_string())); + self.state_trackers + .insert(session_id.to_string(), tracker.clone()); + + if let Some(coordinator) = crate::agentic::coordination::get_global_coordinator() { + let sub_id = format!("remote_tracker_{}", session_id); + coordinator.subscribe_internal(sub_id, tracker.clone()); + info!("Registered state tracker for session {session_id}"); + } + + tracker + } + pub async fn generate_initial_sync(&self) -> RemoteResponse { use crate::infrastructure::{get_workspace_path, PathManager}; use crate::service::conversation::ConversationPersistenceManager; @@ -403,6 +913,59 @@ impl RemoteServer { } } + // ── Poll command handler ──────────────────────────────────────── + + async fn handle_poll_command(&self, cmd: &RemoteCommand) -> RemoteResponse { + let RemoteCommand::PollSession { + session_id, + since_version, + known_msg_count, + } = cmd + else { + return RemoteResponse::Error { + message: "expected poll_session".into(), + }; + }; + + let tracker = self.ensure_tracker(session_id); + let current_version = tracker.version(); + + if *since_version == current_version && *since_version > 0 { + return RemoteResponse::SessionPoll { + version: current_version, + changed: false, + session_state: None, + title: None, + new_messages: None, + total_msg_count: None, + active_turn: None, + }; + } + + let (all_chat_msgs, _) = + load_chat_messages_from_conversation_persistence(session_id).await; + let total_msg_count = all_chat_msgs.len(); + let skip = *known_msg_count; + let new_messages: Vec = + all_chat_msgs.into_iter().skip(skip).collect(); + + let active_turn = tracker.snapshot_active_turn(); + let sess_state = tracker.session_state(); + let title = tracker.title(); + + RemoteResponse::SessionPoll { + version: current_version, + changed: true, + session_state: Some(sess_state), + title: if title.is_empty() { None } else { Some(title) }, + new_messages: Some(new_messages), + total_msg_count: Some(total_msg_count), + active_turn, + } + } + + // ── Workspace commands ────────────────────────────────────────── + async fn handle_workspace_command(&self, cmd: &RemoteCommand) -> RemoteResponse { use crate::infrastructure::get_workspace_path; use crate::service::workspace::get_global_workspace_service; @@ -411,9 +974,7 @@ impl RemoteServer { RemoteCommand::GetWorkspaceInfo => { let ws_path = get_workspace_path(); let (project_name, git_branch) = if let Some(ref p) = ws_path { - let name = p - .file_name() - .map(|n| n.to_string_lossy().to_string()); + let name = p.file_name().map(|n| n.to_string_lossy().to_string()); let branch = git2::Repository::open(p) .ok() .and_then(|repo| { @@ -474,7 +1035,9 @@ impl RemoteServer { ) .await { - error!("Failed to initialize snapshot after remote workspace set: {e}"); + error!( + "Failed to initialize snapshot after remote workspace set: {e}" + ); } RemoteResponse::WorkspaceUpdated { success: true, @@ -491,10 +1054,14 @@ impl RemoteServer { }, } } - _ => RemoteResponse::Error { message: "Unknown workspace command".into() }, + _ => RemoteResponse::Error { + message: "Unknown workspace command".into(), + }, } } + // ── Session commands ──────────────────────────────────────────── + async fn handle_session_command(&self, cmd: &RemoteCommand) -> RemoteResponse { use crate::agentic::{coordination::get_global_coordinator, core::SessionConfig}; @@ -508,18 +1075,17 @@ impl RemoteServer { }; match cmd { - RemoteCommand::ListSessions { workspace_path, limit, offset } => { - // Only query the explicitly-requested workspace (or fall back to the - // desktop's current workspace when none is specified). Sessions are - // served page-by-page so the frontend never needs to receive more than - // it intends to display. + RemoteCommand::ListSessions { + workspace_path, + limit, + offset, + } => { use crate::infrastructure::{get_workspace_path, PathManager}; use crate::service::conversation::ConversationPersistenceManager; let page_size = limit.unwrap_or(30).min(100); let page_offset = offset.unwrap_or(0); - // Resolve which workspace to query let effective_ws: Option = workspace_path .as_deref() .map(std::path::PathBuf::from) @@ -527,8 +1093,8 @@ impl RemoteServer { if let Some(ref wp) = effective_ws { let ws_str = wp.to_string_lossy().to_string(); - let workspace_name = wp.file_name() - .map(|n| n.to_string_lossy().to_string()); + let workspace_name = + wp.file_name().map(|n| n.to_string_lossy().to_string()); if let Ok(pm) = PathManager::new() { let pm = std::sync::Arc::new(pm); @@ -536,8 +1102,6 @@ impl RemoteServer { Ok(conv_mgr) => { match conv_mgr.get_session_list().await { Ok(all_meta) => { - // The list is already sorted by last_active_at desc - // at persistence time; apply server-side pagination. let total = all_meta.len(); let has_more = page_offset + page_size < total; let sessions: Vec = all_meta @@ -545,8 +1109,10 @@ impl RemoteServer { .skip(page_offset) .take(page_size) .map(|s| { - let created = (s.created_at / 1000).to_string(); - let updated = (s.last_active_at / 1000).to_string(); + let created = + (s.created_at / 1000).to_string(); + let updated = + (s.last_active_at / 1000).to_string(); SessionInfo { session_id: s.session_id, name: s.session_name, @@ -559,17 +1125,25 @@ impl RemoteServer { } }) .collect(); - return RemoteResponse::SessionList { sessions, has_more }; + return RemoteResponse::SessionList { + sessions, + has_more, + }; + } + Err(e) => { + debug!("Session list read failed for {ws_str}: {e}") } - Err(e) => debug!("Session list read failed for {ws_str}: {e}"), } } - Err(e) => debug!("ConversationPersistenceManager init failed for {ws_str}: {e}"), + Err(e) => { + debug!( + "ConversationPersistenceManager init failed for {ws_str}: {e}" + ) + } } } } - // Fallback: global in-memory sessions (no workspace available) match coordinator.list_sessions().await { Ok(summaries) => { let total = summaries.len(); @@ -579,12 +1153,14 @@ impl RemoteServer { .skip(page_offset) .take(page_size) .map(|s| { - let created = s.created_at + let created = s + .created_at .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() .as_secs() .to_string(); - let updated = s.last_activity_at + let updated = s + .last_activity_at .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() .as_secs() @@ -603,7 +1179,9 @@ impl RemoteServer { .collect(); RemoteResponse::SessionList { sessions, has_more } } - Err(e) => RemoteResponse::Error { message: e.to_string() }, + Err(e) => RemoteResponse::Error { + message: e.to_string(), + }, } } RemoteCommand::CreateSession { @@ -612,7 +1190,9 @@ impl RemoteServer { workspace_path: requested_ws_path, } => { use crate::infrastructure::{get_workspace_path, PathManager}; - use crate::service::conversation::{ConversationPersistenceManager, SessionMetadata, SessionStatus}; + use crate::service::conversation::{ + ConversationPersistenceManager, SessionMetadata, SessionStatus, + }; let agent = resolve_agent_type(agent_type.as_deref()); let session_name = custom_name @@ -622,17 +1202,19 @@ impl RemoteServer { "Cowork" => "Remote Cowork Session", _ => "Remote Code Session", }); - // Determine the binding workspace BEFORE creating the session so that - // the workspace_path can be embedded in the SessionCreated event. - // This allows the desktop UI to filter out sessions from other workspaces. let binding_ws_path: Option = requested_ws_path .as_deref() .map(std::path::PathBuf::from) .or_else(|| get_workspace_path()); - let binding_ws_str = binding_ws_path.as_ref() - .map(|p| p.to_string_lossy().to_string()); - - debug!("Remote CreateSession: requested_ws={:?}, binding_ws={:?}", requested_ws_path, binding_ws_str); + let binding_ws_str = + binding_ws_path + .as_ref() + .map(|p| p.to_string_lossy().to_string()); + + debug!( + "Remote CreateSession: requested_ws={:?}, binding_ws={:?}", + requested_ws_path, binding_ws_str + ); match coordinator .create_session_with_workspace( None, @@ -649,11 +1231,14 @@ impl RemoteServer { if let Some(wp) = binding_ws_path { if let Ok(pm) = PathManager::new() { let pm = std::sync::Arc::new(pm); - if let Ok(conv_mgr) = ConversationPersistenceManager::new(pm, wp.clone()).await { + if let Ok(conv_mgr) = + ConversationPersistenceManager::new(pm, wp.clone()).await + { let now_ms = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() - .as_millis() as u64; + .as_millis() + as u64; let meta = SessionMetadata { session_id: session_id.clone(), session_name: session_name.to_string(), @@ -672,10 +1257,16 @@ impl RemoteServer { todos: None, workspace_path: binding_ws_str, }; - if let Err(e) = conv_mgr.save_session_metadata(&meta).await { - error!("Failed to sync remote session to workspace: {e}"); + if let Err(e) = + conv_mgr.save_session_metadata(&meta).await + { + error!( + "Failed to sync remote session to workspace: {e}" + ); } else { - info!("Remote session synced to workspace: {session_id}"); + info!( + "Remote session synced to workspace: {session_id}" + ); } } } @@ -688,72 +1279,27 @@ impl RemoteServer { }, } } - RemoteCommand::GetSessionMessages { session_id, limit, before_message_id } => { - let limit = limit.unwrap_or(50); - let session_mgr = coordinator.get_session_manager(); - if session_mgr.get_session(session_id).is_none() { - let _ = coordinator.restore_session(session_id).await; - } - match coordinator.get_messages_paginated(session_id, limit, before_message_id.as_deref()).await { - Ok((messages, has_more)) => { - let chat_msgs = messages - .into_iter() - .map(|m| { - use crate::agentic::core::MessageRole; - let role = match m.role { - MessageRole::User => "user", - MessageRole::Assistant => "assistant", - MessageRole::Tool => "tool", - MessageRole::System => "system", - }; - let raw_content = match &m.content { - crate::agentic::core::MessageContent::Text(t) => t.clone(), - crate::agentic::core::MessageContent::Mixed { - text, .. - } => text.clone(), - crate::agentic::core::MessageContent::ToolResult { - result_for_assistant, - result, - .. - } => result_for_assistant - .clone() - .unwrap_or_else(|| result.to_string()), - }; - // Strip agent-internal XML tags from user messages - let content = if matches!(m.role, MessageRole::User) { - strip_user_input_tags(&raw_content) - } else { - raw_content - }; - let ts = m - .timestamp - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_secs() - .to_string(); - ChatMessage { - id: m.id.clone(), - role: role.to_string(), - content, - timestamp: ts, - metadata: None, - } - }) - .collect(); - RemoteResponse::Messages { - session_id: session_id.clone(), - messages: chat_msgs, - has_more, - } - } - Err(e) => { - RemoteResponse::Error { - message: e.to_string(), - } - } + RemoteCommand::GetSessionMessages { + session_id, + limit: _, + before_message_id: _, + } => { + let (chat_msgs, has_more) = + load_chat_messages_from_conversation_persistence(session_id).await; + RemoteResponse::Messages { + session_id: session_id.clone(), + messages: chat_msgs, + has_more, } } RemoteCommand::DeleteSession { session_id } => { + self.state_trackers.remove(session_id); + if let Some(coordinator) = + crate::agentic::coordination::get_global_coordinator() + { + let sub_id = format!("remote_tracker_{}", session_id); + coordinator.unsubscribe_internal(&sub_id); + } match coordinator.delete_session(session_id).await { Ok(_) => RemoteResponse::SessionDeleted { session_id: session_id.clone(), @@ -763,10 +1309,14 @@ impl RemoteServer { }, } } - _ => RemoteResponse::Error { message: "Unknown session command".into() }, + _ => RemoteResponse::Error { + message: "Unknown session command".into(), + }, } } + // ── Execution commands ────────────────────────────────────────── + async fn handle_execution_command(&self, cmd: &RemoteCommand) -> RemoteResponse { use crate::agentic::coordination::get_global_coordinator; @@ -786,6 +1336,8 @@ impl RemoteServer { agent_type: requested_agent_type, images, } => { + self.ensure_tracker(session_id); + let session_mgr = coordinator.get_session_manager(); let (session_agent_type, session_ws) = session_mgr .get_session(session_id) @@ -800,7 +1352,8 @@ impl RemoteServer { if let Some(ws_path_str) = &session_ws { use crate::infrastructure::{get_workspace_path, set_workspace_path}; let current = get_workspace_path(); - let current_str = current.as_ref().map(|p| p.to_string_lossy().to_string()); + let current_str = + current.as_ref().map(|p| p.to_string_lossy().to_string()); if current_str.as_deref() != Some(ws_path_str.as_str()) { info!("Remote send_message: temporarily setting workspace for session={session_id} to {ws_path_str}"); set_workspace_path(Some(std::path::PathBuf::from(ws_path_str))); @@ -824,7 +1377,9 @@ impl RemoteServer { let mut extra = String::new(); for (i, img) in imgs.iter().enumerate() { if let Some(ref dir) = save_dir { - if let Some(saved) = save_data_url_image(dir, &img.name, &img.data_url) { + if let Some(saved) = + save_data_url_image(dir, &img.name, &img.data_url) + { let path_str = saved.to_string_lossy(); extra.push_str(&format!( "\n\n[Image: {}]\nPath: {}\nTip: You can use the AnalyzeImage tool with the image_path parameter.", @@ -845,7 +1400,15 @@ impl RemoteServer { content.clone() }; - info!("Remote send_message: session={session_id}, agent_type={agent_type}, images={}", images.as_ref().map_or(0, |v| v.len())); + let is_first_message = session_mgr + .get_session(session_id) + .map(|s| s.dialog_turn_ids.is_empty()) + .unwrap_or(true); + + info!( + "Remote send_message: session={session_id}, agent_type={agent_type}, images={}", + images.as_ref().map_or(0, |v| v.len()) + ); let turn_id = format!("turn_{}", chrono::Utc::now().timestamp_millis()); match coordinator .start_dialog_turn( @@ -857,10 +1420,35 @@ impl RemoteServer { ) .await { - Ok(()) => RemoteResponse::MessageSent { - session_id: session_id.clone(), - turn_id, - }, + Ok(()) => { + if is_first_message { + let sid = session_id.clone(); + let msg = content.clone(); + let ws = session_ws.clone(); + tokio::spawn(async move { + if let Some(coord) = get_global_coordinator() { + match coord + .generate_session_title(&sid, &msg, Some(20)) + .await + { + Ok(title) => { + Self::persist_session_title(&sid, &title, ws.as_ref()) + .await; + } + Err(e) => { + debug!( + "Remote session title generation failed: {e}" + ); + } + } + } + }); + } + RemoteResponse::MessageSent { + session_id: session_id.clone(), + turn_id, + } + } Err(e) => RemoteResponse::Error { message: e.to_string(), }, @@ -874,271 +1462,64 @@ impl RemoteServer { .update_session_state(session_id, SessionState::Idle) .await; if let Some(last_turn_id) = session.dialog_turn_ids.last() { - let _ = coordinator.cancel_dialog_turn(session_id, last_turn_id).await; + let _ = + coordinator.cancel_dialog_turn(session_id, last_turn_id).await; } } RemoteResponse::TaskCancelled { session_id: session_id.clone(), } } - _ => RemoteResponse::Error { message: "Unknown execution command".into() }, - } - } - - async fn handle_subscription_command(&self, cmd: &RemoteCommand) -> RemoteResponse { - match cmd { - RemoteCommand::SubscribeSession { session_id } => { - let subscriber_id = format!("remote_stream_{}", session_id); - - let mut subs = self.active_subscriptions.lock().unwrap(); - if !subs.contains(&subscriber_id) { - if let Some((sub_id, mut stream_rx)) = register_stream_forwarder(session_id, self.shared_secret) { - subs.insert(sub_id.clone()); - - let stream_tx = self.stream_tx.clone(); - tokio::spawn(async move { - while let Some(payload) = stream_rx.recv().await { - let _ = stream_tx.send(payload); - } - debug!("Stream forwarder channel closed: {sub_id}"); - }); - } - } - - RemoteResponse::SessionSubscribed { - session_id: session_id.clone(), - } - } - RemoteCommand::UnsubscribeSession { session_id } => { - let subscriber_id = format!("remote_stream_{}", session_id); - - let mut subs = self.active_subscriptions.lock().unwrap(); - if subs.remove(&subscriber_id) { - unregister_stream_forwarder(&subscriber_id); - } - - RemoteResponse::SessionUnsubscribed { - session_id: session_id.clone(), + RemoteCommand::AnswerQuestion { tool_id, answers } => { + use crate::agentic::tools::user_input_manager::get_user_input_manager; + let mgr = get_user_input_manager(); + match mgr.send_answer(tool_id, answers.clone()) { + Ok(()) => RemoteResponse::AnswerAccepted, + Err(e) => RemoteResponse::Error { message: e }, } } - _ => RemoteResponse::Error { message: "Unknown subscription command".into() }, - } - } -} - -// ── Stream event forwarding ────────────────────────────────────── - -/// Converts `AgenticEvent`s for a specific session into encrypted relay -/// payloads and sends them through a channel. -pub struct RemoteEventForwarder { - target_session_id: String, - shared_secret: [u8; 32], - payload_tx: mpsc::UnboundedSender, -} - -impl RemoteEventForwarder { - pub fn new( - target_session_id: String, - shared_secret: [u8; 32], - payload_tx: mpsc::UnboundedSender, - ) -> Self { - Self { - target_session_id, - shared_secret, - payload_tx, + _ => RemoteResponse::Error { + message: "Unknown execution command".into(), + }, } } - fn try_forward(&self, event: &crate::agentic::events::AgenticEvent) { - use bitfun_events::AgenticEvent as AE; - - // Check if this is a direct event for our session, or a subagent event - // whose parent session is our target. Subagent events carry subagent_parent_info - // with the parent session id. We forward both cases so the mobile can see - // subagent tool calls and streaming text as part of the main session. - let is_direct = event.session_id() == Some(self.target_session_id.as_str()); - let parent_turn_id: Option = if !is_direct { - match event { - AE::TextChunk { subagent_parent_info, .. } - | AE::ThinkingChunk { subagent_parent_info, .. } - | AE::ToolEvent { subagent_parent_info, .. } => { - subagent_parent_info.as_ref().and_then(|p| { - if p.session_id == self.target_session_id { - Some(p.dialog_turn_id.clone()) - } else { - None - } - }) - } - _ => None, - } - } else { - None - }; - - // Only proceed if this is a direct event or a relevant subagent event - if !is_direct && parent_turn_id.is_none() { - return; - } + async fn persist_session_title( + session_id: &str, + title: &str, + workspace_path: Option<&String>, + ) { + use crate::infrastructure::{get_workspace_path, PathManager}; + use crate::service::conversation::ConversationPersistenceManager; - let session_id = self.target_session_id.clone(); + let ws = workspace_path + .map(std::path::PathBuf::from) + .or_else(get_workspace_path); + let Some(wp) = ws else { return }; - let (event_type, payload) = match event { - AE::TextChunk { text, turn_id, .. } => { - // For subagent text chunks, use the parent turn_id so mobile groups them correctly - let effective_turn_id = parent_turn_id.as_deref().unwrap_or(turn_id.as_str()); - ( - "text_chunk", - serde_json::json!({ "text": text, "turn_id": effective_turn_id }), - ) - } - AE::ThinkingChunk { - content, turn_id, .. - } => { - // Strip model-internal boundary markers (e.g. ) from thinking content - let clean_content = content - .replace("", "") - .replace("", "") - .replace("", ""); - let effective_turn_id = parent_turn_id.as_deref().unwrap_or(turn_id.as_str()); - ( - "thinking_chunk", - serde_json::json!({ "content": clean_content, "turn_id": effective_turn_id }), - ) - } - AE::ToolEvent { - tool_event, - turn_id, - .. - } => { - let effective_turn_id = parent_turn_id.as_deref().unwrap_or(turn_id.as_str()); - ( - "tool_event", - serde_json::json!({ - "turn_id": effective_turn_id, - "tool_event": serde_json::to_value(tool_event).unwrap_or_default(), - }), - ) - } - // The following events are only forwarded for the direct (main) session - AE::DialogTurnStarted { - turn_id, - user_input, - .. - } => ( - "stream_start", - serde_json::json!({ "turn_id": turn_id, "user_input": strip_user_input_tags(user_input) }), - ), - AE::DialogTurnCompleted { - turn_id, - total_rounds, - duration_ms, - .. - } => ( - "stream_end", - serde_json::json!({ - "turn_id": turn_id, - "total_rounds": total_rounds, - "duration_ms": duration_ms, - }), - ), - AE::DialogTurnFailed { - turn_id, error, .. - } => ( - "stream_error", - serde_json::json!({ "turn_id": turn_id, "error": error }), - ), - AE::DialogTurnCancelled { turn_id, .. } => ( - "stream_cancelled", - serde_json::json!({ "turn_id": turn_id }), - ), - AE::ModelRoundStarted { - turn_id, - round_index, - .. - } => ( - "round_started", - serde_json::json!({ "turn_id": turn_id, "round_index": round_index }), - ), - AE::ModelRoundCompleted { - turn_id, - has_tool_calls, - .. - } => ( - "round_completed", - serde_json::json!({ "turn_id": turn_id, "has_tool_calls": has_tool_calls }), - ), - AE::SessionStateChanged { new_state, .. } => ( - "session_state_changed", - serde_json::json!({ "new_state": new_state }), - ), - AE::SessionTitleGenerated { title, .. } => ( - "session_title", - serde_json::json!({ "title": title }), - ), - _ => return, + let pm = match PathManager::new() { + Ok(pm) => std::sync::Arc::new(pm), + Err(_) => return, }; - - let resp = RemoteResponse::StreamEvent { - session_id, - event_type: event_type.to_string(), - payload, + let conv_mgr = match ConversationPersistenceManager::new(pm, wp).await { + Ok(m) => m, + Err(_) => return, }; - - match encryption::encrypt_to_base64( - &self.shared_secret, - &serde_json::to_string(&resp).unwrap_or_default(), - ) { - Ok(encrypted) => { - let _ = self.payload_tx.send(encrypted); - } - Err(e) => { - error!("Failed to encrypt stream event: {e}"); + if let Ok(Some(mut meta)) = conv_mgr.load_session_metadata(session_id).await { + meta.session_name = title.to_string(); + meta.last_active_at = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64; + if let Err(e) = conv_mgr.save_session_metadata(&meta).await { + error!("Failed to persist remote session title: {e}"); + } else { + info!("Remote session title persisted: session_id={session_id}, title={title}"); } } } } -#[async_trait::async_trait] -impl crate::agentic::events::EventSubscriber for RemoteEventForwarder { - async fn on_event( - &self, - event: &crate::agentic::events::AgenticEvent, - ) -> crate::util::errors::BitFunResult<()> { - self.try_forward(event); - Ok(()) - } -} - -/// Register a forwarder for a session. Returns the subscriber_id (for later unsubscription) -/// and the receiving end of the encrypted payload channel. -pub fn register_stream_forwarder( - session_id: &str, - shared_secret: [u8; 32], -) -> Option<(String, mpsc::UnboundedReceiver)> { - use crate::agentic::coordination::get_global_coordinator; - - let coordinator = get_global_coordinator()?; - let (tx, rx) = mpsc::unbounded_channel(); - let subscriber_id = format!("remote_stream_{}", session_id); - - let forwarder = RemoteEventForwarder::new(session_id.to_string(), shared_secret, tx); - - coordinator.subscribe_internal(subscriber_id.clone(), forwarder); - info!("Registered remote stream forwarder: {subscriber_id}"); - Some((subscriber_id, rx)) -} - -/// Unregister a previously registered forwarder. -pub fn unregister_stream_forwarder(subscriber_id: &str) { - use crate::agentic::coordination::get_global_coordinator; - - if let Some(coordinator) = get_global_coordinator() { - coordinator.unsubscribe_internal(subscriber_id); - info!("Unregistered remote stream forwarder: {subscriber_id}"); - } -} - #[cfg(test)] mod tests { use super::*; @@ -1150,8 +1531,7 @@ mod tests { let bob = KeyPair::generate(); let shared = alice.derive_shared_secret(&bob.public_key_bytes()); - let (stream_tx, _stream_rx) = mpsc::unbounded_channel::(); - let bridge = RemoteServer::new(shared, stream_tx); + let bridge = RemoteServer::new(shared); let cmd_json = serde_json::json!({ "cmd": "send_message", @@ -1181,8 +1561,7 @@ mod tests { fn test_response_with_request_id() { let alice = KeyPair::generate(); let shared = alice.derive_shared_secret(&alice.public_key_bytes()); - let (stream_tx, _stream_rx) = mpsc::unbounded_channel::(); - let bridge = RemoteServer::new(shared, stream_tx); + let bridge = RemoteServer::new(shared); let resp = RemoteResponse::Pong; let (enc, nonce) = bridge.encrypt_response(&resp, Some("req_xyz")).unwrap(); diff --git a/src/mobile-web/src/App.tsx b/src/mobile-web/src/App.tsx index b80324a5..b4b893c1 100644 --- a/src/mobile-web/src/App.tsx +++ b/src/mobile-web/src/App.tsx @@ -3,45 +3,28 @@ import PairingPage from './pages/PairingPage'; import WorkspacePage from './pages/WorkspacePage'; import SessionListPage from './pages/SessionListPage'; import ChatPage from './pages/ChatPage'; -import { RelayConnection } from './services/RelayConnection'; +import { RelayHttpClient } from './services/RelayHttpClient'; import { RemoteSessionManager } from './services/RemoteSessionManager'; -import { useMobileStore } from './services/store'; -import './styles/mobile.scss'; +import { ThemeProvider } from './theme'; +import './styles/index.scss'; type Page = 'pairing' | 'workspace' | 'sessions' | 'chat'; -const App: React.FC = () => { +const AppContent: React.FC = () => { const [page, setPage] = useState('pairing'); const [activeSessionId, setActiveSessionId] = useState(null); const [activeSessionName, setActiveSessionName] = useState('Session'); - const relayRef = useRef(null); + const clientRef = useRef(null); const sessionMgrRef = useRef(null); - const handlePaired = useCallback((relay: RelayConnection, sessionMgr: RemoteSessionManager) => { - relayRef.current = relay; - sessionMgrRef.current = sessionMgr; - - relay.setMessageHandler((json: string) => { - sessionMgr.handleMessage(json); - }); - - // Listen for the initial sync that the desktop pushes right after pairing. - // This pre-populates workspace + sessions so the list page can render instantly. - sessionMgr.onInitialSync((data) => { - const store = useMobileStore.getState(); - if (data.has_workspace) { - store.setCurrentWorkspace({ - has_workspace: true, - path: data.path, - project_name: data.project_name, - git_branch: data.git_branch, - }); - } - store.setSessions(data.sessions); - }); - - setPage('sessions'); - }, []); + const handlePaired = useCallback( + (client: RelayHttpClient, sessionMgr: RemoteSessionManager) => { + clientRef.current = client; + sessionMgrRef.current = sessionMgr; + setPage('sessions'); + }, + [], + ); const handleOpenWorkspace = useCallback(() => { setPage('workspace'); @@ -90,4 +73,10 @@ const App: React.FC = () => { ); }; +const App: React.FC = () => ( + + + +); + export default App; diff --git a/src/mobile-web/src/pages/ChatPage.tsx b/src/mobile-web/src/pages/ChatPage.tsx index cbb79d54..ea364c66 100644 --- a/src/mobile-web/src/pages/ChatPage.tsx +++ b/src/mobile-web/src/pages/ChatPage.tsx @@ -3,8 +3,17 @@ import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; import { oneDark } from 'react-syntax-highlighter/dist/esm/styles/prism'; -import { RemoteSessionManager } from '../services/RemoteSessionManager'; +import { + RemoteSessionManager, + SessionPoller, + type PollResponse, + type ActiveTurnSnapshot, + type RemoteToolStatus, + type ChatMessage, + type ChatMessageItem, +} from '../services/RemoteSessionManager'; import { useMobileStore } from '../services/store'; +import { useTheme } from '../theme'; interface ChatPageProps { sessionMgr: RemoteSessionManager; @@ -13,21 +22,8 @@ interface ChatPageProps { onBack: () => void; } -interface ToolCallEntry { - id: string; - name: string; - status: 'running' | 'done' | 'error'; - duration?: number; - startMs: number; -} - -interface StreamingAccum { - thinking: string; - text: string; - toolCalls: ToolCallEntry[]; -} +// ─── Markdown ─────────────────────────────────────────────────────────────── -/** Renders markdown content with syntax highlighting */ const MarkdownContent: React.FC<{ content: string }> = ({ content }) => ( = ({ content }) => ( ); -/** Desktop-style collapsible thinking block */ +// ─── Thinking (ModelThinkingDisplay-style) ─────────────────────────────────── + const ThinkingBlock: React.FC<{ thinking: string; streaming?: boolean }> = ({ thinking, streaming }) => { const [open, setOpen] = useState(false); + const wrapperRef = useRef(null); + const [scrollState, setScrollState] = useState({ atTop: true, atBottom: true }); + + const handleScroll = useCallback(() => { + const el = wrapperRef.current; + if (!el) return; + setScrollState({ + atTop: el.scrollTop < 4, + atBottom: el.scrollHeight - el.scrollTop - el.clientHeight < 4, + }); + }, []); + if (!thinking && !streaming) return null; + const charCount = thinking.length; + const label = streaming && charCount === 0 + ? 'Thinking...' + : `Thought for ${charCount} characters`; + return ( -
+
- {open && thinking && ( -
- + +
+
+ {thinking && ( +
+
+ +
+
+ )}
- )} +
); }; -/** Desktop-style individual tool card */ -const ToolCard: React.FC<{ tool: ToolCallEntry; now: number }> = ({ tool, now }) => { - const [_expanded, setExpanded] = useState(false); +// ─── Tool Card ────────────────────────────────────────────────────────────── + +const TOOL_TYPE_MAP: Record = { + explore: 'Explore', + read_file: 'Read', + write_file: 'Write', + list_directory: 'LS', + bash: 'Shell', + glob: 'Glob', + grep: 'Grep', + create_file: 'Write', + delete_file: 'Delete', + execute_subagent: 'Task', + search: 'Search', + edit_file: 'Edit', + web_search: 'Web', +}; - const durationLabel = tool.status === 'done' && tool.duration != null - ? `${(tool.duration / 1000).toFixed(1)}s` - : tool.status === 'running' - ? `${((now - tool.startMs) / 1000).toFixed(1)}s` +const ToolCard: React.FC<{ tool: RemoteToolStatus; now: number }> = ({ tool, now }) => { + const toolKey = tool.name.toLowerCase().replace(/[\s-]/g, '_'); + const typeLabel = TOOL_TYPE_MAP[toolKey] || TOOL_TYPE_MAP[tool.name] || 'Tool'; + const isRunning = tool.status === 'running'; + const isCompleted = tool.status === 'completed'; + + const durationLabel = isCompleted && tool.duration_ms != null + ? `${(tool.duration_ms / 1000).toFixed(1)}s` + : isRunning && tool.start_ms + ? `${((now - tool.start_ms) / 1000).toFixed(1)}s` : ''; - const toolTypeMap: Record = { - 'explore': 'Explore', - 'read_file': 'Read', - 'write_file': 'Write', - 'list_directory': 'LS', - 'bash': 'Shell', - 'glob': 'Glob', - 'grep': 'Grep', - 'create_file': 'Write', - 'delete_file': 'Delete', - 'execute_subagent': 'Task', - 'search': 'Search', - }; - const toolKey = tool.name.toLowerCase().replace(/[\s-]/g, '_'); - const typeLabel = toolTypeMap[toolKey] || toolTypeMap[tool.name] || 'Tool'; + const statusClass = isRunning ? 'running' : isCompleted ? 'done' : 'error'; return ( -
-
setExpanded(e => !e)}> +
+
- {tool.status === 'running' ? ( + {isRunning ? ( - ) : tool.status === 'done' ? ( + ) : isCompleted ? ( @@ -127,25 +158,295 @@ const ToolCard: React.FC<{ tool: ToolCallEntry; now: number }> = ({ tool, now }) ); }; -/** Tool list */ -const ToolList: React.FC<{ toolCalls: ToolCallEntry[]; now: number }> = ({ toolCalls, now }) => { - if (!toolCalls || toolCalls.length === 0) return null; +const TOOL_LIST_COLLAPSE_THRESHOLD = 2; + +const ToolList: React.FC<{ tools: RemoteToolStatus[]; now: number }> = ({ tools, now }) => { + const scrollRef = useRef(null); + const prevCountRef = useRef(0); + + useEffect(() => { + if (tools.length > prevCountRef.current && scrollRef.current) { + scrollRef.current.scrollTop = scrollRef.current.scrollHeight; + } + prevCountRef.current = tools.length; + }, [tools.length]); + + if (!tools || tools.length === 0) return null; + + if (tools.length <= TOOL_LIST_COLLAPSE_THRESHOLD) { + return ( +
+ {tools.map((tc) => ( + + ))} +
+ ); + } + + const runningCount = tools.filter(t => t.status === 'running').length; + const doneCount = tools.filter(t => t.status === 'completed').length; + return ( -
- {toolCalls.map((tc) => ( - - ))} +
+
+ {tools.length} tool calls + + {doneCount > 0 && {doneCount} done} + {runningCount > 0 && {runningCount} running} + +
+
+ {tools.map((tc) => ( + + ))} +
); }; -/** Typing indicator dots */ +// ─── Typing indicator ─────────────────────────────────────────────────────── + const TypingDots: React.FC = () => ( ); +// ─── AskUserQuestion Card ───────────────────────────────────────────────── + +interface AskQuestionCardProps { + tool: RemoteToolStatus; + onAnswer: (toolId: string, answers: any) => void; +} + +const AskQuestionCard: React.FC = ({ tool, onAnswer }) => { + const questions: any[] = tool.tool_input?.questions || []; + const [selected, setSelected] = useState>({}); + const [customTexts, setCustomTexts] = useState>({}); + const [submitted, setSubmitted] = useState(false); + + if (questions.length === 0) return null; + + const handleSelect = (qIdx: number, label: string, multi: boolean) => { + setSelected(prev => { + if (multi) { + const arr = (prev[qIdx] as string[] | undefined) || []; + return { ...prev, [qIdx]: arr.includes(label) ? arr.filter(l => l !== label) : [...arr, label] }; + } + return { ...prev, [qIdx]: prev[qIdx] === label ? undefined! : label }; + }); + }; + + const handleSubmit = () => { + const answers: Record = {}; + questions.forEach((q, idx) => { + const sel = selected[idx]; + if (sel === '其他' || sel === 'Other') { + answers[String(idx)] = customTexts[idx] || sel; + } else { + answers[String(idx)] = sel ?? ''; + } + }); + setSubmitted(true); + onAnswer(tool.id, answers); + }; + + const allAnswered = questions.every((q, idx) => { + const s = selected[idx]; + if (q.multiSelect) return Array.isArray(s) && s.length > 0; + return !!s; + }); + + return ( +
+
+ {questions.length} question{questions.length > 1 ? 's' : ''} + + {!submitted && ( + Waiting + )} +
+ {questions.map((q, qIdx) => { + const isOtherSelected = selected[qIdx] === '其他' || selected[qIdx] === 'Other'; + return ( +
+
+ {q.header} + {q.question} +
+
+ {(q.options || []).map((opt: any, oIdx: number) => { + const isSelected = q.multiSelect + ? (selected[qIdx] as string[] || []).includes(opt.label) + : selected[qIdx] === opt.label; + return ( + + ); + })} + {/* "Other" option */} + + {isOtherSelected && ( + setCustomTexts(prev => ({ ...prev, [qIdx]: e.target.value }))} + disabled={submitted} + /> + )} +
+
+ ); + })} +
+ ); +}; + +// ─── Ordered Items renderer ───────────────────────────────────────────────── + +function renderOrderedItems(items: ChatMessageItem[], now: number) { + const groups: { type: string; entries: ChatMessageItem[] }[] = []; + for (const item of items) { + const last = groups[groups.length - 1]; + if (last && last.type === item.type) { + last.entries.push(item); + } else { + groups.push({ type: item.type, entries: [item] }); + } + } + + return groups.map((g, gi) => { + if (g.type === 'thinking') { + const text = g.entries.map(e => e.content || '').join('\n\n'); + return ; + } + if (g.type === 'tool') { + const tools = g.entries.map(e => e.tool!).filter(Boolean); + return ; + } + if (g.type === 'text') { + return g.entries.map((entry, ii) => + entry.content ? ( +
+ +
+ ) : null, + ); + } + return null; + }); +} + +// ─── Active turn items renderer (with AskUserQuestion support) ───────────── + +function renderActiveTurnItems( + items: ChatMessageItem[], + now: number, + sessionMgr: RemoteSessionManager, + setError: (e: string) => void, +) { + const groups: { type: string; entries: ChatMessageItem[] }[] = []; + for (const item of items) { + const last = groups[groups.length - 1]; + if (last && last.type === item.type) { + last.entries.push(item); + } else { + groups.push({ type: item.type, entries: [item] }); + } + } + + return groups.map((g, gi) => { + if (g.type === 'thinking') { + const text = g.entries.map(e => e.content || '').join('\n\n'); + return ; + } + if (g.type === 'tool') { + const askEntries = g.entries.filter( + e => e.tool?.name === 'AskUserQuestion' && e.tool?.status === 'running' && e.tool?.tool_input, + ); + const regularEntries = g.entries.filter(e => !askEntries.includes(e)); + const regularTools = regularEntries.map(e => e.tool!).filter(Boolean); + + return ( + + {regularTools.length > 0 && } + {askEntries.map(e => ( + { + sessionMgr.answerQuestion(toolId, answers).catch(err => { setError(String(err)); }); + }} + /> + ))} + + ); + } + if (g.type === 'text') { + return g.entries.map((entry, ii) => + entry.content ? ( +
+ +
+ ) : null, + ); + } + return null; + }); +} + +// ─── Theme toggle icon ───────────────────────────────────────────────────── + +const ThemeToggleIcon: React.FC<{ isDark: boolean }> = ({ isDark }) => ( + + {isDark ? ( + + ) : ( + + )} + +); + +// ─── Agent Mode ───────────────────────────────────────────────────────────── + type AgentMode = 'agentic' | 'Plan' | 'debug'; const MODE_OPTIONS: { id: AgentMode; label: string }[] = [ @@ -154,32 +455,38 @@ const MODE_OPTIONS: { id: AgentMode; label: string }[] = [ { id: 'debug', label: 'Debug' }, ]; +// ─── ChatPage ─────────────────────────────────────────────────────────────── + const ChatPage: React.FC = ({ sessionMgr, sessionId, sessionName, onBack }) => { const { getMessages, setMessages, - appendMessage, - updateLastMessageFull, - isStreaming, - setIsStreaming, + appendNewMessages, + activeTurn, + setActiveTurn, setError, currentWorkspace, + updateSessionName, } = useMobileStore(); + const { isDark, toggleTheme } = useTheme(); const messages = getMessages(sessionId); const [input, setInput] = useState(''); const [agentMode, setAgentMode] = useState('agentic'); + const [liveTitle, setLiveTitle] = useState(sessionName); const [pendingImages, setPendingImages] = useState<{ name: string; dataUrl: string }[]>([]); + const [inputFocused, setInputFocused] = useState(false); const inputRef = useRef(null); const fileInputRef = useRef(null); - const streamRef = useRef({ thinking: '', text: '', toolCalls: [] }); + const pollerRef = useRef(null); const [isLoadingMore, setIsLoadingMore] = useState(false); const [hasMore, setHasMore] = useState(true); const messagesEndRef = useRef(null); const messagesContainerRef = useRef(null); - // Live timer for running tools + const isStreaming = activeTurn != null && activeTurn.status === 'active'; + const [now, setNow] = useState(() => Date.now()); useEffect(() => { if (!isStreaming) return; @@ -215,119 +522,49 @@ const ChatPage: React.FC = ({ sessionMgr, sessionId, sessionName, } }, [hasMore, isLoadingMore, getMessages, sessionId, loadMessages]); + // Initial load + start poller useEffect(() => { - sessionMgr.subscribeSession(sessionId).catch(console.error); - loadMessages(); - - const unsub = sessionMgr.onStreamEvent((event) => { - if (event.session_id !== sessionId) return; - const eventType = event.event_type; - - if (eventType === 'stream_start') { - streamRef.current = { thinking: '', text: '', toolCalls: [] }; - setIsStreaming(true); - appendMessage(sessionId, { - id: `stream-${Date.now()}`, - role: 'assistant', - content: '', - timestamp: new Date().toISOString(), - metadata: { thinking: '', toolCalls: [] }, - }); + loadMessages().then(() => { + const initialMsgCount = useMobileStore.getState().getMessages(sessionId).length; - const userInput = event.payload?.user_input; - if (userInput && userInput.trim()) { - const currentMsgs = getMessages(sessionId); - const lastUserMsg = [...currentMsgs].reverse().find(m => m.role === 'user'); - if (!lastUserMsg || lastUserMsg.content !== userInput.trim()) { - const msgs = getMessages(sessionId); - const newUserMsg = { - id: `user-remote-${Date.now()}`, - role: 'user', - content: userInput.trim(), - timestamp: new Date().toISOString(), - }; - setMessages(sessionId, [ - ...msgs.slice(0, -1), - newUserMsg, - msgs[msgs.length - 1], - ]); - } + const poller = new SessionPoller(sessionMgr, sessionId, (resp: PollResponse) => { + if (resp.new_messages && resp.new_messages.length > 0) { + appendNewMessages(sessionId, resp.new_messages); } - } else if (eventType === 'text_chunk') { - streamRef.current.text += event.payload?.text || ''; - updateLastMessageFull(sessionId, streamRef.current.text, { - thinking: streamRef.current.thinking, - toolCalls: streamRef.current.toolCalls, - }); - } else if (eventType === 'thinking_chunk') { - streamRef.current.thinking += event.payload?.content || ''; - updateLastMessageFull(sessionId, streamRef.current.text, { - thinking: streamRef.current.thinking, - toolCalls: streamRef.current.toolCalls, - }); - } else if (eventType === 'tool_event') { - const toolEvt = event.payload?.tool_event; - if (toolEvt?.event_type === 'Started') { - streamRef.current.toolCalls = [ - ...streamRef.current.toolCalls, - { - id: toolEvt.tool_id || `${toolEvt.tool_name}-${Date.now()}`, - name: toolEvt.tool_name, - status: 'running', - startMs: Date.now(), - }, - ]; - updateLastMessageFull(sessionId, streamRef.current.text, { - thinking: streamRef.current.thinking, - toolCalls: streamRef.current.toolCalls, - }); - } else if (toolEvt?.event_type === 'Completed' || toolEvt?.event_type === 'Succeeded') { - streamRef.current.toolCalls = streamRef.current.toolCalls.map(tc => - (tc.id === toolEvt.tool_id || tc.name === toolEvt.tool_name) && tc.status === 'running' - ? { ...tc, status: 'done' as const, duration: toolEvt.duration_ms } - : tc - ); - updateLastMessageFull(sessionId, streamRef.current.text, { - thinking: streamRef.current.thinking, - toolCalls: streamRef.current.toolCalls, - }); - } else if (toolEvt?.event_type === 'Failed') { - streamRef.current.toolCalls = streamRef.current.toolCalls.map(tc => - (tc.id === toolEvt.tool_id || tc.name === toolEvt.tool_name) && tc.status === 'running' - ? { ...tc, status: 'error' as const } - : tc - ); - updateLastMessageFull(sessionId, streamRef.current.text, { - thinking: streamRef.current.thinking, - toolCalls: streamRef.current.toolCalls, - }); + if (resp.title) { + setLiveTitle(resp.title); + updateSessionName(sessionId, resp.title); } - } else if (eventType === 'stream_end') { - setIsStreaming(false); - streamRef.current = { thinking: '', text: '', toolCalls: [] }; - // Reload to get persisted history - loadMessages(); - } else if (eventType === 'stream_error') { - setIsStreaming(false); - setError(event.payload?.error || 'Stream error'); - streamRef.current = { thinking: '', text: '', toolCalls: [] }; - } else if (eventType === 'stream_cancelled') { - setIsStreaming(false); - streamRef.current = { thinking: '', text: '', toolCalls: [] }; - } + setActiveTurn(resp.active_turn ?? null); + }); + + poller.start(initialMsgCount); + pollerRef.current = poller; }); return () => { - unsub(); - sessionMgr.unsubscribeSession(sessionId).catch(console.error); + pollerRef.current?.stop(); + pollerRef.current = null; + setActiveTurn(null); }; - }, [sessionId, sessionMgr, setIsStreaming, appendMessage, updateLastMessageFull, setError, setMessages, getMessages]); + }, [sessionId, sessionMgr]); useEffect(() => { if (!isLoadingMore) { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); } - }, [messages, isLoadingMore]); + }, [messages, activeTurn, isLoadingMore]); + + // Reload messages when a turn completes so the messages array + // contains the final persisted content instead of stale partial data. + const prevActiveTurnRef = useRef(null); + useEffect(() => { + const prev = prevActiveTurnRef.current; + prevActiveTurnRef.current = activeTurn; + if (prev && !activeTurn) { + loadMessages(); + } + }, [activeTurn, loadMessages]); const handleSend = useCallback(async () => { const text = input.trim(); @@ -336,16 +573,6 @@ const ChatPage: React.FC = ({ sessionMgr, sessionId, sessionName, setInput(''); setPendingImages([]); - const displayParts = [text]; - if (imgs.length > 0) { - displayParts.push(`[${imgs.length} image(s) attached]`); - } - appendMessage(sessionId, { - id: `user-${Date.now()}`, - role: 'user', - content: displayParts.filter(Boolean).join('\n'), - timestamp: new Date().toISOString(), - }); try { const imagePayload = imgs.length > 0 ? imgs.map(i => ({ name: i.name, data_url: i.dataUrl })) @@ -354,7 +581,7 @@ const ChatPage: React.FC = ({ sessionMgr, sessionId, sessionName, } catch (e: any) { setError(e.message); } - }, [input, pendingImages, isStreaming, sessionId, sessionMgr, appendMessage, setError, agentMode]); + }, [input, pendingImages, isStreaming, sessionId, sessionMgr, setError, agentMode]); const handleImageSelect = useCallback(() => { fileInputRef.current?.click(); @@ -402,28 +629,30 @@ const ChatPage: React.FC = ({ sessionMgr, sessionId, sessionName, const workspaceName = currentWorkspace?.project_name || currentWorkspace?.path?.split('/').pop() || ''; const gitBranch = currentWorkspace?.git_branch; - const displayName = sessionName || 'Session'; + const displayName = liveTitle || sessionName || 'Session'; return ( -
+
{/* Header */}
{displayName}
- {isStreaming && ( - - )} +
+ +
{workspaceName && (
- + @@ -431,7 +660,7 @@ const ChatPage: React.FC = ({ sessionMgr, sessionId, sessionName, {workspaceName} {gitBranch && ( - + {gitBranch} )} @@ -445,54 +674,92 @@ const ChatPage: React.FC = ({ sessionMgr, sessionId, sessionName,
Loading older messages…
)} - {messages.map((m) => { + {messages.map((m, _idx) => { if (m.role === 'system' || m.role === 'tool') return null; - const thinking: string = m.metadata?.thinking || ''; - const toolCalls: ToolCallEntry[] = m.metadata?.toolCalls || []; - const isLastMsg = messages.indexOf(m) === messages.length - 1; - const streamingThis = isStreaming && isLastMsg && m.role === 'assistant'; - if (m.role === 'user') { return (
-
- {m.content} +
+
U
+
{m.content}
); } - // Assistant message return (
- {/* Thinking block */} - {(thinking || (streamingThis && !m.content)) && ( - + {m.items && m.items.length > 0 ? ( + renderOrderedItems(m.items, now) + ) : ( + <> + {m.thinking && } + {m.tools && m.tools.length > 0 && } + {m.content && ( +
+ +
+ )} + )} +
+ ); + })} - {/* Tool cards */} - + {/* Active turn overlay (streaming content from poller) */} + {activeTurn && (() => { + if (activeTurn.items && activeTurn.items.length > 0) { + return ( +
+ {renderActiveTurnItems(activeTurn.items, now, sessionMgr, setError)} + {activeTurn.status === 'active' && !activeTurn.thinking && !activeTurn.text && activeTurn.tools.length === 0 && ( +
+ )} +
+ ); + } - {/* Main content */} - {m.content ? ( -
- -
- ) : streamingThis && !thinking && toolCalls.length === 0 ? ( + const askTools = activeTurn.tools.filter( + t => t.name === 'AskUserQuestion' && t.status === 'running' && t.tool_input, + ); + const askToolIds = new Set(askTools.map(t => t.id)); + const regularTools = activeTurn.tools.filter(t => !askToolIds.has(t.id)); + + return ( +
+ {(activeTurn.thinking || activeTurn.status === 'active') && ( + + )} + + {askTools.map(at => ( + { + sessionMgr.answerQuestion(toolId, answers).catch(err => { setError(String(err)); }); + }} + /> + ))} + {activeTurn.text ? (
- +
+ ) : activeTurn.status === 'active' && !activeTurn.thinking && activeTurn.tools.length === 0 ? ( +
) : null}
); - })} + })()}
- {/* Input bar */} -
+ {/* Floating Input Bar */} +
{MODE_OPTIONS.map((opt) => ( @@ -526,7 +793,7 @@ const ChatPage: React.FC = ({ sessionMgr, sessionId, sessionName, disabled={isStreaming || pendingImages.length >= 5} aria-label="Attach image" > - + @@ -547,18 +814,32 @@ const ChatPage: React.FC = ({ sessionMgr, sessionId, sessionName, value={input} onChange={(e) => setInput(e.target.value)} onKeyDown={handleKeyDown} + onFocus={() => setInputFocused(true)} + onBlur={() => setInputFocused(false)} rows={1} disabled={isStreaming} /> - + {isStreaming ? ( + + ) : ( + + )}
diff --git a/src/mobile-web/src/pages/PairingPage.tsx b/src/mobile-web/src/pages/PairingPage.tsx index 5a8dc5cc..339b133d 100644 --- a/src/mobile-web/src/pages/PairingPage.tsx +++ b/src/mobile-web/src/pages/PairingPage.tsx @@ -1,16 +1,27 @@ import React, { useEffect, useRef } from 'react'; -import { RelayConnection } from '../services/RelayConnection'; +import { RelayHttpClient } from '../services/RelayHttpClient'; import { RemoteSessionManager } from '../services/RemoteSessionManager'; import { useMobileStore } from '../services/store'; interface PairingPageProps { - onPaired: (relay: RelayConnection, sessionMgr: RemoteSessionManager) => void; + onPaired: (client: RelayHttpClient, sessionMgr: RemoteSessionManager) => void; } +const CubeLogo: React.FC = () => ( +
+
+
+
+
+
+
+
+
+
+); + const PairingPage: React.FC = ({ onPaired }) => { - const { connectionState, setConnectionState, setError, error } = useMobileStore(); - const relayRef = useRef(null); - // Track whether pairing has completed so we don't disconnect the live relay on unmount. + const { connectionStatus, setConnectionStatus, setError, error } = useMobileStore(); const pairedRef = useRef(false); useEffect(() => { @@ -18,76 +29,89 @@ const PairingPage: React.FC = ({ onPaired }) => { const params = new URLSearchParams(hash.replace(/^#\/pair\?/, '')); const room = params.get('room'); const pk = params.get('pk'); - const did = params.get('did'); - const relayWs = params.get('relay'); + const relayParam = params.get('relay'); if (!room || !pk) { setError('Invalid QR code: missing room or public key'); + setConnectionStatus('error'); return; } - // Use the explicit relay WebSocket URL from QR params, - // falling back to origin + pathname (for backward compat) - let wsBaseUrl: string; - if (relayWs) { - wsBaseUrl = relayWs; + let httpBaseUrl: string; + if (relayParam) { + httpBaseUrl = relayParam + .replace(/^wss:\/\//, 'https://') + .replace(/^ws:\/\//, 'http://') + .replace(/\/ws\/?$/, '') + .replace(/\/$/, ''); } else { - const base = window.location.origin + window.location.pathname.replace(/\/$/, ''); - wsBaseUrl = base.replace(/^https/, 'wss').replace(/^http/, 'ws'); + const origin = window.location.origin; + const pathname = window.location.pathname + .replace(/\/[^/]*$/, '') + .replace(/\/r\/[^/]*$/, ''); + httpBaseUrl = origin + pathname; } - const relay = new RelayConnection(wsBaseUrl, room, pk, did || '', { - onStateChange: (state) => { - setConnectionState(state); - if (state === 'paired') { - pairedRef.current = true; - const sessionMgr = new RemoteSessionManager(relay); - onPaired(relay, sessionMgr); + const client = new RelayHttpClient(httpBaseUrl, room); + + (async () => { + try { + setConnectionStatus('pairing'); + const initialSync = await client.pair(pk); + pairedRef.current = true; + setConnectionStatus('paired'); + + const sessionMgr = new RemoteSessionManager(client); + + const store = useMobileStore.getState(); + if (initialSync.has_workspace) { + store.setCurrentWorkspace({ + has_workspace: true, + path: initialSync.path, + project_name: initialSync.project_name, + git_branch: initialSync.git_branch, + }); + } + if (initialSync.sessions) { + store.setSessions(initialSync.sessions); } - }, - onMessage: () => {}, - onError: (msg) => setError(msg), - }); - - relayRef.current = relay; - relay.connect(); - - return () => { - // Only disconnect if pairing has not completed. - // After pairing, the relay is owned by App.tsx and must stay alive. - if (!pairedRef.current) { - relay.disconnect(); + + onPaired(client, sessionMgr); + } catch (e: any) { + setError(e?.message || 'Pairing failed'); + setConnectionStatus('error'); } - }; + })(); }, []); const stateLabels: Record = { - disconnected: 'Disconnected', - connecting: 'Connecting to relay server...', - connected: 'Connected, exchanging keys...', + pairing: 'Connecting and pairing...', paired: 'Paired! Loading sessions...', error: 'Connection error', }; const handleRetry = () => { - // Reload the page — browser will reconnect and re-join the room. window.location.reload(); }; - const showRetry = (connectionState === 'error' || connectionState === 'disconnected') && !!error; + const showRetry = connectionStatus === 'error'; + const showSpinner = connectionStatus === 'pairing'; return (
-
BitFun
-
- {connectionState !== 'error' && connectionState !== 'disconnected' && ( -
- )} + +
BitFun Remote
+ +
+ {showSpinner &&
}
+
- {stateLabels[connectionState] || connectionState} + {stateLabels[connectionStatus] || connectionStatus}
+ {error &&
{error}
} + {showRetry && ( - {showNewMenu && ( -
- - -
- )} +
+ + {showNewMenu && ( +
+ + +
+ )} +
- {/* Workspace banner — tap to switch */}
- + {currentWorkspace?.project_name || currentWorkspace?.path || 'No workspace'} {currentWorkspace?.git_branch && ( - + {currentWorkspace.git_branch} )} @@ -201,7 +212,6 @@ const SessionListPage: React.FC = ({ sessionMgr, onSelectS
- {s.message_count} messages {formatTime(s.updated_at)}
diff --git a/src/mobile-web/src/pages/WorkspacePage.tsx b/src/mobile-web/src/pages/WorkspacePage.tsx index e6909e0c..dc072bff 100644 --- a/src/mobile-web/src/pages/WorkspacePage.tsx +++ b/src/mobile-web/src/pages/WorkspacePage.tsx @@ -66,13 +66,9 @@ const WorkspacePage: React.FC = ({ sessionMgr, onReady }) => } }; - const handleContinue = () => { - onReady(); - }; - if (loading) { return ( -
+
Loading workspace info... @@ -82,7 +78,7 @@ const WorkspacePage: React.FC = ({ sessionMgr, onReady }) => } return ( -
+

Workspace

@@ -98,19 +94,17 @@ const WorkspacePage: React.FC = ({ sessionMgr, onReady }) =>
{workspaceInfo.path}
{workspaceInfo.git_branch && (
- - - + {workspaceInfo.git_branch}
)}
-
@@ -168,7 +162,7 @@ const WorkspacePage: React.FC = ({ sessionMgr, onReady }) => {switching && (
-
+
Opening workspace...
)} diff --git a/src/mobile-web/src/services/RelayHttpClient.ts b/src/mobile-web/src/services/RelayHttpClient.ts new file mode 100644 index 00000000..869d722a --- /dev/null +++ b/src/mobile-web/src/services/RelayHttpClient.ts @@ -0,0 +1,147 @@ +/** + * HTTP client for communicating with the relay server. + * All mobile-to-desktop communication goes through HTTP requests + * that the relay bridges to the desktop via WebSocket. + * + * No WebSocket connection is maintained on the mobile side. + */ + +import { + generateKeyPair, + deriveSharedKey, + encrypt, + decrypt, + toB64, + fromB64, + type MobileKeyPair, +} from './E2EEncryption'; + +export class RelayHttpClient { + private relayUrl: string; + private roomId: string; + private sharedKey: Uint8Array | null = null; + private keyPair: MobileKeyPair | null = null; + + constructor(relayUrl: string, roomId: string) { + this.relayUrl = relayUrl.replace(/\/$/, ''); + this.roomId = roomId; + } + + /** + * Pair with the desktop via two HTTP round-trips: + * 1. POST /pair with our public key → receive encrypted challenge + * 2. POST /command with encrypted challenge_echo → receive initial_sync + */ + async pair(desktopPubKeyB64: string): Promise { + this.keyPair = await generateKeyPair(); + const desktopPub = fromB64(desktopPubKeyB64); + this.sharedKey = await deriveSharedKey(this.keyPair, desktopPub); + + const deviceId = `mobile-${Date.now().toString(36)}`; + const deviceName = this.getMobileDeviceName(); + + // Step 1: POST /pair → encrypted challenge + const pairResp = await fetch( + `${this.relayUrl}/api/rooms/${this.roomId}/pair`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + public_key: toB64(this.keyPair.publicKey), + device_id: deviceId, + device_name: deviceName, + }), + }, + ); + + if (!pairResp.ok) { + throw new Error(`Pairing failed: HTTP ${pairResp.status}`); + } + + const pairData = await pairResp.json(); + const challengeJson = await decrypt( + this.sharedKey, + pairData.encrypted_data, + pairData.nonce, + ); + const challenge = JSON.parse(challengeJson); + + // Step 2: POST /command with challenge_echo → initial_sync + const challengeResponse = JSON.stringify({ + challenge_echo: challenge.challenge, + device_id: deviceId, + device_name: deviceName, + }); + const { data: encData, nonce: encNonce } = await encrypt( + this.sharedKey, + challengeResponse, + ); + + const cmdResp = await fetch( + `${this.relayUrl}/api/rooms/${this.roomId}/command`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ encrypted_data: encData, nonce: encNonce }), + }, + ); + + if (!cmdResp.ok) { + throw new Error(`Pairing verification failed: HTTP ${cmdResp.status}`); + } + + const cmdData = await cmdResp.json(); + const initialSyncJson = await decrypt( + this.sharedKey, + cmdData.encrypted_data, + cmdData.nonce, + ); + return JSON.parse(initialSyncJson); + } + + /** + * Send an encrypted command to the desktop and return the decrypted response. + */ + async sendCommand(cmd: object): Promise { + if (!this.sharedKey) throw new Error('Not paired'); + + const plaintext = JSON.stringify(cmd); + const { data: encData, nonce: encNonce } = await encrypt( + this.sharedKey, + plaintext, + ); + + const resp = await fetch( + `${this.relayUrl}/api/rooms/${this.roomId}/command`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ encrypted_data: encData, nonce: encNonce }), + }, + ); + + if (!resp.ok) { + throw new Error(`Command failed: HTTP ${resp.status}`); + } + + const data = await resp.json(); + const decrypted = await decrypt( + this.sharedKey, + data.encrypted_data, + data.nonce, + ); + return JSON.parse(decrypted) as T; + } + + get isPaired(): boolean { + return this.sharedKey !== null; + } + + private getMobileDeviceName(): string { + const ua = navigator.userAgent; + if (/iPhone/i.test(ua)) return 'iPhone'; + if (/iPad/i.test(ua)) return 'iPad'; + if (/Android/i.test(ua)) return 'Android'; + return 'Mobile Browser'; + } +} diff --git a/src/mobile-web/src/services/RemoteSessionManager.ts b/src/mobile-web/src/services/RemoteSessionManager.ts index 1ea69848..b2eda676 100644 --- a/src/mobile-web/src/services/RemoteSessionManager.ts +++ b/src/mobile-web/src/services/RemoteSessionManager.ts @@ -1,15 +1,14 @@ /** * Manages remote sessions by sending commands to the desktop via the relay. + * All communication is request-response via RelayHttpClient (HTTP). * - * Response delivery uses a dual mechanism: - * 1. WebSocket real-time relay (onMessage callback from RelayConnection) - * 2. HTTP polling (RelayConnection.pollMessages) for missed messages - * - * Polling is started automatically after construction and runs every 2 seconds. - * Both paths feed into the same handleMessage() dispatcher. + * Includes SessionPoller for incremental state synchronization: + * - Active tab: poll every 1 second + * - Inactive tab: poll every 5 seconds + * - On tab activation: immediate poll to catch up on missed changes */ -import { RelayConnection } from './RelayConnection'; +import { RelayHttpClient } from './RelayHttpClient'; export interface WorkspaceInfo { has_workspace: boolean; @@ -35,12 +34,52 @@ export interface SessionInfo { workspace_name?: string; } +export interface ChatMessageItem { + type: 'text' | 'tool' | 'thinking'; + content?: string; + tool?: RemoteToolStatus; +} + export interface ChatMessage { id: string; role: string; content: string; timestamp: string; metadata?: any; + tools?: RemoteToolStatus[]; + thinking?: string; + items?: ChatMessageItem[]; +} + +export interface ActiveTurnSnapshot { + turn_id: string; + status: string; + text: string; + thinking: string; + tools: RemoteToolStatus[]; + round_index: number; + items?: ChatMessageItem[]; +} + +export interface RemoteToolStatus { + id: string; + name: string; + status: string; + duration_ms?: number; + start_ms?: number; + input_preview?: string; + tool_input?: any; +} + +export interface PollResponse { + resp: string; + version: number; + changed: boolean; + session_state?: string; + title?: string; + new_messages?: ChatMessage[]; + total_msg_count?: number; + active_turn?: ActiveTurnSnapshot | null; } export interface InitialSyncData { @@ -53,90 +92,27 @@ export interface InitialSyncData { } export class RemoteSessionManager { - private relay: RelayConnection; - private pendingCallbacks = new Map void>(); - private streamListeners: ((event: any) => void)[] = []; - private initialSyncListeners: ((data: InitialSyncData) => void)[] = []; + private client: RelayHttpClient; - constructor(relay: RelayConnection) { - this.relay = relay; - this.relay.startPolling(2000); - } - - /** Register a listener that fires once when the desktop pushes initial sync after pairing. */ - onInitialSync(listener: (data: InitialSyncData) => void) { - this.initialSyncListeners.push(listener); - return () => { - this.initialSyncListeners = this.initialSyncListeners.filter(l => l !== listener); - }; - } - - onStreamEvent(listener: (event: any) => void) { - this.streamListeners.push(listener); - return () => { - this.streamListeners = this.streamListeners.filter(l => l !== listener); - }; - } - - handleMessage(json: string) { - try { - const msg = JSON.parse(json); - - // Desktop pushes this right after pairing — workspace + sessions in one shot - if (msg.resp === 'initial_sync') { - console.log('[SessionMgr] Received initial_sync from desktop', msg); - const data: InitialSyncData = { - has_workspace: msg.has_workspace, - path: msg.path, - project_name: msg.project_name, - git_branch: msg.git_branch, - sessions: msg.sessions || [], - has_more_sessions: msg.has_more_sessions ?? false, - }; - this.initialSyncListeners.forEach(l => l(data)); - return; - } - - if (msg.resp === 'stream_event') { - this.streamListeners.forEach(l => l(msg)); - return; - } - - if (msg._request_id && this.pendingCallbacks.has(msg._request_id)) { - const cb = this.pendingCallbacks.get(msg._request_id)!; - this.pendingCallbacks.delete(msg._request_id); - cb(msg); - } - } catch (e) { - console.error('[SessionMgr] Failed to parse message', e); - } + constructor(client: RelayHttpClient) { + this.client = client; } private async request(cmd: object): Promise { const requestId = `req_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; const cmdWithId = { ...cmd, _request_id: requestId }; - - return new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - this.pendingCallbacks.delete(requestId); - reject(new Error('Request timeout')); - }, 30_000); - - this.pendingCallbacks.set(requestId, (data) => { - clearTimeout(timeout); - if (data.resp === 'error') { - reject(new Error(data.message)); - } else { - resolve(data as T); - } - }); - - this.relay.sendCommand(cmdWithId).catch(reject); - }); + const resp = await this.client.sendCommand(cmdWithId); + const respAny = resp as any; + if (respAny.resp === 'error') { + throw new Error(respAny.message || 'Unknown error'); + } + return resp; } async getWorkspaceInfo(): Promise { - const resp = await this.request<{ resp: string } & WorkspaceInfo>({ cmd: 'get_workspace_info' }); + const resp = await this.request<{ resp: string } & WorkspaceInfo>({ + cmd: 'get_workspace_info', + }); return { has_workspace: resp.has_workspace, path: resp.path, @@ -146,21 +122,22 @@ export class RemoteSessionManager { } async listRecentWorkspaces(): Promise { - const resp = await this.request<{ resp: string; workspaces: RecentWorkspaceEntry[] }>({ - cmd: 'list_recent_workspaces', - }); + const resp = await this.request<{ + resp: string; + workspaces: RecentWorkspaceEntry[]; + }>({ cmd: 'list_recent_workspaces' }); return resp.workspaces || []; } - async setWorkspace(path: string): Promise<{ success: boolean; path?: string; project_name?: string; error?: string }> { - const resp = await this.request<{ - resp: string; - success: boolean; - path?: string; - project_name?: string; - error?: string; - }>({ cmd: 'set_workspace', path }); - return resp; + async setWorkspace( + path: string, + ): Promise<{ + success: boolean; + path?: string; + project_name?: string; + error?: string; + }> { + return this.request({ cmd: 'set_workspace', path }); } async listSessions( @@ -184,7 +161,11 @@ export class RemoteSessionManager { }; } - async createSession(agentType?: string, sessionName?: string, workspacePath?: string): Promise { + async createSession( + agentType?: string, + sessionName?: string, + workspacePath?: string, + ): Promise { const resp = await this.request<{ resp: string; session_id: string }>({ cmd: 'create_session', agent_type: agentType || undefined, @@ -197,9 +178,13 @@ export class RemoteSessionManager { async getSessionMessages( sessionId: string, limit?: number, - beforeId?: string + beforeId?: string, ): Promise<{ messages: ChatMessage[]; has_more: boolean }> { - const resp = await this.request<{ resp: string; messages: ChatMessage[]; has_more: boolean }>({ + const resp = await this.request<{ + resp: string; + messages: ChatMessage[]; + has_more: boolean; + }>({ cmd: 'get_session_messages', session_id: sessionId, limit, @@ -211,14 +196,6 @@ export class RemoteSessionManager { }; } - async subscribeSession(sessionId: string): Promise { - await this.request({ cmd: 'subscribe_session', session_id: sessionId }); - } - - async unsubscribeSession(sessionId: string): Promise { - await this.request({ cmd: 'unsubscribe_session', session_id: sessionId }); - } - async sendMessage( sessionId: string, content: string, @@ -243,13 +220,112 @@ export class RemoteSessionManager { await this.request({ cmd: 'delete_session', session_id: sessionId }); } + async answerQuestion(toolId: string, answers: any): Promise { + await this.request({ cmd: 'answer_question', tool_id: toolId, answers }); + } + + async pollSession( + sessionId: string, + sinceVersion: number, + knownMsgCount: number, + ): Promise { + return this.request({ + cmd: 'poll_session', + session_id: sessionId, + since_version: sinceVersion, + known_msg_count: knownMsgCount, + }); + } + async ping(): Promise { await this.request({ cmd: 'ping' }); } +} + +// ── SessionPoller ───────────────────────────────────────────────── + +export class SessionPoller { + private intervalId: ReturnType | null = null; + private sinceVersion = 0; + private knownMsgCount = 0; + private sessionId: string; + private sessionMgr: RemoteSessionManager; + private onUpdate: (state: PollResponse) => void; + private polling = false; + private stopped = false; + + constructor( + sessionMgr: RemoteSessionManager, + sessionId: string, + onUpdate: (state: PollResponse) => void, + ) { + this.sessionMgr = sessionMgr; + this.sessionId = sessionId; + this.onUpdate = onUpdate; + } + + start(initialMsgCount = 0) { + this.stopped = false; + this.knownMsgCount = initialMsgCount; + this.scheduleNext(); + document.addEventListener('visibilitychange', this.onVisibilityChange); + } + + stop() { + this.stopped = true; + if (this.intervalId !== null) { + clearTimeout(this.intervalId); + this.intervalId = null; + } + document.removeEventListener('visibilitychange', this.onVisibilityChange); + } + + resetCursors() { + this.sinceVersion = 0; + this.knownMsgCount = 0; + } - dispose() { - this.relay.stopPolling(); - this.pendingCallbacks.clear(); - this.streamListeners = []; + private scheduleNext() { + if (this.stopped) return; + if (this.intervalId !== null) clearTimeout(this.intervalId); + const interval = document.visibilityState === 'visible' ? 1000 : 5000; + this.intervalId = setTimeout(() => this.tick(), interval); + } + + private onVisibilityChange = () => { + if (this.stopped) return; + if (document.visibilityState === 'visible') { + if (this.intervalId !== null) clearTimeout(this.intervalId); + this.tick(); + } else { + this.scheduleNext(); + } + }; + + private async tick() { + if (this.stopped || this.polling) { + this.scheduleNext(); + return; + } + this.polling = true; + try { + const resp = await this.sessionMgr.pollSession( + this.sessionId, + this.sinceVersion, + this.knownMsgCount, + ); + if (resp.changed) { + this.sinceVersion = resp.version; + if (resp.total_msg_count != null) { + this.knownMsgCount = resp.total_msg_count; + } + this.onUpdate(resp); + } + } catch (e) { + console.error('[Poller] poll error', e); + } finally { + this.polling = false; + this.scheduleNext(); + } } } diff --git a/src/mobile-web/src/services/store.ts b/src/mobile-web/src/services/store.ts index 875867ac..f136885c 100644 --- a/src/mobile-web/src/services/store.ts +++ b/src/mobile-web/src/services/store.ts @@ -1,40 +1,43 @@ import { create } from 'zustand'; -import type { SessionInfo, ChatMessage, WorkspaceInfo } from './RemoteSessionManager'; -import type { ConnectionState } from './RelayConnection'; +import type { + SessionInfo, + ChatMessage, + WorkspaceInfo, + ActiveTurnSnapshot, +} from './RemoteSessionManager'; + +export type ConnectionStatus = 'pairing' | 'paired' | 'error'; interface MobileStore { - connectionState: ConnectionState; - setConnectionState: (s: ConnectionState) => void; + connectionStatus: ConnectionStatus; + setConnectionStatus: (s: ConnectionStatus) => void; - // Current workspace context (used when creating new sessions) currentWorkspace: WorkspaceInfo | null; setCurrentWorkspace: (w: WorkspaceInfo | null) => void; sessions: SessionInfo[]; setSessions: (s: SessionInfo[]) => void; appendSessions: (s: SessionInfo[]) => void; + updateSessionName: (sessionId: string, name: string) => void; activeSessionId: string | null; setActiveSessionId: (id: string | null) => void; - // Per-session message storage messagesBySession: Record; getMessages: (sessionId: string) => ChatMessage[]; setMessages: (sessionId: string, m: ChatMessage[]) => void; - appendMessage: (sessionId: string, m: ChatMessage) => void; - updateLastMessage: (sessionId: string, content: string) => void; - updateLastMessageFull: (sessionId: string, content: string, metadata: Record) => void; + appendNewMessages: (sessionId: string, messages: ChatMessage[]) => void; + + activeTurn: ActiveTurnSnapshot | null; + setActiveTurn: (t: ActiveTurnSnapshot | null) => void; error: string | null; setError: (e: string | null) => void; - - isStreaming: boolean; - setIsStreaming: (v: boolean) => void; } export const useMobileStore = create((set, get) => ({ - connectionState: 'disconnected', - setConnectionState: (connectionState) => set({ connectionState }), + connectionStatus: 'pairing', + setConnectionStatus: (connectionStatus) => set({ connectionStatus }), currentWorkspace: null, setCurrentWorkspace: (currentWorkspace) => set({ currentWorkspace }), @@ -43,6 +46,12 @@ export const useMobileStore = create((set, get) => ({ setSessions: (sessions) => set({ sessions }), appendSessions: (newSessions) => set((state) => ({ sessions: [...state.sessions, ...newSessions] })), + updateSessionName: (sessionId, name) => + set((state) => ({ + sessions: state.sessions.map((s) => + s.session_id === sessionId ? { ...s, name } : s, + ), + })), activeSessionId: null, setActiveSessionId: (activeSessionId) => set({ activeSessionId }), @@ -55,37 +64,21 @@ export const useMobileStore = create((set, get) => ({ set((s) => ({ messagesBySession: { ...s.messagesBySession, [sessionId]: m }, })), - appendMessage: (sessionId, m) => + appendNewMessages: (sessionId, messages) => set((s) => { + if (messages.length === 0) return s; const prev = s.messagesBySession[sessionId] || []; return { - messagesBySession: { ...s.messagesBySession, [sessionId]: [...prev, m] }, - }; - }), - updateLastMessage: (sessionId, content) => - set((s) => { - const msgs = [...(s.messagesBySession[sessionId] || [])]; - if (msgs.length > 0) { - msgs[msgs.length - 1] = { ...msgs[msgs.length - 1], content }; - } - return { - messagesBySession: { ...s.messagesBySession, [sessionId]: msgs }, - }; - }), - updateLastMessageFull: (sessionId, content, metadata) => - set((s) => { - const msgs = [...(s.messagesBySession[sessionId] || [])]; - if (msgs.length > 0) { - msgs[msgs.length - 1] = { ...msgs[msgs.length - 1], content, metadata }; - } - return { - messagesBySession: { ...s.messagesBySession, [sessionId]: msgs }, + messagesBySession: { + ...s.messagesBySession, + [sessionId]: [...prev, ...messages], + }, }; }), + activeTurn: null, + setActiveTurn: (activeTurn) => set({ activeTurn }), + error: null, setError: (error) => set({ error }), - - isStreaming: false, - setIsStreaming: (isStreaming) => set({ isStreaming }), })); diff --git a/src/mobile-web/src/styles/components/chat-input.scss b/src/mobile-web/src/styles/components/chat-input.scss new file mode 100644 index 00000000..72c5b47d --- /dev/null +++ b/src/mobile-web/src/styles/components/chat-input.scss @@ -0,0 +1,218 @@ +// Chat input — floating centered, glassmorphism, aligned with desktop ChatInput +.chat-page__input-bar { + position: fixed; + bottom: var(--size-gap-4); + left: 50%; + transform: translateX(-50%); + width: calc(100vw - var(--size-gap-8)); + max-width: 700px; + display: flex; + flex-direction: column; + gap: var(--size-gap-1); + z-index: 20; + + background: color-mix(in srgb, var(--color-bg-elevated) 80%, transparent); + backdrop-filter: var(--blur-strong); + -webkit-backdrop-filter: var(--blur-strong); + border: 1px solid var(--border-base); + border-radius: var(--size-radius-lg); + padding: var(--size-gap-2) var(--size-gap-3); + box-shadow: var(--shadow-xl); + transition: border-color var(--motion-fast) var(--easing-standard), + box-shadow var(--motion-fast) var(--easing-standard); + + &.is-focused { + border-color: var(--color-accent-400); + box-shadow: var(--shadow-xl), + 0 0 0 1px var(--color-accent-300); + } +} + +// Mode selector toolbar +.chat-page__input-toolbar { + display: flex; + align-items: center; + padding-bottom: var(--size-gap-1); +} + +.chat-page__mode-selector { + display: flex; + background: var(--element-bg-subtle); + border: 1px solid var(--border-subtle); + border-radius: var(--size-radius-sm); + padding: 2px; + gap: 2px; +} + +.chat-page__mode-btn { + background: none; + border: none; + border-radius: 4px; + padding: 3px var(--size-gap-2); + font-size: 11px; + font-weight: var(--font-weight-medium); + color: var(--color-text-muted); + cursor: pointer; + transition: all var(--motion-fast) var(--easing-standard); + white-space: nowrap; + + &.is-active { + background: var(--color-accent-500); + color: #fff; + } + + &:not(.is-active):active { + background: var(--element-bg-soft); + } + + &:disabled { + opacity: var(--opacity-disabled); + cursor: not-allowed; + } +} + +// Image previews +.chat-page__image-preview-row { + display: flex; + gap: var(--size-gap-2); + padding: var(--size-gap-1) 0; + overflow-x: auto; + -webkit-overflow-scrolling: touch; +} + +.chat-page__image-thumb { + position: relative; + width: 48px; + height: 48px; + border-radius: var(--size-radius-sm); + overflow: hidden; + flex-shrink: 0; + border: 1px solid var(--border-base); + + img { + width: 100%; + height: 100%; + object-fit: cover; + } +} + +.chat-page__image-remove { + position: absolute; + top: 2px; + right: 2px; + width: 16px; + height: 16px; + border-radius: 50%; + background: rgba(0, 0, 0, 0.6); + color: #fff; + border: none; + font-size: 11px; + line-height: 1; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + padding: 0; +} + +// Input row +.chat-page__input-row { + display: flex; + align-items: flex-end; + gap: var(--size-gap-2); +} + +.chat-page__attach-btn { + display: flex; + align-items: center; + justify-content: center; + background: none; + border: none; + color: var(--color-text-muted); + width: 32px; + height: 36px; + cursor: pointer; + flex-shrink: 0; + padding: 0; + transition: color var(--motion-fast) var(--easing-standard); + border-radius: var(--size-radius-sm); + + &:active:not(:disabled) { + color: var(--color-accent-500); + background: var(--element-bg-subtle); + } + &:disabled { opacity: 0.4; cursor: not-allowed; } +} + +.chat-page__input { + flex: 1; + background: transparent; + border: none; + color: var(--color-text-primary); + font-size: var(--font-size-sm); + padding: var(--size-gap-2) 0; + resize: none; + min-height: 36px; + max-height: 120px; + outline: none; + font-family: var(--font-family-sans); + line-height: var(--line-height-base); + + &::placeholder { + color: var(--color-text-disabled); + } + + &:disabled { + opacity: var(--opacity-disabled); + cursor: not-allowed; + } +} + +.chat-page__send { + display: flex; + align-items: center; + justify-content: center; + background: var(--color-accent-500); + color: #fff; + border: none; + border-radius: var(--size-radius-base); + width: 36px; + height: 36px; + cursor: pointer; + transition: all var(--motion-fast) var(--easing-standard); + flex-shrink: 0; + + &:disabled { + opacity: 0.3; + cursor: not-allowed; + } + + &:active:not(:disabled) { opacity: 0.8; } + + &.is-stop { + background: var(--color-error); + color: #fff; + + &:active { opacity: 0.7; } + } +} + +// Typing indicator dots +.chat-msg__typing { + display: inline-flex; + gap: 4px; + align-items: center; + padding: 4px 0; + + span { + display: inline-block; + width: 5px; + height: 5px; + background: var(--color-text-muted); + border-radius: 50%; + animation: typing-bounce 1.2s ease-in-out infinite; + + &:nth-child(2) { animation-delay: 0.15s; } + &:nth-child(3) { animation-delay: 0.3s; } + } +} diff --git a/src/mobile-web/src/styles/components/chat.scss b/src/mobile-web/src/styles/components/chat.scss new file mode 100644 index 00000000..abc4a02b --- /dev/null +++ b/src/mobile-web/src/styles/components/chat.scss @@ -0,0 +1,192 @@ +.chat-page { + display: flex; + flex-direction: column; + height: 100%; + background: var(--color-bg-flowchat); + animation: fadeIn var(--motion-base) var(--easing-decelerate); +} + +// Header — glassmorphism +.chat-page__header { + display: flex; + flex-direction: column; + padding: var(--size-gap-2) var(--size-gap-3); + border-bottom: 1px dashed var(--border-base); + flex-shrink: 0; + background: color-mix(in srgb, var(--color-bg-elevated) 45%, transparent); + backdrop-filter: var(--blur-base); + -webkit-backdrop-filter: var(--blur-base); + gap: 2px; + position: relative; + z-index: 10; +} + +.chat-page__header-row { + display: flex; + align-items: center; + gap: var(--size-gap-2); + min-height: 32px; +} + +.chat-page__back { + display: flex; + align-items: center; + justify-content: center; + background: none; + border: none; + color: var(--color-text-muted); + cursor: pointer; + padding: var(--size-gap-1); + flex-shrink: 0; + border-radius: var(--size-radius-sm); + transition: all var(--motion-fast) var(--easing-standard); + + &:active { + background: var(--element-bg-subtle); + color: var(--color-text-primary); + } +} + +.chat-page__header-center { + flex: 1; + min-width: 0; + overflow: hidden; + text-align: center; +} + +.chat-page__title { + font-size: var(--font-size-sm); + font-weight: var(--font-weight-semibold); + color: var(--color-text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + display: block; +} + +.chat-page__header-right { + display: flex; + align-items: center; + gap: var(--size-gap-1); + flex-shrink: 0; +} + +.chat-page__theme-btn { + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + background: none; + border: none; + color: var(--color-text-muted); + cursor: pointer; + border-radius: var(--size-radius-sm); + transition: all var(--motion-fast) var(--easing-standard); + + &:active { + background: var(--element-bg-subtle); + } +} + + +.chat-page__header-workspace { + display: flex; + align-items: center; + gap: 4px; + padding: 0 var(--size-gap-1); + font-size: var(--font-size-xs); + color: var(--color-text-muted); + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + justify-content: center; + + svg { + flex-shrink: 0; + color: var(--color-accent-500); + opacity: 0.6; + } +} + +.chat-page__workspace-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--color-accent-500); + font-weight: var(--font-weight-medium); + font-size: 11px; +} + +.chat-page__workspace-branch { + color: var(--color-text-muted); + font-weight: var(--font-weight-normal); + flex-shrink: 0; + font-size: 11px; +} + +// Messages container +.chat-page__messages { + flex: 1; + overflow-y: auto; + padding: var(--size-gap-4) var(--size-gap-4) 100px; + -webkit-overflow-scrolling: touch; + display: flex; + flex-direction: column; + gap: var(--size-gap-4); +} + +.chat-page__load-more-indicator { + text-align: center; + font-size: var(--font-size-xs); + color: var(--color-text-muted); + padding: var(--size-gap-2) 0; +} + +// Message items — FlowChat style (no bubbles) +.chat-msg { + display: flex; + flex-direction: column; + width: 100%; +} + +// User message — card style, left-aligned +.chat-msg--user { + align-items: flex-start; +} + +.chat-msg__user-card { + display: flex; + align-items: flex-start; + gap: var(--size-gap-2); + width: 100%; +} + +.chat-msg__user-avatar { + width: 24px; + height: 24px; + border-radius: var(--size-radius-sm); + background: var(--color-accent-200); + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + color: var(--color-accent-500); + font-size: 11px; + font-weight: var(--font-weight-semibold); +} + +.chat-msg__user-content { + flex: 1; + font-size: var(--font-size-sm); + line-height: var(--line-height-relaxed); + color: var(--color-text-primary); + word-break: break-word; + padding-top: 2px; +} + +// Assistant message — full width, no wrapper +.chat-msg--assistant { + align-items: flex-start; + gap: var(--size-gap-1); +} diff --git a/src/mobile-web/src/styles/components/markdown.scss b/src/mobile-web/src/styles/components/markdown.scss new file mode 100644 index 00000000..66864b2d --- /dev/null +++ b/src/mobile-web/src/styles/components/markdown.scss @@ -0,0 +1,106 @@ +// Markdown content styling — aligned with desktop FlowChat +.chat-msg__assistant-content { + font-size: var(--font-size-sm); + line-height: var(--line-height-relaxed); + color: var(--color-text-primary); + word-break: break-word; + overflow-wrap: break-word; + width: 100%; + + // Inline code + code { + background: var(--element-bg-base); + padding: 2px 5px; + border-radius: 4px; + font-size: var(--font-size-xs); + font-family: var(--font-family-mono); + } + + // Code blocks + pre { + margin: var(--size-gap-2) 0; + border-radius: var(--size-radius-base); + overflow-x: auto; + -webkit-overflow-scrolling: touch; + border: 1px solid var(--border-subtle); + + code { + background: none; + padding: 0; + font-size: var(--font-size-xs); + } + } + + p { + margin-bottom: var(--size-gap-2); + &:last-child { margin-bottom: 0; } + } + + ul, ol { + padding-left: 20px; + margin-bottom: var(--size-gap-2); + li { margin-bottom: var(--size-gap-1); } + } + + h1, h2, h3, h4, h5, h6 { + color: var(--color-text-primary); + font-weight: var(--font-weight-semibold); + margin-top: var(--size-gap-3); + margin-bottom: var(--size-gap-2); + line-height: var(--line-height-tight); + &:first-child { margin-top: 0; } + } + h1 { font-size: var(--font-size-xl); } + h2 { font-size: var(--font-size-lg); } + h3 { font-size: var(--font-size-base); } + h4, h5, h6 { font-size: var(--font-size-sm); } + + blockquote { + border-left: 3px solid var(--color-accent-400); + margin: var(--size-gap-2) 0; + padding: var(--size-gap-2) var(--size-gap-3); + background: var(--color-accent-50); + border-radius: 0 var(--size-radius-sm) var(--size-radius-sm) 0; + color: var(--color-text-muted); + font-style: italic; + p { margin-bottom: 0; } + } + + a { + color: var(--color-accent-500); + text-decoration: none; + word-break: break-all; + &:active { text-decoration: underline; } + } + + hr { + border: none; + border-top: 1px solid var(--border-subtle); + margin: var(--size-gap-3) 0; + } + + strong { font-weight: var(--font-weight-semibold); color: var(--color-text-primary); } + em { font-style: italic; } + del { color: var(--color-text-muted); } + + table { + width: 100%; + border-collapse: collapse; + margin: var(--size-gap-2) 0; + font-size: var(--font-size-xs); + display: block; + overflow-x: auto; + -webkit-overflow-scrolling: touch; + } + thead { background: var(--element-bg-subtle); } + th, td { + border: 1px solid var(--border-subtle); + padding: var(--size-gap-1) var(--size-gap-2); + text-align: left; + white-space: nowrap; + } + th { font-weight: var(--font-weight-semibold); color: var(--color-text-primary); } + td { color: var(--color-text-primary); } + tbody tr:nth-child(even) { background: var(--element-bg-subtle); } + input[type='checkbox'] { margin-right: 6px; accent-color: var(--color-accent-500); } +} diff --git a/src/mobile-web/src/styles/components/pairing.scss b/src/mobile-web/src/styles/components/pairing.scss new file mode 100644 index 00000000..270c6fae --- /dev/null +++ b/src/mobile-web/src/styles/components/pairing.scss @@ -0,0 +1,89 @@ +.pairing-page { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + gap: var(--size-gap-6); + padding: var(--size-gap-8); + animation: fadeIn var(--motion-slow) var(--easing-decelerate); +} + +// 3D Cube Logo +.pairing-page__cube { + perspective: 200px; + width: 48px; + height: 48px; + margin-bottom: var(--size-gap-2); +} + +.pairing-page__cube-inner { + width: 100%; + height: 100%; + position: relative; + transform-style: preserve-3d; + animation: cubeRotate 4s linear infinite; +} + +.pairing-page__cube-face { + position: absolute; + width: 48px; + height: 48px; + border: 2px solid var(--color-accent-400); + background: var(--color-accent-100); + border-radius: var(--size-radius-sm); + + &--front { transform: translateZ(24px); } + &--back { transform: rotateY(180deg) translateZ(24px); } + &--right { transform: rotateY(90deg) translateZ(24px); } + &--left { transform: rotateY(-90deg) translateZ(24px); } + &--top { transform: rotateX(90deg) translateZ(24px); } + &--bottom { transform: rotateX(-90deg) translateZ(24px); } +} + +.pairing-page__brand { + font-size: var(--font-size-2xl); + font-weight: var(--font-weight-semibold); + color: var(--color-accent-500); + letter-spacing: 0.5px; +} + +.pairing-page__state { + font-size: var(--font-size-sm); + color: var(--color-text-muted); + text-align: center; +} + +.pairing-page__spinner-wrap { + height: 32px; + display: flex; + align-items: center; + justify-content: center; +} + +.pairing-page__error { + font-size: var(--font-size-xs); + color: var(--color-error); + text-align: center; + max-width: 280px; + padding: var(--size-gap-2) var(--size-gap-3); + background: var(--color-error-bg); + border: 1px solid var(--color-error-border); + border-radius: var(--size-radius-base); +} + +.pairing-page__retry { + padding: var(--size-gap-3) var(--size-gap-8); + background: var(--btn-default-bg); + color: var(--color-text-primary); + border: 1px solid var(--border-base); + border-radius: var(--size-radius-base); + font-size: var(--font-size-sm); + font-weight: var(--font-weight-medium); + cursor: pointer; + transition: all var(--motion-fast) var(--easing-standard); + + &:active { + background: var(--btn-hover-bg); + } +} diff --git a/src/mobile-web/src/styles/components/sessions.scss b/src/mobile-web/src/styles/components/sessions.scss new file mode 100644 index 00000000..cfaa8ff7 --- /dev/null +++ b/src/mobile-web/src/styles/components/sessions.scss @@ -0,0 +1,275 @@ +.session-list { + display: flex; + flex-direction: column; + height: 100%; + animation: fadeIn var(--motion-base) var(--easing-decelerate); +} + +.session-list__header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 var(--size-gap-5); + height: 44px; + min-height: 44px; + border-bottom: 1px dashed var(--border-base); + background: color-mix(in srgb, var(--color-bg-elevated) 50%, transparent); + backdrop-filter: var(--blur-base); + -webkit-backdrop-filter: var(--blur-base); + flex-shrink: 0; + + h1 { + font-size: var(--font-size-lg); + font-weight: var(--font-weight-semibold); + color: var(--color-text-primary); + } +} + +.session-list__header-actions { + display: flex; + align-items: center; + gap: var(--size-gap-2); +} + +.session-list__theme-btn { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + background: none; + border: none; + color: var(--color-text-muted); + cursor: pointer; + border-radius: var(--size-radius-sm); + transition: all var(--motion-fast) var(--easing-standard); + + &:active { + background: var(--element-bg-subtle); + color: var(--color-text-primary); + } +} + +.session-list__new-wrapper { + position: relative; +} + +.session-list__new-btn { + background: var(--btn-primary-bg); + color: var(--btn-primary-color); + border: none; + border-radius: var(--size-radius-sm); + padding: var(--size-gap-1) var(--size-gap-3); + font-size: var(--font-size-xs); + font-weight: var(--font-weight-medium); + cursor: pointer; + transition: all var(--motion-fast) var(--easing-standard); + + &:active { + background: var(--btn-primary-hover-bg); + } +} + +.session-list__new-menu { + position: absolute; + top: calc(100% + 6px); + right: 0; + background: var(--color-bg-elevated); + border: 1px solid var(--border-base); + border-radius: var(--size-radius-base); + overflow: hidden; + z-index: 10; + min-width: 180px; + box-shadow: var(--shadow-xl); + backdrop-filter: var(--blur-medium); + -webkit-backdrop-filter: var(--blur-medium); + animation: slideDown var(--motion-fast) var(--easing-decelerate); +} + +.session-list__menu-item { + display: flex; + align-items: center; + gap: var(--size-gap-2); + width: 100%; + padding: var(--size-gap-3) var(--size-gap-4); + background: none; + border: none; + border-bottom: 1px solid var(--border-subtle); + color: var(--color-text-primary); + font-size: var(--font-size-sm); + cursor: pointer; + text-align: left; + + &:last-child { border-bottom: none; } + + &:active { + background: var(--element-bg-subtle); + } +} + +.session-list__menu-icon { + font-family: var(--font-family-mono); + font-size: var(--font-size-xs); + color: var(--color-accent-500); + min-width: 24px; + text-align: center; +} + +// Workspace bar +.session-list__workspace-bar { + display: flex; + align-items: center; + gap: var(--size-gap-2); + padding: var(--size-gap-2) var(--size-gap-5); + background: var(--element-bg-subtle); + border-bottom: 1px solid var(--border-subtle); + cursor: pointer; + min-height: 36px; + transition: background var(--motion-fast) var(--easing-standard); + + &:active { + background: var(--element-bg-soft); + } +} + +.session-list__workspace-icon { + flex-shrink: 0; + color: var(--color-text-muted); +} + +.session-list__workspace-name { + font-size: var(--font-size-xs); + font-weight: var(--font-weight-medium); + color: var(--color-accent-500); + flex: 1; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.session-list__workspace-branch { + display: inline-flex; + align-items: center; + gap: 3px; + font-size: 11px; + color: var(--color-accent-500); + background: var(--color-accent-100); + padding: 1px 6px; + border-radius: var(--size-radius-sm); + flex-shrink: 0; + max-width: 100px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.session-list__workspace-switch { + font-size: 11px; + color: var(--color-text-muted); + flex-shrink: 0; +} + +// Session items +.session-list__items { + flex: 1; + overflow-y: auto; + padding: var(--size-gap-2) var(--size-gap-3); + -webkit-overflow-scrolling: touch; + display: flex; + flex-direction: column; + gap: var(--size-gap-1); +} + +.session-list__empty { + text-align: center; + color: var(--color-text-muted); + padding: var(--size-gap-12) var(--size-gap-5); + font-size: var(--font-size-sm); +} + +.session-list__item { + padding: var(--size-gap-3) var(--size-gap-4); + border: 1px solid var(--border-subtle); + border-radius: var(--size-radius-base); + cursor: pointer; + transition: all var(--motion-fast) var(--easing-standard); + background: transparent; + + &:active { + background: var(--element-bg-subtle); + border-color: var(--border-base); + } +} + +.session-list__item-top { + display: flex; + align-items: center; + gap: var(--size-gap-2); +} + +.session-list__item-name { + font-size: var(--font-size-sm); + font-weight: var(--font-weight-medium); + flex: 1; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + color: var(--color-text-primary); +} + +.session-list__agent-badge { + display: inline-flex; + align-items: center; + padding: 2px var(--size-gap-2); + border-radius: var(--size-radius-sm); + font-size: var(--font-size-xs); + font-weight: var(--font-weight-medium); + background: var(--element-bg-base); + color: var(--color-text-secondary); + flex-shrink: 0; + + &--code, + &--agentic { + background: var(--color-accent-200); + color: var(--color-accent-500); + } + + &--cowork, + &--Cowork { + background: var(--color-success-bg); + color: var(--color-success); + } +} + +.session-list__item-meta { + margin-top: 3px; +} + +.session-list__item-time { + font-size: 11px; + color: var(--color-text-muted); +} + +.session-list__load-more { + text-align: center; + font-size: var(--font-size-xs); + color: var(--color-text-muted); + padding: var(--size-gap-3) 0; +} + +.session-list__refresh { + padding: var(--size-gap-3); + background: var(--element-bg-subtle); + color: var(--color-text-muted); + border: none; + border-top: 1px solid var(--border-subtle); + font-size: var(--font-size-xs); + cursor: pointer; + transition: all var(--motion-fast) var(--easing-standard); + flex-shrink: 0; + + &:active { + background: var(--element-bg-soft); + color: var(--color-text-primary); + } +} diff --git a/src/mobile-web/src/styles/components/thinking.scss b/src/mobile-web/src/styles/components/thinking.scss new file mode 100644 index 00000000..8fc0931f --- /dev/null +++ b/src/mobile-web/src/styles/components/thinking.scss @@ -0,0 +1,120 @@ +// Thinking display — aligned with desktop ModelThinkingDisplay +.chat-thinking { + display: flex; + flex-direction: column; + width: 100%; +} + +// Collapsed state +.chat-thinking__toggle { + display: inline-flex; + align-items: center; + gap: 6px; + background: none; + border: none; + color: var(--color-text-muted); + font-size: var(--font-size-xs); + cursor: pointer; + padding: 3px 0; + text-align: left; + + &:active { opacity: 0.7; } +} + +.chat-thinking__chevron { + display: inline-flex; + align-items: center; + transition: transform var(--motion-fast) var(--easing-standard); + + &.is-open { + transform: rotate(90deg); + } +} + +.chat-thinking__label { + color: var(--color-text-muted); + font-style: italic; + font-size: var(--font-size-xs); +} + +// Expand/collapse container using grid animation +.chat-thinking__expand-container { + display: grid; + grid-template-rows: 0fr; + transition: grid-template-rows var(--motion-base) var(--easing-standard); + + &.is-expanded { + grid-template-rows: 1fr; + } +} + +.chat-thinking__expand-inner { + overflow: hidden; +} + +// Content area with gradient masks +.chat-thinking__content-wrapper { + position: relative; + max-height: 300px; + overflow-y: auto; + -webkit-overflow-scrolling: touch; + + // Top gradient mask + &::before, + &::after { + content: ''; + position: sticky; + left: 0; + right: 0; + height: 24px; + z-index: 1; + pointer-events: none; + display: block; + } + + &::before { + top: 0; + background: linear-gradient(to bottom, var(--color-bg-flowchat), transparent); + } + + &::after { + bottom: 0; + background: linear-gradient(to top, var(--color-bg-flowchat), transparent); + } + + &.at-top::before { display: none; } + &.at-bottom::after { display: none; } +} + +.chat-thinking__content { + padding: var(--size-gap-2) 0; + font-size: var(--font-size-xs); + color: var(--color-text-muted); + line-height: var(--line-height-relaxed); + + p { margin-bottom: 6px; &:last-child { margin-bottom: 0; } } + code { + background: var(--element-bg-base); + padding: 1px 4px; + border-radius: 3px; + font-size: 11px; + font-family: var(--font-family-mono); + } +} + +// Streaming state — inline indicator +.chat-thinking--streaming { + .chat-thinking__label { + &::after { + content: ''; + display: inline-block; + width: 4px; + height: 12px; + background: var(--color-text-muted); + margin-left: 4px; + animation: pulse 1s ease-in-out infinite; + vertical-align: middle; + border-radius: 1px; + } + } +} diff --git a/src/mobile-web/src/styles/components/tool-card.scss b/src/mobile-web/src/styles/components/tool-card.scss new file mode 100644 index 00000000..f3c5462e --- /dev/null +++ b/src/mobile-web/src/styles/components/tool-card.scss @@ -0,0 +1,393 @@ +// Tool cards — aligned with desktop BaseToolCard +.chat-tool-list { + display: flex; + flex-direction: column; + gap: var(--size-gap-1); + width: 100%; +} + +.chat-tool-list--collapsed { + border: 1px solid var(--border-base); + border-radius: var(--size-radius-lg); + background: color-mix(in srgb, var(--color-bg-secondary) 60%, transparent); + backdrop-filter: var(--blur-base); + overflow: hidden; +} + +.chat-tool-list__header { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--size-gap-2) var(--size-gap-3); + border-bottom: 1px solid var(--border-subtle); + min-height: 32px; +} + +.chat-tool-list__count { + font-size: 12px; + font-weight: var(--font-weight-medium); + color: var(--color-text-secondary); +} + +.chat-tool-list__stats { + display: flex; + gap: var(--size-gap-2); +} + +.chat-tool-list__stat { + font-size: 11px; + font-variant-numeric: tabular-nums; + + &--done { + color: var(--color-success); + } + + &--running { + color: var(--color-warning); + } +} + +.chat-tool-list__scroll { + display: flex; + flex-direction: column; + gap: var(--size-gap-1); + padding: var(--size-gap-2); + max-height: 140px; + overflow-y: auto; + overscroll-behavior: contain; + -webkit-overflow-scrolling: touch; + scroll-behavior: smooth; + + // Prevent cards from shrinking inside the flex scroll container + .chat-tool-card { + flex-shrink: 0; + } + + // Fade at top edge to hint more content + mask-image: linear-gradient( + to bottom, + transparent 0px, + black 16px, + black 100% + ); + -webkit-mask-image: linear-gradient( + to bottom, + transparent 0px, + black 16px, + black 100% + ); + + // Thin scrollbar + &::-webkit-scrollbar { + width: 3px; + } + + &::-webkit-scrollbar-track { + background: transparent; + } + + &::-webkit-scrollbar-thumb { + background: var(--color-text-muted); + border-radius: 2px; + opacity: 0.4; + } +} + +.chat-tool-card { + border-radius: var(--size-radius-base); + border: 1px solid var(--border-base); + background: var(--color-bg-flowchat); + overflow: hidden; + transition: all var(--motion-fast) var(--easing-standard); +} + +.chat-tool-card--running { + border-color: var(--color-warning-border); +} + +.chat-tool-card--done { + border-color: var(--color-success-border); +} + +.chat-tool-card--error { + border-color: var(--color-error-border); +} + +.chat-tool-card__row { + display: flex; + align-items: center; + gap: var(--size-gap-2); + padding: var(--size-gap-2) var(--size-gap-3); + cursor: pointer; + min-height: 36px; +} + +.chat-tool-card__icon { + display: flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + flex-shrink: 0; +} + +.chat-tool-card__spinner { + display: inline-block; + width: 14px; + height: 14px; + border: 2px solid var(--color-warning-border); + border-top-color: var(--color-warning); + border-radius: 50%; + animation: spin 0.7s linear infinite; +} + +.chat-tool-card__check { + color: var(--color-success); + display: flex; + align-items: center; +} + +.chat-tool-card__error-icon { + color: var(--color-error); + display: flex; + align-items: center; +} + +.chat-tool-card__name { + flex: 1; + font-size: var(--font-size-xs); + color: var(--color-text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + font-weight: var(--font-weight-medium); +} + +.chat-tool-card__type { + display: inline-flex; + align-items: center; + padding: 1px 6px; + border-radius: var(--size-radius-sm); + font-size: 11px; + font-weight: var(--font-weight-medium); + background: var(--color-accent-100); + color: var(--color-accent-500); + flex-shrink: 0; +} + +.chat-tool-card__duration { + font-size: 11px; + color: var(--color-text-muted); + flex-shrink: 0; + font-variant-numeric: tabular-nums; + font-family: var(--font-family-mono); +} + +// Expanded content area +.chat-tool-card__expanded { + border-top: 1px solid var(--border-subtle); + padding: var(--size-gap-2) var(--size-gap-3); + font-size: var(--font-size-xs); + color: var(--color-text-muted); + max-height: 200px; + overflow-y: auto; + font-family: var(--font-family-mono); + line-height: var(--line-height-relaxed); + white-space: pre-wrap; + word-break: break-all; +} + +// ─── AskUserQuestion card ─────────────────────────────────────────────────── + +.chat-ask-card { + border: 1px solid var(--border-base); + border-radius: var(--size-radius-lg); + background: color-mix(in srgb, var(--color-bg-secondary) 80%, transparent); + backdrop-filter: var(--blur-base); + overflow: hidden; + width: 100%; +} + +.chat-ask-card__header { + display: flex; + align-items: center; + gap: var(--size-gap-2); + padding: var(--size-gap-2) var(--size-gap-3); + border-bottom: 1px solid var(--border-subtle); +} + +.chat-ask-card__count { + font-size: 12px; + font-weight: var(--font-weight-medium); + color: var(--color-text-secondary); + flex: 1; +} + +.chat-ask-card__submit { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 4px 10px; + border-radius: var(--size-radius-base); + border: 1px solid var(--color-accent-500); + background: transparent; + color: var(--color-accent-500); + font-size: 12px; + font-weight: var(--font-weight-medium); + cursor: pointer; + transition: all var(--motion-fast) var(--easing-standard); + + &:disabled { + opacity: 0.4; + cursor: not-allowed; + } + + &:not(:disabled):active { + background: var(--color-accent-100); + } +} + +.chat-ask-card__waiting { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 11px; + color: var(--color-warning); + + &::before { + content: ''; + display: inline-block; + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--color-warning); + animation: pulse-dot 1.4s ease-in-out infinite; + } +} + +@keyframes pulse-dot { + 0%, 100% { opacity: 0.4; } + 50% { opacity: 1; } +} + +.chat-ask-card__question { + padding: var(--size-gap-2) var(--size-gap-3); + + & + & { + border-top: 1px solid var(--border-subtle); + } +} + +.chat-ask-card__question-header { + display: flex; + align-items: baseline; + gap: var(--size-gap-2); + margin-bottom: var(--size-gap-2); +} + +.chat-ask-card__tag { + display: inline-flex; + padding: 1px 6px; + border-radius: var(--size-radius-sm); + font-size: 11px; + font-weight: var(--font-weight-medium); + background: var(--color-accent-100); + color: var(--color-accent-500); + flex-shrink: 0; +} + +.chat-ask-card__question-text { + font-size: 13px; + color: var(--color-text-primary); + font-weight: var(--font-weight-medium); +} + +.chat-ask-card__options { + display: flex; + flex-direction: column; + gap: var(--size-gap-1); +} + +.chat-ask-card__option { + display: flex; + align-items: center; + gap: var(--size-gap-2); + padding: 8px 10px; + border-radius: var(--size-radius-base); + border: 1px solid var(--border-base); + background: var(--color-bg-flowchat); + cursor: pointer; + text-align: left; + width: 100%; + transition: all var(--motion-fast) var(--easing-standard); + + &:active:not(:disabled) { + background: var(--color-bg-secondary); + } + + &.is-selected { + border-color: var(--color-accent-500); + background: color-mix(in srgb, var(--color-accent-100) 40%, transparent); + } + + &:disabled { + opacity: 0.6; + cursor: not-allowed; + } +} + +.chat-ask-card__radio { + display: flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + border-radius: 50%; + border: 1.5px solid var(--color-text-muted); + flex-shrink: 0; + color: var(--color-accent-500); + transition: all var(--motion-fast) var(--easing-standard); + + .is-selected & { + border-color: var(--color-accent-500); + background: var(--color-accent-500); + color: white; + } + + &--multi { + border-radius: 3px; + } +} + +.chat-ask-card__option-label { + font-size: 13px; + color: var(--color-text-primary); + font-weight: var(--font-weight-medium); + flex-shrink: 0; +} + +.chat-ask-card__option-desc { + font-size: 11px; + color: var(--color-text-muted); + flex: 1; + text-align: right; +} + +.chat-ask-card__custom-input { + width: 100%; + padding: 6px 10px; + border-radius: var(--size-radius-base); + border: 1px solid var(--border-base); + background: var(--color-bg-flowchat); + color: var(--color-text-primary); + font-size: 13px; + outline: none; + + &:focus { + border-color: var(--color-accent-500); + } + + &:disabled { + opacity: 0.5; + } +} diff --git a/src/mobile-web/src/styles/components/workspace.scss b/src/mobile-web/src/styles/components/workspace.scss new file mode 100644 index 00000000..f44f9d51 --- /dev/null +++ b/src/mobile-web/src/styles/components/workspace.scss @@ -0,0 +1,232 @@ +.workspace-page { + display: flex; + flex-direction: column; + height: 100%; + animation: fadeIn var(--motion-base) var(--easing-decelerate); +} + +.workspace-page__header { + display: flex; + align-items: center; + padding: 0 var(--size-gap-5); + height: 44px; + min-height: 44px; + border-bottom: 1px dashed var(--border-base); + background: color-mix(in srgb, var(--color-bg-elevated) 50%, transparent); + backdrop-filter: var(--blur-base); + -webkit-backdrop-filter: var(--blur-base); + flex-shrink: 0; + + h1 { + font-size: var(--font-size-lg); + font-weight: var(--font-weight-semibold); + } +} + +.workspace-page__content { + flex: 1; + overflow-y: auto; + padding: var(--size-gap-5); + -webkit-overflow-scrolling: touch; +} + +.workspace-page__loading { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + gap: var(--size-gap-4); + color: var(--color-text-muted); + font-size: var(--font-size-sm); +} + +.workspace-page__current { + display: flex; + flex-direction: column; + gap: var(--size-gap-4); +} + +.workspace-page__current-label, +.workspace-page__recent-label { + font-size: var(--font-size-xs); + font-weight: var(--font-weight-semibold); + color: var(--color-text-muted); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.workspace-page__current-card { + border: 1px solid var(--border-base); + border-radius: var(--size-radius-base); + padding: var(--size-gap-4); + background: transparent; + transition: all var(--motion-fast) var(--easing-standard); +} + +.workspace-page__project-name { + font-size: var(--font-size-lg); + font-weight: var(--font-weight-semibold); + margin-bottom: var(--size-gap-1); +} + +.workspace-page__project-path { + font-size: var(--font-size-xs); + color: var(--color-text-muted); + word-break: break-all; + margin-bottom: var(--size-gap-2); + font-family: var(--font-family-mono); +} + +.workspace-page__git-branch { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: var(--font-size-xs); + color: var(--color-accent-500); + background: var(--color-accent-100); + padding: 2px var(--size-gap-2); + border-radius: var(--size-radius-sm); +} + +.workspace-page__actions { + display: flex; + gap: var(--size-gap-2); +} + +.workspace-page__btn { + flex: 1; + padding: var(--size-gap-3) var(--size-gap-4); + border: none; + border-radius: var(--size-radius-base); + font-size: var(--font-size-sm); + font-weight: var(--font-weight-medium); + cursor: pointer; + transition: all var(--motion-fast) var(--easing-standard); + + &--primary { + background: var(--btn-primary-bg); + color: var(--btn-primary-color); + + &:active { + background: var(--btn-primary-hover-bg); + } + } + + &--secondary { + background: var(--btn-default-bg); + color: var(--color-text-primary); + border: 1px solid var(--border-base); + + &:active { + background: var(--btn-hover-bg); + } + } + + &:disabled { + opacity: var(--opacity-disabled); + cursor: not-allowed; + } +} + +.workspace-page__no-workspace { + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + padding: var(--size-gap-10) var(--size-gap-5); + gap: var(--size-gap-3); +} + +.workspace-page__no-workspace-icon { + color: var(--color-text-muted); + opacity: 0.5; +} + +.workspace-page__no-workspace-text { + font-size: var(--font-size-base); + font-weight: var(--font-weight-medium); +} + +.workspace-page__no-workspace-hint { + font-size: var(--font-size-xs); + color: var(--color-text-muted); + max-width: 280px; +} + +.workspace-page__recent { + margin-top: var(--size-gap-5); + display: flex; + flex-direction: column; + gap: var(--size-gap-3); +} + +.workspace-page__recent-empty { + text-align: center; + color: var(--color-text-muted); + font-size: var(--font-size-xs); + padding: var(--size-gap-6) 0; +} + +.workspace-page__recent-list { + display: flex; + flex-direction: column; + gap: var(--size-gap-2); +} + +.workspace-page__recent-item { + display: flex; + flex-direction: column; + align-items: flex-start; + background: transparent; + border: 1px solid var(--border-subtle); + border-radius: var(--size-radius-base); + padding: var(--size-gap-3) var(--size-gap-4); + cursor: pointer; + text-align: left; + color: var(--color-text-primary); + transition: all var(--motion-fast) var(--easing-standard); + + &:active { + background: var(--element-bg-subtle); + border-color: var(--border-base); + } + + &:disabled { + opacity: var(--opacity-disabled); + cursor: not-allowed; + } +} + +.workspace-page__recent-item-name { + font-size: var(--font-size-sm); + font-weight: var(--font-weight-medium); + margin-bottom: 2px; +} + +.workspace-page__recent-item-path { + font-size: 11px; + color: var(--color-text-muted); + word-break: break-all; + font-family: var(--font-family-mono); +} + +.workspace-page__switching { + display: flex; + align-items: center; + justify-content: center; + gap: var(--size-gap-2); + padding: var(--size-gap-4); + color: var(--color-text-muted); + font-size: var(--font-size-xs); +} + +.workspace-page__error { + margin-top: var(--size-gap-3); + font-size: var(--font-size-xs); + color: var(--color-error); + text-align: center; + padding: var(--size-gap-2) var(--size-gap-3); + background: var(--color-error-bg); + border-radius: var(--size-radius-sm); +} diff --git a/src/mobile-web/src/styles/global.scss b/src/mobile-web/src/styles/global.scss new file mode 100644 index 00000000..1060d623 --- /dev/null +++ b/src/mobile-web/src/styles/global.scss @@ -0,0 +1,113 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; + -webkit-tap-highlight-color: transparent; +} + +html, body, #root { + height: 100%; + width: 100%; + overflow: hidden; +} + +body { + font-family: var(--font-family-sans); + font-size: var(--font-size-sm); + line-height: var(--line-height-base); + color: var(--color-text-primary); + background: var(--color-bg-primary); + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +.mobile-app { + height: 100%; + width: 100%; + background: var(--color-bg-primary); + color: var(--color-text-primary); + display: flex; + flex-direction: column; + position: relative; +} + +// Scrollbar +::-webkit-scrollbar { + width: 4px; + height: 4px; +} + +::-webkit-scrollbar-track { + background: transparent; +} + +::-webkit-scrollbar-thumb { + background: var(--scrollbar-thumb); + border-radius: var(--size-radius-full); + + &:hover { + background: var(--scrollbar-thumb-hover); + } +} + +// Animations +@keyframes spin { + to { transform: rotate(360deg); } +} + +@keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.5; } +} + +@keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +@keyframes slideUp { + from { opacity: 0; transform: translateY(12px); } + to { opacity: 1; transform: translateY(0); } +} + +@keyframes slideDown { + from { opacity: 0; transform: translateY(-12px); } + to { opacity: 1; transform: translateY(0); } +} + +@keyframes typing-bounce { + 0%, 60%, 100% { transform: translateY(0); opacity: 0.4; } + 30% { transform: translateY(-4px); opacity: 1; } +} + +@keyframes cubeRotate { + 0% { transform: rotateX(-20deg) rotateY(0deg); } + 100% { transform: rotateX(-20deg) rotateY(360deg); } +} + +@keyframes focusBorderFlow { + 0% { background-position: 0% 50%; } + 50% { background-position: 100% 50%; } + 100% { background-position: 0% 50%; } +} + +// Page transition wrapper +.page-transition { + animation: fadeIn var(--motion-base) var(--easing-decelerate); +} + +// Spinner utility +.spinner { + width: 28px; + height: 28px; + border: 2.5px solid var(--border-subtle); + border-top-color: var(--color-accent-500); + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +.spinner--sm { + width: 14px; + height: 14px; + border-width: 2px; +} diff --git a/src/mobile-web/src/styles/index.scss b/src/mobile-web/src/styles/index.scss new file mode 100644 index 00000000..041e1a08 --- /dev/null +++ b/src/mobile-web/src/styles/index.scss @@ -0,0 +1,9 @@ +@use './global'; +@use './components/pairing'; +@use './components/sessions'; +@use './components/workspace'; +@use './components/chat'; +@use './components/tool-card'; +@use './components/thinking'; +@use './components/chat-input'; +@use './components/markdown'; diff --git a/src/mobile-web/src/styles/mobile.scss b/src/mobile-web/src/styles/mobile.scss deleted file mode 100644 index 6907e817..00000000 --- a/src/mobile-web/src/styles/mobile.scss +++ /dev/null @@ -1,1152 +0,0 @@ -:root { - --bg-primary: #121214; - --bg-secondary: #18181a; - --bg-card: #202024; - --text-primary: #e8e8e8; - --text-secondary: #b0b0b0; - --text-muted: #858585; - --accent: #60a5fa; - --accent-dim: rgba(96, 165, 250, 0.15); - --danger: #ef4444; - --success: #34d399; - --warning: #f59e0b; - --border: rgba(255, 255, 255, 0.12); - --border-medium: rgba(255, 255, 255, 0.18); - --element-bg-subtle: rgba(255, 255, 255, 0.06); - --element-bg-soft: rgba(255, 255, 255, 0.10); - --radius: 10px; -} - -* { - margin: 0; - padding: 0; - box-sizing: border-box; -} - -html, body, #root { - height: 100%; - width: 100%; - overflow: hidden; -} - -.mobile-app { - height: 100%; - width: 100%; - background: var(--bg-primary); - color: var(--text-primary); - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; - display: flex; - flex-direction: column; -} - -// ==================== Pairing Page ==================== - -.pairing-page { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - height: 100%; - gap: 24px; - padding: 32px; -} - -.pairing-page__logo { - font-size: 28px; - font-weight: 700; - color: var(--accent); -} - -.pairing-page__state { - font-size: 14px; - color: var(--text-muted); -} - -.pairing-page__error { - font-size: 13px; - color: var(--danger); - text-align: center; - max-width: 280px; -} - -.pairing-page__retry { - margin-top: 16px; - padding: 10px 32px; - background: var(--accent); - color: #fff; - border: none; - border-radius: 8px; - font-size: 15px; - cursor: pointer; - &:active { opacity: 0.8; } -} - -.spinner { - width: 32px; - height: 32px; - border: 3px solid var(--border); - border-top-color: var(--accent); - border-radius: 50%; - animation: spin 0.8s linear infinite; -} - -@keyframes spin { - to { transform: rotate(360deg); } -} - -// ==================== Workspace Page ==================== - -.workspace-page { - display: flex; - flex-direction: column; - height: 100%; -} - -.workspace-page__header { - display: flex; - align-items: center; - padding: 16px 20px; - border-bottom: 1px solid var(--border); - - h1 { - font-size: 18px; - font-weight: 600; - } -} - -.workspace-page__content { - flex: 1; - overflow-y: auto; - padding: 20px; - -webkit-overflow-scrolling: touch; -} - -.workspace-page__loading { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - height: 100%; - gap: 16px; - color: var(--text-muted); - font-size: 14px; -} - -.workspace-page__current { - display: flex; - flex-direction: column; - gap: 16px; -} - -.workspace-page__current-label, -.workspace-page__recent-label { - font-size: 12px; - font-weight: 600; - color: var(--text-muted); - text-transform: uppercase; - letter-spacing: 0.5px; -} - -.workspace-page__current-card { - background: var(--bg-card); - border: 1px solid var(--border); - border-radius: var(--radius); - padding: 16px; -} - -.workspace-page__project-name { - font-size: 17px; - font-weight: 600; - margin-bottom: 6px; -} - -.workspace-page__project-path { - font-size: 12px; - color: var(--text-muted); - word-break: break-all; - margin-bottom: 8px; -} - -.workspace-page__git-branch { - display: inline-flex; - align-items: center; - gap: 4px; - font-size: 12px; - color: var(--accent); - background: var(--accent-dim); - padding: 2px 8px; - border-radius: 4px; -} - -.workspace-page__branch-icon { - font-size: 14px; -} - -.workspace-page__actions { - display: flex; - gap: 10px; -} - -.workspace-page__btn { - flex: 1; - padding: 12px 16px; - border: none; - border-radius: 8px; - font-size: 14px; - font-weight: 500; - cursor: pointer; - - &--primary { - background: var(--accent); - color: #fff; - } - - &--secondary { - background: var(--bg-card); - color: var(--text-primary); - border: 1px solid var(--border); - } - - &:disabled { - opacity: 0.5; - cursor: not-allowed; - } -} - -.workspace-page__no-workspace { - display: flex; - flex-direction: column; - align-items: center; - text-align: center; - padding: 40px 20px; - gap: 12px; -} - -.workspace-page__no-workspace-icon { - font-size: 40px; -} - -.workspace-page__no-workspace-text { - font-size: 15px; - font-weight: 500; -} - -.workspace-page__no-workspace-hint { - font-size: 13px; - color: var(--text-muted); - max-width: 280px; -} - -.workspace-page__recent { - margin-top: 20px; - display: flex; - flex-direction: column; - gap: 10px; -} - -.workspace-page__recent-empty { - text-align: center; - color: var(--text-muted); - font-size: 13px; - padding: 24px 0; -} - -.workspace-page__recent-list { - display: flex; - flex-direction: column; - gap: 6px; -} - -.workspace-page__recent-item { - display: flex; - flex-direction: column; - align-items: flex-start; - background: var(--bg-card); - border: 1px solid var(--border); - border-radius: var(--radius); - padding: 14px 16px; - cursor: pointer; - text-align: left; - color: var(--text-primary); - transition: background 0.15s; - - &:active { - background: var(--bg-secondary); - } - - &:disabled { - opacity: 0.5; - cursor: not-allowed; - } -} - -.workspace-page__recent-item-name { - font-size: 14px; - font-weight: 500; - margin-bottom: 4px; -} - -.workspace-page__recent-item-path { - font-size: 11px; - color: var(--text-muted); - word-break: break-all; -} - -.workspace-page__switching { - display: flex; - align-items: center; - justify-content: center; - gap: 10px; - padding: 16px; - color: var(--text-muted); - font-size: 13px; -} - -.workspace-page__error { - margin-top: 12px; - font-size: 13px; - color: var(--danger); - text-align: center; -} - -// ==================== Session List ==================== - -.session-list { - display: flex; - flex-direction: column; - height: 100%; -} - -.session-list__header { - display: flex; - align-items: center; - justify-content: space-between; - padding: 16px 20px; - border-bottom: 1px solid var(--border); - - h1 { - font-size: 18px; - font-weight: 600; - } -} - -.session-list__new-wrapper { - position: relative; -} - -.session-list__new-btn { - background: var(--accent); - color: #fff; - border: none; - border-radius: 6px; - padding: 6px 16px; - font-size: 13px; - font-weight: 500; - cursor: pointer; -} - -.session-list__new-menu { - position: absolute; - top: 100%; - right: 0; - margin-top: 6px; - background: var(--bg-card); - border: 1px solid var(--border); - border-radius: 8px; - overflow: hidden; - z-index: 10; - min-width: 180px; - box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4); -} - -.session-list__menu-item { - display: flex; - align-items: center; - gap: 10px; - width: 100%; - padding: 12px 16px; - background: none; - border: none; - border-bottom: 1px solid var(--border); - color: var(--text-primary); - font-size: 14px; - cursor: pointer; - text-align: left; - - &:last-child { - border-bottom: none; - } - - &:active { - background: var(--bg-secondary); - } -} - -.session-list__menu-icon { - font-family: monospace; - font-size: 14px; - color: var(--accent); - min-width: 28px; - text-align: center; -} - -// Workspace banner bar -.session-list__workspace-bar { - display: flex; - align-items: center; - gap: 6px; - padding: 8px 20px; - background: var(--bg-secondary); - border-bottom: 1px solid var(--border); - cursor: pointer; - min-height: 36px; - transition: background 0.15s; - - &:active { - background: var(--bg-card); - } -} - -.session-list__workspace-icon { - font-size: 13px; - flex-shrink: 0; -} - -.session-list__workspace-name { - font-size: 12px; - font-weight: 500; - color: var(--accent); - flex: 1; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.session-list__workspace-branch { - font-size: 11px; - color: var(--text-muted); - background: rgba(96, 165, 250, 0.1); - padding: 1px 6px; - border-radius: 4px; - flex-shrink: 0; - max-width: 100px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.session-list__workspace-switch { - font-size: 11px; - color: var(--text-muted); - flex-shrink: 0; -} - -.session-list__items { - flex: 1; - overflow-y: auto; - padding: 8px 0; - -webkit-overflow-scrolling: touch; -} - -.session-list__empty { - text-align: center; - color: var(--text-muted); - padding: 48px 20px; - font-size: 14px; -} - -.session-list__item { - padding: 14px 20px; - border-bottom: 1px solid var(--border); - cursor: pointer; - transition: background 0.15s; - - &:active { - background: var(--bg-secondary); - } -} - -.session-list__item-top { - display: flex; - align-items: center; - justify-content: space-between; - margin-bottom: 6px; -} - -.session-list__item-name { - font-size: 15px; - font-weight: 500; - flex: 1; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - margin-right: 8px; -} - -.session-list__agent-badge { - font-size: 11px; - font-weight: 600; - padding: 2px 8px; - border-radius: 4px; - background: var(--bg-secondary); - color: var(--text-muted); - flex-shrink: 0; - - &--code, - &--agentic { - background: rgba(96, 165, 250, 0.2); - color: var(--accent); - } - - &--cowork, - &--Cowork { - background: rgba(52, 211, 153, 0.2); - color: var(--success); - } -} - -.session-list__item-meta { - font-size: 12px; - color: var(--text-muted); - display: flex; - justify-content: space-between; -} - -.session-list__item-time { - flex-shrink: 0; -} - -.session-list__load-more { - text-align: center; - font-size: 12px; - color: var(--text-muted); - padding: 12px 0; -} - -.session-list__refresh { - padding: 12px; - background: var(--bg-secondary); - color: var(--text-muted); - border: none; - border-top: 1px solid var(--border); - font-size: 13px; - cursor: pointer; -} - -// ==================== Chat Page ==================== - -.chat-page { - display: flex; - flex-direction: column; - height: 100%; - background: var(--bg-primary); -} - -// ── Header ────────────────────────────────────────────────────────────────── - -.chat-page__header { - display: flex; - flex-direction: column; - padding: 8px 12px; - border-bottom: 1px solid var(--border); - flex-shrink: 0; - background: var(--bg-secondary); - gap: 4px; -} - -.chat-page__header-row { - display: flex; - align-items: center; - gap: 8px; -} - -.chat-page__back { - display: flex; - align-items: center; - justify-content: center; - background: none; - border: none; - color: var(--accent); - cursor: pointer; - padding: 4px; - flex-shrink: 0; - border-radius: 6px; - - &:active { opacity: 0.7; } -} - -.chat-page__header-center { - flex: 1; - min-width: 0; - overflow: hidden; -} - -.chat-page__title { - font-size: 14px; - font-weight: 600; - color: var(--text-primary); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - display: block; -} - -.chat-page__header-workspace { - display: flex; - align-items: center; - gap: 5px; - padding: 0 4px 0 28px; - font-size: 12px; - color: var(--text-muted); - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; - - svg { - flex-shrink: 0; - color: var(--accent); - opacity: 0.7; - } -} - -.chat-page__workspace-name { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - color: var(--accent); - font-weight: 500; -} - -.chat-page__workspace-branch { - color: var(--text-muted); - font-weight: 400; - flex-shrink: 0; - font-size: 11px; -} - -.chat-page__cancel { - background: rgba(239, 68, 68, 0.1); - color: var(--danger); - border: 1px solid rgba(239, 68, 68, 0.3); - border-radius: 6px; - padding: 4px 10px; - font-size: 12px; - font-weight: 500; - cursor: pointer; - flex-shrink: 0; - - &:active { opacity: 0.7; } -} - -// ── Messages container ──────────────────────────────────────────────────────── - -.chat-page__messages { - flex: 1; - overflow-y: auto; - padding: 16px 16px 8px; - -webkit-overflow-scrolling: touch; - display: flex; - flex-direction: column; - gap: 4px; -} - -.chat-page__load-more-indicator { - text-align: center; - font-size: 12px; - color: var(--text-muted); - padding: 8px 0; -} - -// ── Message rows ────────────────────────────────────────────────────────────── - -.chat-msg { - display: flex; - flex-direction: column; - margin-bottom: 12px; -} - -// User bubble — right-aligned, purple background -.chat-msg--user { - align-items: flex-end; -} - -.chat-msg__bubble-user { - background: var(--accent); - color: #fff; - border-radius: 16px 16px 4px 16px; - padding: 10px 14px; - font-size: 14px; - line-height: 1.55; - max-width: 82%; - word-break: break-word; -} - -// Assistant message — left-aligned, no background wrapper (content handles it) -.chat-msg--assistant { - align-items: flex-start; - gap: 4px; -} - -// Assistant markdown content block -.chat-msg__assistant-content { - font-size: 14px; - line-height: 1.65; - color: var(--text-primary); - word-break: break-word; - overflow-wrap: break-word; - width: 100%; - - // Inline code - code { - background: rgba(255, 255, 255, 0.1); - padding: 2px 5px; - border-radius: 4px; - font-size: 12px; - font-family: 'Fira Code', 'JetBrains Mono', Consolas, monospace; - } - - // Code blocks - pre { - margin: 8px 0; - border-radius: 8px; - overflow-x: auto; - -webkit-overflow-scrolling: touch; - - code { - background: none; - padding: 0; - font-size: 12px; - } - } - - p { - margin-bottom: 8px; - &:last-child { margin-bottom: 0; } - } - - ul, ol { - padding-left: 20px; - margin-bottom: 8px; - li { margin-bottom: 4px; } - } - - h1, h2, h3, h4, h5, h6 { - color: var(--text-primary); - font-weight: 600; - margin-top: 12px; - margin-bottom: 6px; - line-height: 1.3; - &:first-child { margin-top: 0; } - } - h1 { font-size: 18px; } - h2 { font-size: 16px; } - h3 { font-size: 15px; } - h4, h5, h6 { font-size: 14px; } - - blockquote { - border-left: 3px solid var(--accent); - margin: 8px 0; - padding: 6px 12px; - background: rgba(96, 165, 250, 0.08); - border-radius: 0 4px 4px 0; - color: var(--text-muted); - font-style: italic; - p { margin-bottom: 0; } - } - - a { - color: var(--accent); - text-decoration: none; - word-break: break-all; - &:active { text-decoration: underline; } - } - - hr { - border: none; - border-top: 1px solid var(--border); - margin: 12px 0; - } - - strong { font-weight: 600; color: var(--text-primary); } - em { font-style: italic; } - del { color: var(--text-muted); } - - table { - width: 100%; - border-collapse: collapse; - margin: 8px 0; - font-size: 13px; - display: block; - overflow-x: auto; - -webkit-overflow-scrolling: touch; - } - thead { background: rgba(96, 165, 250, 0.15); } - th, td { - border: 1px solid var(--border); - padding: 6px 10px; - text-align: left; - white-space: nowrap; - } - th { font-weight: 600; color: var(--text-primary); } - td { color: var(--text-primary); } - tbody tr:nth-child(even) { background: rgba(255, 255, 255, 0.03); } - input[type='checkbox'] { margin-right: 6px; accent-color: var(--accent); } -} - -// ── Thinking block (desktop-style) ──────────────────────────────────────────── - -.chat-thinking { - display: flex; - flex-direction: column; - width: 100%; - margin-bottom: 4px; -} - -.chat-thinking__toggle { - display: inline-flex; - align-items: center; - gap: 6px; - background: none; - border: none; - color: var(--text-muted); - font-size: 12px; - cursor: pointer; - padding: 3px 0; - text-align: left; - - &:active { opacity: 0.7; } -} - -.chat-thinking__arrow { - font-size: 10px; - transition: transform 0.15s ease; - display: inline-block; - - &.is-open { - transform: rotate(90deg); - } -} - -.chat-thinking__label { - color: var(--text-muted); - font-style: italic; - font-size: 12px; -} - -.chat-thinking__content { - margin-top: 6px; - padding: 8px 12px; - background: rgba(96, 165, 250, 0.05); - border-left: 2px solid rgba(96, 165, 250, 0.4); - border-radius: 0 6px 6px 0; - font-size: 12px; - color: var(--text-muted); - line-height: 1.55; - - p { margin-bottom: 6px; &:last-child { margin-bottom: 0; } } - code { - background: rgba(255, 255, 255, 0.08); - padding: 1px 4px; - border-radius: 3px; - font-size: 11px; - font-family: 'Fira Code', monospace; - } -} - -// ── Tool cards (desktop-style) ──────────────────────────────────────────────── - -.chat-tool-list { - display: flex; - flex-direction: column; - gap: 3px; - width: 100%; - margin-bottom: 4px; -} - -.chat-tool-card { - border-radius: 8px; - border: 1px solid var(--border); - background: var(--bg-secondary); - overflow: hidden; - - &--done { - border-color: rgba(52, 211, 153, 0.2); - background: rgba(52, 211, 153, 0.04); - } - - &--error { - border-color: rgba(239, 68, 68, 0.2); - background: rgba(239, 68, 68, 0.04); - } - - &--running { - border-color: rgba(245, 158, 11, 0.2); - background: rgba(245, 158, 11, 0.04); - } -} - -.chat-tool-card__row { - display: flex; - align-items: center; - gap: 8px; - padding: 7px 12px; - cursor: pointer; - min-height: 36px; -} - -.chat-tool-card__icon { - display: flex; - align-items: center; - justify-content: center; - width: 18px; - flex-shrink: 0; -} - -.chat-tool-card__check { - font-size: 12px; - color: var(--success); - font-weight: 700; -} - -.chat-tool-card__error-icon { - font-size: 12px; - color: var(--danger); - font-weight: 700; -} - -.chat-tool-card__spinner { - display: inline-block; - width: 12px; - height: 12px; - border: 2px solid rgba(245, 158, 11, 0.3); - border-top-color: var(--warning); - border-radius: 50%; - animation: spin 0.7s linear infinite; -} - -.chat-tool-card__name { - flex: 1; - font-size: 13px; - color: var(--text-primary); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - font-weight: 500; -} - -.chat-tool-card__type { - font-size: 11px; - font-weight: 600; - color: var(--accent); - background: rgba(96, 165, 250, 0.12); - border-radius: 4px; - padding: 1px 6px; - flex-shrink: 0; -} - -.chat-tool-card__duration { - font-size: 11px; - color: var(--text-muted); - flex-shrink: 0; - font-variant-numeric: tabular-nums; -} - -// ── Typing dots ─────────────────────────────────────────────────────────────── - -.chat-msg__typing { - display: inline-flex; - gap: 4px; - align-items: center; - padding: 4px 0; - - span { - display: inline-block; - width: 6px; - height: 6px; - background: var(--text-muted); - border-radius: 50%; - animation: typing-bounce 1.2s ease-in-out infinite; - - &:nth-child(2) { animation-delay: 0.15s; } - &:nth-child(3) { animation-delay: 0.3s; } - } -} - -@keyframes typing-bounce { - 0%, 60%, 100% { transform: translateY(0); opacity: 0.4; } - 30% { transform: translateY(-4px); opacity: 1; } -} - -// ── Input bar ───────────────────────────────────────────────────────────────── - -.chat-page__input-bar { - display: flex; - flex-direction: column; - padding: 8px 12px; - border-top: 1px solid var(--border); - background: var(--bg-secondary); - gap: 6px; - flex-shrink: 0; -} - -.chat-page__input-toolbar { - display: flex; - align-items: center; -} - -.chat-page__mode-selector { - display: flex; - background: var(--bg-primary); - border: 1px solid var(--border); - border-radius: 8px; - padding: 2px; - gap: 2px; -} - -.chat-page__mode-btn { - background: none; - border: none; - border-radius: 6px; - padding: 4px 12px; - font-size: 12px; - font-weight: 500; - color: var(--text-muted); - cursor: pointer; - transition: all 0.15s; - white-space: nowrap; - - &.is-active { - background: var(--accent); - color: #fff; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15); - } - - &:not(.is-active):active { - background: rgba(96, 165, 250, 0.08); - } - - &:disabled { - opacity: 0.5; - cursor: not-allowed; - } -} - -.chat-page__input-row { - display: flex; - align-items: flex-end; - gap: 8px; -} - -.chat-page__input { - flex: 1; - background: var(--bg-primary); - border: 1px solid var(--border); - border-radius: 10px; - color: var(--text-primary); - font-size: 14px; - padding: 10px 14px; - resize: none; - min-height: 42px; - max-height: 120px; - outline: none; - font-family: inherit; - line-height: 1.4; - transition: border-color 0.15s; - - &:focus { border-color: var(--accent); } - - &:disabled { - opacity: 0.6; - cursor: not-allowed; - } -} - -.chat-page__attach-btn { - display: flex; - align-items: center; - justify-content: center; - background: none; - border: none; - color: var(--text-muted); - width: 36px; - height: 42px; - cursor: pointer; - flex-shrink: 0; - padding: 0; - transition: color 0.15s; - - &:active:not(:disabled) { color: var(--accent); } - &:disabled { opacity: 0.4; cursor: not-allowed; } -} - -.chat-page__send { - display: flex; - align-items: center; - justify-content: center; - background: var(--accent); - color: #fff; - border: none; - border-radius: 10px; - width: 42px; - height: 42px; - cursor: pointer; - transition: opacity 0.15s; - flex-shrink: 0; - - &:disabled { - opacity: 0.4; - cursor: not-allowed; - } - - &:active:not(:disabled) { opacity: 0.8; } - - &.is-streaming { - background: var(--bg-card); - color: var(--text-muted); - } -} - -// ── Image preview row ─────────────────────────────────────────────────────── - -.chat-page__image-preview-row { - display: flex; - gap: 8px; - padding: 0 4px; - overflow-x: auto; - -webkit-overflow-scrolling: touch; -} - -.chat-page__image-thumb { - position: relative; - width: 56px; - height: 56px; - border-radius: 8px; - overflow: hidden; - flex-shrink: 0; - border: 1px solid var(--border); - - img { - width: 100%; - height: 100%; - object-fit: cover; - } -} - -.chat-page__image-remove { - position: absolute; - top: 2px; - right: 2px; - width: 18px; - height: 18px; - border-radius: 50%; - background: rgba(0, 0, 0, 0.6); - color: #fff; - border: none; - font-size: 12px; - line-height: 1; - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - padding: 0; -} diff --git a/src/mobile-web/src/theme/ThemeProvider.tsx b/src/mobile-web/src/theme/ThemeProvider.tsx new file mode 100644 index 00000000..38a4e2bf --- /dev/null +++ b/src/mobile-web/src/theme/ThemeProvider.tsx @@ -0,0 +1,61 @@ +import React, { createContext, useCallback, useEffect, useState } from 'react'; +import { darkTheme } from './presets/dark'; +import { lightTheme } from './presets/light'; + +export type ThemeId = 'dark' | 'light'; + +interface ThemeContextValue { + themeId: ThemeId; + isDark: boolean; + setTheme: (id: ThemeId) => void; + toggleTheme: () => void; +} + +const STORAGE_KEY = 'bitfun-mobile-theme'; + +const themeMap: Record> = { + dark: darkTheme, + light: lightTheme, +}; + +function applyTheme(id: ThemeId) { + const vars = themeMap[id]; + const root = document.documentElement; + for (const [key, value] of Object.entries(vars)) { + root.style.setProperty(key, value); + } + root.setAttribute('data-theme', id); +} + +function getInitialTheme(): ThemeId { + try { + const stored = localStorage.getItem(STORAGE_KEY); + if (stored === 'dark' || stored === 'light') return stored; + } catch { /* ignore */ } + return window.matchMedia?.('(prefers-color-scheme: light)').matches ? 'light' : 'dark'; +} + +export const ThemeContext = createContext({ + themeId: 'dark', + isDark: true, + setTheme: () => {}, + toggleTheme: () => {}, +}); + +export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const [themeId, setThemeId] = useState(getInitialTheme); + + useEffect(() => { + applyTheme(themeId); + try { localStorage.setItem(STORAGE_KEY, themeId); } catch { /* ignore */ } + }, [themeId]); + + const setTheme = useCallback((id: ThemeId) => setThemeId(id), []); + const toggleTheme = useCallback(() => setThemeId(prev => prev === 'dark' ? 'light' : 'dark'), []); + + return ( + + {children} + + ); +}; diff --git a/src/mobile-web/src/theme/index.ts b/src/mobile-web/src/theme/index.ts new file mode 100644 index 00000000..e9cfea1f --- /dev/null +++ b/src/mobile-web/src/theme/index.ts @@ -0,0 +1,3 @@ +export { ThemeProvider, ThemeContext } from './ThemeProvider'; +export { useTheme } from './useTheme'; +export type { ThemeId } from './ThemeProvider'; diff --git a/src/mobile-web/src/theme/presets/dark.ts b/src/mobile-web/src/theme/presets/dark.ts new file mode 100644 index 00000000..7c3fb522 --- /dev/null +++ b/src/mobile-web/src/theme/presets/dark.ts @@ -0,0 +1,141 @@ +export const darkTheme: Record = { + '--color-bg-primary': '#121214', + '--color-bg-secondary': '#18181a', + '--color-bg-tertiary': '#121214', + '--color-bg-quaternary': '#202024', + '--color-bg-elevated': '#18181a', + '--color-bg-workbench': '#121214', + '--color-bg-scene': '#16161a', + '--color-bg-flowchat': '#16161a', + '--color-bg-tooltip': 'rgba(30, 30, 32, 0.92)', + + '--color-text-primary': '#e8e8e8', + '--color-text-secondary': '#b0b0b0', + '--color-text-muted': '#858585', + '--color-text-disabled': '#555555', + + '--element-bg-subtle': 'rgba(255, 255, 255, 0.06)', + '--element-bg-soft': 'rgba(255, 255, 255, 0.10)', + '--element-bg-base': 'rgba(255, 255, 255, 0.13)', + '--element-bg-medium': 'rgba(255, 255, 255, 0.17)', + '--element-bg-strong': 'rgba(255, 255, 255, 0.21)', + '--element-bg-elevated': 'rgba(255, 255, 255, 0.25)', + + '--color-accent-50': 'rgba(96, 165, 250, 0.04)', + '--color-accent-100': 'rgba(96, 165, 250, 0.08)', + '--color-accent-200': 'rgba(96, 165, 250, 0.15)', + '--color-accent-300': 'rgba(96, 165, 250, 0.25)', + '--color-accent-400': 'rgba(96, 165, 250, 0.4)', + '--color-accent-500': '#60a5fa', + '--color-accent-600': '#3b82f6', + '--color-accent-700': 'rgba(59, 130, 246, 0.8)', + '--color-accent-800': 'rgba(59, 130, 246, 0.9)', + + '--color-purple-50': 'rgba(139, 92, 246, 0.04)', + '--color-purple-100': 'rgba(139, 92, 246, 0.08)', + '--color-purple-200': 'rgba(139, 92, 246, 0.15)', + '--color-purple-300': 'rgba(139, 92, 246, 0.25)', + '--color-purple-400': 'rgba(139, 92, 246, 0.4)', + '--color-purple-500': '#8b5cf6', + '--color-purple-600': '#7c3aed', + '--color-purple-700': 'rgba(124, 58, 237, 0.8)', + '--color-purple-800': 'rgba(124, 58, 237, 0.9)', + + '--color-success': '#34d399', + '--color-success-bg': 'rgba(52, 211, 153, 0.1)', + '--color-success-border': 'rgba(52, 211, 153, 0.3)', + '--color-warning': '#f59e0b', + '--color-warning-bg': 'rgba(245, 158, 11, 0.1)', + '--color-warning-border': 'rgba(245, 158, 11, 0.3)', + '--color-error': '#ef4444', + '--color-error-bg': 'rgba(239, 68, 68, 0.1)', + '--color-error-border': 'rgba(239, 68, 68, 0.3)', + '--color-info': '#E1AB80', + '--color-info-bg': 'rgba(225, 171, 128, 0.1)', + '--color-info-border': 'rgba(225, 171, 128, 0.3)', + + '--border-subtle': 'rgba(255, 255, 255, 0.12)', + '--border-base': 'rgba(255, 255, 255, 0.18)', + '--border-medium': 'rgba(255, 255, 255, 0.24)', + '--border-strong': 'rgba(255, 255, 255, 0.32)', + '--border-prominent': 'rgba(225, 171, 128, 0.50)', + + '--shadow-xs': '0 1px 2px rgba(0, 0, 0, 0.9)', + '--shadow-sm': '0 2px 4px rgba(0, 0, 0, 0.8)', + '--shadow-base': '0 4px 8px rgba(0, 0, 0, 0.7)', + '--shadow-lg': '0 8px 16px rgba(0, 0, 0, 0.6)', + '--shadow-xl': '0 12px 24px rgba(0, 0, 0, 0.5)', + '--shadow-2xl': '0 16px 32px rgba(0, 0, 0, 0.4)', + + '--blur-subtle': 'blur(4px) saturate(1.05)', + '--blur-base': 'blur(8px) saturate(1.1)', + '--blur-medium': 'blur(12px) saturate(1.2)', + '--blur-strong': 'blur(16px) saturate(1.3) brightness(1.1)', + '--blur-intense': 'blur(20px) saturate(1.4) brightness(1.15)', + + '--size-radius-sm': '6px', + '--size-radius-base': '8px', + '--size-radius-lg': '12px', + '--size-radius-xl': '16px', + '--size-radius-2xl': '20px', + '--size-radius-full': '9999px', + + '--size-gap-1': '4px', + '--size-gap-2': '8px', + '--size-gap-3': '12px', + '--size-gap-4': '16px', + '--size-gap-5': '20px', + '--size-gap-6': '24px', + '--size-gap-8': '32px', + '--size-gap-10': '40px', + '--size-gap-12': '48px', + '--size-gap-16': '64px', + + '--motion-instant': '0.1s', + '--motion-fast': '0.15s', + '--motion-base': '0.3s', + '--motion-slow': '0.6s', + '--motion-lazy': '1s', + + '--easing-standard': 'cubic-bezier(0.4, 0, 0.2, 1)', + '--easing-decelerate': 'cubic-bezier(0, 0, 0.2, 1)', + '--easing-accelerate': 'cubic-bezier(0.4, 0, 1, 1)', + '--easing-bounce': 'cubic-bezier(0.68, -0.55, 0.265, 1.55)', + '--easing-smooth': 'cubic-bezier(0.4, 0, 0.2, 1)', + + '--font-family-sans': "'Noto Sans SC', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'SF Pro Display', Roboto, sans-serif", + '--font-family-mono': "'FiraCode', 'JetBrains Mono', 'SF Mono', 'Consolas', 'Liberation Mono', monospace", + '--font-weight-normal': '400', + '--font-weight-medium': '500', + '--font-weight-semibold': '600', + '--font-weight-bold': '700', + + '--font-size-xs': '12px', + '--font-size-sm': '14px', + '--font-size-base': '15px', + '--font-size-lg': '16px', + '--font-size-xl': '18px', + '--font-size-2xl': '20px', + '--font-size-3xl': '24px', + + '--line-height-tight': '1.2', + '--line-height-base': '1.5', + '--line-height-relaxed': '1.6', + + '--opacity-disabled': '0.6', + '--opacity-hover': '0.8', + + '--scrollbar-thumb': 'rgba(255, 255, 255, 0.15)', + '--scrollbar-thumb-hover': 'rgba(255, 255, 255, 0.28)', + + '--btn-default-bg': 'rgba(255, 255, 255, 0.08)', + '--btn-default-color': '#9a9a9a', + '--btn-hover-bg': 'rgba(255, 255, 255, 0.14)', + '--btn-hover-color': '#c8c8c8', + '--btn-primary-bg': '#E1AB80', + '--btn-primary-color': '#000000', + '--btn-primary-hover-bg': '#F6D0A3', + '--btn-ghost-bg': 'transparent', + '--btn-ghost-color': '#9a9a9a', + '--btn-ghost-hover-bg': 'rgba(255, 255, 255, 0.10)', +}; diff --git a/src/mobile-web/src/theme/presets/light.ts b/src/mobile-web/src/theme/presets/light.ts new file mode 100644 index 00000000..b522e823 --- /dev/null +++ b/src/mobile-web/src/theme/presets/light.ts @@ -0,0 +1,141 @@ +export const lightTheme: Record = { + '--color-bg-primary': '#f7f8fa', + '--color-bg-secondary': '#ffffff', + '--color-bg-tertiary': '#f3f5f8', + '--color-bg-quaternary': '#ebeef3', + '--color-bg-elevated': '#ffffff', + '--color-bg-workbench': '#f7f8fa', + '--color-bg-scene': '#ffffff', + '--color-bg-flowchat': '#ffffff', + '--color-bg-tooltip': 'rgba(255, 255, 255, 0.98)', + + '--color-text-primary': '#1e293b', + '--color-text-secondary': '#3d4f66', + '--color-text-muted': '#64748b', + '--color-text-disabled': '#94a3b8', + + '--element-bg-subtle': 'rgba(71, 102, 143, 0.05)', + '--element-bg-soft': 'rgba(71, 102, 143, 0.08)', + '--element-bg-base': 'rgba(71, 102, 143, 0.11)', + '--element-bg-medium': 'rgba(71, 102, 143, 0.15)', + '--element-bg-strong': 'rgba(71, 102, 143, 0.20)', + '--element-bg-elevated': 'rgba(255, 255, 255, 0.92)', + + '--color-accent-50': 'rgba(71, 102, 143, 0.04)', + '--color-accent-100': 'rgba(71, 102, 143, 0.08)', + '--color-accent-200': 'rgba(71, 102, 143, 0.14)', + '--color-accent-300': 'rgba(71, 102, 143, 0.22)', + '--color-accent-400': 'rgba(71, 102, 143, 0.36)', + '--color-accent-500': '#5a7bb2', + '--color-accent-600': '#4a6694', + '--color-accent-700': 'rgba(74, 102, 148, 0.8)', + '--color-accent-800': 'rgba(74, 102, 148, 0.9)', + + '--color-purple-50': 'rgba(107, 90, 137, 0.04)', + '--color-purple-100': 'rgba(107, 90, 137, 0.08)', + '--color-purple-200': 'rgba(107, 90, 137, 0.14)', + '--color-purple-300': 'rgba(107, 90, 137, 0.22)', + '--color-purple-400': 'rgba(107, 90, 137, 0.36)', + '--color-purple-500': '#7c6b99', + '--color-purple-600': '#655680', + '--color-purple-700': 'rgba(101, 86, 128, 0.8)', + '--color-purple-800': 'rgba(101, 86, 128, 0.9)', + + '--color-success': '#5b9a6f', + '--color-success-bg': 'rgba(91, 154, 111, 0.08)', + '--color-success-border': 'rgba(91, 154, 111, 0.25)', + '--color-warning': '#c08c42', + '--color-warning-bg': 'rgba(192, 140, 66, 0.08)', + '--color-warning-border': 'rgba(192, 140, 66, 0.25)', + '--color-error': '#c26565', + '--color-error-bg': 'rgba(194, 101, 101, 0.08)', + '--color-error-border': 'rgba(194, 101, 101, 0.25)', + '--color-info': '#5a7bb2', + '--color-info-bg': 'rgba(90, 123, 178, 0.08)', + '--color-info-border': 'rgba(90, 123, 178, 0.25)', + + '--border-subtle': 'rgba(100, 116, 139, 0.15)', + '--border-base': 'rgba(100, 116, 139, 0.22)', + '--border-medium': 'rgba(100, 116, 139, 0.32)', + '--border-strong': 'rgba(100, 116, 139, 0.42)', + '--border-prominent': 'rgba(100, 116, 139, 0.52)', + + '--shadow-xs': '0 1px 2px rgba(71, 85, 105, 0.06)', + '--shadow-sm': '0 2px 4px rgba(71, 85, 105, 0.08)', + '--shadow-base': '0 4px 8px rgba(71, 85, 105, 0.10)', + '--shadow-lg': '0 8px 16px rgba(71, 85, 105, 0.12)', + '--shadow-xl': '0 12px 24px rgba(71, 85, 105, 0.14)', + '--shadow-2xl': '0 16px 32px rgba(71, 85, 105, 0.16)', + + '--blur-subtle': 'blur(4px) saturate(1.02)', + '--blur-base': 'blur(8px) saturate(1.05)', + '--blur-medium': 'blur(12px) saturate(1.08)', + '--blur-strong': 'blur(16px) saturate(1.10) brightness(1.02)', + '--blur-intense': 'blur(20px) saturate(1.12) brightness(1.03)', + + '--size-radius-sm': '6px', + '--size-radius-base': '8px', + '--size-radius-lg': '12px', + '--size-radius-xl': '16px', + '--size-radius-2xl': '20px', + '--size-radius-full': '9999px', + + '--size-gap-1': '4px', + '--size-gap-2': '8px', + '--size-gap-3': '12px', + '--size-gap-4': '16px', + '--size-gap-5': '20px', + '--size-gap-6': '24px', + '--size-gap-8': '32px', + '--size-gap-10': '40px', + '--size-gap-12': '48px', + '--size-gap-16': '64px', + + '--motion-instant': '0.1s', + '--motion-fast': '0.15s', + '--motion-base': '0.3s', + '--motion-slow': '0.6s', + '--motion-lazy': '1s', + + '--easing-standard': 'cubic-bezier(0.4, 0, 0.2, 1)', + '--easing-decelerate': 'cubic-bezier(0, 0, 0.2, 1)', + '--easing-accelerate': 'cubic-bezier(0.4, 0, 1, 1)', + '--easing-bounce': 'cubic-bezier(0.68, -0.55, 0.265, 1.55)', + '--easing-smooth': 'cubic-bezier(0.4, 0, 0.2, 1)', + + '--font-family-sans': "'Noto Sans SC', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'SF Pro Display', Roboto, sans-serif", + '--font-family-mono': "'FiraCode', 'JetBrains Mono', 'SF Mono', 'Consolas', 'Liberation Mono', monospace", + '--font-weight-normal': '400', + '--font-weight-medium': '500', + '--font-weight-semibold': '600', + '--font-weight-bold': '700', + + '--font-size-xs': '12px', + '--font-size-sm': '14px', + '--font-size-base': '15px', + '--font-size-lg': '16px', + '--font-size-xl': '18px', + '--font-size-2xl': '20px', + '--font-size-3xl': '24px', + + '--line-height-tight': '1.2', + '--line-height-base': '1.5', + '--line-height-relaxed': '1.6', + + '--opacity-disabled': '0.55', + '--opacity-hover': '0.75', + + '--scrollbar-thumb': 'rgba(100, 116, 139, 0.2)', + '--scrollbar-thumb-hover': 'rgba(100, 116, 139, 0.35)', + + '--btn-default-bg': 'rgba(71, 102, 143, 0.10)', + '--btn-default-color': '#475569', + '--btn-hover-bg': 'rgba(71, 102, 143, 0.16)', + '--btn-hover-color': '#3d4f66', + '--btn-primary-bg': 'rgba(90, 123, 178, 0.18)', + '--btn-primary-color': '#4a6694', + '--btn-primary-hover-bg': 'rgba(90, 123, 178, 0.28)', + '--btn-ghost-bg': 'transparent', + '--btn-ghost-color': '#475569', + '--btn-ghost-hover-bg': 'rgba(71, 102, 143, 0.12)', +}; diff --git a/src/mobile-web/src/theme/useTheme.ts b/src/mobile-web/src/theme/useTheme.ts new file mode 100644 index 00000000..ee350b3d --- /dev/null +++ b/src/mobile-web/src/theme/useTheme.ts @@ -0,0 +1,6 @@ +import { useContext } from 'react'; +import { ThemeContext } from './ThemeProvider'; + +export function useTheme() { + return useContext(ThemeContext); +}