diff --git a/skills b/skills index dd49a534a..9ed6851a1 160000 --- a/skills +++ b/skills @@ -1 +1 @@ -Subproject commit dd49a534a7389411da7cbeda246bf33f35f3f4a1 +Subproject commit 9ed6851a11a1bcadf9bcc36d826757efefcf99ee diff --git a/src-tauri/src/runtime/qjs_skill_instance.rs b/src-tauri/src/runtime/qjs_skill_instance.rs index 773001f09..c65f3e0b0 100644 --- a/src-tauri/src/runtime/qjs_skill_instance.rs +++ b/src-tauri/src/runtime/qjs_skill_instance.rs @@ -570,6 +570,9 @@ async fn handle_message( "skill/ping" => { handle_js_call(rt, ctx, "onPing", "{}").await } + "skill/sync" => { + handle_js_call(rt, ctx, "onSync", "{}").await + } "oauth/revoked" => { // Clear credential: set to empty string so it's clearly "disconnected" let clear_code = r#"(function() { diff --git a/src-tauri/src/services/quickjs-libs/bootstrap.js b/src-tauri/src/services/quickjs-libs/bootstrap.js index 906abc765..dc3e3f3e4 100644 --- a/src-tauri/src/services/quickjs-libs/bootstrap.js +++ b/src-tauri/src/services/quickjs-libs/bootstrap.js @@ -146,16 +146,20 @@ globalThis.fetch = async function (url, options = {}) { headersObj = headers; } - const result = await __ops.fetch(url.toString(), { + // __ops.fetch expects a JSON string for options (not a JS object) + const resultJson = await __ops.fetch(url.toString(), JSON.stringify({ method, headers: headersObj, body: typeof body === 'string' ? body : body ? JSON.stringify(body) : null, - }); + })); - return new Response(result.body, { - status: result.status, - statusText: result.statusText || '', - headers: new Headers(result.headers), + // __ops.fetch returns a JSON string — parse it to access status/headers/body + const parsed = JSON.parse(resultJson); + + return new Response(parsed.body, { + status: parsed.status, + statusText: parsed.statusText || '', + headers: new Headers(parsed.headers || {}), }); }; diff --git a/src/AppRoutes.tsx b/src/AppRoutes.tsx index 9c6697add..f1ef2ff2b 100644 --- a/src/AppRoutes.tsx +++ b/src/AppRoutes.tsx @@ -7,6 +7,7 @@ import PublicRoute from './components/PublicRoute'; import Agents from './pages/Agents'; import Conversations from './pages/Conversations'; import Home from './pages/Home'; +import Intelligence from './pages/Intelligence'; import Invites from './pages/Invites'; import Login from './pages/Login'; import Onboarding from './pages/onboarding/Onboarding'; @@ -88,6 +89,16 @@ const AppRoutes = () => { } /> + {/* Intelligence */} + + + + } + /> + {/* Conversations */} // ), // }, - // { - // id: 'agents', - // label: 'Agents', - // path: '/agents', - // icon: ( - // - // - // - // ), - // }, + { + id: 'intelligence', + label: 'Intelligence', + path: '/intelligence', + icon: ( + + + + ), + }, { id: 'invites', label: 'Invite Friends', @@ -99,6 +99,7 @@ const MiniSidebar = () => { const isActive = (path: string) => { if (path === '/settings') return location.pathname.startsWith('/settings'); + if (path === '/conversations') return location.pathname.startsWith('/conversations'); return location.pathname === path; }; diff --git a/src/components/SkillsGrid.tsx b/src/components/SkillsGrid.tsx index 19883d377..a4f08c710 100644 --- a/src/components/SkillsGrid.tsx +++ b/src/components/SkillsGrid.tsx @@ -2,57 +2,19 @@ import { invoke } from '@tauri-apps/api/core'; import { platform } from '@tauri-apps/plugin-os'; import { useEffect, useMemo, useState } from 'react'; -import GoogleIcon from '../assets/icons/GoogleIcon'; -import NotionIcon from '../assets/icons/notion.svg'; -import TelegramIcon from '../assets/icons/telegram.svg'; -import { useSkillConnectionStatus } from '../lib/skills/hooks'; -import { skillManager } from '../lib/skills/manager'; -import type { SkillConnectionStatus, SkillHostConnectionState } from '../lib/skills/types'; +import { deriveConnectionStatus, useSkillConnectionStatus } from '../lib/skills/hooks'; +import type { SkillConnectionStatus } from '../lib/skills/types'; import { useAppSelector } from '../store/hooks'; import { IS_DEV } from '../utils/config'; import SkillSetupModal from './skills/SkillSetupModal'; - -// Map skill IDs to icons -const SKILL_ICONS: Record = { - telegram: Telegram, - email: , - notion: Notion, - github: ( - - - - ), - otter: ( - - - - ), -}; - -// Default icon for unknown skills -const DefaultIcon = () => ( -
- - - -
-); - -// Status display text and colors -const STATUS_DISPLAY: Record = { - connected: { text: 'Connected', color: 'text-sage-400' }, - connecting: { text: 'Connecting', color: 'text-amber-400' }, - not_authenticated: { text: 'Not Auth', color: 'text-amber-400' }, - disconnected: { text: 'Disconnected', color: 'text-stone-400' }, - error: { text: 'Error', color: 'text-coral-400' }, - offline: { text: 'Offline', color: 'text-stone-500' }, - setup_required: { text: 'Setup', color: 'text-primary-400' }, -}; +import { + DefaultIcon, + SKILL_ICONS, + SkillActionButton, + STATUS_DISPLAY, + STATUS_PRIORITY, + type SkillListEntry, +} from './skills/shared'; interface SkillRowProps { skillId: string; @@ -106,177 +68,6 @@ function SkillRow({ skillId, name, icon, onConnect }: SkillRowProps) { ); } -interface SkillListEntry { - id: string; - name: string; - description: string; - ignoreInProduction?: boolean; - icon?: React.ReactElement; - hasSetup: boolean; -} - -// Helper function to derive connection status (same logic as in hooks.ts) -function deriveConnectionStatus( - lifecycleStatus: string | undefined, - setupComplete: boolean | undefined, - skillState: Record | undefined -): SkillConnectionStatus { - if (!lifecycleStatus || lifecycleStatus === 'installed' || lifecycleStatus === 'stopping') { - return 'offline'; - } - if (lifecycleStatus === 'error') { - return 'error'; - } - if (lifecycleStatus === 'setup_required' || lifecycleStatus === 'setup_in_progress') { - return 'setup_required'; - } - if (lifecycleStatus === 'starting') { - return 'connecting'; - } - const hostState = skillState as SkillHostConnectionState | undefined; - if (!hostState) { - if (setupComplete && lifecycleStatus === 'ready') { - return 'connected'; - } - return 'connecting'; - } - const connStatus = hostState.connection_status; - const authStatus = hostState.auth_status; - if (connStatus === 'error' || authStatus === 'error') { - return 'error'; - } - if (connStatus === 'connected' && authStatus === 'authenticated') { - return 'connected'; - } - if (connStatus === 'connecting' || authStatus === 'authenticating') { - return 'connecting'; - } - if (connStatus === 'connected' && authStatus === 'not_authenticated') { - return 'not_authenticated'; - } - if (connStatus === 'disconnected') { - return setupComplete ? 'disconnected' : 'setup_required'; - } - return 'connecting'; -} - -// Priority order for sorting (lower number = higher priority) -const STATUS_PRIORITY: Record = { - connected: 1, - connecting: 2, - not_authenticated: 3, - disconnected: 4, - setup_required: 5, - offline: 6, - error: 7, -}; - -// Contextual action button for skills in the management modal -function SkillActionButton({ - skill, - connectionStatus, - onOpenModal, -}: { - skill: SkillListEntry; - connectionStatus: SkillConnectionStatus; - onOpenModal: () => void; -}) { - const [loading, setLoading] = useState(false); - - const handleEnable = async (e: React.MouseEvent) => { - e.stopPropagation(); - setLoading(true); - try { - await skillManager.startSkill({ - id: skill.id, - name: skill.name, - version: '0.0.0', - description: skill.description, - runtime: 'quickjs', - }); - // If skill has setup, the manager will set setup_required status - // and the grid will re-render with the "Setup" button - if (skill.hasSetup) { - onOpenModal(); - } - } catch (err) { - console.error(`Failed to enable ${skill.id}:`, err); - } finally { - setLoading(false); - } - }; - - if (loading) { - return ( -
- - - - -
- ); - } - - // Offline / not started → Enable - if (connectionStatus === 'offline') { - return ( - - ); - } - - // Setup required → Setup - if (connectionStatus === 'setup_required') { - return ( - - ); - } - - // Error → Retry - if (connectionStatus === 'error') { - return ( - - ); - } - - // Running / ready / connected → Configure - return ( - - ); -} - export default function SkillsGrid() { const [skillsList, setSkillsList] = useState([]); const [loading, setLoading] = useState(true); @@ -513,7 +304,7 @@ export default function SkillsGrid() { {sortedSkillsList.map(skill => { const skillState = skillsState[skill.id]; const stateData = skillStates[skill.id]; - const connectionStatus = deriveConnectionStatus( + const connectionStatus: SkillConnectionStatus = deriveConnectionStatus( skillState?.status, skillState?.setupComplete, stateData diff --git a/src/components/skills/SkillManagementPanel.tsx b/src/components/skills/SkillManagementPanel.tsx index 27678982f..19b2bdddd 100644 --- a/src/components/skills/SkillManagementPanel.tsx +++ b/src/components/skills/SkillManagementPanel.tsx @@ -8,6 +8,7 @@ import { useAppSelector } from "../../store/hooks"; import { useSkillConnectionStatus, useSkillConnectionInfo, + useSkillState, } from "../../lib/skills/hooks"; import { skillManager } from "../../lib/skills/manager"; import type { @@ -30,6 +31,20 @@ export default function SkillManagementPanel({ const skill = useAppSelector((state) => state.skills.skills[skillId]); const connectionStatus = useSkillConnectionStatus(skillId); const connectionInfo = useSkillConnectionInfo(skillId); + const skillState = useSkillState<{ + syncInProgress?: boolean; + syncProgress?: number | null; + syncProgressMessage?: string | null; + syncCompleted?: boolean; + syncError?: string | null; + lastSyncTime?: number | null; + storage?: { + chatCount?: number; + messageCount?: number; + contactCount?: number; + unreadCount?: number; + }; + }>(skillId); const [options, setOptions] = useState([]); const [togglingOption, setTogglingOption] = useState(null); @@ -112,6 +127,23 @@ export default function SkillManagementPanel({ } }, [skillId, onClose]); + const handleSync = useCallback(async () => { + try { + await skillManager.triggerSync(skillId); + } catch (err) { + console.error("[SkillManagementPanel] Sync trigger failed:", err); + } + }, [skillId]); + + const syncInProgress = skillState?.syncInProgress ?? false; + const syncProgress = skillState?.syncProgress ?? null; + const syncProgressMessage = skillState?.syncProgressMessage ?? null; + const syncCompleted = skillState?.syncCompleted ?? false; + const syncError = skillState?.syncError ?? null; + const lastSyncTime = skillState?.lastSyncTime ?? null; + // const storage = skillState?.storage; + const hasSyncSupport = syncCompleted || syncInProgress || syncError !== null; + return (
{/* Error message */} @@ -121,6 +153,70 @@ export default function SkillManagementPanel({
)} + {/* Data Sync */} + {hasSyncSupport && ( +
+
+
Data Sync
+ +
+ + {syncInProgress && ( +
+ {syncProgressMessage && ( +
+ {syncProgressMessage} +
+ )} +
+
+
+
+ {syncProgress !== null && ( + + {syncProgress}% + + )} +
+
+ )} + + {!syncInProgress && syncError && ( +
+ Sync failed: {syncError} +
+ )} + + {!syncInProgress && syncCompleted && ( +
+ {lastSyncTime && ( +
+ Last synced: {new Date(lastSyncTime).toLocaleString()} +
+ )} + {/* {storage && (storage.chatCount > 0 || storage.messageCount > 0 || storage.contactCount > 0) && ( +
+ {[ + storage.chatCount > 0 && `${storage.chatCount} chats`, + storage.messageCount > 0 && `${storage.messageCount.toLocaleString()} messages`, + storage.contactCount > 0 && `${storage.contactCount} contacts`, + ].filter(Boolean).join(" \u00B7 ")} +
+ )} */} +
+ )} +
+ )} + {/* Options */} {options.length > 0 && (
diff --git a/src/components/skills/shared.tsx b/src/components/skills/shared.tsx new file mode 100644 index 000000000..d7eeb190d --- /dev/null +++ b/src/components/skills/shared.tsx @@ -0,0 +1,169 @@ +import { useState } from 'react'; + +import GoogleIcon from '../../assets/icons/GoogleIcon'; +import NotionIcon from '../../assets/icons/notion.svg'; +import TelegramIcon from '../../assets/icons/telegram.svg'; +import { skillManager } from '../../lib/skills/manager'; +import type { SkillConnectionStatus } from '../../lib/skills/types'; + +// Map skill IDs to icons +export const SKILL_ICONS: Record = { + telegram: Telegram, + email: , + notion: Notion, + github: ( + + + + ), + otter: ( + + + + ), +}; + +// Default icon for unknown skills +export const DefaultIcon = () => ( +
+ + + +
+); + +// Status display text and colors +export const STATUS_DISPLAY: Record = { + connected: { text: 'Connected', color: 'text-sage-400' }, + connecting: { text: 'Connecting', color: 'text-amber-400' }, + not_authenticated: { text: 'Not Auth', color: 'text-amber-400' }, + disconnected: { text: 'Disconnected', color: 'text-stone-400' }, + error: { text: 'Error', color: 'text-coral-400' }, + offline: { text: 'Offline', color: 'text-stone-500' }, + setup_required: { text: 'Setup', color: 'text-primary-400' }, +}; + +// Priority order for sorting (lower number = higher priority) +export const STATUS_PRIORITY: Record = { + connected: 1, + connecting: 2, + not_authenticated: 3, + disconnected: 4, + setup_required: 5, + offline: 6, + error: 7, +}; + +export interface SkillListEntry { + id: string; + name: string; + description: string; + ignoreInProduction?: boolean; + icon?: React.ReactElement; + hasSetup: boolean; +} + +// Contextual action button for skills +export function SkillActionButton({ + skill, + connectionStatus, + onOpenModal, +}: { + skill: SkillListEntry; + connectionStatus: SkillConnectionStatus; + onOpenModal: () => void; +}) { + const [loading, setLoading] = useState(false); + + const handleEnable = async (e: React.MouseEvent) => { + e.stopPropagation(); + setLoading(true); + try { + await skillManager.startSkill({ + id: skill.id, + name: skill.name, + version: '0.0.0', + description: skill.description, + runtime: 'quickjs', + }); + if (skill.hasSetup) { + onOpenModal(); + } + } catch (err) { + console.error(`Failed to enable ${skill.id}:`, err); + } finally { + setLoading(false); + } + }; + + if (loading) { + return ( +
+ + + + +
+ ); + } + + if (connectionStatus === 'offline') { + return ( + + ); + } + + if (connectionStatus === 'setup_required') { + return ( + + ); + } + + if (connectionStatus === 'error') { + return ( + + ); + } + + return ( + + ); +} diff --git a/src/hooks/useIntelligenceStats.ts b/src/hooks/useIntelligenceStats.ts new file mode 100644 index 000000000..320bddeb1 --- /dev/null +++ b/src/hooks/useIntelligenceStats.ts @@ -0,0 +1,117 @@ +import { invoke } from '@tauri-apps/api/core'; +import { useCallback, useEffect, useState } from 'react'; + +import { apiClient } from '../services/apiClient'; +import type { AIStatus } from '../store/aiSlice'; +import { useAppSelector } from '../store/hooks'; + +interface SessionEntry { + sessionId: string; + updatedAt: number; + inputTokens: number; + outputTokens: number; + totalTokens: number; + compactionCount: number; + memoryFlushAt?: number; +} + +interface SessionStats { + total: number; + totalTokens: number; + compactions: number; + memoryFlushes: number; +} + +export interface IntelligenceStats { + sessions: SessionStats | null; + memoryFiles: number | null; + entities: Record | null; + entityError: boolean; + aiStatus: AIStatus; + isLoading: boolean; + refetch: () => void; +} + +const ENTITY_TYPES = ['contact', 'chat', 'message', 'wallet', 'token', 'transaction']; + +export function useIntelligenceStats(): IntelligenceStats { + const aiStatus = useAppSelector(state => state.ai.status); + const [sessions, setSessions] = useState(null); + const [memoryFiles, setMemoryFiles] = useState(null); + const [entities, setEntities] = useState | null>(null); + const [entityError, setEntityError] = useState(false); + const [isLoading, setIsLoading] = useState(true); + + const fetchStats = useCallback(async () => { + setIsLoading(true); + + // Fetch local stats (Tauri invoke) + try { + const index = await invoke>('ai_sessions_load_index'); + const entries = Object.values(index); + setSessions({ + total: entries.length, + totalTokens: entries.reduce((sum, e) => sum + (e.totalTokens || 0), 0), + compactions: entries.reduce((sum, e) => sum + (e.compactionCount || 0), 0), + memoryFlushes: entries.filter(e => e.memoryFlushAt).length, + }); + } catch { + setSessions(null); + } + + try { + const files = await invoke('ai_list_memory_files', { relativeDir: 'memory' }); + setMemoryFiles(files.length); + } catch { + setMemoryFiles(null); + } + + // Fetch entity counts from backend API (graceful degradation) + try { + const counts: Record = {}; + const results = await Promise.allSettled( + ENTITY_TYPES.map(async type => { + const resp = await apiClient.get<{ count?: number; total?: number; data?: unknown[] }>( + `/api/entity-graph/entities?type=${type}&limit=1` + ); + return { type, count: resp.count ?? resp.total ?? (resp.data ? resp.data.length : 0) }; + }) + ); + + let anySuccess = false; + for (const result of results) { + if (result.status === 'fulfilled') { + counts[result.value.type] = result.value.count; + anySuccess = true; + } + } + + if (anySuccess) { + setEntities(counts); + setEntityError(false); + } else { + setEntities(null); + setEntityError(true); + } + } catch { + setEntities(null); + setEntityError(true); + } + + setIsLoading(false); + }, []); + + useEffect(() => { + fetchStats(); + }, [fetchStats, aiStatus]); + + return { + sessions, + memoryFiles, + entities, + entityError, + aiStatus, + isLoading, + refetch: fetchStats, + }; +} diff --git a/src/lib/skills/manager.ts b/src/lib/skills/manager.ts index 4f4ff2ad3..9b52a02fb 100644 --- a/src/lib/skills/manager.ts +++ b/src/lib/skills/manager.ts @@ -233,6 +233,18 @@ class SkillManager { return runtime.listOptions(); } + /** + * Trigger a manual sync for a running skill. + * Progress updates are published to Redux via the skill's state fields. + */ + async triggerSync(skillId: string): Promise { + const runtime = this.runtimes.get(skillId); + if (!runtime) { + throw new Error(`Skill ${skillId} is not running`); + } + await runtime.triggerSync(); + } + /** * Set a single option on a running skill. */ diff --git a/src/lib/skills/runtime.ts b/src/lib/skills/runtime.ts index 35d1c6306..ac1556321 100644 --- a/src/lib/skills/runtime.ts +++ b/src/lib/skills/runtime.ts @@ -152,6 +152,14 @@ export class SkillRuntime { return this.transport.request("skill/ping"); } + /** + * Trigger the skill's onSync lifecycle hook. + * Progress updates flow via published state fields, not the RPC response. + */ + async triggerSync(): Promise { + return this.transport.request("skill/sync"); + } + /** * Trigger periodic tick. */ diff --git a/src/pages/Conversations.tsx b/src/pages/Conversations.tsx index 49e813074..5ebf582b5 100644 --- a/src/pages/Conversations.tsx +++ b/src/pages/Conversations.tsx @@ -1,29 +1,412 @@ +import { type PointerEvent as ReactPointerEvent, useCallback, useEffect, useRef, useState } from 'react'; + +import { useAppDispatch, useAppSelector } from '../store/hooks'; +import { + addOptimisticMessage, + clearSelectedThread, + createThread, + fetchThreadMessages, + fetchThreads, + purgeThreads, + sendMessage, + setSelectedThread, +} from '../store/threadSlice'; + +const MIN_PANEL_WIDTH = 200; +const MAX_PANEL_WIDTH = 480; +const DEFAULT_PANEL_WIDTH = 320; + +function formatRelativeTime(dateStr: string): string { + const now = Date.now(); + const then = new Date(dateStr).getTime(); + const diffMs = now - then; + if (diffMs < 60_000) return 'just now'; + const mins = Math.floor(diffMs / 60_000); + if (mins < 60) return `${mins}m ago`; + const hours = Math.floor(mins / 60); + if (hours < 24) return `${hours}h ago`; + const days = Math.floor(hours / 24); + return `${days}d ago`; +} + const Conversations = () => { + const dispatch = useAppDispatch(); + const { + threads, + isLoading, + selectedThreadId, + messages, + isLoadingMessages, + createStatus, + purgeStatus, + sendStatus, + sendError, + } = useAppSelector(state => state.thread); + + const [showPurgeConfirm, setShowPurgeConfirm] = useState(false); + const [inputValue, setInputValue] = useState(''); + const [panelWidth, setPanelWidth] = useState(DEFAULT_PANEL_WIDTH); + const isDragging = useRef(false); + const messagesEndRef = useRef(null); + + const handleResizePointerDown = useCallback((e: ReactPointerEvent) => { + e.preventDefault(); + isDragging.current = true; + const startX = e.clientX; + const startWidth = panelWidth; + + const onPointerMove = (ev: globalThis.PointerEvent) => { + const delta = ev.clientX - startX; + const newWidth = Math.min(MAX_PANEL_WIDTH, Math.max(MIN_PANEL_WIDTH, startWidth + delta)); + setPanelWidth(newWidth); + }; + + 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]); + + // Fetch threads on mount + useEffect(() => { + dispatch(fetchThreads()); + }, [dispatch]); + + // Fetch messages when a thread is selected + useEffect(() => { + if (selectedThreadId) { + dispatch(fetchThreadMessages(selectedThreadId)); + } + }, [dispatch, selectedThreadId]); + + // Auto-scroll to bottom when messages load + useEffect(() => { + if (messages.length > 0) { + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); + } + }, [messages]); + + const handleSelectThread = (threadId: string) => { + if (threadId === selectedThreadId) return; + dispatch(setSelectedThread(threadId)); + }; + + const handleNewThread = () => { + dispatch(createThread(undefined)); + }; + + const handlePurge = async () => { + const result = await dispatch(purgeThreads()); + if (purgeThreads.fulfilled.match(result)) { + setShowPurgeConfirm(false); + dispatch(clearSelectedThread()); + } + }; + + const handleSendMessage = () => { + const trimmed = inputValue.trim(); + if (!trimmed || !selectedThreadId || sendStatus === 'loading') return; + dispatch(addOptimisticMessage({ content: trimmed })); + setInputValue(''); + dispatch(sendMessage({ threadId: selectedThreadId, message: trimmed })); + }; + + const handleInputKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + handleSendMessage(); + } + }; + + const selectedThread = threads.find(t => t.id === selectedThreadId); + return ( -
-
-
-
-
-
- + {/* Left Panel: Thread List */} +
+ {/* Header */} +
+

Conversations

+
-

Conversations

-

Your conversations will appear here

-
-
+ strokeWidth="4" + /> + + + ) : ( + + + + )} +
+ + {/* Thread list */} +
+ {isLoading ? ( +
+ {Array.from({ length: 6 }).map((_, i) => ( +
+ ))} +
+ ) : threads.length > 0 ? ( +
+ {threads.map(thread => ( + + ))} +
+ ) : ( +
+ + + +

No conversations yet

+ +
+ )} +
+ + {/* Footer: Delete All */} + {threads.length > 0 && ( +
+ {showPurgeConfirm ? ( +
+ Delete all threads? +
+ + +
+
+ ) : ( + + )} +
+ )} +
+ + {/* Resize Handle */} +
+ + {/* Right Panel: Messages */} +
+ {selectedThread ? ( + <> + {/* Thread header */} +
+
+
+

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

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

+ Created {formatRelativeTime(selectedThread.createdAt)} +

+
+
+ + {/* Messages */} +
+ {isLoadingMessages ? ( +
+ {Array.from({ length: 4 }).map((_, i) => ( +
+
+
+ ))} +
+ ) : messages.length > 0 ? ( +
+ {messages.map(msg => ( +
+
+

{msg.content}

+

+ {formatRelativeTime(msg.createdAt)} +

+
+
+ ))} +
+
+ ) : ( +
+

No messages in this thread

+
+ )} +
+ + {/* Message Input */} +
+ {sendError && ( +

{sendError}

+ )} +
+