Files
NomadArch/packages/ui/src/components/chat/multix-v2/index.tsx
Gemini AI 1e991d9ebd
Some checks failed
Release Binaries / release (push) Has been cancelled
fix: complete session persistence overhaul (Codex 5.2)
1. Implemented auto-selection of tasks in MultiXV2 to prevent empty initial state.
2. Added force-loading logic for task session messages with debouncing.
3. Updated session-actions to return full assistant text and immediately persist native messages.
4. Fixed caching logic in instance-shell2 to retain active task sessions in memory.
2025-12-27 20:36:43 +04:00

877 lines
41 KiB
TypeScript

/**
* 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<Set<string>>(new Set());
const [chatInput, setChatInput] = createSignal("");
const [isCompacting, setIsCompacting] = createSignal(false);
const [attachments, setAttachments] = createSignal<ReturnType<typeof createFileAttachment>[]>([]);
const [userScrolling, setUserScrolling] = createSignal(false);
const [isEnhancing, setIsEnhancing] = createSignal(false);
// Cached store values - updated via polling
const [tasks, setTasks] = createSignal<Task[]>([]);
const [visibleTasks, setVisibleTasks] = createSignal<Task[]>([]);
const [selectedTaskId, setSelectedTaskIdLocal] = createSignal<string | null>(null);
const [messageIds, setMessageIds] = createSignal<string[]>([]);
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<HTMLDivElement | null>(null);
const [hasUserSelection, setHasUserSelection] = createSignal(false);
const forcedLoadTimestamps = new Map<string, number>();
// 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<string>());
});
// 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<string>());
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: <Zap size={14} class="text-amber-400" />
});
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<typeof createFileAttachment>) => {
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 (
<div class="absolute inset-0 flex flex-col bg-[#0a0a0b] text-zinc-300 font-sans selection:bg-indigo-500/30 overflow-hidden">
<DebugOverlay />
{/* ===== GEMINI 3 PRO HEADER ===== */}
<header class="h-12 px-2 flex items-center justify-between bg-[#0a0a0b]/90 backdrop-blur-xl border-b border-white/5 relative z-30 shrink-0 select-none">
<div class="flex items-center gap-2 overflow-hidden flex-1">
{/* Brand / Mode Indicator */}
<div class="flex items-center gap-2 px-2 py-1 rounded-md text-zinc-400">
<Layers size={14} class="text-indigo-500" />
<span class="text-[11px] font-bold tracking-wider text-zinc-300">MULTIX</span>
</div>
<div class="h-4 w-px bg-white/5 shrink-0" />
{/* Pipeline / Task Switcher */}
<div class="flex items-center gap-1 overflow-x-auto no-scrollbar mask-linear-fade">
{/* Pipeline Tab */}
<button
onClick={() => setSelectedTaskId(null)}
class={`flex items-center gap-2 px-3 py-1.5 rounded-lg text-[10px] font-bold uppercase tracking-wider transition-all border ${!selectedTaskId()
? "bg-indigo-500/10 text-indigo-400 border-indigo-500/20 shadow-[0_0_10px_rgba(99,102,241,0.1)]"
: "text-zinc-500 border-transparent hover:text-zinc-300 hover:bg-white/5"
}`}
>
<span class="font-mono">PIPELINE</span>
</button>
{/* Active Tasks */}
<For each={visibleTasks()}>
{(task) => (
<button
onClick={() => setSelectedTaskId(task.id)}
class={`group flex items-center gap-2 px-3 py-1.5 rounded-lg text-[10px] font-bold transition-all border max-w-[140px] ${selectedTaskId() === task.id
? "bg-zinc-800 text-zinc-100 border-zinc-700 shadow-lg"
: "text-zinc-500 border-transparent hover:text-zinc-300 hover:bg-white/5"
} ${task.title.toLowerCase().includes("smart fix") ? "smart-fix-highlight" : ""}`}
>
<div class={`w-1.5 h-1.5 rounded-full ${task.status === "completed" ? "bg-emerald-500" :
task.status === "interrupted" ? "bg-rose-500" :
"bg-indigo-500 animate-pulse"
}`} />
<span class="truncate">{task.title}</span>
<span
onClick={(e) => { e.stopPropagation(); handleArchiveTask(task.id); }}
class="opacity-0 group-hover:opacity-100 hover:text-red-400 transition-opacity"
>
<X size={10} />
</span>
</button>
)}
</For>
{/* New Task */}
<button
onClick={handleCreateTask}
class="w-6 h-6 flex items-center justify-center rounded-md text-zinc-600 hover:text-zinc-200 hover:bg-white/5 transition-colors"
>
<Plus size={14} />
</button>
</div>
</div>
{/* Right Actions */}
<div class="flex items-center gap-2 shrink-0 pl-4">
{/* Stream Status */}
<Show when={isAgentThinking()}>
<div class="flex items-center gap-2 px-2 py-1 rounded-full bg-violet-500/10 border border-violet-500/20">
<Loader2 size={10} class="animate-spin text-violet-400" />
<span class="text-[9px] font-mono text-violet-300">{formatTokenTotal(cachedTokensUsed())}</span>
</div>
</Show>
<div class="h-4 w-px bg-white/5" />
{/* Tools */}
<button
onClick={handleCompact}
disabled={!selectedTaskId()}
class="p-1.5 text-zinc-500 hover:text-zinc-200 hover:bg-white/5 rounded-md transition-colors disabled:opacity-30"
title="Compact Context"
>
<FileArchive size={14} />
</button>
<button
onClick={() => emitSessionSidebarRequest({ instanceId: props.instanceId, action: "show-skills" })}
class="p-1.5 text-zinc-500 hover:text-indigo-300 hover:bg-indigo-500/10 rounded-md transition-colors"
title="Skills"
>
<Sparkles size={14} />
</button>
</div>
</header>
{/* ===== AGENT/MODEL SELECTORS (LITE VERSIONS - PER TASK) ===== */}
<Show when={getSelectedTask()}>
<div class="px-4 py-3 border-b border-white/5 bg-[#0a0a0b]">
<div class="grid grid-cols-1 md:grid-cols-2 gap-2">
<LiteAgentSelector
instanceId={props.instanceId}
sessionId={getActiveTaskSessionId()}
currentAgent={cachedAgent()}
onAgentChange={(agent) => {
// 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);
}}
/>
<LiteModelSelector
instanceId={props.instanceId}
sessionId={getActiveTaskSessionId()}
currentModel={{ providerId: "", modelId: cachedModelId() }}
onModelChange={(model) => {
// 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);
}}
/>
<LiteSkillsSelector
instanceId={props.instanceId}
sessionId={getActiveTaskSessionId()}
/>
</div>
</div>
</Show>
{/* ===== MAIN CONTENT AREA (Row Layout) ===== */}
<div class="flex-1 flex flex-row min-h-0 relative bg-[#050505] overflow-hidden w-full h-full">
{/* Chat Column */}
<div class="flex-1 min-h-0 flex flex-col overflow-hidden relative">
<div ref={scrollContainer} class="flex-1 min-h-0 overflow-y-auto overflow-x-hidden custom-scrollbar">
{/* Compaction Suggestion Banner */}
<Show when={compactionSuggestion()}>
<div class="mx-3 mt-3 mb-1 rounded-xl border border-emerald-500/30 bg-emerald-500/10 px-3 py-2 text-[11px] text-emerald-200 flex items-center justify-between gap-3">
<span class="font-semibold">Compact suggested: {compactionSuggestion()?.reason}</span>
<button
type="button"
class="px-2.5 py-1 rounded-lg text-[10px] font-bold uppercase tracking-wide bg-emerald-500/20 border border-emerald-500/40 text-emerald-200 hover:bg-emerald-500/30 transition-colors"
onClick={handleCompact}
>
Compact now
</button>
</div>
</Show>
<Show when={!selectedTaskId()} fallback={
/* Message List - Using full MessageBlockList for proper streaming */
<div class="min-h-full pb-4">
<MessageBlockList
instanceId={props.instanceId}
sessionId={getActiveTaskSessionId()}
store={getMessageStore}
messageIds={() => messageIds()}
lastAssistantIndex={() => lastAssistantIndex()}
showThinking={() => true}
thinkingDefaultExpanded={() => true}
showUsageMetrics={() => true}
scrollContainer={() => scrollContainer}
setBottomSentinel={setBottomSentinel}
/>
{/* Bottom anchor */}
<div id="bottom-anchor" class="h-10 w-full" />
</div>
}>
{/* Pipeline View */}
<PipelineView
visibleTasks={visibleTasks}
onTaskClick={setSelectedTaskId}
onArchiveTask={handleArchiveTask}
/>
</Show>
</div>
{/* ===== INPUT AREA ===== */}
<div class="p-4 bg-[#0a0a0b] border-t border-white/5 shrink-0 z-20">
{/* Input Container */}
<div class="w-full bg-zinc-900/50 border border-white/10 rounded-2xl shadow-sm overflow-hidden focus-within:border-indigo-500/30 transition-all">
{/* Input Header Row */}
<div class="flex items-center justify-between px-3 pt-2 pb-1">
<div class="flex items-center space-x-2">
<div class="flex flex-col">
<span class="text-[10px] font-bold text-zinc-400 uppercase tracking-wide">
{selectedTaskId() ? "Task Context" : "Global Pipeline"}
</span>
</div>
</div>
<div class="flex items-center space-x-1">
{/* APEX / Shield Toggles */}
<button
onClick={() => toggleApex(props.instanceId)}
title="Apex"
class={`p-1 rounded transition-colors ${soloState().isApex ? "text-rose-400 bg-rose-500/10" : "text-zinc-600 hover:text-zinc-400"}`}
>
<Zap size={10} />
</button>
<button
onClick={() => toggleAutoApproval(props.instanceId)}
title="Shield"
class={`p-1 rounded transition-colors ${soloState().autoApproval ? "text-emerald-400 bg-emerald-500/10" : "text-zinc-600 hover:text-zinc-400"}`}
>
<Shield size={10} />
</button>
</div>
</div>
{/* Attachments */}
<Show when={attachments().length > 0}>
<div class="flex flex-wrap gap-1.5 px-3 py-1">
<For each={attachments()}>
{(attachment) => (
<AttachmentChip
attachment={attachment}
onRemove={() => removeAttachment(attachment.id)}
/>
)}
</For>
</div>
</Show>
{/* Text Input */}
<textarea
value={chatInput()}
onInput={(e) => {
setChatInput(e.currentTarget.value);
e.currentTarget.style.height = "auto";
e.currentTarget.style.height = e.currentTarget.scrollHeight + "px";
}}
onKeyDown={handleKeyDown}
placeholder={selectedTaskId() ? "Message agent..." : "Start a new task..."}
class="w-full bg-transparent text-zinc-200 placeholder-zinc-500 text-sm p-3 outline-none resize-none max-h-[300px] min-h-[44px]"
rows={1}
disabled={isSending()}
/>
{/* Toolbar */}
<div class="flex items-center justify-between px-2 pb-2 mt-1 border-t border-white/5 pt-2 bg-zinc-900/30">
<div class="flex items-center space-x-1">
<input
ref={fileInputRef}
type="file"
multiple
class="hidden"
onChange={handleFileSelect}
/>
<button
onClick={() => fileInputRef?.click()}
class="p-1.5 text-zinc-500 hover:text-zinc-300 rounded hover:bg-white/5 transition-colors"
>
<Paperclip size={14} />
</button>
<button
onClick={handleEnhancePrompt}
disabled={!chatInput().trim() || isEnhancing()}
class={`p-1.5 rounded hover:bg-white/5 transition-colors ${isEnhancing() ? "text-amber-400 animate-pulse" : "text-zinc-500 hover:text-amber-300"}`}
>
<Wand2 size={14} class={isEnhancing() ? "animate-spin" : ""} />
</button>
</div>
<div class="flex items-center space-x-2">
<div class="text-[9px] text-zinc-600 font-mono hidden md:block">
{cachedModelId()}
</div>
{/* Stop Button (visible when agent is thinking) */}
<Show when={isAgentThinking() || isSending()}>
<button
onClick={handleStopAgent}
class="p-1.5 bg-rose-500/20 hover:bg-rose-500/30 text-rose-400 border border-rose-500/30 rounded-lg transition-all shadow-sm"
title="Stop Agent (Shift+Click = Force Reset)"
>
<StopCircle size={14} strokeWidth={2.5} />
</button>
</Show>
{/* Send Button */}
<button
onClick={handleSendMessage}
disabled={(!chatInput().trim() && attachments().length === 0) || isSending()}
class="p-1.5 bg-zinc-100 hover:bg-white text-black rounded-lg disabled:opacity-50 disabled:cursor-not-allowed transition-all shadow-sm"
>
<Show when={isSending()} fallback={<ArrowUp size={14} strokeWidth={3} />}>
<Loader2 size={14} class="animate-spin" />
</Show>
</button>
</div>
</div>
</div>
</div>
</div>
{/* Sidebar (Right) */}
<Show when={selectedTaskId() && messageIds().length > 0}>
<MessageNavSidebar
messageIds={messageIds}
store={getMessageStore}
scrollContainer={scrollContainer}
onTabClick={handleTabClick}
/>
</Show>
</div>
</div>
);
}