/** * MultiX v2 - Main Entry Point * * A complete rebuild of the MultiTaskChat component with: * 1. Local signals + polling (no reactive cascade = no freeze) * 2. 100% feature parity with original * 3. New features: Context-Engine, Compaction, Prompt Enhancement */ import { createSignal, Show, onMount, For, onCleanup, batch } from "solid-js"; import toast from "solid-toast"; import { sessions, activeSessionId, setActiveSession } from "@/stores/session-state"; import { loadMessages, fetchSessions, flushSessionPersistence } from "@/stores/sessions"; import { sendMessage, compactSession, updateSessionAgent, updateSessionModelForSession, forceReset, abortSession } from "@/stores/session-actions"; import { addTask, setActiveTask, archiveTask } from "@/stores/task-actions"; import { messageStoreBus } from "@/stores/message-v2/bus"; import { formatTokenTotal } from "@/lib/formatters"; import { addToTaskQueue, getSoloState, setActiveTaskId, toggleAutonomous, toggleAutoApproval, toggleApex } from "@/stores/solo-store"; import { getLogger } from "@/lib/logger"; import { clearCompactionSuggestion, getCompactionSuggestion } from "@/stores/session-compaction"; import { emitSessionSidebarRequest } from "@/lib/session-sidebar-events"; import { Command, Plus, PanelRight, ListTodo, AtSign, Hash, Mic, ArrowUp, ChevronRight, Loader2, X, Zap, Layers, Sparkles, StopCircle, Key, FileArchive, Paperclip, Wand2, Shield, } from "lucide-solid"; // Using Lite versions to avoid reactive cascade // import ModelSelector from "@/components/model-selector"; // import AgentSelector from "@/components/agent-selector"; import { DebugOverlay, setForceResetFn } from "@/components/debug-overlay"; import AttachmentChip from "@/components/attachment-chip"; import { createFileAttachment } from "@/types/attachment"; import type { InstanceMessageStore } from "@/stores/message-v2/instance-store"; import type { Task, Session } from "@/types/session"; // Sub-components import { SimpleMessageBlock } from "./core/SimpleMessageBlock"; import { PipelineView } from "./features/PipelineView"; import { MessageNavSidebar } from "./features/MessageNavSidebar"; import { enhancePrompt } from "./features/PromptEnhancer"; import { LiteAgentSelector } from "./features/LiteAgentSelector"; import { LiteModelSelector } from "./features/LiteModelSelector"; import { LiteSkillsSelector } from "./features/LiteSkillsSelector"; import MessageBlockList from "@/components/message-block-list"; const OPEN_ADVANCED_SETTINGS_EVENT = "open-advanced-settings"; const log = getLogger("multix-v2"); interface MultiXV2Props { instanceId: string; sessionId: string; } export default function MultiXV2(props: MultiXV2Props) { // ============================================================================ // LOCAL STATE (No reactive memos on stores - polling instead) // ============================================================================ // Per-task sending state (Map of taskId -> boolean) const [sendingTasks, setSendingTasks] = createSignal>(new Set()); const [chatInput, setChatInput] = createSignal(""); const [isCompacting, setIsCompacting] = createSignal(false); const [attachments, setAttachments] = createSignal[]>([]); const [userScrolling, setUserScrolling] = createSignal(false); const [isEnhancing, setIsEnhancing] = createSignal(false); // Cached store values - updated via polling const [tasks, setTasks] = createSignal([]); const [visibleTasks, setVisibleTasks] = createSignal([]); const [selectedTaskId, setSelectedTaskIdLocal] = createSignal(null); const [messageIds, setMessageIds] = createSignal([]); const [cachedModelId, setCachedModelId] = createSignal("unknown"); const [cachedAgent, setCachedAgent] = createSignal(""); const [cachedTokensUsed, setCachedTokensUsed] = createSignal(0); const [cachedCost, setCachedCost] = createSignal(0); const [isAgentThinking, setIsAgentThinking] = createSignal(false); const [compactionSuggestion, setCompactionSuggestion] = createSignal<{ reason: string } | null>(null); const [soloState, setSoloState] = createSignal({ isApex: false, isAutonomous: false, autoApproval: false, activeTaskId: null as string | null }); const [lastAssistantIndex, setLastAssistantIndex] = createSignal(-1); const [bottomSentinel, setBottomSentinel] = createSignal(null); const [hasUserSelection, setHasUserSelection] = createSignal(false); const forcedLoadTimestamps = new Map(); // Helper to check if CURRENT task is sending const isSending = () => { const taskId = selectedTaskId(); if (!taskId) return sendingTasks().size > 0; // If no task selected, check if any is sending return sendingTasks().has(taskId); }; // Helper to set sending state for a task const setTaskSending = (taskId: string, sending: boolean) => { setSendingTasks(prev => { const next = new Set(prev); if (sending) { next.add(taskId); } else { next.delete(taskId); } return next; }); }; let scrollContainer: HTMLDivElement | undefined; let fileInputRef: HTMLInputElement | undefined; // ============================================================================ // STORE ACCESS HELPERS (Non-reactive reads) // ============================================================================ function getSession(): Session | undefined { const instanceSessions = sessions().get(props.instanceId); return instanceSessions?.get(props.sessionId); } function getMessageStore(): InstanceMessageStore { return messageStoreBus.getOrCreate(props.instanceId); } function getSelectedTask(): Task | undefined { return visibleTasks().find(t => t.id === selectedTaskId()); } function getActiveTaskSessionId(): string { const task = getSelectedTask(); return task?.taskSessionId || props.sessionId; } function getActiveTaskSession(): Session | undefined { const sessionId = getActiveTaskSessionId(); const instanceSessions = sessions().get(props.instanceId); return instanceSessions?.get(sessionId); } // ============================================================================ // POLLING-BASED SYNC (Updates local state from stores every 150ms) // ============================================================================ function syncFromStore() { try { const session = getSession(); if (session) { const allTasks = session.tasks || []; setTasks(allTasks); setVisibleTasks(allTasks.filter(t => !t.archived)); // NOTE: Don't overwrite selectedTaskId from store - local state is authoritative // This prevents the reactive cascade when the store updates if (!selectedTaskId() && !hasUserSelection() && allTasks.length > 0) { const preferredId = session.activeTaskId || allTasks[0].id; setSelectedTaskIdLocal(preferredId); } } // Get message IDs for currently selected task const currentTaskId = selectedTaskId(); if (currentTaskId) { const task = visibleTasks().find(t => t.id === currentTaskId); if (task) { const store = getMessageStore(); if (task.taskSessionId) { const cachedIds = store.getSessionMessageIds(task.taskSessionId); if (cachedIds.length === 0) { const lastForced = forcedLoadTimestamps.get(task.taskSessionId) ?? 0; if (Date.now() - lastForced > 1000) { forcedLoadTimestamps.set(task.taskSessionId, Date.now()); loadMessages(props.instanceId, task.taskSessionId, true).catch((error) => log.error("Failed to load task session messages", error) ); } } else { loadMessages(props.instanceId, task.taskSessionId).catch((error) => log.error("Failed to load task session messages", error) ); } setMessageIds(store.getSessionMessageIds(task.taskSessionId)); } else { setMessageIds(task.messageIds || []); } } else { setMessageIds([]); } } else { setMessageIds([]); } const taskSession = getActiveTaskSession(); if (taskSession?.model?.modelId) { setCachedModelId(taskSession.model.modelId); } if (taskSession?.agent) { setCachedAgent(taskSession.agent); } const store = getMessageStore(); const usage = store.getSessionUsage(props.sessionId); if (usage) { setCachedTokensUsed(usage.actualUsageTokens ?? 0); setCachedCost(usage.totalCost ?? 0); } const ids = messageIds(); if (ids.length > 0) { const lastMsg = store.getMessage(ids[ids.length - 1]); setIsAgentThinking( lastMsg?.role === "assistant" && (lastMsg.status === "streaming" || lastMsg.status === "sending") ); // Calculate lastAssistantIndex let lastIdx = -1; for (let i = ids.length - 1; i >= 0; i--) { const msg = store.getMessage(ids[i]); if (msg?.role === "assistant") { lastIdx = i; break; } } setLastAssistantIndex(lastIdx); } else { setIsAgentThinking(false); setLastAssistantIndex(-1); } const suggestion = getCompactionSuggestion(props.instanceId, getActiveTaskSessionId()); setCompactionSuggestion(suggestion); setSoloState(getSoloState(props.instanceId)); } catch (e) { log.error("syncFromStore error", e); } } // ============================================================================ // LIFECYCLE // ============================================================================ onMount(() => { setForceResetFn(() => { forceReset(); // Clear all sending states on force reset setSendingTasks(new Set()); }); // Initialize loadMessages(props.instanceId, props.sessionId); fetchSessions(props.instanceId); syncFromStore(); const interval = setInterval(syncFromStore, 150); const handleScroll = () => { if (!scrollContainer) return; const isAtBottom = scrollContainer.scrollHeight - scrollContainer.scrollTop - scrollContainer.clientHeight < 50; setUserScrolling(!isAtBottom); }; scrollContainer?.addEventListener('scroll', handleScroll, { passive: true }); onCleanup(() => { clearInterval(interval); scrollContainer?.removeEventListener('scroll', handleScroll); // Ensure any pending task updates are saved immediately before we potentially reload them flushSessionPersistence(props.instanceId); }); }); // ============================================================================ // ACTIONS // ============================================================================ const scrollToBottom = () => { if (scrollContainer && !userScrolling()) { scrollContainer.scrollTop = scrollContainer.scrollHeight; } }; const setSelectedTaskId = (id: string | null) => { // Update local state immediately (fast) setSelectedTaskIdLocal(id); setHasUserSelection(true); // Immediately sync to load the new task's agent/model syncFromStore(); // Defer the global store update using idle callback (non-blocking) if (typeof requestIdleCallback !== 'undefined') { requestIdleCallback(() => { setActiveTask(props.instanceId, props.sessionId, id || undefined); }, { timeout: 500 }); } else { // Fallback: use setTimeout with longer delay setTimeout(() => { setActiveTask(props.instanceId, props.sessionId, id || undefined); }, 50); } }; const handleSendMessage = async () => { const message = chatInput().trim(); if (!message) return; // Check if THIS specific task is already sending const currentTaskId = selectedTaskId(); if (currentTaskId && sendingTasks().has(currentTaskId)) return; const currentMessage = message; const currentAttachments = attachments(); batch(() => { setChatInput(""); setAttachments([]); }); // Track which task we're sending for (might be created below) let taskIdForSending: string | null = null; try { let taskId = currentTaskId; let targetSessionId = props.sessionId; if (!taskId) { // Create new task const title = currentMessage.length > 30 ? currentMessage.substring(0, 27) + "..." : currentMessage; log.info("[MultiX] Creating task...", { title }); const result = await addTask(props.instanceId, props.sessionId, title); taskId = result.id; targetSessionId = result.taskSessionId || props.sessionId; log.info("[MultiX] Task created", { taskId, targetSessionId, hasTaskSession: !!result.taskSessionId }); // Immediately sync to get the new task in our local state syncFromStore(); // Set the selected task setSelectedTaskId(taskId); const s = soloState(); if (s.isAutonomous) { if (!s.activeTaskId) { setActiveTaskId(props.instanceId, taskId); } else { addToTaskQueue(props.instanceId, taskId); } } } else { // Existing task - get up-to-date task info syncFromStore(); const task = visibleTasks().find(t => t.id === taskId); targetSessionId = task?.taskSessionId || props.sessionId; log.info("[MultiX] Existing task", { taskId, targetSessionId }); } // Mark THIS task as sending taskIdForSending = taskId; setTaskSending(taskId, true); log.info("[MultiX] Sending message", { instanceId: props.instanceId, targetSessionId, messageLength: currentMessage.length, taskId }); // Send the message (this is async and will stream) await sendMessage(props.instanceId, targetSessionId, currentMessage, currentAttachments, taskId || undefined); log.info("[MultiX] Message sent successfully"); // Force sync after message is sent to pick up the new messages setTimeout(() => syncFromStore(), 100); setTimeout(() => syncFromStore(), 500); setTimeout(() => syncFromStore(), 1000); setTimeout(scrollToBottom, 150); } catch (error) { log.error("Send failed:", error); console.error("[MultiX] Send failed:", error); } finally { // Clear sending state for this specific task if (taskIdForSending) { setTaskSending(taskIdForSending, false); } } }; const handleCreateTask = () => { // Allow creating new tasks even when other tasks are processing const nextIndex = tasks().length + 1; const title = `Task ${nextIndex} `; setTimeout(async () => { try { const result = await addTask(props.instanceId, props.sessionId, title); setSelectedTaskId(result.id); setTimeout(() => syncFromStore(), 50); } catch (error) { log.error("handleCreateTask failed", error); } }, 0); }; const handleKeyDown = (e: KeyboardEvent) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); handleSendMessage(); } }; const handleStopAgent = async (e?: MouseEvent) => { if (e?.shiftKey) { forceReset(); // Clear all sending states on force reset setSendingTasks(new Set()); return; } const task = getSelectedTask(); // If no task selected, we might be in global pipeline, use sessionId const targetSessionId = task?.taskSessionId || props.sessionId; const taskId = task?.id || selectedTaskId(); try { await abortSession(props.instanceId, targetSessionId); // Manually force UI update if (taskId) { setTaskSending(taskId, false); } setIsAgentThinking(false); setTimeout(() => syncFromStore(), 50); } catch (error) { log.error("Failed to stop agent", error); } }; const handleCompact = async () => { const targetSessionId = getActiveTaskSessionId(); if (isCompacting()) return; // Get message count to verify we have messages to compact const store = getMessageStore(); const msgIds = store.getSessionMessageIds(targetSessionId); log.info("[MultiX] Starting compaction", { instanceId: props.instanceId, sessionId: targetSessionId, messageCount: msgIds.length }); if (msgIds.length < 3) { log.info("[MultiX] Session too small to compact", { count: msgIds.length }); toast.success("Session is already concise. No compaction needed.", { icon: }); return; } setIsCompacting(true); const toastId = toast.loading("Compacting session history..."); try { clearCompactionSuggestion(props.instanceId, targetSessionId); const result = await compactSession(props.instanceId, targetSessionId); // CRITICAL: Restore the parent session as active to prevent navigation away from MultiX const currentActive = activeSessionId().get(props.instanceId); if (currentActive !== props.sessionId) { setActiveSession(props.instanceId, props.sessionId); } log.info("[MultiX] Compaction complete", { success: result.success, tokenBefore: result.token_before, tokenAfter: result.token_after, reduction: result.token_reduction_pct }); toast.success(`Compacted! Reduced by ${result.token_reduction_pct}% (${result.token_after} tokens)`, { id: toastId, duration: 4000 }); // Sync to update UI after compaction syncFromStore(); } catch (error) { log.error("Failed to compact session", error); toast.error("Compaction failed. Please try again.", { id: toastId }); } finally { setIsCompacting(false); } }; const handleOpenAdvancedSettings = () => { window.dispatchEvent(new CustomEvent(OPEN_ADVANCED_SETTINGS_EVENT, { detail: { instanceId: props.instanceId, sessionId: props.sessionId } })); }; const handleEnhancePrompt = async () => { const input = chatInput().trim(); if (!input || isEnhancing()) return; setIsEnhancing(true); try { // Pass sessionId so it uses the task's configured model const taskSessionId = getActiveTaskSessionId(); const enhanced = await enhancePrompt(input, props.instanceId, taskSessionId); setChatInput(enhanced); } catch (error) { log.error("Prompt enhancement failed", error); } finally { setIsEnhancing(false); } }; const toggleApexPro = () => { const s = soloState(); const currentState = s.isAutonomous && s.autoApproval; if (currentState) { if (s.isAutonomous) toggleAutonomous(props.instanceId); if (s.autoApproval) toggleAutoApproval(props.instanceId); } else { if (!s.isAutonomous) toggleAutonomous(props.instanceId); if (!s.autoApproval) toggleAutoApproval(props.instanceId); } }; const isApexPro = () => { const s = soloState(); return s.isAutonomous && s.autoApproval; }; const handleArchiveTask = (taskId: string) => { archiveTask(props.instanceId, props.sessionId, taskId); }; const addAttachment = (attachment: ReturnType) => { setAttachments((prev) => [...prev, attachment]); }; const removeAttachment = (attachmentId: string) => { setAttachments((prev) => prev.filter((item) => item.id !== attachmentId)); }; const handleFileSelect = (event: Event) => { const input = event.currentTarget as HTMLInputElement; if (!input.files || input.files.length === 0) return; Array.from(input.files).forEach((file) => { const reader = new FileReader(); reader.onload = () => { const buffer = reader.result instanceof ArrayBuffer ? reader.result : null; const data = buffer ? new Uint8Array(buffer) : undefined; const attachment = createFileAttachment(file.name, file.name, file.type || "application/octet-stream", data); if (file.type.startsWith("image/") && typeof reader.result === "string") { attachment.url = reader.result; } addAttachment(attachment); }; reader.readAsArrayBuffer(file); }); input.value = ""; }; const handleTabClick = (messageId: string) => { const anchorId = `message-anchor-${messageId}`; const element = scrollContainer?.querySelector(`#${anchorId}`); if (element) { element.scrollIntoView({ behavior: "smooth", block: "center" }); element.classList.add("message-highlight"); setTimeout(() => element.classList.remove("message-highlight"), 2000); } }; // ============================================================================ // RENDER (Gemini 3 Pro) // ============================================================================ return (
{/* ===== GEMINI 3 PRO HEADER ===== */}
{/* Brand / Mode Indicator */}
MULTIX
{/* Pipeline / Task Switcher */}
{/* Pipeline Tab */} {/* Active Tasks */} {(task) => ( )} {/* New Task */}
{/* Right Actions */}
{/* Stream Status */}
{formatTokenTotal(cachedTokensUsed())}
{/* Tools */}
{/* ===== AGENT/MODEL SELECTORS (LITE VERSIONS - PER TASK) ===== */}
{ // Update the TASK's session, not a global cache const taskSessionId = getActiveTaskSessionId(); log.info("[MultiX] Changing agent for task session", { taskSessionId, agent }); updateSessionAgent(props.instanceId, taskSessionId, agent); // Force immediate sync to reflect the change setTimeout(() => syncFromStore(), 50); }} /> { // Update the TASK's session, not a global cache const taskSessionId = getActiveTaskSessionId(); log.info("[MultiX] Changing model for task session", { taskSessionId, model }); updateSessionModelForSession(props.instanceId, taskSessionId, model); // Force immediate sync to reflect the change setTimeout(() => syncFromStore(), 50); }} />
{/* ===== MAIN CONTENT AREA (Row Layout) ===== */}
{/* Chat Column */}
{/* Compaction Suggestion Banner */}
Compact suggested: {compactionSuggestion()?.reason}
messageIds()} lastAssistantIndex={() => lastAssistantIndex()} showThinking={() => true} thinkingDefaultExpanded={() => true} showUsageMetrics={() => true} scrollContainer={() => scrollContainer} setBottomSentinel={setBottomSentinel} /> {/* Bottom anchor */}
}> {/* Pipeline View */}
{/* ===== INPUT AREA ===== */}
{/* Input Container */}
{/* Input Header Row */}
{selectedTaskId() ? "Task Context" : "Global Pipeline"}
{/* APEX / Shield Toggles */}
{/* Attachments */} 0}>
{(attachment) => ( removeAttachment(attachment.id)} /> )}
{/* Text Input */}