Merge branches 'develop' and 'develop' of github.com:vezuresdotxyz/frontend-runner-alphahuman into develop

This commit is contained in:
Steven Enamakel
2026-03-15 05:23:14 -07:00
20 changed files with 754 additions and 3222 deletions
+196 -2471
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -12,7 +12,7 @@
"compile": "tsc --noEmit",
"preview": "vite preview",
"tauri": "tauri",
"tauri:dev": "source scripts/load-dotenv.sh 2>/dev/null; (cd src-tauri && bash scripts/download-tdlib.sh) && LOCAL_TDLIB_PATH=$(pwd)/src-tauri/tdlib-cache/macos-$(uname -m | sed 's/arm64/aarch64/; s/x86_64/x86_64/') tauri dev",
"tauri:dev": "RUST_LOG=debug source scripts/load-dotenv.sh 2>/dev/null; (cd src-tauri && bash scripts/download-tdlib.sh) && LOCAL_TDLIB_PATH=$(pwd)/src-tauri/tdlib-cache/macos-$(uname -m | sed 's/arm64/aarch64/; s/x86_64/x86_64/') tauri dev",
"macos:build:debug": "yarn macos:build:release --debug",
"macos:build:release": "source scripts/load-dotenv.sh 2>/dev/null; (cd src-tauri && bash scripts/download-tdlib.sh) && LOCAL_TDLIB_PATH=$(pwd)/src-tauri/tdlib-cache/macos-$(uname -m | sed 's/arm64/aarch64/; s/x86_64/x86_64/') tauri build --bundles app dmg",
"macos:build:sign:release": "source scripts/load-env.sh && tauri build --bundles app dmg",
@@ -22,7 +22,7 @@
"android:build": "tauri android build",
"test": "vitest run",
"test:unit": "vitest run",
"test:unit:watch": "vitest",
"test:unit:watch": "vi13:17:20.609 ERROR [skill:gmail] start() failed: start() timed out after 30s\ntest",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"test:rust": "source $HOME/.cargo/env 2>/dev/null; cargo test --manifest-path src-tauri/Cargo.toml",
+1 -1
Submodule skills updated: 88c9c8c8d1...8168045b0b
+1
View File
@@ -14,3 +14,4 @@
/tdlib-build/
# TDLib downloaded by scripts/download-tdlib.sh (avoids build timeout)
/tdlib-cache/
/tdlib-prebuilt
+1 -1
View File
@@ -338,7 +338,7 @@ async fn spawn_state_writer(
_ = interval.tick() => {
event_count += 1;
if event_count % 12 == 1 { // Log every minute (12 * 5s = 60s)
log::info!("[alphahuman] Health monitoring active (event #{})", event_count);
// log::info!("[alphahuman] Health monitoring active (event #{})", event_count);
}
},
_ = cancel.cancelled() => {
@@ -3,84 +3,123 @@
use parking_lot::RwLock;
use rquickjs::{function::Async, Ctx, Function, Object};
use std::collections::HashMap;
use std::sync::Arc;
use std::sync::{Arc, OnceLock};
use super::types::{js_err, WebSocketConnection, WebSocketState};
pub fn register<'js>(ctx: &Ctx<'js>, ops: &Object<'js>, ws_state: Arc<RwLock<WebSocketState>>) -> rquickjs::Result<()> {
/// Shared HTTP client — built once, reused across all fetch calls.
/// Using a shared client enables connection pooling, persistent TLS sessions,
/// and prevents per-request TLS handshake overhead that can cause hangs.
static HTTP_CLIENT: OnceLock<reqwest::Client> = OnceLock::new();
fn get_http_client() -> &'static reqwest::Client {
HTTP_CLIENT.get_or_init(|| {
reqwest::Client::builder()
.use_rustls_tls()
.connect_timeout(std::time::Duration::from_secs(10))
.pool_idle_timeout(std::time::Duration::from_secs(90))
.pool_max_idle_per_host(10)
.build()
.expect("failed to build shared HTTP client")
})
}
pub fn register<'js>(
ctx: &Ctx<'js>,
ops: &Object<'js>,
ws_state: Arc<RwLock<WebSocketState>>,
) -> rquickjs::Result<()> {
// ========================================================================
// Fetch (1) - ASYNC
// ========================================================================
ops.set("fetch", Function::new(ctx.clone(),
Async(move |url: String, options: String| async move {
let opts: serde_json::Value =
serde_json::from_str(&options).map_err(|e| js_err(e.to_string()))?;
ops.set(
"fetch",
Function::new(
ctx.clone(),
Async(move |url: String, options: String| async move {
let opts: serde_json::Value =
serde_json::from_str(&options).map_err(|e| js_err(e.to_string()))?;
let method = opts["method"].as_str().unwrap_or("GET");
let headers_obj = opts["headers"].as_object();
let body = opts["body"].as_str();
let timeout_secs = opts["timeout"]
.as_u64()
.or_else(|| opts["timeout"].as_f64().map(|f| f as u64))
.unwrap_or(30);
let method = opts["method"].as_str().unwrap_or("GET");
let headers_obj = opts["headers"].as_object();
let body = opts["body"].as_str();
let timeout_secs = opts["timeout"]
.as_u64()
.or_else(|| opts["timeout"].as_f64().map(|f| f as u64))
.unwrap_or(30);
let client = reqwest::Client::builder()
.use_rustls_tls()
.build()
.map_err(|e| js_err(e.to_string()))?;
let mut req = match method {
"GET" => client.get(&url),
"POST" => client.post(&url),
"PUT" => client.put(&url),
"PATCH" => client.patch(&url),
"DELETE" => client.delete(&url),
_ => client.get(&url),
};
let client = get_http_client();
let mut req = match method {
"GET" => client.get(&url),
"POST" => client.post(&url),
"PUT" => client.put(&url),
"PATCH" => client.patch(&url),
"DELETE" => client.delete(&url),
_ => client.get(&url),
};
req = req.timeout(std::time::Duration::from_secs(timeout_secs));
req = req.timeout(std::time::Duration::from_secs(timeout_secs));
if let Some(h) = headers_obj {
for (k, v) in h {
if let Some(val_str) = v.as_str() {
req = req.header(k, val_str);
if let Some(h) = headers_obj {
for (k, v) in h {
if let Some(val_str) = v.as_str() {
req = req.header(k, val_str);
}
}
}
}
if let Some(b) = body {
req = req.body(b.to_string());
}
let response = req.send().await.map_err(|e| {
let mut msg = e.to_string();
let mut source = std::error::Error::source(&e);
while let Some(cause) = source {
msg.push_str(&format!(" | caused by: {cause}"));
source = std::error::Error::source(cause);
if let Some(b) = body {
req = req.body(b.to_string());
}
js_err(msg)
})?;
let status = response.status().as_u16();
let status_text = response.status().canonical_reason().unwrap_or("").to_string();
let headers: HashMap<String, String> = response
.headers()
.iter()
.map(|(k, v)| (k.to_string(), v.to_str().unwrap_or("").to_string()))
.collect();
let body_text = response.text().await.map_err(|e| js_err(e.to_string()))?;
// Hard safety net: tokio timeout wraps send+body read so a stalled
// connection cannot block the QuickJS event loop indefinitely even
// if the per-request timeout on the reqwest builder fails to fire.
let total_deadline = std::time::Duration::from_secs(timeout_secs + 5);
let response = tokio::time::timeout(total_deadline, req.send())
.await
.map_err(|_| js_err(format!("request timed out after {}s", timeout_secs + 5)))?
.map_err(|e| {
let mut msg = e.to_string();
let mut source = std::error::Error::source(&e);
while let Some(cause) = source {
msg.push_str(&format!(" | caused by: {cause}"));
source = std::error::Error::source(cause);
}
js_err(msg)
})?;
let result = serde_json::json!({
"status": status,
"statusText": status_text,
"headers": headers,
"body": body_text,
});
let status = response.status().as_u16();
let status_text = response
.status()
.canonical_reason()
.unwrap_or("")
.to_string();
let headers: HashMap<String, String> = response
.headers()
.iter()
.map(|(k, v)| (k.to_string(), v.to_str().unwrap_or("").to_string()))
.collect();
let body_text = tokio::time::timeout(
std::time::Duration::from_secs(timeout_secs + 5),
response.text(),
)
.await
.map_err(|_| js_err(format!("body read timed out after {}s", timeout_secs + 5)))?
.map_err(|e| js_err(e.to_string()))?;
Ok::<String, rquickjs::Error>(result.to_string())
}),
))?;
let result = serde_json::json!({
"status": status,
"statusText": status_text,
"headers": headers,
"body": body_text,
});
Ok::<String, rquickjs::Error>(result.to_string())
}),
),
)?;
// ========================================================================
// WebSocket (4) - placeholders
@@ -88,43 +127,57 @@ pub fn register<'js>(ctx: &Ctx<'js>, ops: &Object<'js>, ws_state: Arc<RwLock<Web
{
let ws = ws_state.clone();
ops.set("ws_connect", Function::new(ctx.clone(),
Async(move |url: String| {
let ws = ws.clone();
async move {
let mut state = ws.write();
let id = state.next_id;
state.next_id += 1;
state.connections.insert(id, WebSocketConnection { url });
Ok::<u32, rquickjs::Error>(id)
}
}),
))?;
ops.set(
"ws_connect",
Function::new(
ctx.clone(),
Async(move |url: String| {
let ws = ws.clone();
async move {
let mut state = ws.write();
let id = state.next_id;
state.next_id += 1;
state.connections.insert(id, WebSocketConnection { url });
Ok::<u32, rquickjs::Error>(id)
}
}),
),
)?;
}
{
let ws = ws_state.clone();
ops.set("ws_send", Function::new(ctx.clone(), move |_id: u32, _data: String| {
let _state = ws.read();
}))?;
ops.set(
"ws_send",
Function::new(ctx.clone(), move |_id: u32, _data: String| {
let _state = ws.read();
}),
)?;
}
{
let ws = ws_state.clone();
ops.set("ws_recv", Function::new(ctx.clone(),
Async(move |_id: u32| {
let _ws = ws.clone();
async move { Ok::<Option<String>, rquickjs::Error>(None) }
}),
))?;
ops.set(
"ws_recv",
Function::new(
ctx.clone(),
Async(move |_id: u32| {
let _ws = ws.clone();
async move { Ok::<Option<String>, rquickjs::Error>(None) }
}),
),
)?;
}
{
let ws = ws_state;
ops.set("ws_close", Function::new(ctx.clone(), move |id: u32, _code: u16, _reason: String| {
let mut state = ws.write();
state.connections.remove(&id);
}))?;
ops.set(
"ws_close",
Function::new(ctx.clone(), move |id: u32, _code: u16, _reason: String| {
let mut state = ws.write();
state.connections.remove(&id);
}),
)?;
}
Ok(())
}
+4 -4
View File
@@ -52,7 +52,7 @@ export const oauthProviderConfigs: OAuthProviderConfig[] = [
color: 'bg-white border border-gray-200',
hoverColor: 'hover:bg-gray-50 hover:border-gray-300',
textColor: 'text-gray-900',
loginUrl: `${BACKEND_URL}/auth/google/login${IS_DEV ? '?debug=true' : ''}`,
loginUrl: `${BACKEND_URL}/auth/google/login?${IS_DEV ? 'responseType=json' : ''}`,
},
{
id: 'github',
@@ -61,7 +61,7 @@ export const oauthProviderConfigs: OAuthProviderConfig[] = [
color: 'bg-gray-900 border border-gray-800',
hoverColor: 'hover:bg-gray-800 hover:border-gray-700',
textColor: 'text-white',
loginUrl: `${BACKEND_URL}/auth/github/login${IS_DEV ? '?debug=true' : ''}`,
loginUrl: `${BACKEND_URL}/auth/github/login?${IS_DEV ? 'responseType=json' : ''}`,
},
{
id: 'twitter',
@@ -70,7 +70,7 @@ export const oauthProviderConfigs: OAuthProviderConfig[] = [
color: 'bg-black border border-gray-800',
hoverColor: 'hover:bg-gray-900 hover:border-gray-700',
textColor: 'text-white',
loginUrl: `${BACKEND_URL}/auth/twitter/login${IS_DEV ? '?debug=true' : ''}`,
loginUrl: `${BACKEND_URL}/auth/twitter/login?${IS_DEV ? 'responseType=json' : ''}`,
},
{
id: 'discord',
@@ -79,7 +79,7 @@ export const oauthProviderConfigs: OAuthProviderConfig[] = [
color: 'bg-indigo-600 border border-indigo-500',
hoverColor: 'hover:bg-indigo-700 hover:border-indigo-600',
textColor: 'text-white',
loginUrl: `${BACKEND_URL}/auth/discord/login${IS_DEV ? '?debug=true' : ''}`,
loginUrl: `${BACKEND_URL}/auth/discord/login?${IS_DEV ? 'responseType=json' : ''}`,
},
];
+134 -6
View File
@@ -1,16 +1,48 @@
import { clearToken } from '../../store/authSlice';
import { useAppDispatch } from '../../store/hooks';
import { useState } from 'react';
import { skillManager } from '../../lib/skills/manager';
import { persistor } from '../../store';
import SettingsHeader from './components/SettingsHeader';
import SettingsMenuItem from './components/SettingsMenuItem';
import { useSettingsNavigation } from './hooks/useSettingsNavigation';
const SettingsHome = () => {
const dispatch = useAppDispatch();
const { navigateToSettings, closeSettings } = useSettingsNavigation();
const { navigateToSettings } = useSettingsNavigation();
const [showLogoutAndClearModal, setShowLogoutAndClearModal] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleLogout = async () => {
await dispatch(clearToken());
closeSettings();
// Simple logout without clearing data - redirect to login
window.location.href = '/login';
};
const clearAllAppData = async () => {
await persistor.purge();
window.localStorage.clear();
window.sessionStorage.clear();
// NEW: Clear skills databases (emails, chats, cached files)
try {
await skillManager.clearAllSkillsData();
} catch (error) {
console.warn('Failed to clear skills data:', error);
// Continue with logout even if skills clearing fails
}
// Complete reset - redirect to login for fresh start
window.location.href = '/login';
};
const handleLogoutAndClearData = async () => {
try {
setIsLoading(true);
setError(null);
await clearAllAppData(); // This will redirect to login
} catch (_error) {
setError('Failed to clear data and logout. Please try again.');
setIsLoading(false);
}
};
// const handleViewEncryptionKey = () => {
@@ -244,6 +276,23 @@ const SettingsHome = () => {
onClick: handleDeleteAllData,
dangerous: true,
},
{
id: 'logout-and-clear',
title: 'Log Out & Clear App Data',
description: 'Sign out and permanently clear all local data',
icon: (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"
/>
</svg>
),
onClick: () => setShowLogoutAndClearModal(true),
dangerous: true,
},
{
id: 'logout',
title: 'Log out',
@@ -302,6 +351,85 @@ const SettingsHome = () => {
</div>
</div>
</div>
{/* Log Out & Clear Data Confirmation Modal */}
{showLogoutAndClearModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60">
<div className="bg-stone-900 rounded-2xl max-w-md w-full p-6 border border-stone-700/50">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-lg bg-amber-500/20 flex items-center justify-center">
<svg
className="w-5 h-5 text-amber-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"
/>
</svg>
</div>
<div>
<h3 className="text-lg font-semibold text-white">Log Out & Clear App Data</h3>
</div>
</div>
<div className="mb-6">
<p className="text-stone-300 text-sm leading-relaxed">
This will sign you out and permanently delete ALL data including: App settings and
conversations Email data from Gmail Chat history from Telegram Cached files
from Notion All other skills data
<br />
<br />
This action cannot be undone and may take a few moments to complete.
</p>
{error && (
<div className="mt-3 p-3 rounded-lg bg-coral-500/10 border border-coral-500/20">
<p className="text-coral-400 text-sm">{error}</p>
</div>
)}
</div>
<div className="flex gap-3">
<button
onClick={() => {
setShowLogoutAndClearModal(false);
setError(null);
}}
disabled={isLoading}
className="flex-1 px-4 py-2 rounded-lg border border-stone-600 text-stone-300 hover:bg-stone-800 transition-colors disabled:opacity-50">
Cancel
</button>
<button
onClick={handleLogoutAndClearData}
disabled={isLoading}
className="flex-1 px-4 py-2 rounded-lg bg-amber-600 hover:bg-amber-500 text-white transition-colors disabled:opacity-50 flex items-center justify-center gap-2">
{isLoading && (
<svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
/>
</svg>
)}
{isLoading ? 'Clearing All Data...' : 'Log Out & Clear Everything'}
</button>
</div>
</div>
</div>
)}
</div>
);
};
+50 -4
View File
@@ -1,7 +1,10 @@
/**
* Send Notion user metadata to the backend via the
* Send Notion metadata to the backend via the
* `integration:metadata-sync` socket event so the server can merge it
* into the user's Notion OAuth integration metadata.
*
* Mirrors the Gmail metadata sync pattern: we send the primary profile plus
* additional Notion data (pages, summaries) when available.
*/
import { emitViaRustSocket } from '../../../utils/tauriSocket';
@@ -16,14 +19,47 @@ export interface NotionUserProfileLike {
avatar_url?: string | null;
}
export interface NotionPageSummaryLike {
id: string;
title: string;
url: string | null;
last_edited_time: string;
content_text: string | null;
}
export interface NotionSummaryLike {
id: number;
pageId: string;
url: string | null;
summary: string;
category: string | null;
sentiment: string;
topics: string[];
sourceCreatedAt: string;
sourceUpdatedAt: string;
}
/**
* Emit `integration:metadata-sync` with Notion user profile so the
* backend can merge it into the user's Notion OAuth integration.
* Shape of Notion data we care about for backend integration metadata sync.
* This is populated from the Notion Redux slice in the runner.
*/
export interface NotionStateForSync {
profile?: NotionUserProfileLike | null;
pages?: NotionPageSummaryLike[] | null;
summaries?: NotionSummaryLike[] | null;
}
/**
* Emit `integration:metadata-sync` with Notion profile plus additional
* Notion data (pages and summaries) so the backend can merge everything
* into the user's Notion OAuth integration metadata.
*
* No-op when profile is missing or invalid.
*/
export function syncNotionMetadataToBackend(
profile: NotionUserProfileLike | null | undefined
notionState: NotionStateForSync | null | undefined
): void {
const profile = notionState?.profile;
if (!profile || !profile.id) return;
const metadata: Record<string, unknown> = {
@@ -34,6 +70,16 @@ export function syncNotionMetadataToBackend(
avatar_url: profile.avatar_url ?? null,
};
if (Array.isArray(notionState.pages) && notionState.pages.length > 0) {
metadata.pages = notionState.pages;
metadata.pages_total = notionState.pages.length;
}
if (Array.isArray(notionState.summaries) && notionState.summaries.length > 0) {
metadata.summaries = notionState.summaries;
metadata.summaries_total = notionState.summaries.length;
}
const payload = { requestId: crypto.randomUUID(), provider: PROVIDER_NOTION, metadata };
void emitViaRustSocket(INTEGRATION_METADATA_SYNC_EVENT, payload);
+38
View File
@@ -527,6 +527,44 @@ class SkillManager {
throw new Error(`Unknown reverse RPC method: ${method}`);
}
}
/**
* Clear all skills databases and cached data.
* Used for nuclear reset functionality.
*/
async clearAllSkillsData(): Promise<void> {
try {
// Stop all running skills first
await this.stopAll();
// Get all skill IDs from Redux state
const state = store.getState();
const skillIds = Object.keys(state.skills.skills);
// Clear data for each skill
const clearPromises = skillIds.map(async (skillId) => {
try {
// Get skill data directory path
const dataDir = await invoke<string>("runtime_skill_data_dir", { skillId });
// Note: We don't directly delete directories here since there's no exposed
// Tauri command for that. Instead, we rely on the backend to handle
// clearing when skills are disabled/reset via Redux state clearing.
console.log(`[SkillManager] Skill ${skillId} data directory: ${dataDir}`);
} catch (err) {
console.warn(`[SkillManager] Failed to get data directory for skill ${skillId}:`, err);
}
});
await Promise.all(clearPromises);
console.log("[SkillManager] Skills data clearing initiated");
} catch (error) {
console.error("[SkillManager] Failed to clear skills data:", error);
throw new Error("Failed to clear skills databases");
}
}
}
// Export singleton
-1
View File
@@ -6,7 +6,6 @@
*/
import { invoke } from '@tauri-apps/api/core';
import { clearToolsCache } from '../ai/tools/loader';
import { forceToolsCacheRefresh } from './file-watcher';
// Prevent excessive updates - limit to once per 10 seconds
+2 -2
View File
@@ -99,10 +99,10 @@ function simpleHash(str: string): number {
/**
* Force a cache refresh (useful for manual triggers)
*/
export function forceToolsCacheRefresh(): Promise<void> {
export async function forceToolsCacheRefresh(): Promise<void> {
console.log('🔄 Forcing tools cache refresh...');
clearToolsCache();
clearAICache();
lastModifiedTime = null; // Reset to trigger next check
return loadAIConfig();
await loadAIConfig();
}
+86 -13
View File
@@ -22,7 +22,7 @@ import { useAppDispatch, useAppSelector } from '../store/hooks';
import type { NotionPageSummary, NotionSummary, NotionUserProfile } from '../store/notionSlice';
import {
addInferenceResponse,
addOptimisticMessage,
addMessageLocal,
clearDeleteStatus,
clearPurgeStatus,
clearSelectedThread,
@@ -30,11 +30,13 @@ import {
deleteThreadLocal,
fetchSuggestedQuestions,
purgeThreads,
removeOptimisticMessages,
setActiveThread,
setLastViewed,
setPanelWidth,
setSelectedThread,
updateMessagesForThread,
} from '../store/threadSlice';
import type { ThreadMessage } from '../types/thread';
const MIN_PANEL_WIDTH = 200;
const MAX_PANEL_WIDTH = 480;
@@ -113,6 +115,7 @@ const Conversations = () => {
lastViewedAt,
suggestedQuestions,
isLoadingSuggestions,
activeThreadId,
} = useAppSelector(state => state.thread);
const skillsState = useAppSelector(state => state.skills);
@@ -306,14 +309,45 @@ const Conversations = () => {
const trimmed = text ?? inputValue.trim();
if (!trimmed || !selectedThreadId || isSending) return;
// Snapshot history before the optimistic update (exclude stale optimistic msgs)
const historySnapshot = messages.filter(m => !m.id.startsWith('optimistic-'));
// Check if another thread is already sending
if (activeThreadId && activeThreadId !== selectedThreadId) {
return; // Block sending from non-active threads
}
// 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,
type: 'text',
extraMetadata: {},
sender: 'user',
createdAt: new Date().toISOString(),
};
// Immediately persist user message to both current view and persistent storage
dispatch(addMessageLocal({ threadId: sendingThreadId, message: userMessage }));
// 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
);
dispatch(addOptimisticMessage({ content: trimmed }));
setInputValue('');
setSendError(null);
setIsSending(true);
// Set this thread as active
dispatch(setActiveThread(sendingThreadId));
try {
// Process user message with SOUL + TOOLS injection
let processedUserContent = trimmed;
@@ -447,6 +481,7 @@ const Conversations = () => {
toolArgs
);
const result = await skillManager.callTool(skillId, toolName, toolArgs);
console.log(`[Conversations] tool "${toolName}" calling result:`, result);
toolResultContent = result.content.map(c => c.text).join('\n');
let toolReturnedError = result.isError;
if (!toolReturnedError && toolResultContent) {
@@ -490,14 +525,32 @@ const Conversations = () => {
break;
}
dispatch(addInferenceResponse({ content: finalContent }));
// Pass the original sending thread ID to ensure response goes to correct thread
dispatch(addInferenceResponse({ content: finalContent, threadId: sendingThreadId }));
} catch (err) {
dispatch(removeOptimisticMessages());
// Remove the user message from persistent storage on error
// We'll use a thunk-like approach to access current state
dispatch((dispatch, getState) => {
const state = getState() as {
thread: { messagesByThreadId: Record<string, ThreadMessage[]> };
};
const persistedMessages = state.thread.messagesByThreadId[sendingThreadId] || [];
const currentMessages = persistedMessages.filter(m => m.id !== userMessage.id);
dispatch(updateMessagesForThread({ threadId: sendingThreadId, messages: currentMessages }));
// Also remove from current view if this is the selected thread
if (sendingThreadId === selectedThreadId) {
dispatch(setSelectedThread(sendingThreadId));
}
});
const msg =
err && typeof err === 'object' && 'error' in err
? String((err as { error: unknown }).error)
: 'Failed to get response';
setSendError(msg);
// Clear active thread on error
dispatch(setActiveThread(null));
} finally {
setIsSending(false);
}
@@ -899,8 +952,8 @@ const Conversations = () => {
</div>
</div>
))}
{/* Typing indicator (#14) */}
{isSending && (
{/* Typing indicator (#14) - Only show for the active thread */}
{activeThreadId === selectedThreadId && isSending && (
<div className="flex justify-start">
<div className="bg-white/5 rounded-2xl rounded-bl-md px-4 py-3">
<div className="flex items-center gap-1">
@@ -929,7 +982,9 @@ const Conversations = () => {
key={i}
type="button"
onClick={() => handleSendMessage(s.text)}
disabled={isSending}
disabled={
isSending || !!(activeThreadId && activeThreadId !== selectedThreadId)
}
className="flex-shrink-0 px-3 py-1.5 rounded-lg text-[12px] whitespace-nowrap bg-white/5 text-stone-400 hover:bg-white/10 transition-colors disabled:opacity-50 disabled:cursor-not-allowed">
{s.text}
</button>
@@ -940,6 +995,15 @@ const Conversations = () => {
{/* Message Input */}
<div className="flex-shrink-0 border-t border-white/10 px-4 py-3">
{/* Show warning if another thread is active */}
{activeThreadId && activeThreadId !== selectedThreadId && (
<div className="mb-3 p-2 rounded-lg bg-amber-500/10 border border-amber-500/20">
<p className="text-xs text-amber-400">
Another conversation is active. Please wait for it to complete before sending
messages here.
</p>
</div>
)}
{/* Model selector */}
<div className="flex items-center gap-2 mb-2">
{isLoadingModels ? (
@@ -982,13 +1046,22 @@ const Conversations = () => {
value={inputValue}
onChange={e => setInputValue(e.target.value)}
onKeyDown={handleInputKeyDown}
placeholder="Type a message..."
placeholder={
activeThreadId && activeThreadId !== selectedThreadId
? 'Another conversation is active...'
: 'Type a message...'
}
rows={1}
className="flex-1 resize-none bg-white/5 border border-white/10 rounded-xl px-4 py-2.5 text-sm placeholder:text-stone-500 focus:outline-none focus:ring-1 focus:ring-primary-500/50 focus:border-primary-500/50 transition-all max-h-32"
disabled={!!(activeThreadId && activeThreadId !== selectedThreadId)}
className="flex-1 resize-none bg-white/5 border border-white/10 rounded-xl px-4 py-2.5 text-sm placeholder:text-stone-500 focus:outline-none focus:ring-1 focus:ring-primary-500/50 focus:border-primary-500/50 transition-all max-h-32 disabled:opacity-50 disabled:cursor-not-allowed"
/>
<button
onClick={() => handleSendMessage()}
disabled={!inputValue.trim() || isSending}
disabled={
!inputValue.trim() ||
isSending ||
!!(activeThreadId && activeThreadId !== selectedThreadId)
}
className="p-2.5 rounded-xl bg-primary-600 hover:bg-primary-500 disabled:opacity-40 disabled:cursor-not-allowed transition-colors flex-shrink-0">
{isSending ? (
<svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
+25 -48
View File
@@ -12,12 +12,15 @@ import {
type GmailStateForSync,
syncGmailMetadataToBackend,
} from '../lib/gmail/services/metadataSync';
import { syncNotionMetadataToBackend } from '../lib/notion/services/metadataSync';
import {
type NotionStateForSync,
syncNotionMetadataToBackend,
} from '../lib/notion/services/metadataSync';
import { skillManager } from '../lib/skills/manager';
import type { SkillManifest } from '../lib/skills/types';
import { buildManualSentryEvent, enqueueError } from '../services/errorReportQueue';
import {
GmailEmailSummary,
GmailEmailBatch,
type GmailProfile,
setGmailEmails,
setGmailProfile,
@@ -97,50 +100,38 @@ function syncGmailStateToSlice(
: null
)
);
dispatch(
setGmailEmails(
Array.isArray(gmailState.emails) ? (gmailState.emails as GmailEmailSummary[]) : []
)
);
dispatch(setGmailEmails(gmailState.emails as GmailEmailBatch | null));
syncGmailMetadataToBackend(gmailState as GmailStateForSync);
}
/** Sync pages and summaries from notion skill state into notionSlice. */
/** Sync profile, pages, and summaries from notion skill state into notionSlice and backend metadata. */
function syncNotionStateToSlice(
notionState: Record<string, unknown> | undefined,
dispatch: ReturnType<typeof useAppDispatch>
): void {
if (!notionState || typeof notionState !== 'object') return;
if (Array.isArray(notionState.pages)) {
dispatch(setNotionPages(notionState.pages as NotionPageSummary[]));
}
if (Array.isArray(notionState.summaries)) {
dispatch(setNotionSummaries(notionState.summaries as NotionSummary[]));
}
}
const profile =
notionState.profile !== undefined && notionState.profile != null
? (notionState.profile as NotionUserProfile)
: null;
const pages = Array.isArray(notionState.pages) ? (notionState.pages as NotionPageSummary[]) : [];
const summaries = Array.isArray(notionState.summaries)
? (notionState.summaries as NotionSummary[])
: [];
async function syncNotionUserOnConnect(dispatch: ReturnType<typeof useAppDispatch>): Promise<void> {
try {
const toolResult = await skillManager.callTool('notion', 'get-user', { user_id: 'me' });
if (!toolResult || toolResult.isError || toolResult.content.length === 0) {
return;
}
const first = toolResult.content[0];
const raw = first?.text;
if (!raw) return;
// Update profile in notionSlice if present
dispatch(setNotionProfile(profile));
const parsed = JSON.parse(raw) as NotionUserProfile | { error?: string };
if ('error' in parsed && parsed.error) {
return;
}
const profile = parsed as NotionUserProfile;
dispatch(setNotionProfile(profile));
syncNotionMetadataToBackend(profile);
} catch (e) {
console.error('[SkillProvider] Failed to call Notion get-user tool after connect:', e);
if (pages.length > 0) {
dispatch(setNotionPages(pages));
}
if (summaries.length > 0) {
dispatch(setNotionSummaries(summaries));
}
const stateForSync: NotionStateForSync = { profile, pages, summaries };
syncNotionMetadataToBackend(stateForSync);
}
export default function SkillProvider({ children }: { children: ReactNode }) {
@@ -149,7 +140,6 @@ export default function SkillProvider({ children }: { children: ReactNode }) {
const skillStates = useAppSelector(state => state.skills.skillStates);
const dispatch = useAppDispatch();
const initRef = useRef(false);
const lastNotionConnectionStatusRef = useRef<string | undefined>(undefined);
// Keep gmailSlice in sync with skills.skillStates.gmail (event handler + rehydration)
const gmailSkillState = skillStates?.gmail as Record<string, unknown> | undefined;
@@ -165,19 +155,6 @@ export default function SkillProvider({ children }: { children: ReactNode }) {
syncNotionStateToSlice(notionSkillState, dispatch);
}, [notionSkillState, dispatch]);
// When Notion connection_status transitions to "connected", fetch the current user
// via the notion get-user tool, store it in notionSlice, and sync metadata to backend.
useEffect(() => {
if (!notionSkillState || typeof notionSkillState !== 'object') return;
const connectionStatus = notionSkillState.connection_status as string | undefined;
const prev = lastNotionConnectionStatusRef.current;
lastNotionConnectionStatusRef.current = connectionStatus;
if (connectionStatus === 'connected' && prev !== 'connected') {
void syncNotionUserOnConnect(dispatch);
}
}, [notionSkillState, dispatch]);
// Listen for skill state changes emitted from the Rust runtime event loop
useEffect(() => {
let unlisten: (() => void) | undefined;
-536
View File
@@ -1,536 +0,0 @@
import { beforeEach, describe, expect, type Mock, test, vi } from 'vitest';
import type {
AgentExecutionOptions,
AgentExecutionResult,
AgentToolExecution,
AgentToolSchema,
} from '../../types/agent';
import { AgentLoopService } from '../agentLoop';
import { AgentToolRegistry } from '../agentToolRegistry';
import { apiClient } from '../apiClient';
// Mock dependencies
vi.mock('../agentToolRegistry');
vi.mock('../apiClient');
describe('AgentLoopService', () => {
let service: AgentLoopService;
const mockToolRegistry = AgentToolRegistry as vi.MockedClass<typeof AgentToolRegistry>;
const mockApiClient = apiClient as { post: Mock };
const mockToolSchemas: AgentToolSchema[] = [
{
type: 'function',
function: {
name: 'github_list_issues',
description: 'List GitHub issues for a repository',
parameters: {
type: 'object',
properties: {
owner: { type: 'string', description: 'Repository owner' },
repo: { type: 'string', description: 'Repository name' },
},
required: ['owner', 'repo'],
},
},
},
{
type: 'function',
function: {
name: 'notion_create_page',
description: 'Create a new Notion page',
parameters: {
type: 'object',
properties: {
title: { type: 'string', description: 'Page title' },
content: { type: 'string', description: 'Page content' },
},
required: ['title'],
},
},
},
];
beforeEach(() => {
service = AgentLoopService.getInstance();
vi.clearAllMocks();
// Setup default mock implementations
const mockRegistryInstance = { loadToolSchemas: vi.fn(), executeTool: vi.fn() };
mockToolRegistry.getInstance.mockReturnValue(mockRegistryInstance as any);
mockRegistryInstance.loadToolSchemas.mockResolvedValue(mockToolSchemas);
});
describe('executeTask', () => {
test('should execute simple task without tool calls', async () => {
const mockResponse = {
choices: [
{
message: {
role: 'assistant' as const,
content: 'Hello! How can I help you today?',
tool_calls: undefined,
},
finish_reason: 'stop' as const,
},
],
usage: { prompt_tokens: 20, completion_tokens: 10, total_tokens: 30 },
};
mockApiClient.post.mockResolvedValue({ data: mockResponse });
const result = await service.executeTask('Hello', 'conv_123', {
maxIterations: 5,
timeoutMs: 30000,
});
expect(result.status).toBe('completed');
expect(result.finalResponse).toBe('Hello! How can I help you today?');
expect(result.iterations).toBe(1);
expect(result.toolExecutions).toHaveLength(0);
expect(result.executionTime).toBeGreaterThan(0);
// Verify API call format
expect(mockApiClient.post).toHaveBeenCalledWith(
'/api/v1/conversations/conv_123/messages',
expect.objectContaining({
model: expect.any(String),
messages: expect.arrayContaining([
expect.objectContaining({ role: 'user', content: 'Hello' }),
]),
tools: mockToolSchemas,
tool_choice: 'auto',
})
);
});
test('should execute task with single tool call', async () => {
const mockToolCallResponse = {
choices: [
{
message: {
role: 'assistant' as const,
content: null,
tool_calls: [
{
id: 'call_123',
type: 'function' as const,
function: {
name: 'github_list_issues',
arguments: '{"owner":"user","repo":"test"}',
},
},
],
},
finish_reason: 'tool_calls' as const,
},
],
};
const mockFinalResponse = {
choices: [
{
message: {
role: 'assistant' as const,
content: 'I found 3 open issues in your repository.',
tool_calls: undefined,
},
finish_reason: 'stop' as const,
},
],
usage: { prompt_tokens: 50, completion_tokens: 20, total_tokens: 70 },
};
const mockToolExecution: AgentToolExecution = {
id: 'exec_123',
toolName: 'list_issues',
skillId: 'github',
arguments: '{"owner":"user","repo":"test"}',
status: 'success',
startTime: Date.now() - 1500,
endTime: Date.now(),
executionTimeMs: 1500,
result: '{"issues":[{"title":"Bug fix","number":1}]}',
};
// Setup mocks
const mockRegistryInstance = mockToolRegistry.getInstance();
mockRegistryInstance.executeTool.mockResolvedValue(mockToolExecution);
mockApiClient.post
.mockResolvedValueOnce({ data: mockToolCallResponse })
.mockResolvedValueOnce({ data: mockFinalResponse });
const result = await service.executeTask('Show me GitHub issues', 'conv_123');
expect(result.status).toBe('completed');
expect(result.finalResponse).toBe('I found 3 open issues in your repository.');
expect(result.iterations).toBe(2);
expect(result.toolExecutions).toHaveLength(1);
expect(result.toolExecutions[0].toolName).toBe('list_issues');
expect(result.toolExecutions[0].status).toBe('success');
// Verify tool execution was called with correct parameters
expect(mockRegistryInstance.executeTool).toHaveBeenCalledWith(
'github',
'list_issues',
'{"owner":"user","repo":"test"}'
);
});
test('should handle multiple tool calls in sequence', async () => {
const mockFirstToolCallResponse = {
choices: [
{
message: {
role: 'assistant' as const,
content: null,
tool_calls: [
{
id: 'call_1',
type: 'function' as const,
function: {
name: 'github_list_issues',
arguments: '{"owner":"user","repo":"test"}',
},
},
],
},
finish_reason: 'tool_calls' as const,
},
],
};
const mockSecondToolCallResponse = {
choices: [
{
message: {
role: 'assistant' as const,
content: null,
tool_calls: [
{
id: 'call_2',
type: 'function' as const,
function: { name: 'notion_create_page', arguments: '{"title":"Issues Summary"}' },
},
],
},
finish_reason: 'tool_calls' as const,
},
],
};
const mockFinalResponse = {
choices: [
{
message: {
role: 'assistant' as const,
content: 'I created a summary page with your GitHub issues.',
tool_calls: undefined,
},
finish_reason: 'stop' as const,
},
],
};
const mockToolExecution1: AgentToolExecution = {
id: 'exec_1',
toolName: 'list_issues',
skillId: 'github',
arguments: '{"owner":"user","repo":"test"}',
status: 'success',
startTime: Date.now() - 2000,
endTime: Date.now() - 1000,
executionTimeMs: 1000,
result: '{"issues":[{"title":"Bug fix","number":1}]}',
};
const mockToolExecution2: AgentToolExecution = {
id: 'exec_2',
toolName: 'create_page',
skillId: 'notion',
arguments: '{"title":"Issues Summary"}',
status: 'success',
startTime: Date.now() - 800,
endTime: Date.now(),
executionTimeMs: 800,
result: '{"page_id":"page_123"}',
};
// Setup mocks
const mockRegistryInstance = mockToolRegistry.getInstance();
mockRegistryInstance.executeTool
.mockResolvedValueOnce(mockToolExecution1)
.mockResolvedValueOnce(mockToolExecution2);
mockApiClient.post
.mockResolvedValueOnce({ data: mockFirstToolCallResponse })
.mockResolvedValueOnce({ data: mockSecondToolCallResponse })
.mockResolvedValueOnce({ data: mockFinalResponse });
const result = await service.executeTask(
'Get GitHub issues and create a summary page',
'conv_123',
{ maxIterations: 5 }
);
expect(result.status).toBe('completed');
expect(result.iterations).toBe(3);
expect(result.toolExecutions).toHaveLength(2);
expect(result.toolExecutions[0].skillId).toBe('github');
expect(result.toolExecutions[1].skillId).toBe('notion');
});
test('should handle tool execution timeout', async () => {
const result = await service.executeTask(
'Test timeout',
'conv_123',
{ maxIterations: 1, timeoutMs: 100 } // Very short timeout
);
// The timeout logic depends on how it's implemented in the actual service
// This test may need adjustment based on the actual implementation
expect(result.status).toBe('timeout');
expect(result.error).toContain('timeout');
});
test('should respect maximum iterations limit', async () => {
const mockToolCallResponse = {
choices: [
{
message: {
role: 'assistant' as const,
tool_calls: [
{
id: 'call_1',
type: 'function' as const,
function: { name: 'github_list_issues', arguments: '{}' },
},
],
},
finish_reason: 'tool_calls' as const,
},
],
};
// Mock to always return tool calls (infinite loop scenario)
mockApiClient.post.mockResolvedValue({ data: mockToolCallResponse });
const mockToolExecution: AgentToolExecution = {
id: 'exec_1',
toolName: 'list_issues',
skillId: 'github',
arguments: '{}',
status: 'success',
startTime: Date.now() - 100,
endTime: Date.now(),
executionTimeMs: 100,
result: '{}',
};
const mockRegistryInstance = mockToolRegistry.getInstance();
mockRegistryInstance.executeTool.mockResolvedValue(mockToolExecution);
const result = await service.executeTask('Infinite loop test', 'conv_123', {
maxIterations: 2,
timeoutMs: 10000,
});
expect(result.status).toBe('max_iterations');
expect(result.iterations).toBe(2);
expect(result.error).toContain('maximum iterations');
});
test('should handle tool execution error gracefully', async () => {
const mockToolCallResponse = {
choices: [
{
message: {
role: 'assistant' as const,
tool_calls: [
{
id: 'call_1',
type: 'function' as const,
function: { name: 'invalid_tool', arguments: '{}' },
},
],
},
finish_reason: 'tool_calls' as const,
},
],
};
const mockErrorResponse = {
choices: [
{
message: {
role: 'assistant' as const,
content: 'I encountered an error while executing the tool.',
tool_calls: undefined,
},
finish_reason: 'stop' as const,
},
],
};
const mockToolExecution: AgentToolExecution = {
id: 'exec_1',
toolName: 'invalid_tool',
skillId: 'unknown',
arguments: '{}',
status: 'error',
startTime: Date.now() - 100,
endTime: Date.now(),
executionTimeMs: 100,
errorMessage: 'Tool not found',
};
const mockRegistryInstance = mockToolRegistry.getInstance();
mockRegistryInstance.executeTool.mockResolvedValue(mockToolExecution);
mockApiClient.post
.mockResolvedValueOnce({ data: mockToolCallResponse })
.mockResolvedValueOnce({ data: mockErrorResponse });
const result = await service.executeTask('Test error handling', 'conv_123');
expect(result.status).toBe('completed');
expect(result.toolExecutions).toHaveLength(1);
expect(result.toolExecutions[0].status).toBe('error');
expect(result.toolExecutions[0].errorMessage).toBe('Tool not found');
});
test('should handle API client errors', async () => {
mockApiClient.post.mockRejectedValue(new Error('Network error'));
const result = await service.executeTask('Test API error', 'conv_123');
expect(result.status).toBe('error');
expect(result.error).toContain('Network error');
expect(result.iterations).toBe(0);
expect(result.toolExecutions).toHaveLength(0);
});
test('should parse tool name from function name correctly', async () => {
const mockToolCallResponse = {
choices: [
{
message: {
role: 'assistant' as const,
tool_calls: [
{
id: 'call_1',
type: 'function' as const,
function: {
name: 'github_list_issues', // Should parse to skillId=github, toolName=list_issues
arguments: '{"owner":"user","repo":"test"}',
},
},
],
},
finish_reason: 'tool_calls' as const,
},
],
};
const mockFinalResponse = {
choices: [
{
message: { role: 'assistant' as const, content: 'Done', tool_calls: undefined },
finish_reason: 'stop' as const,
},
],
};
const mockToolExecution: AgentToolExecution = {
id: 'exec_1',
toolName: 'list_issues',
skillId: 'github',
arguments: '{"owner":"user","repo":"test"}',
status: 'success',
startTime: Date.now() - 100,
endTime: Date.now(),
executionTimeMs: 100,
result: '{}',
};
const mockRegistryInstance = mockToolRegistry.getInstance();
mockRegistryInstance.executeTool.mockResolvedValue(mockToolExecution);
mockApiClient.post
.mockResolvedValueOnce({ data: mockToolCallResponse })
.mockResolvedValueOnce({ data: mockFinalResponse });
await service.executeTask('Test tool parsing', 'conv_123');
// Verify correct parsing of skill ID and tool name
expect(mockRegistryInstance.executeTool).toHaveBeenCalledWith(
'github',
'list_issues',
'{"owner":"user","repo":"test"}'
);
});
});
describe('singleton behavior', () => {
test('should return the same instance', () => {
const instance1 = AgentLoopService.getInstance();
const instance2 = AgentLoopService.getInstance();
expect(instance1).toBe(instance2);
});
});
describe('task execution options', () => {
test('should use default options when none provided', async () => {
const mockResponse = {
choices: [
{
message: { role: 'assistant' as const, content: 'Test response' },
finish_reason: 'stop' as const,
},
],
};
mockApiClient.post.mockResolvedValue({ data: mockResponse });
const result = await service.executeTask('Test', 'conv_123');
// Should complete successfully with defaults
expect(result.status).toBe('completed');
expect(result.executionTime).toBeGreaterThan(0);
});
test('should respect custom execution options', async () => {
const mockResponse = {
choices: [
{
message: { role: 'assistant' as const, content: 'Test response' },
finish_reason: 'stop' as const,
},
],
};
mockApiClient.post.mockResolvedValue({ data: mockResponse });
const customOptions: AgentExecutionOptions = {
maxIterations: 3,
timeoutMs: 5000,
model: 'gpt-3.5-turbo',
temperature: 0.7,
};
const result = await service.executeTask('Test', 'conv_123', customOptions);
expect(result.status).toBe('completed');
// Verify custom options were passed to API
expect(mockApiClient.post).toHaveBeenCalledWith(
'/api/v1/conversations/conv_123/messages',
expect.objectContaining({ model: 'gpt-3.5-turbo', temperature: 0.7 })
);
});
});
});
+28 -10
View File
@@ -1,12 +1,30 @@
import { createSlice, type PayloadAction } from '@reduxjs/toolkit';
export interface GmailEmailSummary {
id: string;
export interface GmailEmailEntity {
identifier: string;
kind: 'sender' | 'recipient' | 'recipient_cc' | string;
name: string;
}
export interface GmailEmailMetadata {
emailId: string;
threadId: string;
snippet?: string;
subject?: string;
from?: string;
date?: string;
date: number;
}
export interface GmailEmailChunk {
content: string;
entities: GmailEmailEntity[];
labels: string[];
metadata: GmailEmailMetadata;
title: string;
}
export interface GmailEmailBatch {
chunks: GmailEmailChunk[]; // up to 20 in your example
createdAt: number; // when this batch was generated
emailIds: string[]; // same ids as in chunks[*].emailId
total: number; // total emails in this batch
}
export interface GmailProfile {
@@ -18,22 +36,22 @@ export interface GmailProfile {
interface GmailState {
/** Emails fetched after OAuth connection (from Gmail skill) */
emails: GmailEmailSummary[];
emails: GmailEmailBatch | null;
/** Profile of the connected Gmail user (from Gmail skill) */
profile: GmailProfile | null;
}
const initialState: GmailState = { emails: [], profile: null };
const initialState: GmailState = { emails: null, profile: null };
const gmailSlice = createSlice({
name: 'gmail',
initialState,
reducers: {
setGmailEmails(state, action: PayloadAction<GmailEmailSummary[]>) {
setGmailEmails(state, action: PayloadAction<GmailEmailBatch | null>) {
state.emails = action.payload;
},
clearGmailEmails(state) {
state.emails = [];
state.emails = null;
},
setGmailProfile(state, action: PayloadAction<GmailProfile | null>) {
state.profile = action.payload;
+1
View File
@@ -47,6 +47,7 @@ const aiPersistConfig = { key: 'ai', storage, whitelist: ['config'] };
const skillsPersistConfig = { key: 'skills', storage, whitelist: ['skills'] };
// Persist config for thread data and UI prefs (includes threads and messages)
// Note: activeThreadId is intentionally excluded as it's transient state
const threadPersistConfig = {
key: 'thread',
storage,
+31 -33
View File
@@ -4,7 +4,6 @@ import { injectAll } from '../lib/ai/injector';
import type { Message } from '../lib/ai/providers/interface';
import { threadApi } from '../services/api/threadApi';
import type { Thread, ThreadMessage } from '../types/thread';
import type { RootState } from './index';
interface ThreadState {
// Existing local data (will be persisted)
@@ -12,6 +11,7 @@ interface ThreadState {
selectedThreadId: string | null;
panelWidth: number;
lastViewedAt: Record<string, number>;
activeThreadId: string | null; // Track which thread is currently sending/receiving
// NEW: Add efficient message storage
messagesByThreadId: Record<string, ThreadMessage[]>;
@@ -36,6 +36,7 @@ const initialState: ThreadState = {
selectedThreadId: null,
panelWidth: 320,
lastViewedAt: {},
activeThreadId: null,
messagesByThreadId: {},
messages: [],
isLoadingMessages: false,
@@ -201,7 +202,7 @@ const threadSlice = createSlice({
createdAt: new Date().toISOString(),
});
},
addInferenceResponse: (state, action: { payload: { content: string } }) => {
addInferenceResponse: (state, action: { payload: { content: string; threadId?: string } }) => {
const aiMessage: ThreadMessage = {
id: `inference-${Date.now()}`,
content: action.payload.content,
@@ -211,47 +212,33 @@ const threadSlice = createSlice({
createdAt: new Date().toISOString(),
};
// Add to current messages view
state.messages.push(aiMessage);
// Use provided threadId or fall back to activeThreadId to ensure response goes to correct thread
const targetThreadId = action.payload.threadId || state.activeThreadId;
// Also add to persistent storage
if (state.selectedThreadId) {
if (!state.messagesByThreadId[state.selectedThreadId]) {
state.messagesByThreadId[state.selectedThreadId] = [];
if (targetThreadId) {
// Ensure messagesByThreadId exists for this thread
if (!state.messagesByThreadId[targetThreadId]) {
state.messagesByThreadId[targetThreadId] = [];
}
// CRITICAL FIX: Ensure the preceding user message is also persisted
// Find the last user message that might not be in persistent storage yet
const lastUserMessage = state.messages.filter(m => m.sender === 'user').pop();
// Add the AI response to persistent storage
state.messagesByThreadId[targetThreadId].push(aiMessage);
if (lastUserMessage) {
const persistedMessages = state.messagesByThreadId[state.selectedThreadId];
const userMessageExists = persistedMessages.some(m => m.id === lastUserMessage.id);
// If user message isn't persisted yet, add it first.
// Replace optimistic- prefix with a stable id so historySnapshot
// (which filters out optimistic- ids) includes it on future sends.
if (!userMessageExists) {
const stableMessage = lastUserMessage.id.startsWith('optimistic-')
? { ...lastUserMessage, id: `msg_${Date.now()}_user` }
: lastUserMessage;
persistedMessages.push(stableMessage);
// Keep state.messages in sync with the stable id
const idx = state.messages.findLastIndex(m => m.id === lastUserMessage.id);
if (idx !== -1) state.messages[idx] = stableMessage;
}
// Add to current messages view only if it's the currently selected thread
if (targetThreadId === state.selectedThreadId) {
state.messages.push(aiMessage);
}
// Now add the AI response
state.messagesByThreadId[state.selectedThreadId].push(aiMessage);
// Update thread metadata
const thread = state.threads.find(t => t.id === state.selectedThreadId);
const thread = state.threads.find(t => t.id === targetThreadId);
if (thread) {
thread.messageCount = state.messagesByThreadId[state.selectedThreadId].length;
thread.messageCount = state.messagesByThreadId[targetThreadId].length;
thread.lastMessageAt = aiMessage.createdAt;
}
}
// Clear active thread when response is received
state.activeThreadId = null;
},
removeOptimisticMessages: state => {
state.messages = state.messages.filter(m => !m.id.startsWith('optimistic-'));
@@ -327,6 +314,10 @@ const threadSlice = createSlice({
state.selectedThreadId = null;
state.messages = [];
state.lastViewedAt = {};
state.activeThreadId = null;
},
setActiveThread: (state, action: { payload: string | null }) => {
state.activeThreadId = action.payload;
},
},
extraReducers: builder => {
@@ -352,22 +343,28 @@ const threadSlice = createSlice({
state.selectedThreadId = null;
state.messages = [];
state.lastViewedAt = {};
state.activeThreadId = null;
})
// sendMessage
.addCase(sendMessage.pending, state => {
.addCase(sendMessage.pending, (state, action) => {
state.sendStatus = 'loading';
state.sendError = null;
// Set the active thread when message sending starts
state.activeThreadId = action.meta.arg.threadId;
})
.addCase(sendMessage.fulfilled, state => {
state.sendStatus = 'success';
state.suggestedQuestions = [];
state.suggestError = null;
// Don't clear activeThreadId here - let addInferenceResponse handle it
})
.addCase(sendMessage.rejected, (state, action) => {
state.sendStatus = 'error';
state.sendError = action.payload as string;
// Remove optimistic messages so the user doesn't see phantom messages
state.messages = state.messages.filter(m => !m.id.startsWith('optimistic-'));
// Clear active thread on error
state.activeThreadId = null;
})
// fetchSuggestedQuestions
.addCase(fetchSuggestedQuestions.pending, state => {
@@ -403,5 +400,6 @@ export const {
deleteThreadLocal,
updateMessagesForThread,
clearAllThreads,
setActiveThread,
} = threadSlice.actions;
export default threadSlice.reducer;
+6 -2
View File
@@ -50,7 +50,11 @@ export interface IAgentToolRegistry {
/**
* Execute a tool using the skill system
*/
executeTool(skillId: string, toolName: string, toolArguments: string): Promise<AgentToolExecution>;
executeTool(
skillId: string,
toolName: string,
toolArguments: string
): Promise<AgentToolExecution>;
/**
* Get a specific tool by name
@@ -61,4 +65,4 @@ export interface IAgentToolRegistry {
* Get all available tools
*/
getAllTools(): AgentToolSchema[];
}
}
+10 -3
View File
@@ -40,6 +40,7 @@ function getCurrentUserId(): string | null {
*/
const handleAuthDeepLink = async (parsed: URL) => {
const token = parsed.searchParams.get('token');
const key = parsed.searchParams.get('key');
if (!token) {
console.warn('[DeepLink] URL did not contain a token query parameter');
return;
@@ -53,9 +54,14 @@ const handleAuthDeepLink = async (parsed: URL) => {
console.warn('[DeepLink] Failed to show window:', err);
}
const jwtToken = await consumeLoginToken(token);
store.dispatch(setToken(jwtToken));
window.location.hash = '/onboarding';
if (key === 'auth') {
store.dispatch(setToken(token));
window.location.hash = '/onboarding';
} else {
const jwtToken = await consumeLoginToken(token);
store.dispatch(setToken(jwtToken));
window.location.hash = '/onboarding';
}
};
/**
@@ -234,6 +240,7 @@ const handleOAuthDeepLink = async (parsed: URL) => {
}
await skillManager.notifyOAuthComplete(skillId, integrationId, undefined, extraCredential);
await skillManager.triggerSync(skillId);
} catch (err) {
console.error('[DeepLink] Failed to notify OAuth complete:', err);
}