v0.5.0: Binary-Free Mode - No OpenCode binary required

 Major Features:
- Native session management without OpenCode binary
- Provider routing: OpenCode Zen (free), Qwen OAuth, Z.AI
- Streaming chat with tool execution loop
- Mode detection API (/api/meta/mode)
- MCP integration fix (resolved infinite loading)
- NomadArch Native option in UI with comparison info

🆓 Free Models (No API Key):
- GPT-5 Nano (400K context)
- Grok Code Fast 1 (256K context)
- GLM-4.7 (205K context)
- Doubao Seed Code (256K context)
- Big Pickle (200K context)

📦 New Files:
- session-store.ts: Native session persistence
- native-sessions.ts: REST API for sessions
- lite-mode.ts: UI mode detection client
- native-sessions.ts (UI): SolidJS store

🔧 Updated:
- All installers: Optional binary download
- All launchers: Mode detection display
- Binary selector: Added NomadArch Native option
- README: Binary-Free Mode documentation
This commit is contained in:
Gemini AI
2025-12-26 02:08:13 +04:00
Unverified
parent 8dddf4d0cf
commit 4bd2893864
83 changed files with 10678 additions and 1290 deletions

View File

@@ -0,0 +1,320 @@
/**
* MINIMAL CHAT BYPASS
*
* This is a stripped-down chat component that:
* - Uses minimal store access (just for model/session info)
* - Makes direct fetch calls
* - Has NO complex effects/memos
* - Renders messages as a simple list
*
* Purpose: Test if the UI responsiveness issue is in the
* reactivity system or something else entirely.
*/
import { createSignal, For, Show, onMount } from "solid-js"
import { sessions } from "@/stores/session-state"
interface Message {
id: string
role: "user" | "assistant"
content: string
timestamp: number
status: "sending" | "streaming" | "complete" | "error"
}
interface MinimalChatProps {
instanceId: string
sessionId: string
}
export function MinimalChat(props: MinimalChatProps) {
const [messages, setMessages] = createSignal<Message[]>([])
const [inputText, setInputText] = createSignal("")
const [isLoading, setIsLoading] = createSignal(false)
const [error, setError] = createSignal<string | null>(null)
const [currentModel, setCurrentModel] = createSignal("minimax-m1")
let scrollContainer: HTMLDivElement | undefined
let inputRef: HTMLTextAreaElement | undefined
function generateId() {
return `msg_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`
}
function scrollToBottom() {
if (scrollContainer) {
scrollContainer.scrollTop = scrollContainer.scrollHeight
}
}
// Get model from session on mount (one-time read, no reactive dependency)
onMount(() => {
try {
const instanceSessions = sessions().get(props.instanceId)
const session = instanceSessions?.get(props.sessionId)
if (session?.model?.modelId) {
setCurrentModel(session.model.modelId)
}
} catch (e) {
console.warn("Could not get session model, using default", e)
}
inputRef?.focus()
})
async function sendMessage() {
const text = inputText().trim()
if (!text || isLoading()) return
setError(null)
setInputText("")
setIsLoading(true)
const userMessage: Message = {
id: generateId(),
role: "user",
content: text,
timestamp: Date.now(),
status: "complete"
}
const assistantMessage: Message = {
id: generateId(),
role: "assistant",
content: "",
timestamp: Date.now(),
status: "streaming"
}
// Add messages to state
setMessages(prev => [...prev, userMessage, assistantMessage])
scrollToBottom()
try {
// Direct fetch with streaming
const response = await fetch("/api/ollama/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
model: currentModel(),
messages: [
...messages().filter(m => m.status === "complete").map(m => ({ role: m.role, content: m.content })),
{ role: "user", content: text }
],
stream: true
})
})
if (!response.ok) {
throw new Error(`Request failed: ${response.status}`)
}
const reader = response.body?.getReader()
if (!reader) throw new Error("No response body")
const decoder = new TextDecoder()
let fullContent = ""
let buffer = ""
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split("\n")
buffer = lines.pop() || ""
for (const line of lines) {
const trimmed = line.trim()
if (!trimmed.startsWith("data:")) continue
const data = trimmed.slice(5).trim()
if (!data || data === "[DONE]") continue
try {
const chunk = JSON.parse(data)
const delta = chunk?.message?.content
if (typeof delta === "string" && delta.length > 0) {
fullContent += delta
// Update assistant message content (simple state update)
setMessages(prev =>
prev.map(m =>
m.id === assistantMessage.id
? { ...m, content: fullContent }
: m
)
)
scrollToBottom()
}
} catch {
// Ignore parse errors
}
}
}
// Mark as complete
setMessages(prev =>
prev.map(m =>
m.id === assistantMessage.id
? { ...m, status: "complete" }
: m
)
)
} catch (e) {
const errorMsg = e instanceof Error ? e.message : "Unknown error"
setError(errorMsg)
// Mark as error
setMessages(prev =>
prev.map(m =>
m.id === assistantMessage.id
? { ...m, status: "error", content: `Error: ${errorMsg}` }
: m
)
)
} finally {
setIsLoading(false)
scrollToBottom()
}
}
function handleKeyDown(e: KeyboardEvent) {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault()
sendMessage()
}
}
return (
<div style={{
display: "flex",
"flex-direction": "column",
height: "100%",
background: "#0a0a0b",
color: "#e4e4e7"
}}>
{/* Header */}
<div style={{
padding: "16px",
"border-bottom": "1px solid #27272a",
background: "#18181b"
}}>
<h2 style={{ margin: 0, "font-size": "16px" }}>
🧪 Minimal Chat (Bypass Mode)
</h2>
<p style={{ margin: "4px 0 0", "font-size": "12px", color: "#71717a" }}>
Model: {currentModel()} | Testing UI responsiveness
</p>
</div>
{/* Messages */}
<div
ref={scrollContainer}
style={{
flex: 1,
overflow: "auto",
padding: "16px"
}}
>
<Show when={messages().length === 0}>
<div style={{
"text-align": "center",
color: "#71717a",
padding: "48px"
}}>
Send a message to test UI responsiveness
</div>
</Show>
<For each={messages()}>
{(message) => (
<div style={{
"margin-bottom": "16px",
padding: "12px",
background: message.role === "user" ? "#27272a" : "#18181b",
"border-radius": "8px",
"border-left": message.role === "assistant" ? "3px solid #6366f1" : "none"
}}>
<div style={{
"font-size": "11px",
color: "#71717a",
"margin-bottom": "8px"
}}>
{message.role === "user" ? "You" : "Assistant"}
{message.status === "streaming" && " (streaming...)"}
{message.status === "error" && " (error)"}
</div>
<div style={{
"white-space": "pre-wrap",
"word-break": "break-word",
"font-size": "14px",
"line-height": "1.6"
}}>
{message.content || (message.status === "streaming" ? "▋" : "")}
</div>
</div>
)}
</For>
</div>
{/* Error display */}
<Show when={error()}>
<div style={{
padding: "8px 16px",
background: "#7f1d1d",
color: "#fecaca",
"font-size": "12px"
}}>
Error: {error()}
</div>
</Show>
{/* Input area */}
<div style={{
padding: "16px",
"border-top": "1px solid #27272a",
background: "#18181b"
}}>
<div style={{ display: "flex", gap: "8px" }}>
<textarea
ref={inputRef}
value={inputText()}
onInput={(e) => setInputText(e.currentTarget.value)}
onKeyDown={handleKeyDown}
placeholder="Type a message... (Enter to send)"
disabled={isLoading()}
style={{
flex: 1,
padding: "12px",
background: "#27272a",
border: "1px solid #3f3f46",
"border-radius": "8px",
color: "#e4e4e7",
resize: "none",
"font-size": "14px",
"min-height": "48px",
"max-height": "150px"
}}
rows={1}
/>
<button
onClick={sendMessage}
disabled={isLoading() || !inputText().trim()}
style={{
padding: "12px 24px",
background: isLoading() ? "#3f3f46" : "#6366f1",
color: "white",
border: "none",
"border-radius": "8px",
cursor: isLoading() ? "wait" : "pointer",
"font-weight": "600"
}}
>
{isLoading() ? "..." : "Send"}
</button>
</div>
</div>
</div>
)
}
export default MinimalChat

View File

