Files
NomadArch/packages/ui/src/components/chat/multi-task-chat.tsx
Gemini AI c4ac079660 restore: bring back all custom UI enhancements from checkpoint
Restored from commit 52be710 (checkpoint before qwen oauth + todo roller):

Enhanced UI Features:
- SMART FIX button with AI code analysis
- APEX (Autonomous Programming EXecution) mode
- SHIELD (Auto-approval) mode
- MULTIX MODE multi-task pipeline interface
- Live streaming token counter
- Thinking indicator with bouncing dots animation

Components restored:
- packages/ui/src/components/chat/multi-task-chat.tsx
- packages/ui/src/components/instance/instance-shell2.tsx
- packages/ui/src/components/settings/OllamaCloudSettings.tsx
- packages/ui/src/components/settings/QwenCodeSettings.tsx
- packages/ui/src/stores/solo-store.ts
- packages/ui/src/stores/task-actions.ts
- packages/ui/src/stores/session-events.ts (autonomous mode)
- packages/server/src/integrations/ollama-cloud.ts
- packages/server/src/server/routes/ollama.ts
- packages/server/src/server/routes/qwen.ts

This ensures all custom features are preserved in source control.
2025-12-23 13:18:37 +04:00

504 lines
22 KiB
TypeScript

import { createSignal, Show, onMount, For, createMemo, createEffect } from "solid-js";
import { sessions, withSession, setActiveSession } from "@/stores/session-state";
import { instances } from "@/stores/instances";
import { sendMessage } from "@/stores/session-actions";
import { addTask, setActiveTask } from "@/stores/task-actions";
import { messageStoreBus } from "@/stores/message-v2/bus";
import MessageBlockList from "@/components/message-block-list";
import { formatTokenTotal } from "@/lib/formatters";
import { addToTaskQueue, getSoloState, setActiveTaskId, toggleAutonomous, toggleAutoApproval } from "@/stores/solo-store";
import { getLogger } from "@/lib/logger";
import {
Command,
Plus,
CheckCircle2,
PanelRight,
ListTodo,
AtSign,
Hash,
Mic,
ArrowUp,
Terminal,
ChevronRight,
Loader2,
AlertCircle,
X,
Zap,
Layers,
Shield,
Activity,
} from "lucide-solid";
import type { InstanceMessageStore } from "@/stores/message-v2/instance-store";
import type { Task } from "@/types/session";
const log = getLogger("multix-chat");
interface MultiTaskChatProps {
instanceId: string;
sessionId: string;
}
export default function MultiTaskChat(props: MultiTaskChatProps) {
const selectedTaskId = () => session()?.activeTaskId || null;
const setSelectedTaskId = (id: string | null) => setActiveTask(props.instanceId, props.sessionId, id || undefined);
const [isSending, setIsSending] = createSignal(false);
const [chatInput, setChatInput] = createSignal("");
let scrollContainer: HTMLDivElement | undefined;
const [bottomSentinel, setBottomSentinel] = createSignal<HTMLDivElement | null>(null);
// Scroll to bottom helper
const scrollToBottom = () => {
if (scrollContainer) {
scrollContainer.scrollTop = scrollContainer.scrollHeight;
}
};
// Get current session and tasks
const session = () => {
const instanceSessions = sessions().get(props.instanceId);
return instanceSessions?.get(props.sessionId);
};
const tasks = () => session()?.tasks || [];
const selectedTask = () => tasks().find(t => t.id === selectedTaskId());
// Message store integration
const messageStore = () => messageStoreBus.getOrCreate(props.instanceId);
const lastAssistantIndex = () => {
const ids = filteredMessageIds();
const store = messageStore();
for (let i = ids.length - 1; i >= 0; i--) {
const msg = store.getMessage(ids[i]);
if (msg?.role === "assistant") return i;
}
return -1;
};
// Filter messages based on selected task - use store's session messages for the task session
const filteredMessageIds = () => {
const task = selectedTask();
if (!task) return []; // Show no messages in Pipeline view
// If task has a dedicated session, get messages from the store for that session
if (task.taskSessionId) {
const store = messageStore();
return store.getSessionMessageIds(task.taskSessionId);
}
// Fallback to task.messageIds for backward compatibility
return task.messageIds || [];
};
// Note: Auto-scroll is handled in two places:
// 1. After sending a message (in handleSendMessage)
// 2. During streaming (in the isAgentThinking effect below)
// We intentionally don't scroll on message count change to let users scroll freely
// Token and status tracking
const sessionUsage = createMemo(() => {
const store = messageStore();
return store.getSessionUsage(props.sessionId);
});
const tokenStats = createMemo(() => {
const usage = sessionUsage();
return {
used: usage?.actualUsageTokens ?? 0,
total: usage?.totalCost ?? 0,
};
});
const activeTaskSessionId = createMemo(() => {
const task = selectedTask();
return task?.taskSessionId || props.sessionId;
});
const solo = () => getSoloState(props.instanceId);
const isAgentThinking = createMemo(() => {
// Show thinking while we're actively sending
if (isSending()) return true;
const store = messageStore();
// Check for streaming in the specific task session
const taskSessionId = activeTaskSessionId();
const sessionRecord = store.state.sessions[taskSessionId];
const sessionMessages = sessionRecord ? sessionRecord.messageIds : [];
const isAnyStreaming = sessionMessages.some((id: string) => {
const m = store.getMessage(id);
return m?.role === "assistant" && (m.status === "streaming" || m.status === "sending");
});
if (isAnyStreaming) return true;
// Also check the filtered message IDs (for tasks)
const ids = filteredMessageIds();
if (ids.length === 0) return false;
const lastMsg = store.getMessage(ids[ids.length - 1]);
return lastMsg?.role === "assistant" && (lastMsg.status === "streaming" || lastMsg.status === "sending");
});
// Auto-scroll during streaming - must be after isAgentThinking is defined
createEffect(() => {
const streaming = isAgentThinking();
if (!streaming) return;
// During streaming, scroll periodically to keep up with content
const interval = setInterval(scrollToBottom, 300);
return () => clearInterval(interval);
});
const handleSendMessage = async () => {
const message = chatInput().trim();
if (!message || isSending()) return;
setIsSending(true);
log.info("handleSendMessage started", {
instanceId: props.instanceId,
sessionId: props.sessionId,
selectedTaskId: selectedTaskId(),
messageLength: message.length
});
try {
let taskId = selectedTaskId();
let targetSessionId = props.sessionId;
// If no task selected, create one automatically
if (!taskId) {
log.info("No task selected, creating new task");
const title = message.length > 30 ? message.substring(0, 27) + "..." : message;
const result = await addTask(props.instanceId, props.sessionId, title);
taskId = result.id;
targetSessionId = result.taskSessionId || props.sessionId;
log.info("New task created", { taskId, targetSessionId });
setSelectedTaskId(taskId);
// If autonomous mode is on, we might want to queue it or set it as active
const s = solo();
if (s.isAutonomous) {
log.info("Autonomous mode active, setting active task or queuing");
if (!s.activeTaskId) {
setActiveTaskId(props.instanceId, taskId);
} else {
addToTaskQueue(props.instanceId, taskId);
}
}
} else {
const task = selectedTask();
targetSessionId = task?.taskSessionId || props.sessionId;
}
log.info("Target session identified", { targetSessionId, taskId });
const store = messageStore();
log.info("Message store check before sending", {
instanceId: props.instanceId,
storeExists: !!store,
messageCount: store?.getSessionMessageIds(targetSessionId).length
});
await sendMessage(
props.instanceId,
targetSessionId,
message,
[],
taskId || undefined
);
log.info("sendMessage call completed");
setChatInput("");
// Auto-scroll to bottom after sending
setTimeout(scrollToBottom, 100);
} catch (error) {
log.error("handleSendMessage failed", error);
console.error("[MultiTaskChat] Send failed:", error);
} finally {
setIsSending(false);
log.info("handleSendMessage finished");
}
};
const handleKeyDown = (e: KeyboardEvent) => {
// Enter to submit, Shift+Enter for new line
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSendMessage();
}
};
return (
<main class="h-full flex flex-col bg-[#0a0a0b] text-zinc-300 font-sans selection:bg-indigo-500/30">
{/* Header */}
<header class="h-14 px-4 flex items-center justify-between bg-zinc-900/60 backdrop-blur-xl border-b border-white/5 relative z-30 shrink-0">
<div class="flex items-center space-x-3">
<div class="flex items-center bg-indigo-500/10 border border-indigo-500/20 rounded-lg px-2.5 py-1.5 shadow-[0_0_20px_rgba(99,102,241,0.1)]">
<span class="text-[10px] font-black text-indigo-400 mr-2.5 tracking-tighter uppercase">MULTIX</span>
<div class="bg-indigo-500 rounded-md w-4 h-4 flex items-center justify-center shadow-lg shadow-indigo-500/40">
<Zap size={10} class="text-white fill-current" />
</div>
</div>
<Show when={selectedTaskId()}>
<div class="flex items-center space-x-2 animate-in fade-in slide-in-from-left-2 duration-300">
<ChevronRight size={14} class="text-zinc-600" />
<div class="flex items-center space-x-2 px-2.5 py-1 bg-white/5 rounded-lg border border-white/5">
<ListTodo size={14} class="text-indigo-400" />
<span class="text-[11px] font-bold text-zinc-100 truncate max-w-[120px]">
{selectedTask()?.title || "Active Task"}
</span>
<button
onClick={() => setSelectedTaskId(null)}
class="ml-1 p-0.5 hover:bg-white/10 rounded-md transition-colors text-zinc-500 hover:text-white"
>
<X size={12} />
</button>
</div>
</div>
</Show>
</div>
<div class="flex items-center space-x-2">
<button class="p-2 text-zinc-500 hover:text-white transition-all hover:bg-white/5 rounded-xl active:scale-90">
<Command size={18} strokeWidth={2} />
</button>
<button class="p-2 text-zinc-500 hover:text-white transition-all hover:bg-white/5 rounded-xl active:scale-90">
<PanelRight size={18} strokeWidth={2} />
</button>
</div>
</header>
{/* Task Tabs (Horizontal Scroll) */}
<Show when={tasks().length > 0}>
<div class="flex items-center bg-[#0a0a0b] border-b border-white/5 px-2 py-2 space-x-1.5 overflow-x-auto custom-scrollbar-hidden no-scrollbar shrink-0">
<button
onClick={() => setSelectedTaskId(null)}
class={`flex items-center space-x-2 px-3.5 py-2 rounded-xl text-[10px] font-black uppercase tracking-widest transition-all shrink-0 border ${!selectedTaskId()
? "bg-indigo-500/15 text-indigo-400 border-indigo-500/30 shadow-[0_0_15px_rgba(99,102,241,0.1)]"
: "text-zinc-500 hover:text-zinc-300 hover:bg-white/5 border-transparent"
}`}
>
<Layers size={12} class={!selectedTaskId() ? "text-indigo-400" : "text-zinc-600"} />
<span>Pipeline</span>
</button>
<div class="w-px h-4 bg-white/10 shrink-0 mx-0.5" />
<div class="flex items-center space-x-1.5 overflow-x-auto no-scrollbar">
<For each={tasks()}>
{(task) => (
<button
onClick={() => setSelectedTaskId(task.id)}
class={`flex items-center space-x-2 px-3.5 py-2 rounded-xl text-[10px] font-bold transition-all shrink-0 max-w-[160px] border group ${selectedTaskId() === task.id
? "bg-white/10 text-zinc-100 border-white/20 shadow-xl shadow-black/20"
: "text-zinc-500 hover:text-zinc-300 hover:bg-white/5 border-transparent"
}`}
>
<div class={`w-2 h-2 rounded-full transition-all duration-500 ${task.status === "completed" ? "bg-emerald-500 shadow-[0_0_8px_rgba(16,185,129,0.4)]" :
task.status === "interrupted" ? "bg-rose-500 shadow-[0_0_8px_rgba(244,63,94,0.4)]" :
"bg-indigo-500 shadow-[0_0_8px_rgba(99,102,241,0.4)] animate-pulse"
}`} />
<span class="truncate">{task.title}</span>
<Show when={selectedTaskId() === task.id}>
<div class="ml-1 w-1 h-1 bg-indigo-400 rounded-full animate-ping" />
</Show>
</button>
)}
</For>
</div>
<button
onClick={() => {
setChatInput("");
setSelectedTaskId(null);
}}
class="flex items-center justify-center w-8 h-8 rounded-xl text-zinc-500 hover:text-indigo-400 hover:bg-indigo-500/10 transition-all shrink-0 ml-1 border border-transparent hover:border-indigo-500/20"
title="New Task"
>
<Plus size={16} strokeWidth={3} />
</button>
</div>
</Show>
{/* Main Content Area - min-h-0 is critical for flex containers with overflow */}
<div class="flex-1 min-h-0 relative overflow-hidden flex flex-col">
<div
ref={scrollContainer}
class="flex-1 min-h-0 overflow-y-auto overflow-x-hidden custom-scrollbar"
>
<Show when={!selectedTaskId()} fallback={
<div class="p-3 pb-4 overflow-x-hidden">
<MessageBlockList
instanceId={props.instanceId}
sessionId={activeTaskSessionId()}
store={messageStore}
messageIds={filteredMessageIds}
lastAssistantIndex={lastAssistantIndex}
showThinking={() => true}
thinkingDefaultExpanded={() => true}
showUsageMetrics={() => true}
scrollContainer={() => scrollContainer}
setBottomSentinel={setBottomSentinel}
/>
</div>
}>
{/* Pipeline View */}
<div class="p-4 space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-500">
<div class="space-y-2">
<h2 class="text-2xl font-black text-white tracking-tight leading-none">Pipeline</h2>
<p class="text-xs font-medium text-zinc-500 uppercase tracking-[0.2em]">Agentic Orchestration</p>
</div>
<div class="space-y-4">
<div class="flex items-center justify-between">
<span class="text-[10px] font-bold text-zinc-600 uppercase tracking-widest">Active Threads</span>
<div class="h-px flex-1 bg-white/5 mx-4" />
<span class="text-[10px] font-black text-indigo-400 bg-indigo-500/10 px-2 py-0.5 rounded border border-indigo-500/20">
{tasks().length}
</span>
</div>
<div class="grid gap-3">
<For each={tasks()} fallback={
<div class="group relative p-8 rounded-3xl border border-dashed border-white/5 bg-zinc-900/20 flex flex-col items-center justify-center text-center space-y-4 transition-all hover:bg-zinc-900/40 hover:border-white/10">
<div class="w-12 h-12 rounded-2xl bg-white/5 flex items-center justify-center text-zinc-600 group-hover:text-indigo-400 group-hover:scale-110 transition-all duration-500">
<Plus size={24} strokeWidth={1.5} />
</div>
<div class="space-y-1">
<p class="text-sm font-bold text-zinc-400">No active tasks</p>
<p class="text-[11px] text-zinc-600">Send a message below to start a new thread</p>
</div>
</div>
}>
{(task) => (
<button
onClick={() => setSelectedTaskId(task.id)}
class="group relative p-4 rounded-2xl border border-white/5 bg-zinc-900/40 hover:bg-zinc-800/60 hover:border-indigo-500/30 transition-all duration-300 text-left flex items-start space-x-4 active:scale-[0.98]"
>
<div class={`mt-1 w-2 h-2 rounded-full shadow-[0_0_10px_rgba(var(--color),0.5)] ${task.status === "completed" ? "bg-emerald-500 shadow-emerald-500/40" :
task.status === "in-progress" ? "bg-indigo-500 shadow-indigo-500/40 animate-pulse" :
"bg-zinc-600 shadow-zinc-600/20"
}`} />
<div class="flex-1 min-w-0 space-y-1">
<p class="text-sm font-bold text-zinc-100 truncate group-hover:text-white transition-colors">
{task.title}
</p>
<div class="flex items-center space-x-3 text-[10px] font-bold text-zinc-500 uppercase tracking-tight">
<span>{new Date(task.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</span>
<span class="w-1 h-1 rounded-full bg-zinc-800" />
<span>{task.messageIds?.length || 0} messages</span>
</div>
</div>
<ChevronRight size={16} class="text-zinc-700 group-hover:text-indigo-400 group-hover:translate-x-1 transition-all" />
</button>
)}
</For>
</div>
</div>
</div>
</Show>
</div>
{/* Chat Input Area - Fixed at bottom */}
<div class="p-3 bg-[#0a0a0b] border-t border-white/5 shrink-0">
<div class="w-full bg-zinc-900/80 border border-white/10 rounded-2xl shadow-lg p-3">
{/* Header Row */}
<div class="flex items-center justify-between mb-2">
<div class="flex items-center space-x-2">
<div class="w-5 h-5 rounded-lg bg-gradient-to-br from-indigo-500 to-violet-600 flex items-center justify-center">
<AtSign size={10} class="text-white" strokeWidth={3} />
</div>
<div class="flex flex-col">
<span class="text-[10px] font-bold text-zinc-100 uppercase tracking-wide">
{selectedTaskId() ? "Task Context" : "Global Pipeline"}
</span>
<span class="text-[9px] text-zinc-500 uppercase">
{selectedTaskId() ? "MultiX Threaded" : "Auto-Task"}
</span>
</div>
</div>
<div class="flex items-center space-x-2">
<button
onClick={() => toggleAutonomous(props.instanceId)}
class={`px-2 py-0.5 rounded text-[9px] font-bold uppercase border ${solo().isAutonomous
? "bg-indigo-500/20 border-indigo-500/40 text-indigo-400"
: "bg-white/5 border-white/10 text-zinc-500"
}`}
>
Auto
</button>
<button
onClick={() => toggleAutoApproval(props.instanceId)}
class={`px-2 py-0.5 rounded text-[9px] font-bold uppercase border ${solo().autoApproval
? "bg-emerald-500/20 border-emerald-500/40 text-emerald-400"
: "bg-white/5 border-white/10 text-zinc-500"
}`}
>
Shield
</button>
<Show when={tokenStats().used > 0}>
<div class="px-2 py-0.5 bg-emerald-500/10 rounded border border-emerald-500/20 text-[9px] font-bold text-emerald-400">
{formatTokenTotal(tokenStats().used)}
</div>
</Show>
<Show when={isSending() || isAgentThinking()}>
<div class="flex items-center space-x-1 px-2 py-0.5 bg-indigo-500/10 rounded border border-indigo-500/20">
<Loader2 size={10} class="text-indigo-400 animate-spin" />
<span class="text-[9px] font-bold text-indigo-400">{isAgentThinking() ? "Thinking" : "Sending"}</span>
</div>
</Show>
</div>
</div>
{/* Text Input */}
<textarea
value={chatInput()}
onInput={(e) => setChatInput(e.currentTarget.value)}
placeholder={selectedTaskId() ? "Send instruction to this task..." : "Type to create a new task and begin..."}
class="w-full bg-transparent border-none focus:ring-0 focus:outline-none text-[13px] text-zinc-100 placeholder-zinc-600 resize-none min-h-[40px] max-h-32 leading-relaxed disabled:opacity-50"
onKeyDown={handleKeyDown}
disabled={isSending()}
rows={1}
/>
{/* Footer Row */}
<div class="flex items-center justify-between pt-2 border-t border-white/5 mt-2">
<div class="flex items-center space-x-3">
<button class="text-zinc-600 hover:text-zinc-400 transition-colors p-1">
<Hash size={14} />
</button>
<button class="text-zinc-600 hover:text-zinc-400 transition-colors p-1">
<Mic size={14} />
</button>
<div class="w-px h-3 bg-zinc-800" />
<div class="flex items-center space-x-1 text-zinc-600">
<kbd class="px-1.5 py-0.5 bg-zinc-800 rounded text-[9px] font-bold border border-white/5">ENTER</kbd>
<span class="text-[9px]">to send</span>
</div>
</div>
<button
onClick={handleSendMessage}
disabled={!chatInput().trim() || isSending()}
class="px-4 py-1.5 bg-indigo-500 hover:bg-indigo-400 text-white rounded-lg text-[11px] font-bold uppercase tracking-wide transition-all disabled:opacity-30 disabled:cursor-not-allowed flex items-center space-x-1.5"
>
<Show when={isSending()} fallback={
<>
<span>{selectedTaskId() ? "Update Task" : "Launch Task"}</span>
<ArrowUp size={12} strokeWidth={3} />
</>
}>
<Loader2 size={12} class="animate-spin" />
</Show>
</button>
</div>
</div>
</div>
</div>
</main>
);
}