diff --git a/package.json b/package.json index a8e848029..c3b3d991f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "openhuman", - "version": "0.49.11", + "version": "0.49.12", "type": "module", "scripts": { "dev": "vite", diff --git a/src-tauri/.gitignore b/src-tauri/.gitignore index 0dec1adc9..b21bd681d 100644 --- a/src-tauri/.gitignore +++ b/src-tauri/.gitignore @@ -5,11 +5,3 @@ # Generated by Tauri # will have schema files for capabilities auto-completion /gen/schemas - -# TDLib and dependencies copied by build.rs for macOS bundling -/libraries/ - -# TDLib built from source (see scripts/build-tdlib-from-source.sh) -/tdlib-local/ -/tdlib-build/ -/tdlib-cache/ diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 2a28c98b6..8c674ee09 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "OpenHuman" -version = "0.49.11" +version = "0.49.12" description = "OpenHuman - AI-powered Super Assistant" authors = ["OpenHuman"] edition = "2021" diff --git a/src-tauri/build.rs b/src-tauri/build.rs index 62efffafd..6c8e332d4 100644 --- a/src-tauri/build.rs +++ b/src-tauri/build.rs @@ -1,5 +1,4 @@ use std::env; -use std::path::PathBuf; fn main() { maybe_override_tauri_config_for_local_builds(); @@ -9,13 +8,8 @@ fn main() { fn maybe_override_tauri_config_for_local_builds() { let profile = env::var("PROFILE").unwrap_or_default(); let skip_resources = env::var("TAURI_SKIP_RESOURCES").is_ok() || profile == "test"; - let is_release = profile == "release"; - let manifest_dir = - PathBuf::from(env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR not set")); - let tdlib_framework_path = manifest_dir.join("libraries/libtdjson.1.8.29.dylib"); - let skip_missing_frameworks = !is_release && !tdlib_framework_path.exists(); - if !skip_resources && !skip_missing_frameworks { + if !skip_resources { return; } @@ -23,9 +17,6 @@ fn maybe_override_tauri_config_for_local_builds() { if skip_resources { merge_config["bundle"]["resources"] = serde_json::json!([]); } - if skip_missing_frameworks { - merge_config["bundle"]["macOS"]["frameworks"] = serde_json::json!([]); - } match serde_json::to_string(&merge_config) { Ok(json) => { @@ -33,16 +24,9 @@ fn maybe_override_tauri_config_for_local_builds() { if skip_resources { println!("cargo:warning=TAURI resources disabled for local build"); } - if skip_missing_frameworks { - println!( - "cargo:warning=TAURI macOS frameworks disabled because {} is missing", - tdlib_framework_path.display() - ); - } } Err(err) => { println!("cargo:warning=Failed to serialize TAURI_CONFIG override: {err}"); } } } - diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index e45796a52..e48fa5e2b 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1242,8 +1242,7 @@ pub fn run() { } } - // Gracefully shut down TDLib before process exit to prevent - // use-after-free crash in the blocking receive loop. + // Gracefully shut down background services before process exit. #[cfg(not(any(target_os = "android", target_os = "ios")))] RunEvent::Exit => { log::info!("[app] Exit event received, shutting down"); diff --git a/src-tauri/src/runtime/qjs_engine.rs b/src-tauri/src/runtime/qjs_engine.rs index 7c2c994fe..278a6e24f 100644 --- a/src-tauri/src/runtime/qjs_engine.rs +++ b/src-tauri/src/runtime/qjs_engine.rs @@ -17,7 +17,7 @@ use crate::runtime::skill_registry::SkillRegistry; use crate::runtime::socket_manager::SocketManager; use crate::runtime::types::{events, SkillMessage, SkillSnapshot, SkillStatus, ToolResult}; use crate::runtime::qjs_skill_instance::{BridgeDeps, QjsSkillInstance}; -// IdbStorage removed with TDLib cleanup +// IdbStorage removed during runtime cleanup /// The central runtime engine using QuickJS. pub struct RuntimeEngine { @@ -463,20 +463,20 @@ impl RuntimeEngine { } /// Read a KV value from a skill's database. - /// TODO: Removed with TDLib cleanup - reimplement if needed + /// TODO: Removed during runtime cleanup - reimplement if needed pub fn kv_get(&self, _skill_id: &str, _key: &str) -> Result { - Err("KV storage removed with TDLib cleanup".to_string()) + Err("KV storage removed during runtime cleanup".to_string()) } /// Write a KV value into a skill's database. - /// TODO: Removed with TDLib cleanup - reimplement if needed + /// TODO: Removed during runtime cleanup - reimplement if needed pub fn kv_set( &self, _skill_id: &str, _key: &str, _value: &serde_json::Value, ) -> Result<(), String> { - Err("KV storage removed with TDLib cleanup".to_string()) + Err("KV storage removed during runtime cleanup".to_string()) } /// Route a JSON-RPC method call. diff --git a/src-tauri/src/services/quickjs_libs/bootstrap.js b/src-tauri/src/services/quickjs_libs/bootstrap.js index a184ff7f8..23ca2de4e 100644 --- a/src-tauri/src/services/quickjs_libs/bootstrap.js +++ b/src-tauri/src/services/quickjs_libs/bootstrap.js @@ -1,7 +1,7 @@ /** * Bootstrap JavaScript for QuickJS Runtime * - * Provides browser-like API shims that tdweb expects. + * Provides browser-like API shims for skill execution. * These shims call Rust "ops" for actual I/O. */ @@ -343,7 +343,7 @@ WebSocket._instances = new Map(); globalThis.WebSocket = WebSocket; // ============================================================================ -// IndexedDB API (for tdweb persistence) +// IndexedDB API (persistent local storage) // ============================================================================ class IDBFactory { open(name, version = 1) { @@ -940,74 +940,6 @@ globalThis.skills = { }, }; -// ============================================================================ -// TDLib Bridge API (telegram skill only) -// ============================================================================ -// Provides native TDLib access for the telegram skill. -// This is only available on desktop - Android uses Tauri invoke() instead. - -globalThis.tdlib = { - /** - * Check if TDLib ops are available. - * @returns {boolean} True on desktop, false on mobile/web. - */ - isAvailable: function () { - try { - return typeof __ops?.tdlib_is_available === 'function' - ? __ops.tdlib_is_available() - : false; - } catch (e) { - return false; - } - }, - - /** - * Create client and set TDLib parameters in a single atomic call. - * API credentials are stored on the Rust side only. - * @param {string} dataDir - Path to store TDLib data files. - * @returns {Promise} Client ID. - */ - ensureInitialized: async function (dataDir) { - return await __ops.tdlib_ensure_initialized(dataDir); - }, - - /** - * Create a TDLib client with the given data directory. - * @param {string} dataDir - Path to store TDLib data files. - * @returns {Promise} Client ID (always 1 for singleton). - */ - createClient: function (dataDir) { - return __ops.tdlib_create_client(dataDir); - }, - - /** - * Send a TDLib request and wait for the response. - * @param {string} requestJson - JSON-serialized TDLib API request with @type field. - * @returns {Promise} JSON-serialized TDLib response. - */ - send: async function (requestJson) { - return await __ops.tdlib_send(requestJson); - }, - - /** - * Receive the next TDLib update (with timeout). - * @param {number} [timeoutMs=1000] - Timeout in milliseconds. - * @returns {Promise} Update object or null if timeout. - */ - receive: async function (timeoutMs = 1000) { - const resultJson = await __ops.tdlib_receive(timeoutMs); - return resultJson ? JSON.parse(resultJson) : null; - }, - - /** - * Destroy the TDLib client and clean up resources. - * @returns {Promise} - */ - destroy: async function () { - return await __ops.tdlib_destroy(); - }, -}; - // ============================================================================ // Model Bridge API (routes to cloud backend) // ============================================================================ diff --git a/src-tauri/src/services/quickjs_libs/mod.rs b/src-tauri/src/services/quickjs_libs/mod.rs index aa590cdfb..dc0fd8855 100644 --- a/src-tauri/src/services/quickjs_libs/mod.rs +++ b/src-tauri/src/services/quickjs_libs/mod.rs @@ -1,13 +1,10 @@ -//! TDLib Runtime Module +//! QuickJS Runtime Support Module //! //! Provides a QuickJS JavaScript runtime (via rquickjs) for running -//! skill JavaScript code and TDLib integration. Provides a browser-like +//! skill JavaScript code and supporting browser-like shims. //! environment for skill execution. pub mod qjs_ops; -pub mod service; pub mod storage; -#[allow(unused_imports)] -pub use service::{TdClientAdapter, TdClientConfig, TdUpdate, TdlibV8Service}; pub use storage::IdbStorage; diff --git a/src-tauri/src/services/quickjs_libs/service.rs b/src-tauri/src/services/quickjs_libs/service.rs deleted file mode 100644 index f9b886d0b..000000000 --- a/src-tauri/src/services/quickjs_libs/service.rs +++ /dev/null @@ -1,457 +0,0 @@ -//! TdlibV8Service — High-level TDLib service using V8 runtime. -//! -//! Manages TDLib client instances running in V8 with tdweb. -//! Provides async send/receive interface and update broadcasting. - -use std::path::PathBuf; - -use serde::{Deserialize, Serialize}; -use tokio::sync::{broadcast, mpsc, oneshot}; - -use super::storage::IdbStorage; - -/// Configuration for a TDLib client. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[allow(dead_code)] -pub struct TdClientConfig { - /// API ID from my.telegram.org - pub api_id: i32, - /// API hash from my.telegram.org - pub api_hash: String, - /// Database directory name - pub database_directory: String, - /// Files directory name - pub files_directory: String, - /// Use test DC - #[serde(default)] - pub use_test_dc: bool, - /// Use file database - #[serde(default = "default_true")] - pub use_file_database: bool, - /// Use chat info database - #[serde(default = "default_true")] - pub use_chat_info_database: bool, - /// Use message database - #[serde(default = "default_true")] - pub use_message_database: bool, - /// System language code - #[serde(default = "default_lang")] - pub system_language_code: String, - /// Device model - #[serde(default = "default_device")] - pub device_model: String, - /// Application version - #[serde(default = "default_version")] - pub application_version: String, -} - -#[allow(dead_code)] -fn default_true() -> bool { - true -} - -#[allow(dead_code)] -fn default_lang() -> String { - "en".to_string() -} - -#[allow(dead_code)] -fn default_device() -> String { - "Desktop".to_string() -} - -#[allow(dead_code)] -fn default_version() -> String { - "1.0.0".to_string() -} - -impl Default for TdClientConfig { - fn default() -> Self { - Self { - api_id: 0, - api_hash: String::new(), - database_directory: "tdlib".to_string(), - files_directory: "tdlib_files".to_string(), - use_test_dc: false, - use_file_database: true, - use_chat_info_database: true, - use_message_database: true, - system_language_code: "en".to_string(), - device_model: "Desktop".to_string(), - application_version: "1.0.0".to_string(), - } - } -} - -/// A TDLib update received from the server. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[allow(dead_code)] -pub struct TdUpdate { - /// The update type (e.g., "updateNewMessage") - #[serde(rename = "@type")] - pub update_type: String, - /// The full update data - #[serde(flatten)] - pub data: serde_json::Value, -} - -/// Messages sent to the TDLib service. -#[derive(Debug)] -#[allow(dead_code)] -pub enum TdServiceMessage { - /// Send a TDLib query - Send { - user_id: String, - query: serde_json::Value, - reply: oneshot::Sender>, - }, - /// Get current auth state - GetAuthState { - user_id: String, - reply: oneshot::Sender>, - }, - /// Create a new client - CreateClient { - user_id: String, - config: TdClientConfig, - reply: oneshot::Sender>, - }, - /// Destroy a client - DestroyClient { - user_id: String, - reply: oneshot::Sender>, - }, - /// Stop the service - Stop, -} - -/// State for a single TDLib client. -#[allow(dead_code)] -struct TdClientState { - /// Current authorization state - auth_state: serde_json::Value, - /// Whether the client is ready - is_ready: bool, -} - -/// TDLib V8 Service that manages TDLib clients. -#[allow(dead_code)] -pub struct TdlibV8Service { - /// Data directory for TDLib databases - data_dir: PathBuf, - /// Storage layer for IndexedDB emulation - storage: IdbStorage, - /// Message sender for the service - tx: mpsc::Sender, - /// Update broadcaster - update_tx: broadcast::Sender<(String, TdUpdate)>, -} - -#[allow(dead_code)] -impl TdlibV8Service { - /// Create a new TDLib V8 service. - pub async fn new(data_dir: PathBuf) -> Result { - let storage = IdbStorage::new(&data_dir)?; - let (tx, _rx) = mpsc::channel(64); - let (update_tx, _) = broadcast::channel(256); - - let service = Self { - data_dir, - storage, - tx, - update_tx, - }; - - Ok(service) - } - - /// Get a sender for the service. - pub fn sender(&self) -> mpsc::Sender { - self.tx.clone() - } - - /// Subscribe to TDLib updates. - /// Returns a receiver that will receive (user_id, update) tuples. - pub fn subscribe(&self) -> broadcast::Receiver<(String, TdUpdate)> { - self.update_tx.subscribe() - } - - /// Send a query to TDLib. - pub async fn send( - &self, - user_id: &str, - query: serde_json::Value, - ) -> Result { - let (reply_tx, reply_rx) = oneshot::channel(); - - self.tx - .send(TdServiceMessage::Send { - user_id: user_id.to_string(), - query, - reply: reply_tx, - }) - .await - .map_err(|e| format!("Failed to send query: {e}"))?; - - reply_rx - .await - .map_err(|_| "Service did not respond".to_string())? - } - - /// Get the current authorization state. - pub async fn get_auth_state(&self, user_id: &str) -> Result { - let (reply_tx, reply_rx) = oneshot::channel(); - - self.tx - .send(TdServiceMessage::GetAuthState { - user_id: user_id.to_string(), - reply: reply_tx, - }) - .await - .map_err(|e| format!("Failed to get auth state: {e}"))?; - - reply_rx - .await - .map_err(|_| "Service did not respond".to_string())? - } - - /// Create a new TDLib client for a user. - pub async fn create_client( - &self, - user_id: &str, - config: TdClientConfig, - ) -> Result<(), String> { - let (reply_tx, reply_rx) = oneshot::channel(); - - self.tx - .send(TdServiceMessage::CreateClient { - user_id: user_id.to_string(), - config, - reply: reply_tx, - }) - .await - .map_err(|e| format!("Failed to create client: {e}"))?; - - reply_rx - .await - .map_err(|_| "Service did not respond".to_string())? - } - - /// Destroy a TDLib client. - pub async fn destroy_client(&self, user_id: &str) -> Result<(), String> { - let (reply_tx, reply_rx) = oneshot::channel(); - - self.tx - .send(TdServiceMessage::DestroyClient { - user_id: user_id.to_string(), - reply: reply_tx, - }) - .await - .map_err(|e| format!("Failed to destroy client: {e}"))?; - - reply_rx - .await - .map_err(|_| "Service did not respond".to_string())? - } -} - -/// TDLib client adapter that wraps a V8 runtime. -/// -/// This is a placeholder for the full tdweb integration. -/// In the full implementation, this would: -/// 1. Load the tdweb JavaScript bundle -/// 2. Initialize TdClient with the WASM module -/// 3. Handle send/receive through the V8 runtime -#[allow(dead_code)] -pub struct TdClientAdapter { - user_id: String, - config: TdClientConfig, - data_dir: PathBuf, - storage: IdbStorage, - auth_state: serde_json::Value, - query_id: u64, -} - -#[allow(dead_code)] -impl TdClientAdapter { - /// Create a new TDLib client adapter. - pub fn new( - user_id: String, - config: TdClientConfig, - data_dir: PathBuf, - storage: IdbStorage, - ) -> Self { - Self { - user_id, - config, - data_dir, - storage, - auth_state: serde_json::json!({ - "@type": "authorizationStateWaitTdlibParameters" - }), - query_id: 0, - } - } - - /// Initialize the TDLib client. - /// - /// This would load the tdweb bundle and initialize the WASM module. - pub async fn init(&mut self) -> Result<(), String> { - log::info!("[tdlib:{}] Initializing TDLib client", self.user_id); - - // TODO: Load tdweb.js and libtdjson.wasm - // TODO: Create TdClient instance in V8 - // TODO: Set up update handlers - - // For now, simulate initialization - self.auth_state = serde_json::json!({ - "@type": "authorizationStateWaitTdlibParameters" - }); - - Ok(()) - } - - /// Send a query to TDLib. - pub async fn send(&mut self, query: serde_json::Value) -> Result { - self.query_id += 1; - let query_id = self.query_id; - - log::debug!( - "[tdlib:{}] Sending query {}: {:?}", - self.user_id, - query_id, - query.get("@type") - ); - - // TODO: Execute query through V8/tdweb - // For now, return a placeholder response - - let query_type = query - .get("@type") - .and_then(|v| v.as_str()) - .unwrap_or("unknown"); - - match query_type { - "getAuthorizationState" => Ok(self.auth_state.clone()), - "setTdlibParameters" => { - self.auth_state = serde_json::json!({ - "@type": "authorizationStateWaitPhoneNumber" - }); - Ok(serde_json::json!({ "@type": "ok" })) - } - _ => Ok(serde_json::json!({ - "@type": "error", - "code": 400, - "message": "TDLib not fully initialized - tdweb integration pending" - })), - } - } - - /// Get the current authorization state. - pub fn get_auth_state(&self) -> serde_json::Value { - self.auth_state.clone() - } - - /// Destroy the client. - pub async fn destroy(&mut self) -> Result<(), String> { - log::info!("[tdlib:{}] Destroying TDLib client", self.user_id); - - // TODO: Properly close TDLib client - // TODO: Cleanup V8 runtime - - Ok(()) - } -} - -/// JavaScript code to initialize the TDLib bridge in V8. -/// -/// This provides the `__tdlib_send` and `__tdlib_get_auth_state` functions -/// that the bootstrap.js exposes to skills. -#[allow(dead_code)] -pub const TDLIB_BRIDGE_JS: &str = r#" -// TDLib Bridge for V8 Runtime -// This will be replaced with actual tdweb integration - -(function() { - // Client state - const clients = new Map(); - let defaultClient = null; - - // Create a mock client for now - class MockTdClient { - constructor(options) { - this.options = options; - this.authState = { '@type': 'authorizationStateWaitTdlibParameters' }; - this.queryId = 0; - this.callbacks = new Map(); - } - - async send(query) { - this.queryId++; - const queryType = query['@type']; - - console.log('[TDLib] Send:', queryType); - - // Handle known query types - switch (queryType) { - case 'getAuthorizationState': - return this.authState; - - case 'setTdlibParameters': - this.authState = { '@type': 'authorizationStateWaitPhoneNumber' }; - return { '@type': 'ok' }; - - case 'setAuthenticationPhoneNumber': - this.authState = { '@type': 'authorizationStateWaitCode' }; - return { '@type': 'ok' }; - - default: - return { - '@type': 'error', - 'code': 400, - 'message': 'TDLib not fully initialized - waiting for tdweb integration' - }; - } - } - - getAuthState() { - return this.authState; - } - } - - // Initialize default client - function initClient(userId, options) { - const client = new MockTdClient(options); - clients.set(userId, client); - if (!defaultClient) { - defaultClient = client; - } - return client; - } - - // Get or create client - function getClient(userId) { - if (!clients.has(userId)) { - initClient(userId, {}); - } - return clients.get(userId); - } - - // Export to global scope - globalThis.__tdlib_send = async function(userId, query) { - const client = getClient(userId); - return await client.send(query); - }; - - globalThis.__tdlib_get_auth_state = function(userId) { - const client = getClient(userId); - return client.getAuthState(); - }; - - globalThis.__tdlib_init = function(userId, options) { - return initClient(userId, options); - }; - - console.log('[TDLib] Bridge initialized (mock mode)'); -})(); -"#; diff --git a/src-tauri/src/services/quickjs_libs/storage.rs b/src-tauri/src/services/quickjs_libs/storage.rs index 955c9ffb0..55b308ed4 100644 --- a/src-tauri/src/services/quickjs_libs/storage.rs +++ b/src-tauri/src/services/quickjs_libs/storage.rs @@ -1,7 +1,7 @@ //! IndexedDB Storage Layer (SQLite-backed) //! //! Provides persistent storage for: -//! - IndexedDB API (for tdweb) +//! - IndexedDB API //! - Skill databases (db bridge) //! - Skill key-value store (store bridge) @@ -12,7 +12,7 @@ use std::path::{Path, PathBuf}; use std::sync::Arc; /// Result of opening an IndexedDB database. -/// Used by the IndexedDB emulation layer for tdweb. +/// Used by the IndexedDB emulation layer. #[derive(Debug, Clone, serde::Serialize)] pub struct IdbOpenResult { /// Whether a version upgrade is needed. diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 659d0075f..dc83563aa 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "OpenHuman", - "version": "0.49.11", + "version": "0.49.12", "identifier": "com.tinyhumansai.openhuman", "build": { "beforeDevCommand": "npm run dev", diff --git a/src/AppRoutes.tsx b/src/AppRoutes.tsx index 70f6124bb..a48554a8d 100644 --- a/src/AppRoutes.tsx +++ b/src/AppRoutes.tsx @@ -124,7 +124,7 @@ const AppRoutes = () => { path="/conversations/:threadId" element={ - + } /> diff --git a/src/pages/Conversations.tsx b/src/pages/Conversations.tsx index 8e7b4a9a2..0d2597289 100644 --- a/src/pages/Conversations.tsx +++ b/src/pages/Conversations.tsx @@ -1,13 +1,6 @@ -import { - type PointerEvent as ReactPointerEvent, - useCallback, - useEffect, - useMemo, - useRef, - useState, -} from 'react'; +import { useEffect, useRef, useState } from 'react'; import Markdown from 'react-markdown'; -import { useNavigate, useParams } from 'react-router-dom'; +import { useNavigate } from 'react-router-dom'; import { creditsApi, type TeamUsage } from '../services/api/creditsApi'; import { inferenceApi, type ModelInfo } from '../services/api/inferenceApi'; @@ -25,27 +18,25 @@ import type { NotionPageSummary, NotionSummary, NotionUserProfile } from '../sto import { addInferenceResponse, addMessageLocal, - clearDeleteStatus, - clearPurgeStatus, - clearSelectedThread, createThreadLocal, - deleteThreadLocal, fetchSuggestedQuestions, - purgeThreads, setActiveThread, setLastViewed, - setPanelWidth, setSelectedThread, } from '../store/threadSlice'; import type { ThreadMessage } from '../types/thread'; import { BACKEND_URL } from '../utils/config'; -const MIN_PANEL_WIDTH = 200; -const MAX_PANEL_WIDTH = 480; +const DEFAULT_THREAD_ID = 'default-thread'; +const DEFAULT_THREAD_TITLE = 'Conversation'; +type ToolTimelineEntryStatus = 'running' | 'success' | 'error'; -// --------------------------------------------------------------------------- -// Notion context builder -// --------------------------------------------------------------------------- +interface ToolTimelineEntry { + id: string; + name: string; + round: number; + status: ToolTimelineEntryStatus; +} function buildNotionContext( profile: NotionUserProfile | null, @@ -104,17 +95,12 @@ function formatRelativeTime(dateStr: string): string { const Conversations = () => { const dispatch = useAppDispatch(); const navigate = useNavigate(); - const { threadId: urlThreadId } = useParams<{ threadId?: string }>(); const { threads, selectedThreadId, messages, isLoadingMessages, messagesError, - deleteStatus, - purgeStatus, - panelWidth, - lastViewedAt, suggestedQuestions, isLoadingSuggestions, activeThreadId, @@ -130,95 +116,47 @@ const Conversations = () => { | null) ?? null ); - const [showPurgeConfirm, setShowPurgeConfirm] = useState(false); - const [confirmDeleteId, setConfirmDeleteId] = useState(null); const [inputValue, setInputValue] = useState(''); - const [searchQuery, setSearchQuery] = useState(''); const [copiedMessageId, setCopiedMessageId] = useState(null); - // Inference model state const [availableModels, setAvailableModels] = useState([]); const [selectedModel, setSelectedModel] = useState('neocortex-mk1'); const [isLoadingModels, setIsLoadingModels] = useState(false); const [isSending, setIsSending] = useState(false); const [sendError, setSendError] = useState(null); - const [activeToolCall, setActiveToolCall] = useState<{ name: string; args: unknown } | null>( - null - ); + const [toolTimelineByThread, setToolTimelineByThread] = useState< + Record + >({}); const rustChat = useRustChat(); - // Ref to track selectedThreadId inside event callbacks without re-subscribing const selectedThreadIdRef = useRef(selectedThreadId); useEffect(() => { selectedThreadIdRef.current = selectedThreadId; }, [selectedThreadId]); - // Budget state const [teamUsage, setTeamUsage] = useState(null); const [isLoadingBudget, setIsLoadingBudget] = useState(false); - const isDragging = useRef(false); const messagesEndRef = useRef(null); - const lastPanelWidthRef = useRef(panelWidth); - // Filtered threads based on search query (#13) - const filteredThreads = useMemo(() => { - if (!searchQuery.trim()) return threads; - const q = searchQuery.toLowerCase(); - return threads.filter(t => (t.title || 'Untitled Thread').toLowerCase().includes(q)); - }, [threads, searchQuery]); - - // Unread: thread has messages since last view (#15) - const isThreadUnread = useCallback( - (thread: { id: string; lastMessageAt?: string | null; createdAt: string }) => { - const viewed = lastViewedAt[thread.id]; - const lastMsg = new Date(thread.lastMessageAt || thread.createdAt).getTime(); - return viewed == null || lastMsg > viewed; - }, - [lastViewedAt] - ); - - // Mobile: detect small screens for responsive layout (#12) - const [isMobile, setIsMobile] = useState(false); useEffect(() => { - const mq = window.matchMedia('(max-width: 767px)'); - setIsMobile(mq.matches); - const handler = (e: MediaQueryListEvent) => setIsMobile(e.matches); - mq.addEventListener('change', handler); - return () => mq.removeEventListener('change', handler); - }, []); + const defaultThread = threads.find(t => t.id === DEFAULT_THREAD_ID); - const handleResizePointerDown = useCallback( - (e: ReactPointerEvent) => { - e.preventDefault(); - isDragging.current = true; - const startX = e.clientX; - const startWidth = panelWidth; + if (!defaultThread) { + dispatch( + createThreadLocal({ + id: DEFAULT_THREAD_ID, + title: DEFAULT_THREAD_TITLE, + createdAt: new Date().toISOString(), + }) + ); + } - const onPointerMove = (ev: globalThis.PointerEvent) => { - const delta = ev.clientX - startX; - const newWidth = Math.min(MAX_PANEL_WIDTH, Math.max(MIN_PANEL_WIDTH, startWidth + delta)); - lastPanelWidthRef.current = newWidth; - dispatch(setPanelWidth(newWidth)); - }; + if (selectedThreadId !== DEFAULT_THREAD_ID) { + dispatch(setSelectedThread(DEFAULT_THREAD_ID)); + } + }, [dispatch, selectedThreadId, threads]); - const onPointerUp = () => { - isDragging.current = false; - document.removeEventListener('pointermove', onPointerMove); - document.removeEventListener('pointerup', onPointerUp); - document.body.style.cursor = ''; - document.body.style.userSelect = ''; - }; - - document.addEventListener('pointermove', onPointerMove); - document.addEventListener('pointerup', onPointerUp); - document.body.style.cursor = 'col-resize'; - document.body.style.userSelect = 'none'; - }, - [panelWidth, dispatch] - ); - - // Fetch available inference models on mount useEffect(() => { setIsLoadingModels(true); inferenceApi @@ -235,7 +173,6 @@ const Conversations = () => { .finally(() => setIsLoadingModels(false)); }, []); - // Fetch inference budget on mount useEffect(() => { setIsLoadingBudget(true); creditsApi @@ -247,61 +184,28 @@ const Conversations = () => { .finally(() => setIsLoadingBudget(false)); }, []); - // Remove thread fetching - threads are now loaded from Redux persist - - // Sync URL → Redux: when URL has a threadId param, select that thread - useEffect(() => { - if (urlThreadId && urlThreadId !== selectedThreadId) { - dispatch(setSelectedThread(urlThreadId)); - } else if (!urlThreadId && selectedThreadId) { - dispatch(clearSelectedThread()); - } - }, [urlThreadId]); // eslint-disable-line react-hooks/exhaustive-deps - - // Mark thread as viewed when selected (#15) — stored in Redux (persisted via redux-persist) useEffect(() => { if (selectedThreadId) dispatch(setLastViewed(selectedThreadId)); }, [selectedThreadId, dispatch]); - // Remove message fetching - messages load from local storage automatically - - // Fetch suggested questions when thread is empty (beginning of new thread) useEffect(() => { if (selectedThreadId && messages.length === 0) { dispatch(fetchSuggestedQuestions(selectedThreadId)); } }, [selectedThreadId, messages.length, dispatch]); - // Auto-scroll to bottom when messages load useEffect(() => { if (messages.length > 0) { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); } }, [messages]); - // Remove create status handling - using local thread creation - - useEffect(() => { - if (deleteStatus === 'success' || deleteStatus === 'error') { - dispatch(clearDeleteStatus()); - } - }, [deleteStatus, dispatch]); - - useEffect(() => { - if (purgeStatus === 'success' || purgeStatus === 'error') { - dispatch(clearPurgeStatus()); - } - }, [purgeStatus, dispatch]); - - // Clear send error when user starts typing again useEffect(() => { if (sendError && inputValue.length > 0) { setSendError(null); } }, [inputValue, sendError]); - // Subscribe to Rust chat events when running in Tauri. - // Registered ONCE (deps: [rustChat]) — uses refs for values that change. useEffect(() => { if (!rustChat) return; @@ -310,33 +214,86 @@ const Conversations = () => { subscribeChatEvents({ onToolCall: (event: ChatToolCallEvent) => { - if (event.thread_id !== selectedThreadIdRef.current) return; - setActiveToolCall({ name: event.tool_name, args: event.args }); + setToolTimelineByThread(prev => { + const existing = prev[event.thread_id] ?? []; + return { + ...prev, + [event.thread_id]: [ + ...existing, + { + id: `${event.thread_id}:${event.round}:${existing.length}:${event.tool_name}`, + name: event.tool_name, + round: event.round, + status: 'running', + }, + ], + }; + }); }, onToolResult: (event: ChatToolResultEvent) => { - if (event.thread_id !== selectedThreadIdRef.current) return; - setActiveToolCall(null); + setToolTimelineByThread(prev => { + const existing = prev[event.thread_id] ?? []; + if (existing.length === 0) return prev; + + const nextEntries = [...existing]; + let changed = false; + for (let i = nextEntries.length - 1; i >= 0; i--) { + const entry = nextEntries[i]; + if ( + entry.status === 'running' && + entry.name === event.tool_name && + entry.round === event.round + ) { + nextEntries[i] = { + ...entry, + status: event.success ? 'success' : 'error', + }; + changed = true; + break; + } + } + + if (!changed) return prev; + return { ...prev, [event.thread_id]: nextEntries }; + }); }, onDone: event => { - // Guard against duplicate dispatch (React StrictMode double-fires effects in dev) const currentState = store.getState() as { thread: { messagesByThreadId: Record }; }; const threadMessages = currentState.thread.messagesByThreadId[event.thread_id] || []; const lastMsg = threadMessages[threadMessages.length - 1]; if (lastMsg?.sender === 'agent' && lastMsg?.content === event.full_response) { - return; // Already added — skip duplicate + return; } dispatch(addInferenceResponse({ content: event.full_response, threadId: event.thread_id })); + setToolTimelineByThread(prev => { + const existing = prev[event.thread_id] ?? []; + if (existing.length === 0) return prev; + return { + ...prev, + [event.thread_id]: existing.map(entry => + entry.status === 'running' ? { ...entry, status: 'success' as const } : entry + ), + }; + }); setIsSending(false); - setActiveToolCall(null); dispatch(setActiveThread(null)); }, onError: event => { if (event.thread_id !== selectedThreadIdRef.current) return; setIsSending(false); - setActiveToolCall(null); + setToolTimelineByThread(prev => { + const existing = prev[event.thread_id] ?? []; + if (existing.length === 0) return prev; + return { + ...prev, + [event.thread_id]: existing.map(entry => + entry.status === 'running' ? { ...entry, status: 'error' as const } : entry + ), + }; + }); if (event.error_type !== 'cancelled') { dispatch( @@ -394,18 +351,21 @@ const Conversations = () => { }; const handleSendMessage = async (text?: string) => { - const trimmed = text ?? inputValue.trim(); - if (!trimmed || !selectedThreadId || isSending) return; + const normalized = text ?? inputValue; + const trimmed = normalized.trim(); - // Check if another thread is already sending + if (!trimmed || !selectedThreadId || isSending) return; + if (!rustChat) { + setSendError('Desktop runtime required for chat in this build.'); + return; + } + if (activeThreadId && activeThreadId !== selectedThreadId) { - return; // Block sending from non-active threads + return; } - // Store the original thread ID to ensure response goes to correct thread const sendingThreadId = selectedThreadId; - // Create stable user message and persist immediately const userMessage: ThreadMessage = { id: `msg_${Date.now()}_${Math.random()}`, content: trimmed, @@ -415,16 +375,9 @@ const Conversations = () => { createdAt: new Date().toISOString(), }; - // Immediately persist user message to both current view and persistent storage dispatch(addMessageLocal({ threadId: sendingThreadId, message: userMessage })); + dispatch(setSelectedThread(sendingThreadId)); - // Update current view if this is the selected thread - if (sendingThreadId === selectedThreadId) { - // Message is already added to persistent storage, reload current view - dispatch(setSelectedThread(sendingThreadId)); - } - - // Snapshot history for AI request (excluding the just-added user message since we'll add it manually) const historySnapshot = messages.filter( m => !m.id.startsWith('optimistic-') && m.id !== userMessage.id ); @@ -432,8 +385,7 @@ const Conversations = () => { setInputValue(''); setSendError(null); setIsSending(true); - - // Set this thread as active + setToolTimelineByThread(prev => ({ ...prev, [sendingThreadId]: [] })); dispatch(setActiveThread(sendingThreadId)); if (!rustChat) { @@ -488,11 +440,10 @@ const Conversations = () => { const handleInputKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); - handleSendMessage(); + void handleSendMessage(); } }; - // Copy message to clipboard (#10) const handleCopyMessage = async (messageId: string, content: string) => { try { await navigator.clipboard.writeText(content); @@ -503,649 +454,48 @@ const Conversations = () => { } }; - // Mobile: back to thread list - const handleMobileBack = () => { - navigate('/conversations', { replace: true }); - }; - const selectedThread = threads.find(t => t.id === selectedThreadId); - // Mobile layout: show only one panel at a time (#12) - const showThreadList = !isMobile || !selectedThreadId; - const showMessages = !isMobile || !!selectedThreadId; - return (
- {/* Left Panel: Thread List */} - {showThreadList && ( -
- {/* Header */} -
-

Conversations

- -
- - {/* Search bar (#13) */} - {threads.length > 0 && ( -
-
- - - - setSearchQuery(e.target.value)} - placeholder="Search threads..." - className="w-full bg-white/5 border border-white/10 rounded-lg pl-8 pr-3 py-1.5 text-xs placeholder:text-stone-500 focus:outline-none focus:ring-1 focus:ring-primary-500/50 focus:border-primary-500/50 transition-all" - /> - {searchQuery && ( - - )} -
+
+
+
+
+

+ {selectedThread?.title || DEFAULT_THREAD_TITLE} +

+ {selectedThread?.isActive && ( + + Active + + )}
- )} - - {/* Thread list */} -
- {filteredThreads.length > 0 ? ( -
- {filteredThreads.map(thread => ( -
- {confirmDeleteId === thread.id ? ( -
- Delete this thread? -
- - -
-
- ) : ( - <> - - {/* Delete button — visible on hover */} - - - )} -
- ))} -
- ) : threads.length > 0 && searchQuery ? ( -
-

No matching threads

- -
- ) : ( -
- - - -

No conversations yet

- -
+ {selectedThread?.createdAt && ( +

+ Created {formatRelativeTime(selectedThread.createdAt)} +

)}
- - {/* Footer: Delete All */} - {threads.length > 0 && ( -
- {showPurgeConfirm ? ( -
- Delete all threads? -
- - -
-
- ) : ( - - )} -
- )}
- )} - {/* Resize Handle — desktop only */} - {!isMobile && ( -
- )} - - {/* Right Panel: Messages */} - {showMessages && ( -
- {selectedThread ? ( - <> - {/* Thread header */} -
- {/* Mobile back button (#12) */} - {isMobile && ( - - )} -
-
-

- {selectedThread.title || 'Untitled Thread'} -

- {selectedThread.isActive && ( - - Active - - )} -
-

- Created {formatRelativeTime(selectedThread.createdAt)} -

-
-
- - {/* Messages */} -
- {isLoadingMessages ? ( -
- {Array.from({ length: 4 }).map((_, i) => ( -
-
-
- ))} -
- ) : messagesError ? ( -
- - - -

Failed to load messages

-

{messagesError}

- -
- ) : messages.length > 0 ? ( -
- {messages.map(msg => ( -
-
-
- {msg.sender === 'agent' ? ( -
- {msg.content} -
- ) : ( -

- {msg.content} -

- )} -

- {formatRelativeTime(msg.createdAt)} -

-
- {/* Copy button (#10) */} - -
-
- ))} - {/* Typing indicator (#14) - Only show for the active thread */} - {activeThreadId === selectedThreadId && isSending && ( -
-
-
- - - -
-
-
- )} - {/* Tool call indicator — shown when Rust backend is executing a tool */} - {activeToolCall && isSending && ( -
- Running tool: {activeToolCall.name} -
- )} - {/* Cancel button — shown when Rust backend is processing */} - {isSending && rustChat && ( -
- -
- )} -
-
- ) : ( -
-

No messages in this thread

-
- )} -
- - {/* Suggested questions — only at start of new thread (no messages yet); horizontal scroll */} - {messages.length === 0 && suggestedQuestions.length > 0 && !isLoadingSuggestions && ( -
-
- {suggestedQuestions.map((s, i) => ( - - ))} -
-
- )} - - {/* Message Input */} -
- {/* Budget depleted banner — show top-up CTA */} - {teamUsage && teamUsage.remainingUsd <= 0 && ( -
-
- - - -

- Daily inference budget exhausted. Top up to continue. -

-
- -
- )} - - {/* Show warning if another thread is active */} - {activeThreadId && activeThreadId !== selectedThreadId && ( -
-

- Another conversation is active. Please wait for it to complete before sending - messages here. -

-
- )} - {/* Model selector + budget indicator */} -
- {isLoadingModels ? ( - Loading models… - ) : ( - <> - Model - - - )} -
- {/* Budget indicator — circular */} - {(isLoadingBudget || teamUsage) && - (() => { - const size = 22; - const r = 9; - const circ = 2 * Math.PI * r; - const pct = teamUsage - ? Math.min(1, teamUsage.remainingUsd / teamUsage.cycleBudgetUsd) - : 0; - const dash = pct * circ; - return ( -
- - - {teamUsage ? ( - - ) : ( - - )} - - {teamUsage && ( - - ${teamUsage.remainingUsd.toFixed(2)} - - )} -
- ); - })()} -
- {sendError && ( -
-

{sendError}

- -
- )} -
-