@@ -1,7 +1,7 @@
import { createSignal, Show, onMount, For, createMemo, createEffect, onCleanup } from "solid-js";
import { createSignal, Show, onMount, For, createMemo, createEffect, onCleanup, untrack } from "solid-js";
import { sessions, withSession, setActiveSession } from "@/stores/session-state";
import { instances } from "@/stores/instances";
import { sendMessage, compactSession, updateSessionAgent, updateSessionModelForSession } from "@/stores/session-actions";
import { sendMessage, compactSession, updateSessionAgent, updateSessionModelForSession, forceReset } from "@/stores/session-actions";
import { addTask, setActiveTask, archiveTask } from "@/stores/task-actions";
import { messageStoreBus } from "@/stores/message-v2/bus";
import MessageBlockList, { getMessageAnchorId } from "@/components/message-block-list";
@@ -42,6 +42,7 @@ import {
} from "lucide-solid";
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";
@@ -145,26 +146,22 @@ export default function MultiTaskChat(props: MultiTaskChatProps) {
});
const tokenStats = createMemo(() => {
const usage = sessionUsage();
return {
used: usage?.actualUsageTokens ?? 0,
total: usage?.totalCost ?? 0,
// input: usage?.inputTokens ?? 0,
// output: usage?.outputTokens ?? 0,
// reasoning: usage?.reasoningTokens ?? 0,
// cacheRead: usage?.cacheReadTokens ?? 0,
// cacheWrite: usage?.cacheWriteTokens ?? 0,
cost: usage?.totalCost ?? 0,
};
});
// Get current model from active task session
const currentModel = createMemo(() => {
const instanceSessions = sessions().get(props.instanceId);
const session = instanceSessions?.get(activeTaskSessionId());
return session?.model?.modelId || "unknown";
return untrack(() => {
const usage = sessionUsage();
return {
used: usage?.actualUsageTokens ?? 0,
total: usage?.totalCost ?? 0,
// input: usage?.inputTokens ?? 0,
// output: usage?.outputTokens ?? 0,
// reasoning: usage?.reasoningTokens ?? 0,
// cacheRead: usage?.cacheReadTokens ?? 0,
// cacheWrite: usage?.cacheWriteTokens ?? 0,
cost: usage?.totalCost ?? 0,
};
});
});
// Get active task session ID (must be defined before memos that use it)
const activeTaskSessionId = createMemo(() => {
const task = selectedTask();
return task?.taskSessionId || props.sessionId;
@@ -175,6 +172,13 @@ export default function MultiTaskChat(props: MultiTaskChatProps) {
return instanceSessions?.get(activeTaskSessionId());
});
// Get current model from active task session
const currentModel = createMemo(() => {
const instanceSessions = sessions().get(props.instanceId);
const session = instanceSessions?.get(activeTaskSessionId());
return session?.model?.modelId || "unknown";
});
const currentTaskAgent = createMemo(() => activeTaskSession()?.agent || "");
const currentTaskModel = createMemo(() => activeTaskSession()?.model || { providerId: "", modelId: "" });
@@ -207,8 +211,6 @@ export default function MultiTaskChat(props: MultiTaskChatProps) {
// Show thinking while we're actively sending
if (isSending()) return true;
// Only check the last message instead of iterating all messages
// This prevents O(n) reactive subscriptions during streaming
const ids = filteredMessageIds();
if (ids.length === 0) return false;
@@ -217,22 +219,34 @@ export default function MultiTaskChat(props: MultiTaskChatProps) {
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 (unless user is scrolling)
const interval = setInterval(() => {
if (!userScrolling()) {
scrollToBottom();
}
}, 300);
return () => clearInterval(interval);
});
// Auto-scroll during streaming - DISABLED for performance testing
// createEffect(() => {
// const streaming = isAgentThinking();
// if (!streaming) return;
//
// let lastScrollTime = 0;
// const scrollThrottled = () => {
// const now = Date.now();
// if (now - lastScrollTime > 500) {
// lastScrollTime = now;
// if (!userScrolling()) {
// scrollToBottom();
// }
// }
// };
//
// const interval = setInterval(() => {
// if (!userScrolling()) {
// requestAnimationFrame(scrollToBottom);
// }
// }, 200);
// return () => clearInterval(interval);
// });
// Auto-scroll when new messages arrive (throttled to count changes only)
let lastScrolledCount = 0;
let scrollTimeoutId: ReturnType<typeof setTimeout> | undefined;
createEffect(() => {
const ids = filteredMessageIds();
const count = ids.length;
@@ -242,14 +256,27 @@ export default function MultiTaskChat(props: MultiTaskChatProps) {
// Note: Streaming scrolling is handled by the interval in the isAgentThinking effect above
if (count !== lastScrolledCount && count > 0 && !userScrolling()) {
lastScrolledCount = count;
// Clear any existing timeout to prevent timer accumulation
if (scrollTimeoutId) {
clearTimeout(scrollTimeoutId);
}
// Use requestAnimationFrame for smoother scrolling without locking specific frames
requestAnimationFrame(() => {
setTimeout(scrollToBottom, 50);
scrollToBottom();
});
}
});
// Scroll event listener to detect user scrolling
onMount(() => {
// Wire up debug overlay reset function (must be inside onMount to avoid SolidJS errors)
setForceResetFn(() => {
forceReset();
setIsSending(false);
});
const handleScroll = () => {
if (scrollContainer) {
const isScrollingUp = scrollContainer.scrollTop < lastScrollTop();
@@ -270,10 +297,24 @@ export default function MultiTaskChat(props: MultiTaskChatProps) {
container?.addEventListener('scroll', handleScroll, { passive: true });
return () => {
container?.removeEventListener('scroll', handleScroll);
// Enhanced cleanup: remove scroll listener and clear any pending timeouts
if (container) {
container.removeEventListener('scroll', handleScroll);
}
// Clear any pending scroll timeout
if (scrollTimeoutId) {
clearTimeout(scrollTimeoutId);
}
};
});
// Additional cleanup on component unmount
onCleanup(() => {
if (scrollTimeoutId) {
clearTimeout(scrollTimeoutId);
}
});
const handleSendMessage = async () => {
const message = chatInput().trim();
if (!message || isSending()) return;
@@ -371,7 +412,15 @@ export default function MultiTaskChat(props: MultiTaskChatProps) {
};
// Stop/cancel the current agent operation
const handleStopAgent = async () => {
const handleStopAgent = async (e?: MouseEvent) => {
// Check for Force Reset (Shift + Click)
if (e?.shiftKey) {
log.warn("Shift+Click detected on Stop Agent - Triggering Force Reset");
forceReset();
setIsSending(false);
return;
}
const task = selectedTask();
if (!task) return;
@@ -447,6 +496,7 @@ export default function MultiTaskChat(props: MultiTaskChatProps) {
return (
<main class="absolute inset-0 flex flex-col bg-[#0a0a0b] text-zinc-300 font-sans selection:bg-indigo-500/30 overflow-hidden">
<DebugOverlay />
{/* 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">
@@ -875,7 +925,7 @@ export default function MultiTaskChat(props: MultiTaskChatProps) {
<button
onClick={handleStopAgent}
class="px-3 py-1.5 bg-rose-500/20 hover:bg-rose-500/30 text-rose-300 rounded-lg text-[10px] font-bold uppercase tracking-wide transition-all border border-rose-500/30"
title="Stop response"
title="Stop response (Shift+Click to Force Reset UI)"
>
<StopCircle size={12} class="inline-block mr-1" />
Stop

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,101 @@
/**
* SimpleMessageBlock - Polling-based message renderer
*
* Updates content via interval, not reactive cascade.
* This prevents the freeze during streaming.
*/
import { createSignal, Show, onMount, onCleanup } from "solid-js";
import type { InstanceMessageStore } from "@/stores/message-v2/instance-store";
interface SimpleMessageBlockProps {
messageId: string;
store: () => InstanceMessageStore;
}
export function SimpleMessageBlock(props: SimpleMessageBlockProps) {
const [content, setContent] = createSignal("");
const [isStreaming, setIsStreaming] = createSignal(false);
const [isUser, setIsUser] = createSignal(false);
const [timestamp, setTimestamp] = createSignal("");
const [tokenCount, setTokenCount] = createSignal(0);
function updateFromStore() {
const message = props.store().getMessage(props.messageId);
if (!message) return;
setIsUser(message.role === "user");
setIsStreaming(message.status === "streaming" || message.status === "sending");
// Extract text content from parts
const parts = message.parts || {};
let text = "";
for (const partId of Object.keys(parts)) {
const partRecord = parts[partId];
if (partRecord?.data?.type === "text") {
text = (partRecord.data as any).text || "";
break;
}
}
// Fallback to direct content
if (!text && (message as any).content) {
text = (message as any).content;
}
setContent(text);
setTokenCount(Math.ceil(text.length / 4));
// Note: MessageRecord doesn't have time property, skip timestamp
}
onMount(() => {
updateFromStore();
// Poll for updates during streaming (every 100ms)
const interval = setInterval(() => {
const msg = props.store().getMessage(props.messageId);
if (msg?.status === "streaming" || msg?.status === "sending" || isStreaming()) {
updateFromStore();
}
}, 100);
onCleanup(() => clearInterval(interval));
});
return (
<div
id={`message-anchor-${props.messageId}`}
class={`rounded-xl p-4 transition-all min-w-0 overflow-hidden ${isUser()
? "bg-zinc-800/50 border border-zinc-700/50"
: "bg-zinc-900/50 border border-indigo-500/20"
}`}
>
<div class="flex items-center justify-between mb-2">
<div class="flex items-center gap-2">
<div class={`text-[10px] font-bold uppercase tracking-wide ${isUser() ? "text-indigo-400" : "text-emerald-400"}`}>
{isUser() ? "You" : "Assistant"}
</div>
<Show when={isStreaming()}>
<div class="flex items-center gap-2">
<div class="flex items-center gap-1 text-[9px] text-violet-400">
<div class="w-1.5 h-1.5 bg-violet-400 rounded-full animate-pulse" />
<span>Thinking...</span>
</div>
<span class="text-[9px] font-mono text-zinc-500 bg-zinc-800/50 px-1 rounded">
{tokenCount()} tks
</span>
</div>
</Show>
</div>
<div class="text-[9px] text-zinc-600">{timestamp()}</div>
</div>
<div
class="text-sm text-zinc-100 leading-relaxed whitespace-pre-wrap break-words overflow-hidden"
style={{ "word-break": "break-word", "overflow-wrap": "anywhere" }}
>
{content() || (isStreaming() ? "▋" : "")}
</div>
</div>
);
}

View File

@@ -0,0 +1,8 @@
// Re-export all MultiX v2 components
export { default as MultiXV2 } from "./index";
export { SimpleMessageBlock } from "./core/SimpleMessageBlock";
export { PipelineView } from "./features/PipelineView";
export { MessageNavSidebar } from "./features/MessageNavSidebar";
export { LiteAgentSelector } from "./features/LiteAgentSelector";
export { LiteModelSelector } from "./features/LiteModelSelector";
export { enhancePrompt, getQuickTips } from "./features/PromptEnhancer";

View File

@@ -0,0 +1,637 @@
/**
* LiteAgentSelector - Non-reactive agent selector for MultiX v2
*
* Uses polling instead of reactive subscriptions to prevent cascading updates.
* Includes AI Agent Generator feature.
*/
import { createSignal, For, onMount, onCleanup, Show } from "solid-js";
import { agents, setAgents, providers } from "@/stores/session-state";
import { fetchAgents } from "@/stores/session-api";
import { updateInstanceConfig } from "@/stores/instance-config";
import { toast } from "solid-toast";
import { ChevronDown, Bot, Plus, Sparkles, Loader2, Save, X, RefreshCw } from "lucide-solid";
import { serverApi } from "@/lib/api-client";
interface LiteAgentSelectorProps {
instanceId: string;
sessionId: string;
currentAgent: string;
onAgentChange: (agent: string) => void;
}
interface AgentInfo {
name: string;
description?: string;
systemPrompt?: string;
}
export function LiteAgentSelector(props: LiteAgentSelectorProps) {
const [isOpen, setIsOpen] = createSignal(false);
const [agentList, setAgentList] = createSignal<AgentInfo[]>([]);
const [isGenerating, setIsGenerating] = createSignal(false);
const [showGenerator, setShowGenerator] = createSignal(false);
const [generatorInput, setGeneratorInput] = createSignal("");
const [generatedAgent, setGeneratedAgent] = createSignal<AgentInfo | null>(null);
const [isSaving, setIsSaving] = createSignal(false);
const [selectedModel, setSelectedModel] = createSignal("glm-4");
const [availableModels, setAvailableModels] = createSignal<{ id: string, name: string, provider: string }[]>([]);
// Load agents once on mount, then poll
function loadAgents() {
try {
const instanceAgents = agents().get(props.instanceId) || [];
const nonSubagents = instanceAgents.filter((a: any) => a.mode !== "subagent");
setAgentList(nonSubagents.map((a: any) => ({
name: a.name,
description: a.description,
systemPrompt: a.systemPrompt
})));
} catch (e) {
console.warn("Failed to load agents", e);
}
}
onMount(() => {
loadAgents();
// Populate available models
const allProviders = providers().get(props.instanceId) || [];
const models: { id: string, name: string, provider: string }[] = [];
allProviders.forEach(p => {
p.models.forEach(m => {
models.push({ id: m.id, name: m.name || m.id, provider: p.id });
});
});
// Add defaults if none found
if (models.length === 0) {
models.push({ id: "glm-4", name: "GLM-4 (Z.AI)", provider: "zai" });
models.push({ id: "qwen-coder-plus-latest", name: "Qwen Coder Plus (Zen)", provider: "opencode-zen" });
models.push({ id: "minimax-m1", name: "MiniMax M1 (Ollama)", provider: "ollama" });
}
setAvailableModels(models);
// Poll every 5 seconds (agents don't change often)
const interval = setInterval(loadAgents, 5000);
onCleanup(() => clearInterval(interval));
});
const handleSelect = (agentName: string) => {
props.onAgentChange(agentName);
setIsOpen(false);
};
const handleGenerateAgent = async () => {
const input = generatorInput().trim();
if (!input || isGenerating()) return;
setIsGenerating(true);
const modelInfo = availableModels().find(m => m.id === selectedModel());
// Normalize provider ID - handle variants like "ollama-cloud" -> "ollama"
let provider = modelInfo?.provider || "zai";
if (provider.includes("ollama")) provider = "ollama";
if (provider.includes("zen")) provider = "opencode-zen";
console.log(`[AgentGenerator] Using provider: ${provider}, model: ${selectedModel()}`);
// AI generation prompt - focused on unique, creative output
const generationPrompt = `Create a unique AI coding assistant agent based on: "${input}"
RULES:
1. NAME: Create a catchy, memorable 1-3 word name (e.g., "Neon Architect", "Logic Ghost", "Cortex", "Syntax Specter"). BE CREATIVE!
2. DESCRIPTION: One powerful sentence about their unique paradigm or specialty.
3. SYSTEM PROMPT: Write a 400+ word deep-dive into their psyche, expertise, and operational style.
- DO NOT be generic.
- Give them a clear VOICE and philosophy.
- Professional, yet distinct.
- Mention specific methodologies they favor.
- Explain how they view the relationship between code and problem-solving.
IMPORTANT: Return ONLY valid JSON in this format:
{"name": "...", "description": "...", "systemPrompt": "..."}`;
const endpoints: Record<string, string> = {
"zai": "/api/zai/chat",
"opencode-zen": "/api/opencode-zen/chat",
"ollama": "/api/ollama/chat"
};
// Timeout wrapper for fetch with 60 second limit
const fetchWithTimeout = async (url: string, options: RequestInit, timeoutMs: number = 60000) => {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(url, { ...options, signal: controller.signal });
clearTimeout(timeoutId);
return response;
} catch (e) {
clearTimeout(timeoutId);
throw e;
}
};
const tryEndpoint = async (prov: string, model: string) => {
try {
console.log(`[AgentGenerator] Attempting generation with ${prov}/${model}...`);
// Use absolute URL from serverApi to avoid port issues
const baseUrl = serverApi.getApiBase();
const endpoint = `${baseUrl}${endpoints[prov]}`;
if (!endpoints[prov]) {
console.warn(`[AgentGenerator] No endpoint configured for provider: ${prov}`);
return null;
}
const response = await fetchWithTimeout(endpoint, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
model: model,
messages: [{ role: "user", content: generationPrompt }],
stream: false
})
}, 60000); // 60 second timeout
if (response.ok) {
const data = await response.json();
const content = prov === "zai" || prov === "opencode-zen"
? (data?.choices?.[0]?.message?.content || data?.message?.content || "")
: (data?.message?.content || "");
console.log(`[AgentGenerator] Received content from ${prov}:`, content.substring(0, 100) + "...");
const result = tryParseAgentJson(content, input);
if (result) return result;
console.warn(`[AgentGenerator] Failed to parse JSON from ${prov} response`);
} else {
const errText = await response.text();
console.error(`[AgentGenerator] Endpoint ${prov} returned ${response.status}:`, errText);
}
} catch (e: any) {
if (e.name === 'AbortError') {
console.warn(`[AgentGenerator] Request to ${prov} timed out after 60s`);
toast.error(`Generation timed out. Try a faster model.`, { duration: 5000 });
} else {
console.warn(`[AgentGenerator] Endpoint ${prov} failed:`, e);
}
}
return null;
};
// 1. Try selected model
let parsed = await tryEndpoint(provider, selectedModel());
// 2. Fallbacks if selected fails - try faster models
if (!parsed) {
console.log("[AgentGenerator] Selected model failed, trying fallbacks...");
const fallbacks = [
{ prov: "ollama", model: "qwen3:8b" },
{ prov: "opencode-zen", model: "qwen-coder-plus-latest" },
{ prov: "zai", model: "glm-4" },
].filter(f => f.model !== selectedModel());
for (const f of fallbacks) {
parsed = await tryEndpoint(f.prov, f.model);
if (parsed) break;
}
}
if (parsed) {
setGeneratedAgent(parsed);
toast.success("Agent generated!", { icon: "🎉", duration: 3000 });
} else {
console.warn("[AgentGenerator] All AI endpoints failed, using smart fallback");
setGeneratedAgent(generateSmartFallback(input));
toast.success("Agent created (local fallback)", { duration: 3000 });
}
setIsGenerating(false);
};
// Try to parse JSON from AI response
const tryParseAgentJson = (content: string, input: string): { name: string; description: string; systemPrompt: string } | null => {
try {
const jsonMatch = content.match(/\{[\s\S]*\}/);
if (jsonMatch) {
const parsed = JSON.parse(jsonMatch[0]);
if (parsed.name && parsed.systemPrompt && parsed.systemPrompt.length > 100) {
return {
name: parsed.name,
description: parsed.description || input,
systemPrompt: parsed.systemPrompt
};
}
}
} catch (e) {
console.error("JSON parse error:", e);
}
return null;
};
// Generate a smart fallback that actually feels unique
const generateSmartFallback = (input: string): { name: string; description: string; systemPrompt: string } => {
const name = generateFallbackName(input);
const timestamp = Date.now();
// Create unique content based on input analysis
const inputLower = input.toLowerCase();
const isFrontend = /react|vue|angular|css|html|ui|frontend|web/.test(inputLower);
const isBackend = /api|server|node|python|database|backend/.test(inputLower);
const isFullStack = /full.?stack|complete|everything/.test(inputLower);
const isAI = /ai|ml|machine|learning|neural|gpt|claude|llm/.test(inputLower);
const isDevOps = /devops|docker|kubernetes|ci|cd|deploy/.test(inputLower);
let specialty = "general software development";
let techStack = "JavaScript, TypeScript, Python";
let uniqueTrait = "methodical approach to problem-solving";
if (isFrontend) {
specialty = "frontend architecture and user experience";
techStack = "React, Vue, TypeScript, CSS, Tailwind";
uniqueTrait = "pixel-perfect attention to detail and smooth animations";
} else if (isBackend) {
specialty = "backend systems and API design";
techStack = "Node.js, Python, PostgreSQL, Redis, GraphQL";
uniqueTrait = "building scalable, fault-tolerant services";
} else if (isFullStack) {
specialty = "end-to-end application development";
techStack = "React, Node.js, PostgreSQL, Docker, AWS";
uniqueTrait = "seamless integration between frontend and backend";
} else if (isAI) {
specialty = "AI/ML integration and prompt engineering";
techStack = "Python, LangChain, OpenAI, HuggingFace, Vector DBs";
uniqueTrait = "crafting intelligent, context-aware AI solutions";
} else if (isDevOps) {
specialty = "infrastructure and deployment automation";
techStack = "Docker, Kubernetes, Terraform, GitHub Actions, AWS";
uniqueTrait = "zero-downtime deployments and infrastructure as code";
}
return {
name,
description: `Expert in ${specialty} with ${uniqueTrait}`,
systemPrompt: `You are ${name}, a senior software engineer with 10+ years of expertise in ${specialty}.
## Your Personality
You are confident but humble, always explaining your reasoning clearly. You prefer elegant, maintainable solutions over clever hacks. When you don't know something, you say so honestly and suggest ways to find the answer.
## Technical Expertise
Your primary stack: ${techStack}
Your specialty: ${specialty}
Your unique strength: ${uniqueTrait}
## How You Work
1. **Understand First**: Before writing code, you analyze the existing codebase structure, patterns, and conventions
2. **Plan Carefully**: You outline your approach before implementing, considering edge cases and potential issues
3. **Code Quality**: Every line you write follows best practices - clean naming, proper error handling, comprehensive types
4. **Test Thinking**: You consider how code will be tested, even if tests aren't explicitly requested
5. **Documentation**: You add meaningful comments for complex logic, not obvious operations
## Code Standards You Follow
- Use descriptive variable and function names that reveal intent
- Keep functions small and focused (single responsibility)
- Handle errors gracefully with informative messages
- Prefer composition over inheritance
- Write self-documenting code, supplement with comments only where needed
- Always consider performance implications
## Communication Style
- Be direct and actionable in your responses
- When suggesting changes, explain WHY not just WHAT
- If multiple approaches exist, briefly mention pros/cons
- Celebrate good code when you see it
- Provide constructive feedback on improvements
## Tool Usage
- Use read_file to understand existing code before modifying
- Use list_files to understand project structure
- Use write_file to create or update files with complete, working code
- Always verify syntax correctness before submitting
Built for: ${input}
Session ID: ${timestamp}`
};
};
// Generate a professional fallback name from user input
const generateFallbackName = (input: string): string => {
// Extract key words and create a professional sounding name
const words = input.toLowerCase().split(/\s+/).filter(w => w.length > 2);
// Common tech keywords to look for
const keywords: Record<string, string> = {
'typescript': 'TypeScript Pro',
'javascript': 'JS Expert',
'react': 'React Master',
'python': 'Python Guru',
'api': 'API Architect',
'code': 'Code Expert',
'full': 'Full Stack Pro',
'frontend': 'Frontend Master',
'backend': 'Backend Pro',
'mcp': 'MCP Specialist',
'agent': 'Smart Agent',
'thinking': 'Deep Thinker',
'claude': 'AI Assistant',
'smart': 'Smart Coder',
'fix': 'Bug Hunter',
'test': 'Test Master',
'debug': 'Debug Pro',
'architect': 'Code Architect',
'review': 'Code Reviewer'
};
// Try to find a matching keyword
for (const word of words) {
for (const [key, name] of Object.entries(keywords)) {
if (word.includes(key)) {
return name;
}
}
}
// Default: Create from first few words
const titleWords = words.slice(0, 2).map(w =>
w.charAt(0).toUpperCase() + w.slice(1)
);
return titleWords.length > 0 ? titleWords.join(' ') + ' Pro' : 'Custom Agent';
}
// Generate a sophisticated fallback prompt when API fails
const generateFallbackPrompt = (description: string): string => {
return `# ${description}
## IDENTITY & CORE MISSION
You are a world-class AI coding assistant specialized in: ${description}. You combine deep technical expertise with exceptional problem-solving abilities to deliver production-ready code that exceeds professional standards.
## CODEBASE AWARENESS PROTOCOL
Before writing any code, you MUST:
1. **Analyze Context**: Understand the existing project structure, patterns, and conventions
2. **Identify Dependencies**: Check package.json, imports, and installed libraries
3. **Match Style**: Adapt your output to the existing code style in the project
4. **Verify Compatibility**: Ensure new code integrates seamlessly with existing modules
## TECHNICAL EXPERTISE
- **Languages**: JavaScript, TypeScript, Python, and relevant frameworks
- **Patterns**: SOLID principles, DRY, KISS, Clean Architecture
- **Testing**: TDD approach, comprehensive test coverage
- **Documentation**: Clear comments, JSDoc/TSDoc, README updates
## CODING STANDARDS
1. **Naming**: Use descriptive, intention-revealing names
2. **Functions**: Single responsibility, max 20-30 lines per function
3. **Error Handling**: Always handle errors gracefully with informative messages
4. **Types**: Prefer strict typing, avoid \`any\` type
5. **Comments**: Explain WHY, not WHAT (the code explains what)
## ARCHITECTURAL PRINCIPLES
- Favor composition over inheritance
- Implement proper separation of concerns
- Design for extensibility and maintainability
- Consider performance implications of design choices
- Apply appropriate design patterns (Factory, Strategy, Observer, etc.)
## COMMUNICATION STYLE
- Be concise but thorough in explanations
- Provide rationale for technical decisions
- Offer alternatives when relevant
- Acknowledge limitations and edge cases
- Use code examples to illustrate concepts
## TOOL USAGE
When modifying the codebase:
1. Use \`read_file\` to understand existing code before making changes
2. Use \`list_files\` to understand project structure
3. Use \`write_file\` to create or update files with complete, working code
4. Always verify your changes are syntactically correct
5. Consider impact on other files that may need updates
## OUTPUT QUALITY STANDARDS
Every piece of code you generate must be:
- ✅ Syntactically correct and immediately runnable
- ✅ Following existing project conventions
- ✅ Properly typed (if TypeScript)
- ✅ Including necessary imports
- ✅ Handling edge cases and errors
- ✅ Well-documented where appropriate
You are committed to excellence and take pride in delivering code that professionals would admire.`
}
const handleSaveAgent = async () => {
const agent = generatedAgent();
if (!agent || isSaving()) return;
setIsSaving(true);
const toastId = toast.loading("Saving agent...");
try {
// Save to backend
const response = await fetch(`/api/workspaces/${props.instanceId}/agents`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: agent.name,
description: agent.description,
systemPrompt: agent.systemPrompt,
mode: "agent"
})
});
if (response.ok) {
// CRITICAL: Update local instance config to keep it in sync with backend
// This is the source of truth that fetchAgents() reads from
await updateInstanceConfig(props.instanceId, (draft) => {
if (!draft.customAgents) {
draft.customAgents = [];
}
const existingIndex = draft.customAgents.findIndex(a => a.name === agent.name);
const agentData = {
name: agent.name,
description: agent.description || "",
prompt: agent.systemPrompt || ""
};
if (existingIndex >= 0) {
draft.customAgents[existingIndex] = agentData;
} else {
draft.customAgents.push(agentData);
}
});
// Fetch fresh agents from backend to update global signals
await fetchAgents(props.instanceId);
// Refresh local agent list
loadAgents();
// Manual update to ensure immediate feedback (fix for list lag)
setAgentList(prev => {
if (prev.some(a => a.name === agent.name)) return prev;
return [...prev, { name: agent.name, description: agent.description, systemPrompt: agent.systemPrompt }];
});
// Select the new agent
props.onAgentChange(agent.name);
toast.success(`Agent "${agent.name}" saved and activated!`, { id: toastId });
// Close generator
setShowGenerator(false);
setGeneratedAgent(null);
setGeneratorInput("");
setIsOpen(false);
} else {
const errorData = await response.json().catch(() => ({}));
console.error("Failed to save agent:", response.status, errorData);
toast.error(`Failed to save agent: ${errorData.error || response.statusText}`, { id: toastId });
}
} catch (error) {
console.error("Failed to save agent:", error);
toast.error("Network error while saving agent", { id: toastId });
} finally {
setIsSaving(false);
}
};
return (
<div class="relative">
<button
onClick={() => setIsOpen(!isOpen())}
class="flex items-center justify-between w-full px-3 py-2 bg-zinc-900/60 border border-white/10 rounded-lg text-left hover:border-indigo-500/30 transition-all"
>
<div class="flex items-center gap-2">
<Bot size={14} class="text-indigo-400" />
<span class="text-[11px] font-bold text-zinc-200 truncate">
{props.currentAgent || "Select Agent"}
</span>
</div>
<ChevronDown size={12} class={`text-zinc-500 transition-transform ${isOpen() ? "rotate-180" : ""}`} />
</button>
<Show when={isOpen()}>
<div class="absolute top-full left-0 right-0 mt-1 bg-zinc-900 border border-white/10 rounded-lg shadow-xl z-50 max-h-[80vh] overflow-y-auto">
{/* Agent Generator Toggle */}
<button
onClick={() => setShowGenerator(!showGenerator())}
class="w-full px-3 py-2 text-left hover:bg-indigo-500/10 transition-colors flex items-center gap-2 border-b border-white/5 text-indigo-400"
>
<Sparkles size={12} />
<span class="text-[11px] font-bold">AI Agent Generator</span>
<Plus size={12} class="ml-auto" />
</button>
{/* Generator Panel */}
<Show when={showGenerator()}>
<div class="p-3 border-b border-white/10 bg-zinc-950/50 space-y-3">
<div class="space-y-1">
<div class="text-[10px] text-zinc-400 font-medium">Generation Model:</div>
<select
value={selectedModel()}
onChange={(e) => setSelectedModel(e.currentTarget.value)}
class="w-full bg-zinc-800 border border-white/10 rounded px-2 py-1.5 text-[10px] text-zinc-200 outline-none focus:border-indigo-500/50"
>
<For each={availableModels()}>
{(model) => (
<option value={model.id}>{model.name}</option>
)}
</For>
</select>
</div>
<div class="text-[10px] text-zinc-400 font-medium">
Describe the agent you want to create:
</div>
<textarea
value={generatorInput()}
onInput={(e) => setGeneratorInput(e.currentTarget.value)}
placeholder="e.g., A TypeScript expert who focuses on clean code and best practices..."
class="w-full bg-zinc-800 border border-white/10 rounded-lg px-3 py-2 text-[11px] text-zinc-200 placeholder-zinc-600 resize-none outline-none focus:border-indigo-500/50"
rows={3}
/>
<div class="flex items-center gap-2">
<button
onClick={handleGenerateAgent}
disabled={!generatorInput().trim() || isGenerating()}
class="flex-1 px-3 py-1.5 bg-indigo-500/20 border border-indigo-500/40 rounded-lg text-[10px] font-bold text-indigo-300 hover:bg-indigo-500/30 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
<Show when={isGenerating()} fallback={<Sparkles size={12} />}>
<Loader2 size={12} class="animate-spin" />
</Show>
{isGenerating() ? "Generating..." : "Generate Agent"}
</button>
</div>
{/* Generated Agent Preview */}
<Show when={generatedAgent()}>
<div class="bg-zinc-800/50 rounded-lg p-3 border border-emerald-500/30 space-y-2">
<div class="flex items-center justify-between">
<span class="text-[10px] font-bold text-emerald-400">Generated Agent</span>
<button
onClick={() => setGeneratedAgent(null)}
class="text-zinc-500 hover:text-zinc-300"
>
<X size={12} />
</button>
</div>
<div class="text-[12px] font-bold text-zinc-100">{generatedAgent()?.name}</div>
<div class="text-[10px] text-zinc-400">{generatedAgent()?.description}</div>
<div class="text-[9px] text-zinc-400 max-h-60 overflow-y-auto whitespace-pre-wrap font-mono bg-black/20 p-2 rounded border border-white/5">
{generatedAgent()?.systemPrompt}
</div>
<button
onClick={handleSaveAgent}
disabled={isSaving()}
class="w-full flex items-center justify-center gap-2 py-2 bg-emerald-600 hover:bg-emerald-500 disabled:opacity-50 disabled:cursor-not-allowed text-white rounded-md text-[11px] font-bold transition-all shadow-lg active:scale-95"
>
<Show when={isSaving()} fallback={<Save size={14} />}>
<Loader2 size={14} class="animate-spin" />
</Show>
{isSaving() ? "Saving..." : "Save & Use Agent"}
</button>
</div>
</Show>
</div>
</Show>
{/* Agent List */}
<div class="px-3 py-1.5 flex items-center justify-between border-t border-white/5 bg-zinc-950/30">
<span class="text-[9px] font-bold text-zinc-500 uppercase tracking-widest">Saved Agents</span>
<button
onClick={(e) => { e.stopPropagation(); loadAgents(); fetchAgents(); }}
class="p-1 hover:bg-white/5 rounded text-zinc-500 hover:text-zinc-300 transition-colors"
title="Refresh agents"
>
<RefreshCw size={10} />
</button>
</div>
<div class="max-h-48 overflow-y-auto custom-scrollbar">
<For each={agentList()}>
{(agent) => (
<button
onClick={() => handleSelect(agent.name)}
class={`w-full px-3 py-2 text-left hover:bg-white/5 transition-colors flex items-center gap-2 ${props.currentAgent === agent.name ? "bg-indigo-500/10 text-indigo-300" : "text-zinc-300"
}`}
>
<Bot size={12} class="text-zinc-500" />
<div class="min-w-0">
<div class="text-[11px] font-bold truncate">{agent.name}</div>
{agent.description && (
<div class="text-[9px] text-zinc-500 truncate">{agent.description}</div>
)}
</div>
</button>
)}
</For>
<Show when={agentList().length === 0}>
<div class="px-3 py-2 text-[10px] text-zinc-600">No agents available</div>
</Show>
</div>
</div>
</Show>
</div>
);
}

View File

@@ -0,0 +1,121 @@
/**
* LiteModelSelector - Non-reactive model selector for MultiX v2
*
* Uses polling instead of reactive subscriptions to prevent cascading updates.
*/
import { createSignal, For, onMount, onCleanup, Show } from "solid-js";
import { providers } from "@/stores/session-state";
import { ChevronDown, Cpu } from "lucide-solid";
interface Model {
id: string;
name: string;
providerId: string;
}
interface Provider {
id: string;
name: string;
models: Model[];
}
interface LiteModelSelectorProps {
instanceId: string;
sessionId: string;
currentModel: { providerId: string; modelId: string };
onModelChange: (model: { providerId: string; modelId: string }) => void;
}
export function LiteModelSelector(props: LiteModelSelectorProps) {
const [isOpen, setIsOpen] = createSignal(false);
const [providerList, setProviderList] = createSignal<Provider[]>([]);
// Load providers once on mount, then poll
function loadProviders() {
try {
const instanceProviders = providers().get(props.instanceId) || [];
setProviderList(instanceProviders.map((p: any) => ({
id: p.id,
name: p.name,
models: (p.models || []).map((m: any) => ({
id: m.id,
name: m.name,
providerId: p.id,
})),
})));
} catch (e) {
console.warn("Failed to load providers", e);
}
}
onMount(() => {
loadProviders();
// Poll every 10 seconds (providers don't change often)
const interval = setInterval(loadProviders, 10000);
onCleanup(() => clearInterval(interval));
});
const handleSelect = (providerId: string, modelId: string) => {
props.onModelChange({ providerId, modelId });
setIsOpen(false);
};
const getCurrentModelName = () => {
if (!props.currentModel.modelId) return "Select Model";
for (const provider of providerList()) {
for (const model of provider.models) {
if (model.id === props.currentModel.modelId) {
return model.name;
}
}
}
return props.currentModel.modelId;
};
return (
<div class="relative">
<button
onClick={() => setIsOpen(!isOpen())}
class="flex items-center justify-between w-full px-3 py-2 bg-zinc-900/60 border border-white/10 rounded-lg text-left hover:border-indigo-500/30 transition-all"
>
<div class="flex items-center gap-2">
<Cpu size={14} class="text-emerald-400" />
<span class="text-[11px] font-bold text-zinc-200 truncate">
{getCurrentModelName()}
</span>
</div>
<ChevronDown size={12} class={`text-zinc-500 transition-transform ${isOpen() ? "rotate-180" : ""}`} />
</button>
<Show when={isOpen()}>
<div class="absolute top-full left-0 right-0 mt-1 bg-zinc-900 border border-white/10 rounded-lg shadow-xl z-50 max-h-64 overflow-y-auto">
<For each={providerList()}>
{(provider) => (
<div>
<div class="px-3 py-1.5 text-[9px] font-bold text-zinc-500 uppercase tracking-wide bg-zinc-950/50 sticky top-0">
{provider.name}
</div>
<For each={provider.models}>
{(model) => (
<button
onClick={() => handleSelect(provider.id, model.id)}
class={`w-full px-3 py-2 text-left hover:bg-white/5 transition-colors flex items-center gap-2 ${props.currentModel.modelId === model.id ? "bg-emerald-500/10 text-emerald-300" : "text-zinc-300"
}`}
>
<Cpu size={12} class="text-zinc-500" />
<span class="text-[11px] font-medium truncate">{model.name}</span>
</button>
)}
</For>
</div>
)}
</For>
<Show when={providerList().length === 0}>
<div class="px-3 py-2 text-[10px] text-zinc-600">No models available</div>
</Show>
</div>
</Show>
</div>
);
}

View File

@@ -0,0 +1,230 @@
/**
* LiteSkillsSelector - Non-reactive skills selector for MultiX v2
*
* Uses polling instead of reactive subscriptions to prevent cascading updates.
* Displays selected skills as chips with ability to add/remove.
*/
import { createSignal, For, onMount, onCleanup, Show } from "solid-js";
import { catalog, catalogLoading, loadCatalog } from "@/stores/skills";
import { getSessionSkills, setSessionSkills } from "@/stores/session-state";
import { ChevronDown, Sparkles, X, Check, Loader2 } from "lucide-solid";
import type { SkillSelection } from "@/types/session";
interface LiteSkillsSelectorProps {
instanceId: string;
sessionId: string;
}
interface SkillInfo {
id: string;
name: string;
description?: string;
}
export function LiteSkillsSelector(props: LiteSkillsSelectorProps) {
const [isOpen, setIsOpen] = createSignal(false);
const [skillList, setSkillList] = createSignal<SkillInfo[]>([]);
const [selectedSkills, setSelectedSkills] = createSignal<SkillSelection[]>([]);
const [isLoading, setIsLoading] = createSignal(false);
const [filterText, setFilterText] = createSignal("");
// Load skills once on mount, then poll
function loadSkills() {
try {
const skills = catalog();
setSkillList(skills.map((s) => ({
id: s.id,
name: s.name || s.id,
description: s.description
})));
} catch (e) {
console.warn("Failed to load skills", e);
}
}
function loadSelected() {
try {
const skills = getSessionSkills(props.instanceId, props.sessionId);
setSelectedSkills(skills);
} catch (e) {
console.warn("Failed to load selected skills", e);
}
}
onMount(async () => {
// Load catalog if not already loaded
if (catalog().length === 0) {
setIsLoading(true);
await loadCatalog();
setIsLoading(false);
}
loadSkills();
loadSelected();
// Poll every 2 seconds
const interval = setInterval(() => {
loadSkills();
loadSelected();
}, 2000);
onCleanup(() => clearInterval(interval));
});
const toggleSkill = (skill: SkillInfo) => {
const current = selectedSkills();
const isSelected = current.some(s => s.id === skill.id);
let next: SkillSelection[];
if (isSelected) {
next = current.filter(s => s.id !== skill.id);
} else {
next = [...current, { id: skill.id, name: skill.name, description: skill.description }];
}
setSelectedSkills(next);
setSessionSkills(props.instanceId, props.sessionId, next);
};
const removeSkill = (id: string) => {
const next = selectedSkills().filter(s => s.id !== id);
setSelectedSkills(next);
setSessionSkills(props.instanceId, props.sessionId, next);
};
const filteredSkills = () => {
const term = filterText().toLowerCase().trim();
if (!term) return skillList();
return skillList().filter(s =>
s.name.toLowerCase().includes(term) ||
s.id.toLowerCase().includes(term) ||
(s.description?.toLowerCase().includes(term) ?? false)
);
};
const isSkillSelected = (id: string) => selectedSkills().some(s => s.id === id);
return (
<div class="relative w-full">
{/* Main Button */}
<button
onClick={() => setIsOpen(!isOpen())}
class="flex items-center justify-between w-full px-3 py-2 bg-zinc-900/60 border border-white/10 rounded-lg text-left hover:border-purple-500/30 transition-all"
>
<div class="flex items-center gap-2 min-w-0 flex-1">
<Sparkles size={14} class="text-purple-400 shrink-0" />
<Show
when={selectedSkills().length > 0}
fallback={<span class="text-[11px] text-zinc-500">No skills</span>}
>
<div class="flex items-center gap-1 overflow-hidden">
<span class="text-[11px] font-bold text-purple-300">
{selectedSkills().length} skill{selectedSkills().length !== 1 ? 's' : ''}
</span>
<For each={selectedSkills().slice(0, 2)}>
{(skill) => (
<span class="text-[10px] px-1.5 py-0.5 bg-purple-500/20 text-purple-300 rounded truncate max-w-[80px]">
{skill.name}
</span>
)}
</For>
<Show when={selectedSkills().length > 2}>
<span class="text-[10px] text-zinc-500">+{selectedSkills().length - 2}</span>
</Show>
</div>
</Show>
</div>
<ChevronDown size={12} class={`text-zinc-500 transition-transform shrink-0 ${isOpen() ? "rotate-180" : ""}`} />
</button>
{/* Dropdown */}
<Show when={isOpen()}>
<div class="absolute top-full left-0 right-0 mt-1 bg-zinc-900 border border-white/10 rounded-lg shadow-xl z-50 max-h-80 overflow-hidden flex flex-col">
{/* Selected Skills Chips */}
<Show when={selectedSkills().length > 0}>
<div class="px-3 py-2 border-b border-white/5 flex flex-wrap gap-1">
<For each={selectedSkills()}>
{(skill) => (
<span class="inline-flex items-center gap-1 px-2 py-0.5 bg-purple-500/20 text-purple-300 rounded-full text-[10px]">
{skill.name}
<button
onClick={(e) => {
e.stopPropagation();
removeSkill(skill.id);
}}
class="hover:text-red-400"
>
<X size={10} />
</button>
</span>
)}
</For>
</div>
</Show>
{/* Filter Input */}
<div class="px-3 py-2 border-b border-white/5">
<input
type="text"
placeholder="Filter skills..."
value={filterText()}
onInput={(e) => setFilterText(e.currentTarget.value)}
class="w-full bg-white/5 border border-white/10 rounded px-2 py-1 text-xs text-zinc-200 outline-none focus:border-purple-500/40"
/>
</div>
{/* Skills List */}
<div class="overflow-y-auto flex-1 max-h-48">
<Show
when={!isLoading() && !catalogLoading()}
fallback={
<div class="px-3 py-4 text-center text-[11px] text-zinc-500 flex items-center justify-center gap-2">
<Loader2 size={12} class="animate-spin" />
Loading skills...
</div>
}
>
<Show
when={filteredSkills().length > 0}
fallback={
<div class="px-3 py-4 text-center text-[11px] text-zinc-500">
No skills found
</div>
}
>
<For each={filteredSkills()}>
{(skill) => (
<button
onClick={() => toggleSkill(skill)}
class={`w-full px-3 py-2 text-left hover:bg-white/5 transition-colors flex items-center gap-2 ${isSkillSelected(skill.id) ? "bg-purple-500/10" : ""
}`}
>
<div class={`w-4 h-4 rounded border flex items-center justify-center shrink-0 ${isSkillSelected(skill.id)
? "bg-purple-500 border-purple-500"
: "border-white/20"
}`}>
<Show when={isSkillSelected(skill.id)}>
<Check size={10} class="text-white" />
</Show>
</div>
<div class="flex-1 min-w-0">
<div class={`text-[11px] font-medium truncate ${isSkillSelected(skill.id) ? "text-purple-300" : "text-zinc-300"
}`}>
{skill.name}
</div>
<Show when={skill.description}>
<div class="text-[10px] text-zinc-500 truncate">
{skill.description}
</div>
</Show>
</div>
</button>
)}
</For>
</Show>
</Show>
</div>
</div>
</Show>
</div>
);
}

View File

@@ -0,0 +1,87 @@
/**
* MessageNavSidebar - Quick navigation for messages
*
* Shows YOU/ASST labels with hover preview.
*/
import { For, Show, createSignal, type Accessor } from "solid-js";
import type { InstanceMessageStore } from "@/stores/message-v2/instance-store";
interface MessageNavSidebarProps {
messageIds: Accessor<string[]>;
store: () => InstanceMessageStore;
scrollContainer: HTMLDivElement | undefined;
onTabClick: (messageId: string) => void;
}
export function MessageNavSidebar(props: MessageNavSidebarProps) {
return (
<div class="w-14 shrink-0 bg-zinc-900/40 border-l border-white/5 overflow-hidden py-2 px-1.5 flex flex-col items-center gap-1">
<For each={props.messageIds()}>
{(messageId, index) => {
const [showPreview, setShowPreview] = createSignal(false);
const msg = () => props.store().getMessage(messageId);
const isUser = () => msg()?.role === "user";
// Get message preview text (first 150 chars)
const previewText = () => {
const message = msg();
if (!message) return "";
// Try to get text from parts
const parts = message.parts || {};
let text = "";
for (const partId of Object.keys(parts)) {
const partRecord = parts[partId];
if (partRecord?.data?.type === "text") {
text = (partRecord.data as any).text || "";
break;
}
}
// Fallback to direct content
if (!text && (message as any).content) {
text = (message as any).content;
}
return text.length > 150 ? text.substring(0, 150) + "..." : text;
};
return (
<div class="relative group">
<button
onClick={() => props.onTabClick(messageId)}
onMouseEnter={() => setShowPreview(true)}
onMouseLeave={() => setShowPreview(false)}
class={`w-10 py-1.5 rounded text-[8px] font-black uppercase transition-all cursor-pointer ${isUser()
? "bg-indigo-500/20 border border-indigo-500/40 text-indigo-400 hover:bg-indigo-500/40 hover:scale-105"
: "bg-emerald-500/20 border border-emerald-500/40 text-emerald-400 hover:bg-emerald-500/40 hover:scale-105"
}`}
>
{isUser() ? "YOU" : "ASST"}
</button>
{/* Hover Preview Tooltip */}
<Show when={showPreview()}>
<div class="absolute right-full mr-2 top-0 w-72 max-h-40 overflow-y-auto bg-zinc-900 border border-white/10 rounded-lg shadow-xl p-3 z-50 animate-in fade-in slide-in-from-right-2 duration-150 custom-scrollbar">
<div class="flex items-center justify-between mb-2">
<div class={`text-[9px] font-bold uppercase ${isUser() ? "text-indigo-400" : "text-emerald-400"}`}>
{isUser() ? "You" : "Assistant"} Msg {index() + 1}
</div>
<div class="text-[8px] text-zinc-600">
{msg()?.status === "streaming" ? "• Streaming" : ""}
</div>
</div>
<p class="text-[10px] text-zinc-300 leading-relaxed whitespace-pre-wrap">
{previewText()}
</p>
</div>
</Show>
</div>
);
}}
</For>
</div>
);
}

View File

@@ -0,0 +1,89 @@
/**
* PipelineView - Task Dashboard
*
* Shows all active tasks as cards when no task is selected.
*/
import { For, Show, type Accessor } from "solid-js";
import { Plus, ChevronRight, X } from "lucide-solid";
import type { Task } from "@/types/session";
interface PipelineViewProps {
visibleTasks: Accessor<Task[]>;
onTaskClick: (taskId: string) => void;
onArchiveTask: (taskId: string) => void;
}
export function PipelineView(props: PipelineViewProps) {
return (
<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">
{props.visibleTasks().length}
</span>
</div>
<div class="grid gap-3">
<Show when={props.visibleTasks().length === 0}>
<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>
</Show>
<For each={props.visibleTasks()}>
{(task) => (
<button
onClick={() => props.onTaskClick(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] ${task.title.toLowerCase().includes("smart fix") ? "smart-fix-highlight" : ""}`}
>
<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>
<div class="flex items-center space-x-2">
<span
role="button"
tabindex={0}
onClick={(event) => {
event.stopPropagation();
props.onArchiveTask(task.id);
}}
class="text-zinc-600 hover:text-zinc-200 transition-colors"
title="Archive task"
>
<X size={14} />
</span>
<ChevronRight size={16} class="text-zinc-700 group-hover:text-indigo-400 group-hover:translate-x-1 transition-all" />
</div>
</button>
)}
</For>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,155 @@
/**
* PromptEnhancer - Clavix-inspired prompt optimization
*
* Source: https://github.com/ClavixDev/Clavix.git
*
* Takes a user's raw input and refines it into a precise,
* context-aware, actionable prompt using the session's configured model.
*/
import { getLogger } from "@/lib/logger";
import { sessions } from "@/stores/session-state";
const log = getLogger("prompt-enhancer");
// The meta-prompt based on Clavix CLEAR framework
const ENHANCEMENT_PROMPT = `You are an ELITE Software Architect and Prompt Engineer, powered by the "ThoughtBox" reasoning engine.
YOUR MISSION:
Transform the user's raw input into a "God-Tier" System Prompt—a comprehensive, execution-ready technical specification that a senior engineer could implement without further questions.
TARGET OUTPUT:
- Detailed, file-level architectural blueprint
- Explicit coding standards (TypeScript/solid-js/tailwindcss context implied)
- Comprehensive error handling and edge case strategy
- Step-by-step implementation plan
METHODOLOGY (ThoughtBox):
1. **Decode Intent**: What is the root problem? What is the *value*?
2. **Context Inference**: Assume a high-performance TypeScript/React/Electron environment. Infer necessary imports, stores, and services.
3. **Architectural Strategy**: Define the component hierarchy, state management (signals/stores), and side effects.
4. **Specification Generation**: Write the actual prompt.
OUTPUT FORMAT:
Return ONLY the enhanced prompt string, formatted as follows:
# 🎯 OBJECTIVE
[Concise, high-level goal]
# 🏗️ ARCHITECTURE & DESIGN
- **Files**: List exact file paths to touch/create.
- **Components**: Define props, state, and interfaces.
- **Data Flow**: Explain signal/store interactions.
# 🛡️ RESTRICTIONS & STANDARDS
- **Tech Stack**: TypeScript, SolidJS, TailwindCSS, Lucide Icons.
- **Rules**: NO placeholders, NO "todo", Strict Types, Accessibility-first.
- **Context**: [Infer from input, e.g., "Use ContextEngine for retrieval"]
# 📝 IMPLEMENTATION PLAN
1. [Step 1: Description]
2. [Step 2: Description]
...
# 💡 ORIGINAL REQUEST
"""
{INPUT}
"""
`;
/**
* Get the model configured for a session
*/
function getSessionModel(instanceId: string, sessionId: string): string {
try {
const instanceSessions = sessions().get(instanceId);
const session = instanceSessions?.get(sessionId);
if (session?.model?.modelId) {
return session.model.modelId;
}
} catch (e) {
log.warn("Could not get session model", e);
}
return "minimax-m1"; // Fallback
}
/**
* Enhance a user's prompt using the session's AI model
*/
export async function enhancePrompt(
userInput: string,
instanceId: string,
sessionId?: string
): Promise<string> {
if (!userInput.trim()) {
return userInput;
}
// Get the model from the session
const model = sessionId ? getSessionModel(instanceId, sessionId) : "minimax-m1";
log.info("Enhancing prompt...", { length: userInput.length, model });
try {
// Call the Ollama API for enhancement using the session's model
const response = await fetch("/api/ollama/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
model,
messages: [
{
role: "user",
content: ENHANCEMENT_PROMPT.replace("{INPUT}", userInput)
}
],
stream: false
})
});
if (!response.ok) {
log.warn("Enhancement API failed, returning original", { status: response.status });
return userInput;
}
const data = await response.json();
const enhanced = data?.message?.content || data?.choices?.[0]?.message?.content;
if (!enhanced || enhanced.trim().length === 0) {
log.warn("Enhancement returned empty, using original");
return userInput;
}
log.info("Prompt enhanced successfully", {
originalLength: userInput.length,
enhancedLength: enhanced.length,
model
});
return enhanced.trim();
} catch (error) {
log.error("Prompt enhancement failed", error);
return userInput;
}
}
/**
* Get a quick suggestion for improving a prompt (synchronous hint)
*/
export function getQuickTips(userInput: string): string[] {
const tips: string[] = [];
if (userInput.length < 20) {
tips.push("Add more context for better results");
}
if (!userInput.includes("file") && !userInput.includes("function") && !userInput.includes("component")) {
tips.push("Mention specific files or functions if applicable");
}
if (!userInput.match(/\b(create|fix|update|add|remove|refactor)\b/i)) {
tips.push("Start with an action verb: create, fix, update, etc.");
}
return tips;
}

View File

@@ -0,0 +1,849 @@
/**
* 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 { 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);
// 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
}
// 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) {
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>());
});
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);
});
});
// ============================================================================
// ACTIONS
// ============================================================================
const scrollToBottom = () => {
if (scrollContainer && !userScrolling()) {
scrollContainer.scrollTop = scrollContainer.scrollHeight;
}
};
const setSelectedTaskId = (id: string | null) => {
// Update local state immediately (fast)
setSelectedTaskIdLocal(id);
// 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
setSelectedTaskIdLocal(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);
setSelectedTaskIdLocal(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>
);
}

View File

@@ -0,0 +1,100 @@
import { createSignal, onMount, onCleanup, Show } from "solid-js"
// Simple debug log storage (no reactive overhead)
export function addDebugLog(message: string, level: "info" | "warn" | "error" = "info") {
// Disabled - no-op for performance
}
// HARD STOP function - forces page reload
function hardStop() {
console.warn("HARD STOP triggered - reloading page")
window.location.reload()
}
// Force reset function import placeholder
let forceResetFn: (() => void) | null = null
export function setForceResetFn(fn: () => void) {
forceResetFn = fn
}
export function DebugOverlay() {
const [visible, setVisible] = createSignal(false)
// Toggle with Ctrl+Shift+D
onMount(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.ctrlKey && e.shiftKey && e.key === "D") {
setVisible((v) => !v)
}
}
window.addEventListener("keydown", handleKeyDown)
onCleanup(() => window.removeEventListener("keydown", handleKeyDown))
})
return (
<Show when={visible()}>
<div
style={{
position: "fixed",
top: "10px",
right: "10px",
"z-index": "99999",
background: "rgba(0,0,0,0.9)",
color: "#fff",
padding: "12px",
"border-radius": "8px",
"font-family": "monospace",
"font-size": "11px",
"min-width": "200px",
border: "1px solid #333",
"pointer-events": "auto",
}}
>
<div style={{ "margin-bottom": "8px", "font-weight": "bold" }}>
DEBUG PANEL (Ctrl+Shift+D to toggle)
</div>
<div style={{ display: "flex", gap: "8px" }}>
<button
onClick={() => {
if (forceResetFn) forceResetFn()
}}
style={{
background: "#f59e0b",
color: "#000",
border: "none",
padding: "6px 12px",
"border-radius": "4px",
cursor: "pointer",
"font-weight": "bold",
"font-size": "10px",
}}
>
RESET UI
</button>
<button
onClick={hardStop}
style={{
background: "#ef4444",
color: "#fff",
border: "none",
padding: "6px 12px",
"border-radius": "4px",
cursor: "pointer",
"font-weight": "bold",
"font-size": "10px",
}}
>
HARD RELOAD
</button>
</div>
<div style={{ "margin-top": "8px", "font-size": "9px", color: "#888" }}>
If stuck: Click HARD RELOAD or press F5
</div>
</div>
</Show>
)
}

View File

@@ -295,7 +295,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
<img src={nomadArchLogo} alt="NomadArch logo" class="h-32 w-auto sm:h-48" loading="lazy" />
</div>
<h1 class="mb-2 text-3xl font-semibold text-primary">NomadArch</h1>
<p class="text-xs text-muted mb-1">Forked from OpenCode</p>
<p class="text-xs text-muted mb-1">An enhanced fork of CodeNomad</p>
<Show when={activeUser()}>
{(user) => (
<p class="text-xs text-muted mb-1">

View File

@@ -10,6 +10,7 @@ import {
type Accessor,
type Component,
} from "solid-js"
import toast from "solid-toast"
import type { ToolState } from "@opencode-ai/sdk"
import { Accordion } from "@kobalte/core"
import { ChevronDown } from "lucide-solid"
@@ -36,8 +37,11 @@ import {
sessions,
setActiveSession,
executeCustomCommand,
sendMessage,
runShellCommand,
} from "../../stores/sessions"
import { compactSession } from "../../stores/session-actions";
import { addTask, setActiveTask } from "../../stores/task-actions"
import { keyboardRegistry, type KeyboardShortcut } from "../../lib/keyboard-registry"
import { messageStoreBus } from "../../stores/message-v2/bus"
import { clearSessionRenderCache } from "../message-block"
@@ -54,14 +58,15 @@ import ModelSelector from "../model-selector"
import ModelStatusSelector from "../model-status-selector"
import CommandPalette from "../command-palette"
import Kbd from "../kbd"
import MultiTaskChat from "../chat/multi-task-chat"
// Using rebuilt MultiX v2 with polling architecture (no freeze)
import MultiTaskChat from "../chat/multix-v2"
import { TodoListView } from "../tool-call/renderers/todo"
import ContextUsagePanel from "../session/context-usage-panel"
import SessionView from "../session/session-view"
import { Sidebar, type FileNode } from "./sidebar"
import { Editor } from "./editor"
import { serverApi } from "../../lib/api-client"
import { Sparkles, Layout as LayoutIcon, Terminal as TerminalIcon, Search, Loader2, Zap, Shield, Settings } from "lucide-solid"
import { Sparkles, Layout as LayoutIcon, Terminal as TerminalIcon, Search, Loader2, Zap, Shield, Settings, FileArchive } from "lucide-solid"
import { formatTokenTotal } from "../../lib/formatters"
import { sseManager } from "../../lib/sse-manager"
import { getLogger } from "../../lib/logger"
@@ -159,18 +164,32 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
const [selectedBinary, setSelectedBinary] = createSignal("opencode")
// Handler to load file content when selected
createEffect(() => {
if (typeof window !== "undefined") {
(window as any).ACTIVE_INSTANCE_ID = props.instance.id;
}
});
const handleFileSelect = async (file: FileNode) => {
try {
const response = await serverApi.readWorkspaceFile(props.instance.id, file.path)
const language = file.name.split('.').pop() || 'text'
setCurrentFile({
const updatedFile = {
...file,
content: response.contents,
language,
})
}
setCurrentFile(updatedFile)
// If it's a previewable file, update the preview URL
if (file.name.endsWith('.html') || file.name.endsWith('.htm')) {
const origin = typeof window !== "undefined" ? window.location.origin : "http://localhost:3000"
const apiOrigin = origin.replace(":3000", ":9898")
const url = `${apiOrigin}/api/workspaces/${props.instance.id}/serve/${file.path}`
setPreviewUrl(url)
}
} catch (error) {
log.error('Failed to read file content', error)
// Still show the file but without content
setCurrentFile(file)
}
}
@@ -292,21 +311,55 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
if (typeof window === "undefined") return
const handler = async (event: Event) => {
const detail = (event as CustomEvent<{ url?: string; instanceId?: string }>).detail
console.log(`[InstanceShell2] Received BUILD_PREVIEW_EVENT`, {
detail,
currentInstanceId: props.instance.id,
match: detail?.instanceId === props.instance.id
});
if (!detail || detail.instanceId !== props.instance.id || !detail.url) return
setPreviewUrl(detail.url)
const confirmed = await showConfirmDialog(`Preview available at ${detail.url}. Open now?`, {
title: "Preview ready",
confirmLabel: "Open preview",
cancelLabel: "Later",
// Auto-switch to preview mode for new AI content
setCenterTab("preview")
toast.success("Preview updated", {
icon: '🚀',
duration: 3000,
position: 'bottom-center'
})
if (confirmed) {
setCenterTab("preview")
}
}
window.addEventListener(BUILD_PREVIEW_EVENT, handler)
onCleanup(() => window.removeEventListener(BUILD_PREVIEW_EVENT, handler))
})
onMount(() => {
if (typeof window === "undefined") return
const handler = async (event: Event) => {
const detail = (event as CustomEvent<{ code: string; fileName: string | null; instanceId: string }>).detail
if (!detail || detail.instanceId !== props.instance.id) return
if (detail.fileName) {
const origin = window.location.origin
const apiOrigin = origin.includes(":3000") ? origin.replace(":3000", ":9898") : origin
const url = `${apiOrigin}/api/workspaces/${props.instance.id}/serve/${detail.fileName}`
setPreviewUrl(url)
} else {
const blob = new Blob([detail.code], { type: 'text/html' })
const url = URL.createObjectURL(blob)
setPreviewUrl(url)
}
setCenterTab("preview")
toast.success("Previewing code block", {
icon: '🔍',
duration: 2000,
position: 'bottom-center'
})
}
window.addEventListener("MANUAL_PREVIEW_EVENT", handler)
onCleanup(() => window.removeEventListener("MANUAL_PREVIEW_EVENT", handler))
})
createEffect(() => {
if (typeof window === "undefined") return
window.localStorage.setItem(LEFT_DRAWER_STORAGE_KEY, sessionSidebarWidth().toString())
@@ -402,23 +455,90 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
showCommandPalette(props.instance.id)
}
/* Compact Logic */
const [isCompacting, setIsCompacting] = createSignal(false);
const handleCompact = async () => {
const sessionId = activeSessionIdForInstance();
if (!sessionId || sessionId === "info" || isCompacting()) return;
setIsCompacting(true);
const toastId = toast.loading("Compacting...", { icon: <FileArchive class="animate-pulse text-indigo-400" /> });
try {
await compactSession(props.instance.id, sessionId);
toast.success("Session compacted!", { id: toastId });
} catch (e) {
toast.error("Failed to compact", { id: toastId });
} finally {
setIsCompacting(false);
}
}
const [isFixing, setIsFixing] = createSignal(false)
const [isBuilding, setIsBuilding] = createSignal(false)
const handleSmartFix = async () => {
const sessionId = activeSessionIdForInstance()
if (!sessionId || sessionId === "info" || isFixing()) {
const parentSessionId = activeSessionIdForInstance()
if (!parentSessionId || parentSessionId === "info" || isFixing()) {
return
}
setIsFixing(true)
const toastId = toast.loading("Smart Fix: Creating analysis task...", {
icon: <Sparkles class="text-indigo-400 animate-spin" />
});
try {
// Smart Fix targets the active task if available, otherwise general fix
const session = activeSessionForInstance()
const activeTaskId = session?.activeTaskId
const args = activeTaskId ? `task:${activeTaskId}` : ""
// ALWAYS create a dedicated "Smart Fix" task in the MultiX pipeline
// This ensures the analysis and fixes appear in their own tab
const timestamp = new Date().toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' })
const taskResult = await addTask(
props.instance.id,
parentSessionId,
`🔧 Smart Fix ${timestamp}`
)
await executeCustomCommand(props.instance.id, sessionId, "fix", args)
const targetSessionId = taskResult.taskSessionId || parentSessionId
const taskId = taskResult.id
// Set this as the active task so the user sees it immediately
setActiveTask(props.instance.id, parentSessionId, taskId)
toast.loading("Analyzing project...", { id: toastId });
// Use sendMessage to force visible feedback in the chat stream
// Prompt enforces: Report → Plan → Approval → Execute workflow
const smartFixPrompt = `**Smart Fix Analysis Request**
Please analyze this project for errors, bugs, warnings, or potential improvements.
**Your response MUST follow this exact format:**
1. **ANALYSIS RESULTS:**
- If NO errors/issues found: Clearly state "✅ No errors or issues detected in the project."
- If errors/issues ARE found: List each issue with file path and line number if applicable.
2. **FIX PLAN (only if issues found):**
For each issue, outline:
- What the problem is
- How you will fix it
- Which files will be modified
3. **AWAIT APPROVAL:**
After presenting the plan, explicitly ask: "Do you approve this fix plan? Reply 'yes' to proceed, or provide feedback for adjustments."
4. **EXECUTION (only after I say 'yes'):**
Only apply fixes after receiving explicit approval. Use write_file tool to make changes.
Now analyze the project and report your findings.`
await sendMessage(
props.instance.id,
targetSessionId,
smartFixPrompt,
[],
taskId
)
toast.success("Smart Fix task created. Check the pipeline.", { id: toastId, duration: 3000 });
// Auto-open right panel to show agent progress if it's not open
if (!rightOpen()) {
@@ -427,6 +547,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
}
} catch (error) {
log.error("Failed to run Smart Fix command", error)
toast.error("Smart Fix failed to start", { id: toastId });
} finally {
setTimeout(() => setIsFixing(false), 2000) // Reset after delay
}
@@ -1180,7 +1301,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
const sessionLayout = (
<div
class="session-shell-panels flex flex-col flex-1 min-h-0 overflow-x-hidden relative bg-[#050505]"
class="session-shell-panels flex flex-col flex-1 min-h-0 w-full overflow-hidden relative bg-[#050505]"
ref={(element) => {
setDrawerHost(element)
measureDrawerHost()
@@ -1190,8 +1311,8 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
<div class="absolute top-[-10%] left-[-10%] w-[40%] h-[40%] bg-blue-600/10 blur-[120px] rounded-full pointer-events-none z-0" />
<div class="absolute bottom-[-10%] right-[-10%] w-[30%] h-[30%] bg-purple-600/5 blur-[100px] rounded-full pointer-events-none z-0" />
<AppBar position="sticky" color="default" elevation={0} class="border-b border-white/5 bg-[#050505]/80 backdrop-blur-md z-20">
<Toolbar variant="dense" class="session-toolbar flex flex-wrap items-center justify-between gap-2 py-0 min-h-[40px]">
<AppBar position="sticky" color="default" elevation={0} class="border-b border-white/5 bg-[#050505]/80 backdrop-blur-md z-20 shrink-0">
<Toolbar variant="dense" class="session-toolbar flex items-center justify-between gap-2 py-0 min-h-[48px]">
<div class="flex items-center space-x-4">
<IconButton
ref={setLeftToggleButtonEl}
@@ -1221,6 +1342,19 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
<div class="flex items-center space-x-4">
<Show when={activeSessionIdForInstance() && activeSessionIdForInstance() !== "info"}>
<div class="flex items-center space-x-2">
{/* Compact Button */}
<button
onClick={handleCompact}
disabled={isCompacting()}
class="flex items-center gap-1.5 px-2.5 py-1 text-[11px] font-semibold text-cyan-400 bg-cyan-500/10 border border-cyan-500/20 hover:bg-cyan-500/20 hover:border-cyan-500/40 transition-all rounded-full"
title="Compact Context: Summarize conversation to save tokens"
>
<Show when={isCompacting()} fallback={<FileArchive size={14} strokeWidth={2} />}>
<Loader2 size={14} class="animate-spin" />
</Show>
<span>Compact</span>
</button>
<ModelStatusSelector
instanceId={props.instance.id}
sessionId={activeSessionIdForInstance()!}
@@ -1246,14 +1380,10 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
onClick={handleSmartFix}
disabled={isFixing()}
title="Smart Fix: Automatically detect and fix issues in your code"
class={`transition-all flex items-center space-x-1.5 px-2 py-1 rounded-full hover:bg-white/10 ${isFixing() ? "text-blue-500" : "text-zinc-400 hover:text-white"}`}
class={`transition-all flex items-center space-x-1.5 px-3 py-1 rounded-full text-[10px] font-bold uppercase tracking-tight ${isFixing() ? "text-blue-500 smart-fix-highlight bg-blue-500/10" : "text-zinc-400 hover:text-white hover:bg-white/5"}`}
>
<Show when={isFixing()} fallback={<Sparkles size={14} class="text-blue-400" />}>
<Loader2 size={14} class="animate-spin text-blue-400" />
</Show>
<span class="text-[10px] font-bold uppercase tracking-tight">
{isFixing() ? "FIXING..." : "SMART FIX"}
</span>
<Zap size={12} class={isFixing() ? "animate-bounce" : ""} />
<span>Fix</span>
</button>
<div class="w-px h-3 bg-white/10" />
<button
@@ -1303,11 +1433,11 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
setRightOpen(newState)
setIsSoloOpen(newState)
}}
class={`flex items-center space-x-1.5 px-3 py-1 rounded-full text-[11px] font-bold transition-all ${(rightOpen() && isSoloOpen()) ? 'bg-blue-600/20 text-blue-400 border border-blue-500/30' : 'bg-white/5 text-zinc-400 border border-white/5'
class={`flex items-center space-x-1.5 px-3 py-1 rounded-full text-[10px] font-bold uppercase tracking-tight transition-all ${(rightOpen() && isSoloOpen()) ? 'bg-blue-600/20 text-blue-400 border border-blue-500/30' : 'bg-white/5 text-zinc-400 border border-white/5'
}`}
>
<span class={`w-1.5 h-1.5 bg-current rounded-full ${(rightOpen() && isSoloOpen()) ? 'animate-pulse' : ''}`} />
<span>MULTIX MODE</span>
<LayoutIcon size={12} />
<span>MultiX</span>
</button>
<IconButton
ref={setRightToggleButtonEl}
@@ -1323,146 +1453,67 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
</Toolbar>
</AppBar>
<Box sx={{ display: "flex", flex: 1, minHeight: 0, overflowX: "hidden", position: "relative", zIndex: 10 }}>
<Box sx={{ display: "flex", flex: 1, minHeight: 0, width: "100%", overflow: "hidden", position: "relative", zIndex: 10 }}>
{renderLeftPanel()}
<Box
component="main"
sx={{ flexGrow: 1, minHeight: 0, display: "flex", flexDirection: "column", overflowX: "hidden" }}
class="content-area relative"
component="div"
sx={{ flexGrow: 1, minHeight: 0, display: "flex", flexDirection: "column", overflow: "hidden" }}
class="content-area relative bg-[#050505]"
>
<div class="flex-1 flex overflow-hidden min-h-0">
<Show when={!isPhoneLayout()}>
<div class="flex-1 flex flex-col min-h-0 bg-[#0d0d0d]">
<div class="h-10 glass border-b border-white/5 flex items-center justify-between px-4 shrink-0">
<div class="flex items-center gap-2">
<button
type="button"
class={`px-2.5 py-1 rounded-md text-[11px] font-semibold uppercase tracking-wide border ${centerTab() === "code"
? "bg-white/10 border-white/20 text-white"
: "border-transparent text-zinc-400 hover:text-zinc-200 hover:bg-white/5"
}`}
onClick={() => setCenterTab("code")}
>
Code
</button>
<button
type="button"
class={`px-2.5 py-1 rounded-md text-[11px] font-semibold uppercase tracking-wide border ${centerTab() === "preview"
? "bg-white/10 border-white/20 text-white"
: "border-transparent text-zinc-400 hover:text-zinc-200 hover:bg-white/5"
}`}
onClick={() => setCenterTab("preview")}
disabled={!previewUrl()}
title={previewUrl() || "Run build to enable preview"}
>
Preview
</button>
</div>
<Show when={previewUrl()}>
{(url) => (
<div class="text-[10px] text-zinc-500 truncate max-w-[50%]" title={url()}>
{url()}
</div>
)}
</Show>
</div>
<Show when={centerTab() === "preview"} fallback={<Editor file={currentFile()} />}>
<Show
when={previewUrl()}
fallback={
<div class="flex-1 flex items-center justify-center text-zinc-500">
<div class="text-center">
<p>No preview available yet.</p>
<p class="text-sm mt-2 opacity-60">Run build to detect a preview URL.</p>
</div>
</div>
}
{/* Main workspace area */}
<div class="flex-1 flex flex-row min-h-0 w-full overflow-hidden">
{/* Center Area (Editor/Preview) */}
<div class="flex-1 flex flex-col min-h-0 bg-[#0d0d0d] overflow-hidden">
<div class="flex items-center justify-between px-4 py-2 border-b border-white/5 bg-[#111112]">
<div class="flex items-center space-x-4">
<button
onClick={() => setCenterTab("code")}
class={`px-2.5 py-1 rounded-md text-[11px] font-semibold uppercase tracking-wide border ${centerTab() === "code"
? "bg-white/10 border-white/20 text-white"
: "border-transparent text-zinc-400 hover:text-zinc-200 hover:bg-white/5"
}`}
>
{(url) => (
<iframe
class="flex-1 w-full h-full border-none bg-black"
src={url()}
title="App Preview"
sandbox="allow-scripts allow-same-origin allow-forms allow-pointer-lock allow-popups"
/>
)}
</Show>
Code
</button>
<button
onClick={() => setCenterTab("preview")}
class={`px-2.5 py-1 rounded-md text-[11px] font-semibold uppercase tracking-wide border ${centerTab() === "preview"
? "bg-white/10 border-white/20 text-white"
: "border-transparent text-zinc-400 hover:text-zinc-200 hover:bg-white/5"
}`}
>
Preview
</button>
</div>
<Show when={previewUrl()}>
{(url) => (
<div class="text-[10px] text-zinc-500 truncate max-w-[50%]" title={url()}>
{url()}
</div>
)}
</Show>
</div>
</Show>
<div
class="flex flex-col relative border-l border-white/5 min-h-0 overflow-hidden min-w-0"
style={{
width: isPhoneLayout() ? "100%" : `${chatPanelWidth()}px`,
"flex-shrink": isPhoneLayout() ? 1 : 0,
}}
>
<div
class="absolute -left-1 top-0 bottom-0 w-2 cursor-col-resize z-20 hover:bg-white/5 active:bg-white/10 transition-colors"
onMouseDown={handleResizeMouseDown("chat")}
/>
<Show when={isSoloOpen()}>
<div class="flex-1 flex flex-col min-h-0 relative">
<MultiTaskChat instanceId={props.instance.id} sessionId={activeSessionIdForInstance() || ""} />
<Show when={centerTab() === "preview"} fallback={<Editor file={currentFile()} />}>
<div class="flex-1 min-h-0 bg-white">
<iframe
src={previewUrl() || "about:blank"}
class="w-full h-full border-none"
title="Preview"
/>
</div>
</Show>
<div class="flex-1 flex flex-col relative min-h-0"
style={{ display: isSoloOpen() ? "none" : "flex" }}>
<Show
when={showingInfoView()}
fallback={
<Show
when={cachedSessionIds().length > 0 && activeSessionIdForInstance()}
fallback={
<div class="flex items-center justify-center h-full">
<div class="text-center text-zinc-500">
<p class="mb-2">No session selected</p>
<p class="text-sm">Select a session to view messages</p>
</div>
</div>
}
>
<For each={cachedSessionIds()}>
{(sessionId) => {
const isActive = () => activeSessionIdForInstance() === sessionId
return (
<div
class="session-cache-pane flex flex-col flex-1 min-h-0"
style={{ display: isActive() ? "flex" : "none" }}
data-session-id={sessionId}
aria-hidden={!isActive()}
>
<SessionView
sessionId={sessionId}
activeSessions={activeSessions()}
instanceId={props.instance.id}
instanceFolder={props.instance.folder}
escapeInDebounce={props.escapeInDebounce}
showSidebarToggle={showEmbeddedSidebarToggle()}
onSidebarToggle={() => setLeftOpen(true)}
forceCompactStatusLayout={showEmbeddedSidebarToggle()}
isActive={isActive()}
/>
</div>
)
}}
</For>
</Show>
}
>
<div class="info-view-pane flex flex-col flex-1 min-h-0 overflow-y-auto">
<InfoView instanceId={props.instance.id} />
</div>
</Show>
</div>
</div>
{/* Right Panel (MultiX Chat) */}
<Show when={rightOpen() && isSoloOpen()}>
<div class="flex flex-col relative border-l border-white/5 min-h-0 overflow-hidden" style={{ width: `${chatPanelWidth()}px`, "flex-shrink": 0 }}>
<MultiTaskChat instanceId={props.instance.id} sessionId={activeSessionIdForInstance()!} />
</div>
</Show>
</div>
{/* Bottom Toolbar/Terminal Area */}
{/* Bottom Toolbar/Terminal Area */}
<div
class="flex flex-col border-t border-white/5 relative bg-[#09090b] z-10 shrink-0 overflow-hidden"
style={{
@@ -1502,23 +1553,12 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
<span class="w-1.5 h-1.5 rounded-full bg-green-500 shadow-[0_0_5px_rgba(34,197,94,0.5)]" />
<span>Sync Active</span>
</div>
<Show when={activeSessionForInstance()}>
{(session) => (
<>
<span class="hover:text-zinc-300 cursor-pointer">{session().model.modelId}</span>
<span class="hover:text-zinc-300 cursor-pointer">{session().agent}</span>
</>
)}
</Show>
</div>
</footer>
</div>
</Box>
{renderRightPanel()}
</Box>
{/* Floating Action Buttons removed - Integrated into Header */}
</div>
)

View File

@@ -55,9 +55,9 @@ const getFileIcon = (fileName: string) => {
return <FileCode size={16} class="text-blue-300" />
}
const FileTree: Component<{
node: FileNode;
depth: number;
const FileTree: Component<{
node: FileNode;
depth: number;
onSelect: (f: FileNode) => void;
instanceId: string;
}> = (props) => {
@@ -69,7 +69,7 @@ const FileTree: Component<{
if (props.node.type === "directory") {
const nextOpen = !isOpen()
setIsOpen(nextOpen)
if (nextOpen && children().length === 0) {
setIsLoading(true)
try {
@@ -173,6 +173,11 @@ export const Sidebar: Component<SidebarProps> = (props) => {
if (typeof window === "undefined") return
const handler = (event: Event) => {
const detail = (event as CustomEvent<{ instanceId?: string }>).detail
console.log(`[Sidebar] Received FILE_CHANGE_EVENT`, {
detail,
currentInstanceId: props.instanceId,
match: detail?.instanceId === props.instanceId
});
if (!detail || detail.instanceId !== props.instanceId) return
void refreshRootFiles()
}
@@ -316,18 +321,18 @@ export const Sidebar: Component<SidebarProps> = (props) => {
</For>
</Show>
<Show when={activeTab() === "sessions"}>
<div class="flex flex-col gap-1">
<For each={props.sessions}>
{(session) => (
<div
onClick={() => props.onSessionSelect(session.id)}
class={`px-3 py-1.5 rounded cursor-pointer text-sm transition-colors ${props.activeSessionId === session.id ? 'bg-blue-600/20 text-blue-400 border border-blue-500/20' : 'text-zinc-400 hover:bg-white/5'}`}
>
{session.title || session.id.slice(0, 8)}
</div>
)}
</For>
</div>
<div class="flex flex-col gap-1">
<For each={props.sessions}>
{(session) => (
<div
onClick={() => props.onSessionSelect(session.id)}
class={`px-3 py-1.5 rounded cursor-pointer text-sm transition-colors ${props.activeSessionId === session.id ? 'bg-blue-600/20 text-blue-400 border border-blue-500/20' : 'text-zinc-400 hover:bg-white/5'}`}
>
{session.title || session.id.slice(0, 8)}
</div>
)}
</For>
</div>
</Show>
<Show when={activeTab() === "search"}>
<div class="flex flex-col gap-3">
@@ -473,11 +478,10 @@ export const Sidebar: Component<SidebarProps> = (props) => {
<button
type="button"
onClick={() => toggleSkillSelection(skill.id)}
class={`w-full text-left px-3 py-2 rounded-md border transition-colors ${
isSelected()
class={`w-full text-left px-3 py-2 rounded-md border transition-colors ${isSelected()
? "border-blue-500/60 bg-blue-500/10 text-blue-200"
: "border-white/10 bg-white/5 text-zinc-300 hover:text-white"
}`}
}`}
>
<div class="text-xs font-semibold">{skill.name}</div>
<Show when={skill.description}>

View File

@@ -1,4 +1,5 @@
import { createEffect, createSignal, onMount, onCleanup } from "solid-js"
import { addDebugLog } from "./debug-overlay"
import { renderMarkdown, onLanguagesLoaded, initMarkdown, decodeHtmlEntities } from "../lib/markdown"
import type { TextPart, RenderCache } from "../types/message"
import { getLogger } from "../lib/logger"
@@ -16,6 +17,7 @@ interface MarkdownProps {
size?: "base" | "sm" | "tight"
disableHighlight?: boolean
onRendered?: () => void
instanceId: string
}
export function Markdown(props: MarkdownProps) {
@@ -27,7 +29,7 @@ export function Markdown(props: MarkdownProps) {
Promise.resolve().then(() => props.onRendered?.())
}
createEffect(async () => {
createEffect(() => {
const part = props.part
const rawText = typeof part.text === "string" ? part.text : ""
const text = decodeHtmlEntities(rawText)
@@ -39,6 +41,7 @@ export function Markdown(props: MarkdownProps) {
latestRequestedText = text
// 1. Check Synchronous Local Cache
const localCache = part.renderCache
if (localCache && localCache.text === text && localCache.theme === themeKey) {
setHtml(localCache.html)
@@ -46,6 +49,7 @@ export function Markdown(props: MarkdownProps) {
return
}
// 2. Check Global Cache
const globalCache = markdownRenderCache.get(cacheKey)
if (globalCache && globalCache.text === text) {
setHtml(globalCache.html)
@@ -54,11 +58,13 @@ export function Markdown(props: MarkdownProps) {
return
}
if (!highlightEnabled) {
part.renderCache = undefined
// 3. Throttle/Debounce Rendering for new content
// We delay the expensive async render to avoid choking the main thread during rapid streaming
const performRender = async () => {
if (latestRequestedText !== text) return // Stale
try {
const rendered = await renderMarkdown(text, { suppressHighlight: true })
const rendered = await renderMarkdown(text, { suppressHighlight: !highlightEnabled })
if (latestRequestedText === text) {
const cacheEntry: RenderCache = { text, html: rendered, theme: themeKey }
@@ -70,36 +76,18 @@ export function Markdown(props: MarkdownProps) {
} catch (error) {
log.error("Failed to render markdown:", error)
if (latestRequestedText === text) {
const cacheEntry: RenderCache = { text, html: text, theme: themeKey }
setHtml(text)
part.renderCache = cacheEntry
markdownRenderCache.set(cacheKey, cacheEntry)
notifyRendered()
setHtml(text) // Fallback
}
}
return
}
try {
const rendered = await renderMarkdown(text)
// Heuristic: If text length matches cache length + small amount, it's streaming.
// We can debounce. If it's a huge jump (initial load), render immediately.
// For now, always debounce slightly to unblock main thread.
// Using 200ms (was 50ms) for less frequent but smoother updates
const timerId = setTimeout(performRender, 200)
if (latestRequestedText === text) {
const cacheEntry: RenderCache = { text, html: rendered, theme: themeKey }
setHtml(rendered)
part.renderCache = cacheEntry
markdownRenderCache.set(cacheKey, cacheEntry)
notifyRendered()
}
} catch (error) {
log.error("Failed to render markdown:", error)
if (latestRequestedText === text) {
const cacheEntry: RenderCache = { text, html: text, theme: themeKey }
setHtml(text)
part.renderCache = cacheEntry
markdownRenderCache.set(cacheKey, cacheEntry)
notifyRendered()
}
}
onCleanup(() => clearTimeout(timerId))
})
onMount(() => {
@@ -121,6 +109,31 @@ export function Markdown(props: MarkdownProps) {
}, 2000)
}
}
return
}
const previewButton = target.closest(".code-block-preview") as HTMLButtonElement
if (previewButton) {
e.preventDefault()
const code = previewButton.getAttribute("data-code")
const lang = previewButton.getAttribute("data-lang")
if (code && lang === "html") {
const decodedCode = decodeURIComponent(code)
// Try to find a filename in the text part
const contentText = props.part.text || ""
const fileMatch = contentText.match(/(\w+\.html)/)
const fileName = fileMatch ? fileMatch[1] : null
window.dispatchEvent(new CustomEvent("MANUAL_PREVIEW_EVENT", {
detail: {
code: decodedCode,
fileName: fileName,
instanceId: props.instanceId
}
}))
}
}
}

View File

@@ -3,7 +3,6 @@ import { ChevronDown, ExternalLink, Plus, RefreshCw, Search, Settings } from "lu
import { Component, For, Show, createEffect, createMemo, createSignal } from "solid-js"
import { serverApi } from "../lib/api-client"
import { getLogger } from "../lib/logger"
import InstanceServiceStatus from "./instance-service-status"
import { useOptionalInstanceMetadataContext } from "../lib/contexts/instance-metadata-context"
type McpServerConfig = {
@@ -110,6 +109,9 @@ const McpManager: Component<McpManagerProps> = (props) => {
const [serverName, setServerName] = createSignal("")
const [serverJson, setServerJson] = createSignal("")
const [saving, setSaving] = createSignal(false)
const [connectionStatus, setConnectionStatus] = createSignal<Record<string, { connected: boolean }>>({})
const [toolCount, setToolCount] = createSignal(0)
const [connecting, setConnecting] = createSignal(false)
const metadataContext = useOptionalInstanceMetadataContext()
const metadata = createMemo(() => metadataContext?.metadata?.() ?? null)
@@ -138,6 +140,38 @@ const McpManager: Component<McpManagerProps> = (props) => {
} finally {
setIsLoading(false)
}
// Fetch connection status separately (non-blocking)
loadConnectionStatus().catch(() => { })
}
const loadConnectionStatus = async () => {
try {
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), 5000)
const status = await serverApi.fetchWorkspaceMcpStatus(props.instanceId)
clearTimeout(timeoutId)
setConnectionStatus(status.servers ?? {})
setToolCount(status.toolCount ?? 0)
} catch (err) {
log.warn("Failed to fetch MCP status", err)
// Don't block UI on status failures
}
}
const connectAllMcps = async () => {
if (connecting()) return
setConnecting(true)
setError(null)
try {
const result = await serverApi.connectWorkspaceMcps(props.instanceId)
setConnectionStatus(result.servers ?? {})
setToolCount(result.toolCount ?? 0)
} catch (err) {
log.error("Failed to connect MCPs", err)
setError("Failed to connect MCP servers.")
} finally {
setConnecting(false)
}
}
createEffect(() => {
@@ -204,6 +238,8 @@ const McpManager: Component<McpManagerProps> = (props) => {
nextConfig.mcpServers = mcpServers
setConfig(nextConfig)
await serverApi.updateWorkspaceMcpConfig(props.instanceId, nextConfig)
// Auto-connect after installing
await loadConnectionStatus()
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to install MCP server."
setError(message)
@@ -325,44 +361,56 @@ const McpManager: Component<McpManagerProps> = (props) => {
{(err) => <div class="text-[11px] text-amber-400">{err()}</div>}
</Show>
<Show when={toolCount() > 0}>
<div class="text-[11px] text-green-400 mb-2">
{toolCount()} MCP tools available
</div>
</Show>
<Show
when={!isLoading() && servers().length > 0}
fallback={<div class="text-[11px] text-zinc-500 italic">{isLoading() ? "Loading MCP servers..." : "No MCP servers configured."}</div>}
>
<div class="mcp-server-list">
<For each={servers()}>
{([name, server]) => (
<div class="mcp-server-card">
<div class="mcp-server-row">
<div class="flex flex-col">
<span class="text-xs font-semibold text-zinc-100">{name}</span>
<span class="text-[11px] text-zinc-500 truncate">
{server.command ? `${server.command} ${(server.args ?? []).join(" ")}` : "Custom config"}
</span>
</div>
<div class="flex items-center gap-2">
<Show when={mcpStatus()?.[name]?.status}>
<span class="mcp-status-chip">
{mcpStatus()?.[name]?.status}
{([name, server]) => {
const isConnected = () => connectionStatus()[name]?.connected ?? false
return (
<div class="mcp-server-card">
<div class="mcp-server-row">
<div class="flex flex-col">
<span class="text-xs font-semibold text-zinc-100">{name}</span>
<span class="text-[11px] text-zinc-500 truncate">
{server.command ? `${server.command} ${(server.args ?? []).join(" ")}` : server.url || "Custom config"}
</span>
</Show>
<Show when={mcpStatus()?.[name]?.error}>
<span class="mcp-status-error" title={String(mcpStatus()?.[name]?.error)}>
error
</span>
</Show>
</div>
<div class="flex items-center gap-2">
<Show when={isConnected()}>
<span class="mcp-status-chip" style={{ background: "var(--status-ok, #22c55e)", color: "#fff" }}>
connected
</span>
</Show>
<Show when={!isConnected()}>
<span class="mcp-status-chip" style={{ background: "var(--status-warning, #eab308)", color: "#000" }}>
not connected
</span>
</Show>
</div>
</div>
</div>
</div>
)}
)
}}
</For>
</div>
<button
onClick={connectAllMcps}
disabled={connecting()}
class="mt-2 px-3 py-1.5 text-xs rounded-md bg-blue-500/20 border border-blue-500/40 text-blue-200 hover:text-white disabled:opacity-60 w-full"
>
{connecting() ? "Connecting..." : "Connect All MCPs"}
</button>
</Show>
<div class="mt-3">
<InstanceServiceStatus sections={["mcp"]} />
</div>
<Dialog open={showManual()} onOpenChange={setShowManual} modal>
<Dialog.Portal>
<Dialog.Overlay class="modal-overlay" />

View File

@@ -1,4 +1,5 @@
import { For, Match, Show, Switch, createEffect, createMemo, createSignal } from "solid-js"
import { For, Match, Show, Switch, createEffect, createMemo, createSignal, untrack } from "solid-js"
import { addDebugLog } from "./debug-overlay"
import MessageItem from "./message-item"
import ToolCall from "./tool-call"
import type { InstanceMessageStore } from "../stores/message-v2/instance-store"
@@ -215,14 +216,30 @@ interface MessageBlockProps {
}
export default function MessageBlock(props: MessageBlockProps) {
const record = createMemo(() => props.store().getMessage(props.messageId))
const messageInfo = createMemo(() => props.store().getMessageInfo(props.messageId))
// CRITICAL FIX: Use untrack for store access to prevent cascading updates during streaming
// The component will still re-render when needed via the Index component in MessageBlockList
const record = createMemo(() => {
// Only create reactive dependency on message ID, not content
const id = props.messageId;
return untrack(() => props.store().getMessage(id));
})
const messageInfo = createMemo(() => {
const id = props.messageId;
return untrack(() => props.store().getMessageInfo(id));
})
const sessionCache = getSessionRenderCache(props.instanceId, props.sessionId)
// CRITICAL: Use a throttled revision check to avoid re-computing on every streaming chunk
const [lastProcessedRevision, setLastProcessedRevision] = createSignal(0);
const block = createMemo<MessageDisplayBlock | null>(() => {
const current = record()
if (!current) return null
// OPTIMIZATION: Skip cache during streaming (revision changes too fast)
// Just return a basic block structure that will be updated when streaming completes
const isStreaming = current.status === "streaming" || current.status === "sending";
const index = props.messageIndex
const lastAssistantIdx = props.lastAssistantIndex()
const isQueued = current.role === "user" && (lastAssistantIdx === -1 || index > lastAssistantIdx)
@@ -236,9 +253,11 @@ export default function MessageBlock(props: MessageBlockProps) {
: infoTime.created ?? 0
const infoError = (info as { error?: { name?: string } } | undefined)?.error
const infoErrorName = typeof infoError?.name === "string" ? infoError.name : ""
// Skip revision in cache signature during streaming
const cacheSignature = [
current.id,
current.revision,
isStreaming ? "streaming" : current.revision,
isQueued ? 1 : 0,
props.showThinking() ? 1 : 0,
props.thinkingDefaultExpanded() ? 1 : 0,
@@ -270,25 +289,23 @@ export default function MessageBlock(props: MessageBlockProps) {
current.role === "assistant" &&
!agentMetaAttached &&
pendingParts.some((part) => partHasRenderableText(part))
let cached = sessionCache.messageItems.get(segmentKey)
if (!cached) {
cached = {
type: "content",
key: segmentKey,
record: current,
parts: pendingParts.slice(),
messageInfo: info,
isQueued,
showAgentMeta: shouldShowAgentMeta,
}
sessionCache.messageItems.set(segmentKey, cached)
} else {
cached.record = current
cached.parts = pendingParts.slice()
cached.messageInfo = info
cached.isQueued = isQueued
cached.showAgentMeta = shouldShowAgentMeta
// Always create a fresh object to ensure granular reactivity in <For>
// when we remove 'keyed' from <Show>. If we mutated properties
// on an existing object, <For> would assume identity match and skip updates.
const cached: ContentDisplayItem = {
type: "content",
key: segmentKey,
record: current,
parts: pendingParts.slice(),
messageInfo: info,
isQueued,
showAgentMeta: shouldShowAgentMeta,
}
// Update cache with the new version (for potential stability elsewhere, though less critical now)
sessionCache.messageItems.set(segmentKey, cached)
if (shouldShowAgentMeta) {
agentMetaAttached = true
}
@@ -396,10 +413,10 @@ export default function MessageBlock(props: MessageBlockProps) {
})
return (
<Show when={block()} keyed>
<Show when={block()}>
{(resolvedBlock) => (
<div class="message-stream-block" data-message-id={resolvedBlock.record.id}>
<For each={resolvedBlock.items}>
<div class="message-stream-block" data-message-id={resolvedBlock().record.id}>
<For each={resolvedBlock().items}>
{(item) => (
<Switch>
<Match when={item.type === "content"}>

View File

@@ -14,8 +14,8 @@ interface MessagePartProps {
instanceId: string
sessionId: string
onRendered?: () => void
}
export default function MessagePart(props: MessagePartProps) {
}
export default function MessagePart(props: MessagePartProps) {
const { isDark } = useTheme()
const { preferences } = useConfig()
@@ -80,7 +80,7 @@ interface MessagePartProps {
}
return {
id: part.id,
type: "text",
type: "text",
text: "",
synthetic: false
}
@@ -97,16 +97,17 @@ interface MessagePartProps {
<Show when={!(props.part.type === "text" && props.part.synthetic && isAssistantMessage()) && partHasRenderableText(props.part)}>
<div class={textContainerClass()}>
<Show
when={isAssistantMessage()}
fallback={<span>{plainTextContent()}</span>}
>
when={isAssistantMessage()}
fallback={<span>{plainTextContent()}</span>}
>
<Markdown
part={createTextPartForMarkdown()}
isDark={isDark()}
size={isAssistantMessage() ? "tight" : "base"}
onRendered={props.onRendered}
instanceId={props.instanceId}
/>
</Show>
</Show>
</div>
</Show>

View File

@@ -70,27 +70,27 @@ export default function ModelSelector(props: ModelSelectorProps) {
window.addEventListener("opencode-zen-offline-models", handleCustom as EventListener)
window.addEventListener("storage", handleStorage)
// Poll Context-Engine status
const pollContextEngine = async () => {
try {
const response = await fetch("/api/context-engine/status")
if (response.ok) {
const data = await response.json() as { status: ContextEngineStatus }
setContextEngineStatus(data.status ?? "stopped")
} else {
setContextEngineStatus("stopped")
}
} catch {
setContextEngineStatus("stopped")
}
}
pollContextEngine()
const pollInterval = setInterval(pollContextEngine, 5000)
// DISABLED: Context-Engine polling was causing performance issues
// const pollContextEngine = async () => {
// try {
// const response = await fetch("/api/context-engine/status")
// if (response.ok) {
// const data = await response.json() as { status: ContextEngineStatus }
// setContextEngineStatus(data.status ?? "stopped")
// } else {
// setContextEngineStatus("stopped")
// }
// } catch {
// setContextEngineStatus("stopped")
// }
// }
// pollContextEngine()
// const pollInterval = setInterval(pollContextEngine, 5000)
onCleanup(() => {
window.removeEventListener("opencode-zen-offline-models", handleCustom as EventListener)
window.removeEventListener("storage", handleStorage)
clearInterval(pollInterval)
// clearInterval(pollInterval)
})
})
@@ -208,10 +208,10 @@ export default function ModelSelector(props: ModelSelectorProps) {
>
<span
class={`w-2 h-2 rounded-full ${contextEngineStatus() === "ready"
? "bg-emerald-500"
: contextEngineStatus() === "indexing"
? "bg-blue-500 animate-pulse"
: "bg-red-500"
? "bg-emerald-500"
: contextEngineStatus() === "indexing"
? "bg-blue-500 animate-pulse"
: "bg-red-500"
}`}
/>
<Database class="w-3 h-3 text-zinc-400" />

View File

@@ -1,5 +1,5 @@
import { Component, For, Show, createEffect, createMemo, createSignal, onCleanup } from "solid-js"
import { FolderOpen, Trash2, Check, AlertCircle, Loader2, Plus } from "lucide-solid"
import { FolderOpen, Trash2, Check, AlertCircle, Loader2, Plus, Sparkles } from "lucide-solid"
import { useConfig } from "../stores/preferences"
import { serverApi } from "../lib/api-client"
import FileSystemBrowserDialog from "./filesystem-browser-dialog"
@@ -7,12 +7,15 @@ import { openNativeFileDialog, supportsNativeDialogs } from "../lib/native/nativ
import { getLogger } from "../lib/logger"
const log = getLogger("actions")
// Special constant for Native mode (no OpenCode binary)
const NATIVE_MODE_PATH = "__nomadarch_native__"
interface BinaryOption {
path: string
version?: string
lastUsed?: number
isDefault?: boolean
isNative?: boolean
}
interface OpenCodeBinarySelectorProps {
@@ -37,17 +40,24 @@ const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) =
const [validatingPaths, setValidatingPaths] = createSignal<Set<string>>(new Set<string>())
const [isBinaryBrowserOpen, setIsBinaryBrowserOpen] = createSignal(false)
const nativeDialogsAvailable = supportsNativeDialogs()
const binaries = () => opencodeBinaries()
const lastUsedBinary = () => preferences().lastUsedBinary
const customBinaries = createMemo(() => binaries().filter((binary) => binary.path !== "opencode"))
const binaryOptions = createMemo<BinaryOption[]>(() => [{ path: "opencode", isDefault: true }, ...customBinaries()])
// Include NomadArch Native as the first option
const binaryOptions = createMemo<BinaryOption[]>(() => [
{ path: NATIVE_MODE_PATH, isNative: true },
{ path: "opencode", isDefault: true },
...customBinaries()
])
const currentSelectionPath = () => props.selectedBinary || "opencode"
const isNativeMode = () => currentSelectionPath() === NATIVE_MODE_PATH
createEffect(() => {
if (!props.selectedBinary && lastUsedBinary()) {
props.onBinaryChange(lastUsedBinary()!)
@@ -97,6 +107,11 @@ const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) =
})
async function validateBinary(path: string): Promise<{ valid: boolean; version?: string; error?: string }> {
// Native mode is always valid
if (path === NATIVE_MODE_PATH) {
return { valid: true, version: "Native" }
}
if (versionInfo().has(path)) {
const cachedVersion = versionInfo().get(path)
return cachedVersion ? { valid: true, version: cachedVersion } : { valid: true }
@@ -149,7 +164,7 @@ const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) =
}
setIsBinaryBrowserOpen(true)
}
async function handleValidateAndAdd(path: string) {
const validation = await validateBinary(path)
@@ -163,13 +178,13 @@ const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) =
setValidationError(validation.error || "Invalid OpenCode binary")
}
}
function handleBinaryBrowserSelect(path: string) {
setIsBinaryBrowserOpen(false)
setCustomPath(path)
void handleValidateAndAdd(path)
}
async function handleCustomPathSubmit() {
const path = customPath().trim()
@@ -209,6 +224,7 @@ const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) =
}
function getDisplayName(path: string): string {
if (path === NATIVE_MODE_PATH) return "🚀 NomadArch Native"
if (path === "opencode") return "opencode (system PATH)"
const parts = path.split(/[/\\]/)
return parts[parts.length - 1] ?? path
@@ -277,18 +293,95 @@ const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) =
</div>
</div>
</Show>
{/* Mode Comparison Info */}
<div class="rounded-lg border border-white/10 overflow-hidden">
<details class="group">
<summary class="flex items-center justify-between px-3 py-2 cursor-pointer bg-white/5 hover:bg-white/10 transition-colors">
<span class="text-xs font-medium text-muted">📊 Compare: Native vs SDK Mode</span>
<svg class="w-4 h-4 text-muted transition-transform group-open:rotate-180" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</summary>
<div class="p-3 space-y-3 text-xs bg-black/20">
{/* Native Mode */}
<div class="space-y-1.5">
<div class="flex items-center gap-2 text-emerald-400 font-medium">
<Sparkles class="w-3.5 h-3.5" />
<span>NomadArch Native (Recommended)</span>
</div>
<div class="pl-5 space-y-1 text-muted">
<div class="flex items-center gap-2">
<span class="text-emerald-400"></span>
<span>No external binary required</span>
</div>
<div class="flex items-center gap-2">
<span class="text-emerald-400"></span>
<span>Free Zen models (GPT-5 Nano, Grok Code, GLM-4.7)</span>
</div>
<div class="flex items-center gap-2">
<span class="text-emerald-400"></span>
<span>Faster startup, simpler setup</span>
</div>
<div class="flex items-center gap-2">
<span class="text-emerald-400"></span>
<span>Full MCP tool support</span>
</div>
<div class="flex items-center gap-2">
<span class="text-amber-400"></span>
<span>No LSP integration (coming soon)</span>
</div>
</div>
</div>
{/* SDK Mode */}
<div class="space-y-1.5 pt-2 border-t border-white/10">
<div class="flex items-center gap-2 text-blue-400 font-medium">
<Check class="w-3.5 h-3.5" />
<span>OpenCode SDK Mode</span>
</div>
<div class="pl-5 space-y-1 text-muted">
<div class="flex items-center gap-2">
<span class="text-blue-400"></span>
<span>Full LSP integration</span>
</div>
<div class="flex items-center gap-2">
<span class="text-blue-400"></span>
<span>All OpenCode features</span>
</div>
<div class="flex items-center gap-2">
<span class="text-blue-400"></span>
<span>More provider options</span>
</div>
<div class="flex items-center gap-2">
<span class="text-amber-400"></span>
<span>Requires binary download</span>
</div>
<div class="flex items-center gap-2">
<span class="text-amber-400"></span>
<span>Platform-specific binaries</span>
</div>
</div>
</div>
</div>
</details>
</div>
</div>
<div class="panel-list panel-list--fill max-h-80 overflow-y-auto">
<For each={binaryOptions()}>
{(binary) => {
const isDefault = binary.isDefault
const isNative = binary.isNative
const versionLabel = () => versionInfo().get(binary.path) ?? binary.version
return (
<div
class="panel-list-item flex items-center"
classList={{ "panel-list-item-highlight": currentSelectionPath() === binary.path }}
classList={{
"panel-list-item-highlight": currentSelectionPath() === binary.path,
"bg-gradient-to-r from-emerald-500/10 to-cyan-500/10 border-l-2 border-emerald-500": isNative && currentSelectionPath() === binary.path,
}}
>
<button
type="button"
@@ -298,31 +391,48 @@ const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) =
>
<div class="flex flex-col flex-1 min-w-0 gap-1.5">
<div class="flex items-center gap-2">
<Check
class={`w-4 h-4 transition-opacity ${currentSelectionPath() === binary.path ? "opacity-100" : "opacity-0"}`}
/>
<span class="text-sm font-medium truncate text-primary">{getDisplayName(binary.path)}</span>
<Show when={isNative}>
<Sparkles
class={`w-4 h-4 transition-opacity ${currentSelectionPath() === binary.path ? "text-emerald-400" : "text-muted"}`}
/>
</Show>
<Show when={!isNative}>
<Check
class={`w-4 h-4 transition-opacity ${currentSelectionPath() === binary.path ? "opacity-100" : "opacity-0"}`}
/>
</Show>
<span class={`text-sm font-medium truncate ${isNative ? "text-emerald-400" : "text-primary"}`}>
{getDisplayName(binary.path)}
</span>
<Show when={isNative}>
<span class="text-[10px] px-1.5 py-0.5 rounded bg-emerald-500/20 text-emerald-400 font-medium">
RECOMMENDED
</span>
</Show>
</div>
<Show when={!isDefault}>
<Show when={!isDefault && !isNative}>
<div class="text-xs font-mono truncate pl-6 text-muted">{binary.path}</div>
</Show>
<div class="flex items-center gap-2 text-xs text-muted pl-6 flex-wrap">
<Show when={versionLabel()}>
<Show when={versionLabel() && !isNative}>
<span class="selector-badge-version">v{versionLabel()}</span>
</Show>
<Show when={isPathValidating(binary.path)}>
<span class="selector-badge-time">Checking</span>
</Show>
<Show when={!isDefault && binary.lastUsed}>
<Show when={!isDefault && !isNative && binary.lastUsed}>
<span class="selector-badge-time">{formatRelativeTime(binary.lastUsed)}</span>
</Show>
<Show when={isDefault}>
<span class="selector-badge-time">Use binary from system PATH</span>
</Show>
<Show when={isNative}>
<span class="text-emerald-400/70">No OpenCode binary needed Free Zen models included</span>
</Show>
</div>
</div>
</button>
<Show when={!isDefault}>
<Show when={!isDefault && !isNative}>
<button
type="button"
class="p-2 text-muted hover:text-primary"
@@ -351,6 +461,8 @@ const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) =
</>
)
}
export default OpenCodeBinarySelector
// Export the native mode constant for use elsewhere
export const NOMADARCH_NATIVE_MODE = NATIVE_MODE_PATH
export default OpenCodeBinarySelector

View File

@@ -125,8 +125,8 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
<header class="remote-header">
<div>
<p class="remote-eyebrow">Remote handover</p>
<h2 class="remote-title">Connect to CodeNomad remotely</h2>
<p class="remote-subtitle">Use the addresses below to open CodeNomad from another device.</p>
<h2 class="remote-title">Connect to NomadArch remotely</h2>
<p class="remote-subtitle">Use the addresses below to open NomadArch from another device.</p>
</div>
<button type="button" class="remote-close" onClick={props.onClose} aria-label="Close remote access">
×

View File

@@ -186,7 +186,7 @@ const ZAISettings: Component = () => {
<label class="block font-medium mb-2">Endpoint</label>
<input
type="text"
placeholder="https://api.z.ai/api/paas/v4"
placeholder="https://api.z.ai/api/coding/paas/v4"
value={config().endpoint || ''}
onChange={(e) => handleConfigChange('endpoint', e.target.value)}
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white dark:bg-gray-800"