Backup before continuing from Codex 5.2 session - User storage, compaction suggestions, streaming improvements

This commit is contained in:
Gemini AI
2025-12-24 21:27:05 +04:00
Unverified
parent f9748391a9
commit e8c38b0add
93 changed files with 10615 additions and 2037 deletions

View File

@@ -1,13 +1,15 @@
import { createSignal, Show, onMount, For, createMemo, createEffect } from "solid-js";
import { createSignal, Show, onMount, For, createMemo, createEffect, onCleanup } from "solid-js";
import { sessions, withSession, setActiveSession } from "@/stores/session-state";
import { instances } from "@/stores/instances";
import { sendMessage } from "@/stores/session-actions";
import { addTask, setActiveTask } from "@/stores/task-actions";
import { sendMessage, compactSession, updateSessionAgent, updateSessionModelForSession } from "@/stores/session-actions";
import { addTask, setActiveTask, archiveTask } from "@/stores/task-actions";
import { messageStoreBus } from "@/stores/message-v2/bus";
import MessageBlockList from "@/components/message-block-list";
import MessageBlockList, { getMessageAnchorId } from "@/components/message-block-list";
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,
@@ -35,10 +37,18 @@ import {
User,
Settings,
Key,
FileArchive,
Paperclip,
} from "lucide-solid";
import ModelSelector from "@/components/model-selector";
import AgentSelector from "@/components/agent-selector";
import AttachmentChip from "@/components/attachment-chip";
import { createFileAttachment } from "@/types/attachment";
import type { InstanceMessageStore } from "@/stores/message-v2/instance-store";
import type { Task } from "@/types/session";
const OPEN_ADVANCED_SETTINGS_EVENT = "open-advanced-settings";
const log = getLogger("multix-chat");
interface MultiTaskChatProps {
@@ -51,17 +61,29 @@ export default function MultiTaskChat(props: MultiTaskChatProps) {
const setSelectedTaskId = (id: string | null) => setActiveTask(props.instanceId, props.sessionId, id || undefined);
const [isSending, setIsSending] = createSignal(false);
const [chatInput, setChatInput] = createSignal("");
const [isCompacting, setIsCompacting] = createSignal(false);
const [attachments, setAttachments] = createSignal<ReturnType<typeof createFileAttachment>[]>([]);
let scrollContainer: HTMLDivElement | undefined;
const [bottomSentinel, setBottomSentinel] = createSignal<HTMLDivElement | null>(null);
const [showApiManager, setShowApiManager] = createSignal(false);
const [userScrolling, setUserScrolling] = createSignal(false);
const [lastScrollTop, setLastScrollTop] = createSignal(0);
let fileInputRef: HTMLInputElement | undefined;
// Scroll to bottom helper
const scrollToBottom = () => {
if (scrollContainer) {
if (scrollContainer && !userScrolling()) {
scrollContainer.scrollTop = scrollContainer.scrollHeight;
}
};
// Track if user is manually scrolling (not at bottom)
const checkUserScrolling = () => {
if (!scrollContainer) return false;
const threshold = 50;
const isAtBottom = scrollContainer.scrollHeight - scrollContainer.scrollTop - scrollContainer.clientHeight < threshold;
return !isAtBottom;
};
// Get current session and tasks
const session = () => {
const instanceSessions = sessions().get(props.instanceId);
@@ -69,7 +91,8 @@ export default function MultiTaskChat(props: MultiTaskChatProps) {
};
const tasks = () => session()?.tasks || [];
const selectedTask = () => tasks().find(t => t.id === selectedTaskId());
const visibleTasks = createMemo(() => tasks().filter((task) => !task.archived));
const selectedTask = () => visibleTasks().find((task) => task.id === selectedTaskId());
// Message store integration
const messageStore = () => messageStoreBus.getOrCreate(props.instanceId);
@@ -114,19 +137,20 @@ export default function MultiTaskChat(props: MultiTaskChatProps) {
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,
// 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 instance
// Get current model from active task session
const currentModel = createMemo(() => {
const instance = instances().get(props.instanceId);
return instance?.modelId || "unknown";
const instanceSessions = sessions().get(props.instanceId);
const session = instanceSessions?.get(activeTaskSessionId());
return session?.model?.modelId || "unknown";
});
const activeTaskSessionId = createMemo(() => {
@@ -134,6 +158,21 @@ export default function MultiTaskChat(props: MultiTaskChatProps) {
return task?.taskSessionId || props.sessionId;
});
const activeTaskSession = createMemo(() => {
const instanceSessions = sessions().get(props.instanceId);
return instanceSessions?.get(activeTaskSessionId());
});
const currentTaskAgent = createMemo(() => activeTaskSession()?.agent || "");
const currentTaskModel = createMemo(() => activeTaskSession()?.model || { providerId: "", modelId: "" });
const compactionSuggestion = createMemo(() => {
const sessionId = activeTaskSessionId();
return getCompactionSuggestion(props.instanceId, sessionId);
});
const hasCompactionSuggestion = createMemo(() => Boolean(compactionSuggestion()));
const solo = () => getSoloState(props.instanceId);
// APEX PRO mode = SOLO + APEX combined (autonomous + auto-approval)
@@ -181,8 +220,12 @@ export default function MultiTaskChat(props: MultiTaskChatProps) {
const streaming = isAgentThinking();
if (!streaming) return;
// During streaming, scroll periodically to keep up with content
const interval = setInterval(scrollToBottom, 300);
// During streaming, scroll periodically to keep up with content (unless user is scrolling)
const interval = setInterval(() => {
if (!userScrolling()) {
scrollToBottom();
}
}, 300);
return () => clearInterval(interval);
});
@@ -191,14 +234,40 @@ export default function MultiTaskChat(props: MultiTaskChatProps) {
const ids = filteredMessageIds();
const thinking = isAgentThinking();
// Scroll when message count changes or when thinking starts
if (ids.length > 0 || thinking) {
// Scroll when message count changes or when thinking starts (unless user is scrolling)
if ((ids.length > 0 || thinking) && !userScrolling()) {
requestAnimationFrame(() => {
setTimeout(scrollToBottom, 50);
});
}
});
// Scroll event listener to detect user scrolling
onMount(() => {
const handleScroll = () => {
if (scrollContainer) {
const isScrollingUp = scrollContainer.scrollTop < lastScrollTop();
const isScrollingDown = scrollContainer.scrollTop > lastScrollTop();
setLastScrollTop(scrollContainer.scrollTop);
// If user scrolls up or scrolls away from bottom, set userScrolling flag
if (checkUserScrolling()) {
setUserScrolling(true);
} else {
// User is back at bottom, reset the flag
setUserScrolling(false);
}
}
};
const container = scrollContainer;
container?.addEventListener('scroll', handleScroll, { passive: true });
return () => {
container?.removeEventListener('scroll', handleScroll);
};
});
const handleSendMessage = async () => {
const message = chatInput().trim();
if (!message || isSending()) return;
@@ -253,12 +322,13 @@ export default function MultiTaskChat(props: MultiTaskChatProps) {
props.instanceId,
targetSessionId,
message,
[],
attachments(),
taskId || undefined
);
log.info("sendMessage call completed");
setChatInput("");
setAttachments([]);
// Auto-scroll to bottom after sending
setTimeout(scrollToBottom, 100);
@@ -271,6 +341,21 @@ export default function MultiTaskChat(props: MultiTaskChatProps) {
}
};
const handleCreateTask = async () => {
if (isSending()) return;
setChatInput("");
try {
const nextIndex = tasks().length + 1;
const title = `Task ${nextIndex}`;
const result = await addTask(props.instanceId, props.sessionId, title);
setSelectedTaskId(result.id);
setTimeout(scrollToBottom, 50);
} catch (error) {
log.error("handleCreateTask failed", error);
console.error("[MultiTaskChat] Task creation failed:", error);
}
};
const handleKeyDown = (e: KeyboardEvent) => {
// Enter to submit, Shift+Enter for new line
if (e.key === "Enter" && !e.shiftKey) {
@@ -298,8 +383,64 @@ export default function MultiTaskChat(props: MultiTaskChatProps) {
}
};
const handleOpenAdvancedSettings = () => {
// Dispatch custom event to trigger Advanced Settings modal from parent
window.dispatchEvent(new CustomEvent(OPEN_ADVANCED_SETTINGS_EVENT, {
detail: { instanceId: props.instanceId, sessionId: props.sessionId }
}));
};
const handleCompact = async () => {
const targetSessionId = activeTaskSessionId();
if (isCompacting()) return;
setIsCompacting(true);
log.info("Compacting session", { instanceId: props.instanceId, sessionId: targetSessionId });
try {
clearCompactionSuggestion(props.instanceId, targetSessionId);
await compactSession(props.instanceId, targetSessionId);
log.info("Session compacted successfully");
} catch (error) {
log.error("Failed to compact session", error);
console.error("[MultiTaskChat] Compact failed:", error);
} finally {
setIsCompacting(false);
log.info("Compact operation finished");
}
};
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 = "";
};
return (
<main class="h-full max-h-full flex flex-col bg-[#0a0a0b] text-zinc-300 font-sans selection:bg-indigo-500/30 overflow-hidden">
<main class="absolute inset-0 flex flex-col bg-[#0a0a0b] text-zinc-300 font-sans selection:bg-indigo-500/30 overflow-hidden">
{/* 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">
@@ -309,6 +450,14 @@ export default function MultiTaskChat(props: MultiTaskChatProps) {
<Zap size={10} class="text-white fill-current" />
</div>
</div>
<button
onClick={() => emitSessionSidebarRequest({ instanceId: props.instanceId, action: "show-skills" })}
class="flex items-center space-x-1.5 px-2.5 py-1.5 rounded-lg border border-white/10 bg-white/5 text-zinc-400 hover:text-indigo-300 hover:border-indigo-500/30 hover:bg-indigo-500/10 transition-all"
title="Open Skills"
>
<Sparkles size={12} class="text-indigo-400" />
<span class="text-[10px] font-black uppercase tracking-tight">Skills</span>
</button>
<Show when={selectedTaskId()}>
<div class="flex items-center space-x-2 animate-in fade-in slide-in-from-left-2 duration-300">
@@ -351,9 +500,25 @@ export default function MultiTaskChat(props: MultiTaskChatProps) {
</div>
</Show>
{/* API Key Manager Button */}
{/* Compact Button - Context Compression & Summary */}
<button
onClick={() => setShowApiManager(true)}
onClick={handleCompact}
class={`flex items-center space-x-1.5 px-2.5 py-1.5 transition-all rounded-xl active:scale-95 border ${isCompacting()
? "text-blue-400 bg-blue-500/15 border-blue-500/40 animate-pulse shadow-[0_0_20px_rgba(59,130,246,0.3)]"
: hasCompactionSuggestion()
? "text-emerald-300 bg-emerald-500/20 border-emerald-500/50 shadow-[0_0_16px_rgba(34,197,94,0.35)] animate-pulse"
: "text-zinc-500 hover:text-blue-400 hover:bg-blue-500/10 border-transparent hover:border-blue-500/30"
}`}
title={isCompacting() ? "Compacting session (compressing context & creating summary)..." : "Compact session - Compress context & create summary"}
disabled={isCompacting()}
>
<FileArchive size={16} strokeWidth={2} />
<span class="text-[10px] font-bold uppercase tracking-tight">{isCompacting() ? "Compacting..." : "Compact"}</span>
</button>
{/* API Key Manager Button - Opens Advanced Settings */}
<button
onClick={handleOpenAdvancedSettings}
class="p-2 text-zinc-500 hover:text-emerald-400 transition-all hover:bg-emerald-500/10 rounded-xl active:scale-90"
title="API Key Manager"
>
@@ -369,7 +534,7 @@ export default function MultiTaskChat(props: MultiTaskChatProps) {
</header>
{/* Task Tabs (Horizontal Scroll) */}
<Show when={tasks().length > 0}>
<Show when={visibleTasks().length > 0}>
<div class="flex items-center bg-[#0a0a0b] border-b border-white/5 px-2 py-2 space-x-1.5 overflow-x-auto custom-scrollbar-hidden no-scrollbar shrink-0">
<button
onClick={() => setSelectedTaskId(null)}
@@ -385,7 +550,7 @@ export default function MultiTaskChat(props: MultiTaskChatProps) {
<div class="w-px h-4 bg-white/10 shrink-0 mx-0.5" />
<div class="flex items-center space-x-1.5 overflow-x-auto no-scrollbar">
<For each={tasks()}>
<For each={visibleTasks()}>
{(task) => (
<button
onClick={() => setSelectedTaskId(task.id)}
@@ -399,6 +564,18 @@ export default function MultiTaskChat(props: MultiTaskChatProps) {
"bg-indigo-500 shadow-[0_0_8px_rgba(99,102,241,0.4)] animate-pulse"
}`} />
<span class="truncate">{task.title}</span>
<span
role="button"
tabindex={0}
onClick={(event) => {
event.stopPropagation();
archiveTask(props.instanceId, props.sessionId, task.id);
}}
class="opacity-0 group-hover:opacity-100 text-zinc-600 hover:text-zinc-200 transition-colors"
title="Archive task"
>
<X size={12} />
</span>
<Show when={selectedTaskId() === task.id}>
<div class="ml-1 w-1 h-1 bg-indigo-400 rounded-full animate-ping" />
</Show>
@@ -409,8 +586,7 @@ export default function MultiTaskChat(props: MultiTaskChatProps) {
<button
onClick={() => {
setChatInput("");
setSelectedTaskId(null);
handleCreateTask();
}}
class="flex items-center justify-center w-8 h-8 rounded-xl text-zinc-500 hover:text-indigo-400 hover:bg-indigo-500/10 transition-all shrink-0 ml-1 border border-transparent hover:border-indigo-500/20"
title="New Task"
@@ -420,6 +596,25 @@ export default function MultiTaskChat(props: MultiTaskChatProps) {
</div>
</Show>
<Show when={selectedTask()}>
<div class="px-4 py-3 border-b border-white/5 bg-zinc-950/40">
<div class="grid grid-cols-1 md:grid-cols-2 gap-2">
<AgentSelector
instanceId={props.instanceId}
sessionId={activeTaskSessionId()}
currentAgent={currentTaskAgent()}
onAgentChange={(agent) => updateSessionAgent(props.instanceId, activeTaskSessionId(), agent)}
/>
<ModelSelector
instanceId={props.instanceId}
sessionId={activeTaskSessionId()}
currentModel={currentTaskModel()}
onModelChange={(model) => updateSessionModelForSession(props.instanceId, activeTaskSessionId(), model)}
/>
</div>
</div>
</Show>
{/* Main Content Area - min-h-0 is critical for flex containers with overflow */}
<div class="flex-1 min-h-0 relative overflow-hidden flex">
{/* Main chat area */}
@@ -428,6 +623,18 @@ export default function MultiTaskChat(props: MultiTaskChatProps) {
ref={scrollContainer}
class="flex-1 min-h-0 overflow-y-auto overflow-x-hidden custom-scrollbar"
>
<Show when={hasCompactionSuggestion()}>
<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={
<div class="p-3 pb-4 overflow-x-hidden">
<MessageBlockList
@@ -456,12 +663,12 @@ export default function MultiTaskChat(props: MultiTaskChatProps) {
<span class="text-[10px] font-bold text-zinc-600 uppercase tracking-widest">Active Threads</span>
<div class="h-px flex-1 bg-white/5 mx-4" />
<span class="text-[10px] font-black text-indigo-400 bg-indigo-500/10 px-2 py-0.5 rounded border border-indigo-500/20">
{tasks().length}
{visibleTasks().length}
</span>
</div>
<div class="grid gap-3">
<For each={tasks()} fallback={
<For each={visibleTasks()} fallback={
<div class="group relative p-8 rounded-3xl border border-dashed border-white/5 bg-zinc-900/20 flex flex-col items-center justify-center text-center space-y-4 transition-all hover:bg-zinc-900/40 hover:border-white/10">
<div class="w-12 h-12 rounded-2xl bg-white/5 flex items-center justify-center text-zinc-600 group-hover:text-indigo-400 group-hover:scale-110 transition-all duration-500">
<Plus size={24} strokeWidth={1.5} />
@@ -491,7 +698,21 @@ export default function MultiTaskChat(props: MultiTaskChatProps) {
<span>{task.messageIds?.length || 0} messages</span>
</div>
</div>
<ChevronRight size={16} class="text-zinc-700 group-hover:text-indigo-400 group-hover:translate-x-1 transition-all" />
<div class="flex items-center space-x-2">
<span
role="button"
tabindex={0}
onClick={(event) => {
event.stopPropagation();
archiveTask(props.instanceId, props.sessionId, 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>
@@ -572,20 +793,22 @@ export default function MultiTaskChat(props: MultiTaskChatProps) {
<span class="text-[9px] font-bold text-indigo-400">{isAgentThinking() ? "THINKING" : "SENDING"}</span>
</div>
</Show>
{/* STOP button */}
<Show when={isAgentThinking()}>
<button
onClick={handleStopAgent}
class="flex items-center space-x-1 px-2 py-0.5 bg-rose-500/20 hover:bg-rose-500/30 rounded border border-rose-500/40 text-[9px] font-bold text-rose-400 transition-all"
title="Stop agent"
>
<StopCircle size={10} />
<span>STOP</span>
</button>
</Show>
</div>
</div>
<Show when={attachments().length > 0}>
<div class="flex flex-wrap gap-2 mb-2">
<For each={attachments()}>
{(attachment) => (
<AttachmentChip
attachment={attachment}
onRemove={() => removeAttachment(attachment.id)}
/>
)}
</For>
</div>
</Show>
{/* Text Input */}
<textarea
value={chatInput()}
@@ -601,49 +824,32 @@ export default function MultiTaskChat(props: MultiTaskChatProps) {
<div class="flex items-center justify-between pt-2 border-t border-white/5 mt-2">
<div class="flex items-center space-x-2 flex-wrap gap-y-1">
{/* Detailed token stats */}
<Show when={tokenStats().input > 0 || tokenStats().output > 0}>
<div class="flex items-center space-x-1.5">
<span class="text-[8px] font-bold text-zinc-600 uppercase">INPUT</span>
<span class="text-[9px] font-bold text-zinc-400">{tokenStats().input.toLocaleString()}</span>
</div>
<div class="w-px h-3 bg-zinc-800" />
<div class="flex items-center space-x-1.5">
<span class="text-[8px] font-bold text-zinc-600 uppercase">OUTPUT</span>
<span class="text-[9px] font-bold text-zinc-400">{tokenStats().output.toLocaleString()}</span>
</div>
<Show when={tokenStats().reasoning > 0}>
<div class="w-px h-3 bg-zinc-800" />
<div class="flex items-center space-x-1.5">
<span class="text-[8px] font-bold text-zinc-600 uppercase">REASONING</span>
<span class="text-[9px] font-bold text-amber-400">{tokenStats().reasoning.toLocaleString()}</span>
</div>
</Show>
<Show when={tokenStats().cacheRead > 0}>
<div class="w-px h-3 bg-zinc-800" />
<div class="flex items-center space-x-1.5">
<span class="text-[8px] font-bold text-zinc-600 uppercase">CACHE READ</span>
<span class="text-[9px] font-bold text-emerald-400">{tokenStats().cacheRead.toLocaleString()}</span>
</div>
</Show>
<Show when={tokenStats().cacheWrite > 0}>
<div class="w-px h-3 bg-zinc-800" />
<div class="flex items-center space-x-1.5">
<span class="text-[8px] font-bold text-zinc-600 uppercase">CACHE WRITE</span>
<span class="text-[9px] font-bold text-cyan-400">{tokenStats().cacheWrite.toLocaleString()}</span>
</div>
</Show>
<div class="w-px h-3 bg-zinc-800" />
<div class="flex items-center space-x-1.5">
<span class="text-[8px] font-bold text-zinc-600 uppercase">COST</span>
<span class="text-[9px] font-bold text-violet-400">${tokenStats().cost.toFixed(4)}</span>
</div>
<div class="w-px h-3 bg-zinc-800" />
<div class="flex items-center space-x-1.5">
<span class="text-[8px] font-bold text-zinc-600 uppercase">MODEL</span>
<span class="text-[9px] font-bold text-indigo-400">{currentModel()}</span>
</div>
</Show>
<Show when={!(tokenStats().input > 0 || tokenStats().output > 0)}>
{/* Detailed breakdown not available */}
<div class="flex items-center space-x-1.5">
<span class="text-[8px] font-bold text-zinc-600 uppercase">COST</span>
<span class="text-[9px] font-bold text-violet-400">${tokenStats().cost.toFixed(4)}</span>
</div>
<div class="w-px h-3 bg-zinc-800" />
<div class="flex items-center space-x-1.5">
<span class="text-[8px] font-bold text-zinc-600 uppercase">MODEL</span>
<span class="text-[9px] font-bold text-indigo-400">{currentModel()}</span>
</div>
<div class="flex items-center space-x-1.5">
<input
ref={fileInputRef}
type="file"
multiple
class="sr-only"
onChange={handleFileSelect}
/>
<button
type="button"
onClick={() => fileInputRef?.click()}
class="text-zinc-600 hover:text-indigo-300 transition-colors p-1"
title="Attach files"
>
<Paperclip size={14} />
</button>
<button class="text-zinc-600 hover:text-zinc-400 transition-colors p-1">
<Hash size={14} />
</button>
@@ -655,23 +861,35 @@ export default function MultiTaskChat(props: MultiTaskChatProps) {
<kbd class="px-1.5 py-0.5 bg-zinc-800 rounded text-[9px] font-bold border border-white/5">ENTER</kbd>
<span class="text-[9px]">to send</span>
</div>
</Show>
</div>
</div>
<button
onClick={handleSendMessage}
disabled={!chatInput().trim() || isSending()}
class="px-4 py-1.5 bg-indigo-500 hover:bg-indigo-400 text-white rounded-lg text-[11px] font-bold uppercase tracking-wide transition-all disabled:opacity-30 disabled:cursor-not-allowed flex items-center space-x-1.5"
>
<Show when={isSending()} fallback={
<>
<span>{selectedTaskId() ? "Update Task" : "Launch Task"}</span>
<ArrowUp size={12} strokeWidth={3} />
</>
}>
<Loader2 size={12} class="animate-spin" />
<div class="flex items-center space-x-2">
<Show when={isAgentThinking() || isSending()}>
<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"
>
<StopCircle size={12} class="inline-block mr-1" />
Stop
</button>
</Show>
</button>
<button
onClick={handleSendMessage}
disabled={!chatInput().trim() || isSending()}
class="px-4 py-1.5 bg-indigo-500 hover:bg-indigo-400 text-white rounded-lg text-[11px] font-bold uppercase tracking-wide transition-all disabled:opacity-30 disabled:cursor-not-allowed flex items-center space-x-1.5"
>
<Show when={isSending()} fallback={
<>
<span>{selectedTaskId() ? "Update Task" : "Launch Task"}</span>
<ArrowUp size={12} strokeWidth={3} />
</>
}>
<Loader2 size={12} class="animate-spin" />
</Show>
</button>
</div>
</div>
</div>
</div>
@@ -679,30 +897,37 @@ export default function MultiTaskChat(props: MultiTaskChatProps) {
{/* Message Navigation Sidebar - YOU/ASST labels with hover preview */}
<Show when={selectedTaskId() && filteredMessageIds().length > 0}>
<div class="w-14 shrink-0 bg-zinc-900/40 border-l border-white/5 overflow-y-auto py-2 px-1.5 flex flex-col items-center gap-1">
<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={filteredMessageIds()}>
{(messageId, index) => {
const msg = () => messageStore().getMessage(messageId);
const isUser = () => msg()?.role === "user";
const [showPreview, setShowPreview] = createSignal(false);
// Get message preview text (first 100 chars)
// Get message preview text (first 150 chars)
const previewText = () => {
const message = msg();
if (!message) return "";
const content = message.parts?.[0]?.content || message.content || "";
const content = (message.parts?.[0] as any)?.text || (message.parts?.[0] as any)?.content || (message as any).content || "";
const text = typeof content === "string" ? content : JSON.stringify(content);
return text.length > 100 ? text.substring(0, 100) + "..." : text;
return text.length > 150 ? text.substring(0, 150) + "..." : text;
};
const handleTabClick = () => {
const anchorId = getMessageAnchorId(messageId);
const element = scrollContainer?.querySelector(`#${anchorId}`);
if (element) {
element.scrollIntoView({ behavior: "smooth", block: "center" });
// Highlight the message briefly
element.classList.add("message-highlight");
setTimeout(() => element.classList.remove("message-highlight"), 2000);
}
};
return (
<div class="relative group">
<button
onClick={() => {
// Scroll to message
const element = document.getElementById(`msg-${messageId}`);
element?.scrollIntoView({ behavior: "smooth", block: "center" });
}}
onClick={handleTabClick}
onMouseEnter={() => setShowPreview(true)}
onMouseLeave={() => setShowPreview(false)}
class={`w-10 py-1.5 rounded text-[8px] font-black uppercase transition-all cursor-pointer ${isUser()
@@ -715,11 +940,16 @@ export default function MultiTaskChat(props: MultiTaskChatProps) {
{/* Hover Preview Tooltip */}
<Show when={showPreview()}>
<div class="absolute right-full mr-2 top-0 w-64 max-h-32 overflow-hidden bg-zinc-900 border border-white/10 rounded-lg shadow-xl p-2 z-50 animate-in fade-in slide-in-from-right-2 duration-150">
<div class={`text-[9px] font-bold uppercase mb-1 ${isUser() ? "text-indigo-400" : "text-emerald-400"}`}>
{isUser() ? "You" : "Assistant"} Message {index() + 1}
<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-[11px] text-zinc-300 leading-relaxed line-clamp-4">
<p class="text-[10px] text-zinc-300 leading-relaxed whitespace-pre-wrap">
{previewText()}
</p>
</div>
@@ -732,79 +962,7 @@ export default function MultiTaskChat(props: MultiTaskChatProps) {
</Show>
</div>
{/* API Key Manager Modal */}
<Show when={showApiManager()}>
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" onClick={() => setShowApiManager(false)}>
<div class="w-full max-w-2xl bg-zinc-900 border border-white/10 rounded-2xl shadow-2xl overflow-hidden" onClick={(e) => e.stopPropagation()}>
<header class="px-6 py-4 border-b border-white/10 flex items-center justify-between">
<div class="flex items-center space-x-3">
<div class="w-10 h-10 rounded-xl bg-gradient-to-br from-emerald-500 to-teal-600 flex items-center justify-center">
<Key size={20} class="text-white" />
</div>
<div>
<h2 class="text-lg font-bold text-white">API Key Manager</h2>
<p class="text-xs text-zinc-500">Manage your access tokens for various AI providers</p>
</div>
</div>
<button onClick={() => setShowApiManager(false)} class="p-2 hover:bg-white/10 rounded-lg transition-colors">
<X size={20} class="text-zinc-400" />
</button>
</header>
<div class="flex h-[400px]">
{/* Sidebar */}
<div class="w-48 bg-zinc-950/50 border-r border-white/5 p-3 space-y-1">
<div class="text-[10px] font-bold text-zinc-600 uppercase tracking-widest px-2 py-1">Built-in</div>
<button class="w-full text-left px-3 py-2 rounded-lg bg-emerald-500/20 border border-emerald-500/30 text-emerald-400 text-sm font-medium">
NomadArch (Free)
</button>
<button class="w-full text-left px-3 py-2 rounded-lg hover:bg-white/5 text-zinc-400 hover:text-white text-sm font-medium transition-colors">
Ollama Cloud
</button>
<button class="w-full text-left px-3 py-2 rounded-lg hover:bg-white/5 text-zinc-400 hover:text-white text-sm font-medium transition-colors">
OpenAI
</button>
<button class="w-full text-left px-3 py-2 rounded-lg hover:bg-white/5 text-zinc-400 hover:text-white text-sm font-medium transition-colors">
Anthropic
</button>
<button class="w-full text-left px-3 py-2 rounded-lg hover:bg-white/5 text-zinc-400 hover:text-white text-sm font-medium transition-colors">
OpenRouter
</button>
<div class="text-[10px] font-bold text-zinc-600 uppercase tracking-widest px-2 py-1 mt-4">Custom</div>
<button class="w-full text-left px-3 py-2 rounded-lg hover:bg-white/5 text-zinc-400 hover:text-white text-sm font-medium transition-colors flex items-center space-x-2">
<Plus size={14} />
<span>Add Custom Provider</span>
</button>
</div>
{/* Content */}
<div class="flex-1 p-6 flex flex-col items-center justify-center">
<div class="w-16 h-16 rounded-2xl bg-emerald-500/20 flex items-center justify-center mb-4">
<Shield size={32} class="text-emerald-400" />
</div>
<h3 class="text-xl font-bold text-white mb-2">NomadArch Managed Models</h3>
<p class="text-sm text-zinc-400 text-center max-w-sm mb-6">
These models are provided free of charge as part of the NomadArch platform. No API key or configuration is required to use them.
</p>
<div class="bg-zinc-800/50 rounded-xl p-4 w-full max-w-sm space-y-3">
<div class="flex justify-between text-sm">
<span class="text-zinc-500">Providers</span>
<span class="text-white font-medium">Qwen, DeepSeek, Google</span>
</div>
<div class="flex justify-between text-sm">
<span class="text-zinc-500">Rate Limit</span>
<span class="text-white font-medium">Generous / Unlimited</span>
</div>
<div class="flex justify-between text-sm">
<span class="text-zinc-500">Status</span>
<span class="text-emerald-400 font-bold">ACTIVE</span>
</div>
</div>
</div>
</div>
</div>
</div>
</Show>
</main>
</main >
);
}

View File

@@ -5,8 +5,9 @@ import AdvancedSettingsModal from "./advanced-settings-modal"
import DirectoryBrowserDialog from "./directory-browser-dialog"
import Kbd from "./kbd"
import { openNativeFolderDialog, supportsNativeDialogs } from "../lib/native/native-functions"
import { users, activeUser, refreshUsers, createUser, updateUser, deleteUser, loginUser, createGuest } from "../stores/users"
const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href
const nomadArchLogo = new URL("../images/NomadArch-Icon.png", import.meta.url).href
interface FolderSelectionViewProps {
@@ -24,9 +25,15 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
const [focusMode, setFocusMode] = createSignal<"recent" | "new" | null>("recent")
const [selectedBinary, setSelectedBinary] = createSignal(preferences().lastUsedBinary || "opencode")
const [isFolderBrowserOpen, setIsFolderBrowserOpen] = createSignal(false)
const [showUserModal, setShowUserModal] = createSignal(false)
const [newUserName, setNewUserName] = createSignal("")
const [newUserPassword, setNewUserPassword] = createSignal("")
const [loginPassword, setLoginPassword] = createSignal("")
const [loginTargetId, setLoginTargetId] = createSignal<string | null>(null)
const [userError, setUserError] = createSignal<string | null>(null)
const nativeDialogsAvailable = supportsNativeDialogs()
let recentListRef: HTMLDivElement | undefined
const folders = () => recentFolders()
const isLoading = () => Boolean(props.isLoading)
@@ -153,6 +160,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
onMount(() => {
window.addEventListener("keydown", handleKeyDown)
refreshUsers()
onCleanup(() => {
window.removeEventListener("keydown", handleKeyDown)
})
@@ -174,7 +182,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
if (isLoading()) return
props.onSelectFolder(path, selectedBinary())
}
async function handleBrowse() {
if (isLoading()) return
setFocusMode("new")
@@ -191,17 +199,48 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
}
setIsFolderBrowserOpen(true)
}
function handleBrowserSelect(path: string) {
setIsFolderBrowserOpen(false)
handleFolderSelect(path)
}
function handleBinaryChange(binary: string) {
setSelectedBinary(binary)
}
async function handleCreateUser() {
const name = newUserName().trim()
const password = newUserPassword()
if (!name || password.length < 4) {
setUserError("Provide a name and a 4+ character password.")
return
}
setUserError(null)
await createUser(name, password)
setNewUserName("")
setNewUserPassword("")
}
async function handleLogin(userId: string) {
const password = loginTargetId() === userId ? loginPassword() : ""
const ok = await loginUser(userId, password)
if (!ok) {
setUserError("Invalid password.")
return
}
setUserError(null)
setLoginPassword("")
setLoginTargetId(null)
setShowUserModal(false)
}
async function handleGuest() {
await createGuest()
setShowUserModal(false)
}
function handleRemove(path: string, e?: Event) {
if (isLoading()) return
e?.stopPropagation()
@@ -231,6 +270,15 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
class="w-full max-w-3xl h-full px-4 sm:px-8 pb-2 flex flex-col overflow-hidden"
aria-busy={isLoading() ? "true" : "false"}
>
<div class="absolute top-4 left-6">
<button
type="button"
class="selector-button selector-button-secondary"
onClick={() => setShowUserModal(true)}
>
Users
</button>
</div>
<Show when={props.onOpenRemoteAccess}>
<div class="absolute top-4 right-6">
<button
@@ -242,15 +290,23 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
</button>
</div>
</Show>
<div class="mb-6 text-center shrink-0">
<div class="mb-3 flex justify-center">
<img src={codeNomadLogo} alt="CodeNomad logo" class="h-32 w-auto sm:h-48" loading="lazy" />
<div class="mb-6 text-center shrink-0">
<div class="mb-3 flex justify-center">
<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>
<Show when={activeUser()}>
{(user) => (
<p class="text-xs text-muted mb-1">
Active user: <span class="text-secondary font-medium">{user().name}</span>
</p>
)}
</Show>
<p class="text-base text-secondary">Select a folder to start coding with AI</p>
</div>
<h1 class="mb-2 text-3xl font-semibold text-primary">CodeNomad</h1>
<p class="text-base text-secondary">Select a folder to start coding with AI</p>
</div>
<div class="space-y-4 flex-1 min-h-0 overflow-hidden flex flex-col">
<Show
@@ -419,6 +475,104 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
onClose={() => setIsFolderBrowserOpen(false)}
onSelect={handleBrowserSelect}
/>
<Show when={showUserModal()}>
<div class="modal-overlay">
<div class="fixed inset-0 flex items-center justify-center p-4">
<div class="modal-surface w-full max-w-lg p-5 flex flex-col gap-4">
<div class="flex items-center justify-between">
<h2 class="text-lg font-semibold text-primary">Users</h2>
<button class="selector-button selector-button-secondary" onClick={() => setShowUserModal(false)}>
Close
</button>
</div>
<Show when={userError()}>
{(msg) => <div class="text-sm text-red-400">{msg()}</div>}
</Show>
<div class="space-y-2">
<div class="text-xs uppercase tracking-wide text-muted">Available</div>
<For each={users()}>
{(user) => (
<div class="flex items-center justify-between gap-3 px-3 py-2 rounded border border-base bg-surface-secondary">
<div class="text-sm text-primary">
{user.name}
<Show when={user.isGuest}>
<span class="ml-2 text-[10px] uppercase text-amber-400">Guest</span>
</Show>
</div>
<div class="flex items-center gap-2">
<Show when={!user.isGuest && loginTargetId() === user.id}>
<input
type="password"
placeholder="Password"
value={loginPassword()}
onInput={(event) => setLoginPassword(event.currentTarget.value)}
class="rounded-md bg-white/5 border border-white/10 px-2 py-1 text-xs text-zinc-200 focus:outline-none focus:border-blue-500/60"
/>
</Show>
<button
class="selector-button selector-button-primary"
onClick={() => {
if (user.isGuest) {
void handleLogin(user.id)
return
}
if (loginTargetId() !== user.id) {
setLoginTargetId(user.id)
setLoginPassword("")
return
}
void handleLogin(user.id)
}}
>
{activeUser()?.id === user.id ? "Active" : loginTargetId() === user.id ? "Unlock" : "Login"}
</button>
<button
class="selector-button selector-button-secondary"
onClick={() => void deleteUser(user.id)}
disabled={user.isGuest}
>
Remove
</button>
</div>
</div>
)}
</For>
</div>
<div class="space-y-2">
<div class="text-xs uppercase tracking-wide text-muted">Create User</div>
<div class="flex flex-col gap-2">
<input
type="text"
placeholder="Name"
value={newUserName()}
onInput={(event) => setNewUserName(event.currentTarget.value)}
class="rounded-md bg-white/5 border border-white/10 px-3 py-2 text-sm text-zinc-200 focus:outline-none focus:border-blue-500/60"
/>
<input
type="password"
placeholder="Password"
value={newUserPassword()}
onInput={(event) => setNewUserPassword(event.currentTarget.value)}
class="rounded-md bg-white/5 border border-white/10 px-3 py-2 text-sm text-zinc-200 focus:outline-none focus:border-blue-500/60"
/>
<div class="flex gap-2">
<button class="selector-button selector-button-primary" onClick={() => void handleCreateUser()}>
Create
</button>
<button class="selector-button selector-button-secondary" onClick={() => void handleGuest()}>
Guest Mode
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</Show>
</>
)
}

View File

@@ -1,7 +1,10 @@
import { Component, For, Show, createMemo } from "solid-js"
import { Component, For, Show, createMemo, createSignal } from "solid-js"
import type { Instance } from "../types/instance"
import { useOptionalInstanceMetadataContext } from "../lib/contexts/instance-metadata-context"
import InstanceServiceStatus from "./instance-service-status"
import DirectoryBrowserDialog from "./directory-browser-dialog"
import { serverApi } from "../lib/api-client"
import { showToastNotification } from "../lib/notifications"
interface InstanceInfoProps {
instance: Instance
@@ -22,6 +25,68 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
const env = environmentVariables()
return env ? Object.entries(env) : []
})
const [showExportDialog, setShowExportDialog] = createSignal(false)
const [showImportSourceDialog, setShowImportSourceDialog] = createSignal(false)
const [showImportDestinationDialog, setShowImportDestinationDialog] = createSignal(false)
const [importSourcePath, setImportSourcePath] = createSignal<string | null>(null)
const [includeConfig, setIncludeConfig] = createSignal(false)
const [isExporting, setIsExporting] = createSignal(false)
const [isImporting, setIsImporting] = createSignal(false)
const handleExport = async (destination: string) => {
if (isExporting()) return
setIsExporting(true)
try {
const response = await serverApi.exportWorkspace(currentInstance().id, {
destination,
includeConfig: includeConfig(),
})
showToastNotification({
title: "Workspace exported",
message: `Export saved to ${response.destination}`,
variant: "success",
duration: 7000,
})
} catch (error) {
showToastNotification({
title: "Export failed",
message: error instanceof Error ? error.message : "Unable to export workspace",
variant: "error",
duration: 8000,
})
} finally {
setIsExporting(false)
}
}
const handleImportDestination = async (destination: string) => {
const source = importSourcePath()
if (!source || isImporting()) return
setIsImporting(true)
try {
const response = await serverApi.importWorkspace({
source,
destination,
includeConfig: includeConfig(),
})
showToastNotification({
title: "Workspace imported",
message: `Imported workspace into ${response.path}`,
variant: "success",
duration: 7000,
})
} catch (error) {
showToastNotification({
title: "Import failed",
message: error instanceof Error ? error.message : "Unable to import workspace",
variant: "error",
duration: 8000,
})
} finally {
setIsImporting(false)
setImportSourcePath(null)
}
}
return (
<div class="panel">
@@ -116,6 +181,39 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
<InstanceServiceStatus initialInstance={props.instance} class="space-y-3" />
<div class="space-y-2">
<div class="text-xs font-medium text-muted uppercase tracking-wide">Workspace Export / Import</div>
<label class="flex items-center gap-2 text-xs text-secondary">
<input
type="checkbox"
checked={includeConfig()}
onChange={(event) => setIncludeConfig(event.currentTarget.checked)}
/>
Include user config (settings, keys)
</label>
<div class="flex flex-wrap gap-2">
<button
type="button"
class="button-secondary"
disabled={isExporting()}
onClick={() => setShowExportDialog(true)}
>
{isExporting() ? "Exporting..." : "Export Workspace"}
</button>
<button
type="button"
class="button-secondary"
disabled={isImporting()}
onClick={() => setShowImportSourceDialog(true)}
>
{isImporting() ? "Importing..." : "Import Workspace"}
</button>
</div>
<div class="text-[11px] text-muted">
Export creates a portable folder. Import restores the workspace into a chosen destination.
</div>
</div>
<Show when={isLoadingMetadata()}>
<div class="text-xs text-muted py-1">
<div class="flex items-center gap-1.5">
@@ -155,6 +253,37 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
</div>
</div>
</div>
<DirectoryBrowserDialog
open={showExportDialog()}
title="Export workspace to folder"
description="Choose a destination folder for the export package."
onClose={() => setShowExportDialog(false)}
onSelect={(destination) => {
setShowExportDialog(false)
void handleExport(destination)
}}
/>
<DirectoryBrowserDialog
open={showImportSourceDialog()}
title="Select export folder"
description="Pick the export folder that contains the workspace package."
onClose={() => setShowImportSourceDialog(false)}
onSelect={(source) => {
setShowImportSourceDialog(false)
setImportSourcePath(source)
setShowImportDestinationDialog(true)
}}
/>
<DirectoryBrowserDialog
open={showImportDestinationDialog()}
title="Select destination folder"
description="Choose the folder where the workspace should be imported."
onClose={() => setShowImportDestinationDialog(false)}
onSelect={(destination) => {
setShowImportDestinationDialog(false)
void handleImportDestination(destination)
}}
/>
</div>
)
}

View File

@@ -66,6 +66,7 @@ import { formatTokenTotal } from "../../lib/formatters"
import { sseManager } from "../../lib/sse-manager"
import { getLogger } from "../../lib/logger"
import AdvancedSettingsModal from "../advanced-settings-modal"
import { showConfirmDialog } from "../../stores/alerts"
import {
getSoloState,
toggleAutonomous,
@@ -103,6 +104,7 @@ const LEFT_DRAWER_STORAGE_KEY = "opencode-session-sidebar-width-v8"
const RIGHT_DRAWER_STORAGE_KEY = "opencode-session-right-drawer-width-v1"
const LEFT_PIN_STORAGE_KEY = "opencode-session-left-drawer-pinned-v1"
const RIGHT_PIN_STORAGE_KEY = "opencode-session-right-drawer-pinned-v1"
const BUILD_PREVIEW_EVENT = "opencode:build-preview"
@@ -150,6 +152,8 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
const [terminalOpen, setTerminalOpen] = createSignal(false)
const [rightPanelExpandedItems, setRightPanelExpandedItems] = createSignal<string[]>(["lsp", "mcp", "plan"])
const [currentFile, setCurrentFile] = createSignal<FileNode | null>(null)
const [centerTab, setCenterTab] = createSignal<"code" | "preview">("code")
const [previewUrl, setPreviewUrl] = createSignal<string | null>(null)
const [isSoloOpen, setIsSoloOpen] = createSignal(true)
const [showAdvancedSettings, setShowAdvancedSettings] = createSignal(false)
const [selectedBinary, setSelectedBinary] = createSignal("opencode")
@@ -284,6 +288,25 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
onCleanup(() => window.removeEventListener("open-advanced-settings", handler))
})
onMount(() => {
if (typeof window === "undefined") return
const handler = async (event: Event) => {
const detail = (event as CustomEvent<{ url?: string; instanceId?: string }>).detail
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",
})
if (confirmed) {
setCenterTab("preview")
}
}
window.addEventListener(BUILD_PREVIEW_EVENT, handler)
onCleanup(() => window.removeEventListener(BUILD_PREVIEW_EVENT, handler))
})
createEffect(() => {
if (typeof window === "undefined") return
window.localStorage.setItem(LEFT_DRAWER_STORAGE_KEY, sessionSidebarWidth().toString())
@@ -449,6 +472,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
let sidebarActionId = 0
const [pendingSidebarAction, setPendingSidebarAction] = createSignal<PendingSidebarAction | null>(null)
const [sidebarRequestedTab, setSidebarRequestedTab] = createSignal<string | null>(null)
const triggerKeyboardEvent = (target: HTMLElement, options: { key: string; code: string; keyCode: number }) => {
target.dispatchEvent(
@@ -499,6 +523,9 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
const handleSidebarRequest = (action: SessionSidebarRequestAction) => {
setPendingSidebarAction({ action, id: sidebarActionId++ })
if (action === "show-skills") {
setSidebarRequestedTab("skills")
}
if (!leftPinned() && !leftOpen()) {
setLeftOpen(true)
measureDrawerHost()
@@ -902,6 +929,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
onToggleTerminal={() => setTerminalOpen((current) => !current)}
isTerminalOpen={terminalOpen()}
onOpenAdvancedSettings={() => setShowAdvancedSettings(true)}
requestedTab={sidebarRequestedTab()}
/>
)
@@ -1243,18 +1271,18 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
</button>
</div>
{/* SOLO Mode & Auto-Approval Toggles */}
{/* APEX PRO Mode & Auto-Approval Toggles */}
<div class="flex items-center bg-white/5 border border-white/5 rounded-full px-1.5 py-1 space-x-1">
<button
onClick={() => toggleAutonomous(props.instance.id)}
title="Autonomous Mode (SOLO): Enable autonomous AI agent operations"
title="Autonomous Mode (APEX PRO): Enable autonomous AI agent operations"
class={`flex items-center space-x-1.5 px-2 py-0.5 rounded-full transition-all ${getSoloState(props.instance.id).isAutonomous
? "bg-blue-500/20 text-blue-400 border border-blue-500/30"
: "text-zinc-500 hover:text-zinc-300"
}`}
>
<Zap size={12} class={getSoloState(props.instance.id).isAutonomous ? "animate-pulse" : ""} />
<span class="text-[9px] font-black uppercase tracking-tighter">SOLO</span>
<span class="text-[9px] font-black uppercase tracking-tighter">APEX PRO</span>
</button>
<button
onClick={() => toggleAutoApproval(props.instance.id)}
@@ -1305,7 +1333,65 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
>
<div class="flex-1 flex overflow-hidden min-h-0">
<Show when={!isPhoneLayout()}>
<Editor file={currentFile()} />
<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() ? 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>
}
>
{(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>
</Show>
</div>
</Show>
<div

View File

@@ -1,4 +1,4 @@
import { Component, createSignal, For, Show, createEffect, createMemo } from "solid-js"
import { Component, createSignal, For, Show, createEffect, createMemo, onCleanup } from "solid-js"
import {
Files,
Search,
@@ -18,6 +18,7 @@ import {
} from "lucide-solid"
import { serverApi } from "../../lib/api-client"
import InstanceServiceStatus from "../instance-service-status"
import McpManager from "../mcp-manager"
import { catalog, catalogLoading, catalogError, loadCatalog } from "../../stores/skills"
import { getSessionSkills, setSessionSkills } from "../../stores/session-state"
@@ -41,6 +42,7 @@ interface SidebarProps {
onToggleTerminal?: () => void
isTerminalOpen?: boolean
onOpenAdvancedSettings?: () => void
requestedTab?: string | null
}
const getFileIcon = (fileName: string) => {
@@ -128,6 +130,7 @@ const FileTree: Component<{
export const Sidebar: Component<SidebarProps> = (props) => {
const [activeTab, setActiveTab] = createSignal("files")
const [rootFiles, setRootFiles] = createSignal<FileNode[]>([])
const [lastRequestedTab, setLastRequestedTab] = createSignal<string | null>(null)
const [searchQuery, setSearchQuery] = createSignal("")
const [searchResults, setSearchResults] = createSignal<FileNode[]>([])
const [searchLoading, setSearchLoading] = createSignal(false)
@@ -141,20 +144,40 @@ export const Sidebar: Component<SidebarProps> = (props) => {
} | null>(null)
const [gitLoading, setGitLoading] = createSignal(false)
const [skillsFilter, setSkillsFilter] = createSignal("")
const FILE_CHANGE_EVENT = "opencode:workspace-files-changed"
createEffect(async () => {
if (props.instanceId) {
try {
const entries = await serverApi.listWorkspaceFiles(props.instanceId, ".")
setRootFiles(entries.map(e => ({
name: e.name,
type: e.type,
path: e.path
})))
} catch (e) {
console.error("Failed to load root files", e)
}
const openExternal = (url: string) => {
if (typeof window === "undefined") return
window.open(url, "_blank", "noopener,noreferrer")
}
const refreshRootFiles = async () => {
if (!props.instanceId) return
try {
const entries = await serverApi.listWorkspaceFiles(props.instanceId, ".")
setRootFiles(entries.map(e => ({
name: e.name,
type: e.type,
path: e.path
})))
} catch (e) {
console.error("Failed to load root files", e)
}
}
createEffect(() => {
void refreshRootFiles()
})
createEffect(() => {
if (typeof window === "undefined") return
const handler = (event: Event) => {
const detail = (event as CustomEvent<{ instanceId?: string }>).detail
if (!detail || detail.instanceId !== props.instanceId) return
void refreshRootFiles()
}
window.addEventListener(FILE_CHANGE_EVENT, handler)
onCleanup(() => window.removeEventListener(FILE_CHANGE_EVENT, handler))
})
createEffect(() => {
@@ -163,6 +186,13 @@ export const Sidebar: Component<SidebarProps> = (props) => {
}
})
createEffect(() => {
const nextTab = props.requestedTab ?? null
if (!nextTab || nextTab === lastRequestedTab()) return
setActiveTab(nextTab)
setLastRequestedTab(nextTab)
})
const filteredSkills = createMemo(() => {
const term = skillsFilter().trim().toLowerCase()
if (!term) return catalog()
@@ -410,10 +440,7 @@ export const Sidebar: Component<SidebarProps> = (props) => {
</div>
</Show>
<Show when={activeTab() === "mcp"}>
<div class="flex flex-col gap-3">
<div class="text-xs uppercase tracking-wide text-zinc-500">MCP Servers</div>
<InstanceServiceStatus sections={["mcp"]} />
</div>
<McpManager instanceId={props.instanceId} />
</Show>
<Show when={activeTab() === "skills"}>
<div class="flex flex-col gap-3">

View File

@@ -0,0 +1,501 @@
import { Dialog } from "@kobalte/core/dialog"
import { ChevronDown, ExternalLink, Plus, RefreshCw, Search, Settings } from "lucide-solid"
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 = {
command?: string
args?: string[]
env?: Record<string, string>
}
type McpConfig = {
mcpServers?: Record<string, McpServerConfig>
}
type McpMarketplaceEntry = {
id: string
name: string
description: string
config: McpServerConfig
tags?: string[]
source?: string
}
interface McpManagerProps {
instanceId: string
}
const log = getLogger("mcp-manager")
const MCP_LINKER_RELEASES = "https://github.com/milisp/mcp-linker/releases"
const MCP_LINKER_MARKET = "https://github.com/milisp/mcp-linker"
const MARKETPLACE_ENTRIES: McpMarketplaceEntry[] = [
{
id: "sequential-thinking",
name: "Sequential Thinking",
description: "Step-by-step reasoning scratchpad for complex tasks.",
config: { command: "npx", args: ["-y", "@modelcontextprotocol/server-sequential-thinking"] },
tags: ["reasoning", "planning"],
source: "curated",
},
{
id: "desktop-commander",
name: "Desktop Commander",
description: "Control local desktop actions and automation.",
config: { command: "npx", args: ["-y", "@modelcontextprotocol/server-desktop-commander"] },
tags: ["automation", "local"],
source: "curated",
},
{
id: "web-reader",
name: "Web Reader",
description: "Fetch and summarize web pages with structured metadata.",
config: { command: "npx", args: ["-y", "@modelcontextprotocol/server-web-reader"] },
tags: ["web", "search"],
source: "curated",
},
{
id: "github",
name: "GitHub",
description: "Query GitHub repos, issues, and pull requests.",
config: { command: "npx", args: ["-y", "@modelcontextprotocol/server-github"] },
tags: ["git", "productivity"],
source: "curated",
},
{
id: "postgres",
name: "PostgreSQL",
description: "Inspect PostgreSQL schemas and run safe queries.",
config: { command: "npx", args: ["-y", "@modelcontextprotocol/server-postgres"] },
tags: ["database"],
source: "curated",
},
]
const McpManager: Component<McpManagerProps> = (props) => {
const [config, setConfig] = createSignal<McpConfig>({ mcpServers: {} })
const [isLoading, setIsLoading] = createSignal(false)
const [error, setError] = createSignal<string | null>(null)
const [menuOpen, setMenuOpen] = createSignal(false)
const [showManual, setShowManual] = createSignal(false)
const [showMarketplace, setShowMarketplace] = createSignal(false)
const [marketplaceQuery, setMarketplaceQuery] = createSignal("")
const [marketplaceLoading, setMarketplaceLoading] = createSignal(false)
const [marketplaceEntries, setMarketplaceEntries] = createSignal<McpMarketplaceEntry[]>([])
const [rawMode, setRawMode] = createSignal(false)
const [serverName, setServerName] = createSignal("")
const [serverJson, setServerJson] = createSignal("")
const [saving, setSaving] = createSignal(false)
const metadataContext = useOptionalInstanceMetadataContext()
const metadata = createMemo(() => metadataContext?.metadata?.() ?? null)
const mcpStatus = createMemo(() => metadata()?.mcpStatus ?? {})
const servers = createMemo(() => Object.entries(config().mcpServers ?? {}))
const filteredMarketplace = createMemo(() => {
const combined = [...MARKETPLACE_ENTRIES, ...marketplaceEntries()]
const query = marketplaceQuery().trim().toLowerCase()
if (!query) return combined
return combined.filter((entry) => {
const haystack = `${entry.name} ${entry.description} ${entry.id} ${(entry.tags || []).join(" ")}`.toLowerCase()
return haystack.includes(query)
})
})
const loadConfig = async () => {
setIsLoading(true)
setError(null)
try {
const data = await serverApi.fetchWorkspaceMcpConfig(props.instanceId)
setConfig(data.config ?? { mcpServers: {} })
} catch (err) {
log.error("Failed to load MCP config", err)
setError("Failed to load MCP configuration.")
} finally {
setIsLoading(false)
}
}
createEffect(() => {
void loadConfig()
})
const openExternal = (url: string) => {
window.open(url, "_blank", "noopener")
}
const resetManualForm = () => {
setServerName("")
setServerJson("")
setRawMode(false)
}
const handleManualSave = async () => {
if (saving()) return
setSaving(true)
setError(null)
try {
const parsed = JSON.parse(serverJson() || "{}")
const nextConfig: McpConfig = { ...(config() ?? {}) }
const mcpServers = { ...(nextConfig.mcpServers ?? {}) }
if (rawMode()) {
if (!parsed || typeof parsed !== "object") {
throw new Error("Raw config must be a JSON object.")
}
setConfig(parsed as McpConfig)
await serverApi.updateWorkspaceMcpConfig(props.instanceId, parsed)
} else {
const name = serverName().trim()
if (!name) {
throw new Error("Server name is required.")
}
if (!parsed || typeof parsed !== "object") {
throw new Error("Server config must be a JSON object.")
}
mcpServers[name] = parsed as McpServerConfig
nextConfig.mcpServers = mcpServers
setConfig(nextConfig)
await serverApi.updateWorkspaceMcpConfig(props.instanceId, nextConfig)
}
resetManualForm()
setShowManual(false)
} catch (err) {
const message = err instanceof Error ? err.message : "Invalid MCP configuration."
setError(message)
} finally {
setSaving(false)
}
}
const handleMarketplaceInstall = async (entry: McpMarketplaceEntry) => {
if (saving()) return
setSaving(true)
setError(null)
try {
const nextConfig: McpConfig = { ...(config() ?? {}) }
const mcpServers = { ...(nextConfig.mcpServers ?? {}) }
mcpServers[entry.id] = entry.config
nextConfig.mcpServers = mcpServers
setConfig(nextConfig)
await serverApi.updateWorkspaceMcpConfig(props.instanceId, nextConfig)
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to install MCP server."
setError(message)
} finally {
setSaving(false)
}
}
const fetchNpmEntries = async (query: string, sourceLabel: string): Promise<McpMarketplaceEntry[]> => {
const url = `https://registry.npmjs.org/-/v1/search?text=${encodeURIComponent(query)}&size=50`
const response = await fetch(url)
if (!response.ok) {
throw new Error(`Failed to fetch ${sourceLabel} MCP entries`)
}
const data = await response.json() as {
objects?: Array<{ package?: { name?: string; description?: string; keywords?: string[] } }>
}
const objects = Array.isArray(data.objects) ? data.objects : []
return objects
.map((entry) => entry.package)
.filter((pkg): pkg is { name: string; description?: string; keywords?: string[] } => Boolean(pkg?.name))
.map((pkg) => ({
id: pkg.name,
name: pkg.name.replace(/^@modelcontextprotocol\/server-/, ""),
description: pkg.description || "Community MCP server package",
config: { command: "npx", args: ["-y", pkg.name] },
tags: pkg.keywords,
source: sourceLabel,
}))
}
const loadMarketplace = async () => {
if (marketplaceLoading()) return
setMarketplaceLoading(true)
try {
const [official, community] = await Promise.allSettled([
fetchNpmEntries("@modelcontextprotocol/server", "npm:official"),
fetchNpmEntries("mcp server", "npm:community"),
])
const next: McpMarketplaceEntry[] = []
if (official.status === "fulfilled") next.push(...official.value)
if (community.status === "fulfilled") next.push(...community.value)
const deduped = new Map<string, McpMarketplaceEntry>()
for (const entry of next) {
if (!deduped.has(entry.id)) deduped.set(entry.id, entry)
}
setMarketplaceEntries(Array.from(deduped.values()))
} catch (err) {
log.error("Failed to load marketplace", err)
setError("Failed to load marketplace sources.")
} finally {
setMarketplaceLoading(false)
}
}
return (
<div class="mcp-manager">
<div class="mcp-manager-header">
<div class="flex items-center gap-2">
<span class="text-xs uppercase tracking-wide text-zinc-500">MCP Servers</span>
<button
onClick={loadConfig}
class="mcp-icon-button"
title="Refresh MCP servers"
>
<RefreshCw size={12} />
</button>
</div>
<div class="mcp-manager-actions">
<div class="relative">
<button
onClick={() => setMenuOpen((prev) => !prev)}
class="mcp-action-button"
title="Add MCP"
>
<Plus size={12} />
<span>Add</span>
<ChevronDown size={12} />
</button>
<Show when={menuOpen()}>
<div class="mcp-menu">
<button
class="mcp-menu-item"
onClick={() => {
setMenuOpen(false)
void loadMarketplace()
setShowMarketplace(true)
}}
>
Add from Marketplace
<ExternalLink size={12} />
</button>
<button
class="mcp-menu-item"
onClick={() => {
setMenuOpen(false)
resetManualForm()
setShowManual(true)
}}
>
Add Manually
</button>
</div>
</Show>
</div>
<button
onClick={() => openExternal(MCP_LINKER_RELEASES)}
class="mcp-link-button"
title="Install MCP Linker"
>
MCP Market
</button>
</div>
</div>
<Show when={error()}>
{(err) => <div class="text-[11px] text-amber-400">{err()}</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}
</span>
</Show>
<Show when={mcpStatus()?.[name]?.error}>
<span class="mcp-status-error" title={String(mcpStatus()?.[name]?.error)}>
error
</span>
</Show>
</div>
</div>
</div>
)}
</For>
</div>
</Show>
<div class="mt-3">
<InstanceServiceStatus sections={["mcp"]} />
</div>
<Dialog open={showManual()} onOpenChange={setShowManual} modal>
<Dialog.Portal>
<Dialog.Overlay class="modal-overlay" />
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
<Dialog.Content class="modal-surface w-full max-w-2xl p-5 flex flex-col gap-4">
<div class="flex items-center justify-between">
<div>
<Dialog.Title class="text-sm font-semibold text-white">Configure MCP Server</Dialog.Title>
<Dialog.Description class="text-xs text-zinc-500">
Paste the MCP server config JSON. Use marketplace via MCP Linker for curated servers.
</Dialog.Description>
</div>
<button
class="text-xs px-2 py-1 rounded border border-white/10 text-zinc-400 hover:text-white"
onClick={() => setRawMode((prev) => !prev)}
>
{rawMode() ? "Server Mode" : "Raw Config (JSON)"}
</button>
</div>
<Show when={!rawMode()}>
<label class="flex flex-col gap-1 text-xs text-zinc-400">
Server Name
<input
value={serverName()}
onInput={(e) => setServerName(e.currentTarget.value)}
class="rounded-md bg-white/5 border border-white/10 px-3 py-2 text-xs text-zinc-200 focus:outline-none focus:border-blue-500/60"
placeholder="example-server"
/>
</label>
</Show>
<label class="flex flex-col gap-1 text-xs text-zinc-400">
Config JSON
<textarea
value={serverJson()}
onInput={(e) => setServerJson(e.currentTarget.value)}
class="min-h-[200px] rounded-md bg-white/5 border border-white/10 px-3 py-2 text-xs text-zinc-200 font-mono focus:outline-none focus:border-blue-500/60"
placeholder='{"command":"npx","args":["-y","mcp-server-example"]}'
/>
</label>
<div class="flex items-center justify-end gap-2">
<button
onClick={() => {
resetManualForm()
setShowManual(false)
}}
class="px-3 py-1.5 text-xs rounded-md border border-white/10 text-zinc-300 hover:text-white"
>
Cancel
</button>
<button
onClick={handleManualSave}
disabled={saving()}
class="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"
>
{saving() ? "Saving..." : "Confirm"}
</button>
</div>
</Dialog.Content>
</div>
</Dialog.Portal>
</Dialog>
<Dialog open={showMarketplace()} onOpenChange={setShowMarketplace} modal>
<Dialog.Portal>
<Dialog.Overlay class="modal-overlay" />
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
<Dialog.Content class="modal-surface w-full max-w-3xl p-5 flex flex-col gap-4">
<div class="flex items-center justify-between">
<div>
<Dialog.Title class="text-sm font-semibold text-white">MCP Marketplace</Dialog.Title>
<Dialog.Description class="text-xs text-zinc-500">
Curated entries inspired by mcp-linker. Install writes to this workspace&apos;s .mcp.json.
</Dialog.Description>
</div>
<button
class="mcp-link-button"
onClick={() => openExternal(MCP_LINKER_MARKET)}
>
Open MCP Linker
</button>
</div>
<div class="mcp-market-search">
<Search size={14} class="text-zinc-500" />
<input
value={marketplaceQuery()}
onInput={(e) => setMarketplaceQuery(e.currentTarget.value)}
placeholder="Search MCP servers..."
class="mcp-market-input"
/>
</div>
<div class="mcp-market-list">
<Show
when={!marketplaceLoading()}
fallback={<div class="text-[11px] text-zinc-500 italic">Loading marketplace sources...</div>}
>
<For each={filteredMarketplace()}>
{(entry) => (
<div class="mcp-market-card">
<div class="mcp-market-card-info">
<div class="mcp-market-card-title">
{entry.name}
<Show when={entry.source}>
{(source) => <span class="mcp-market-source">{source()}</span>}
</Show>
</div>
<div class="mcp-market-card-desc">{entry.description}</div>
<Show when={entry.tags && entry.tags.length > 0}>
<div class="mcp-market-tags">
<For each={entry.tags}>
{(tag) => <span class="mcp-market-tag">{tag}</span>}
</For>
</div>
</Show>
</div>
<div class="mcp-market-card-actions">
<button
class="mcp-icon-button"
title="View config"
onClick={() => {
setShowManual(true)
setRawMode(false)
setServerName(entry.id)
setServerJson(JSON.stringify(entry.config, null, 2))
setShowMarketplace(false)
}}
>
<Settings size={14} />
</button>
<button
class="mcp-market-install"
onClick={() => handleMarketplaceInstall(entry)}
disabled={saving()}
>
<Plus size={12} />
Install
</button>
</div>
</div>
)}
</For>
</Show>
</div>
</Dialog.Content>
</div>
</Dialog.Portal>
</Dialog>
</div>
)
}
export default McpManager

View File

@@ -2,6 +2,8 @@ import { For, Show, createSignal } from "solid-js"
import type { MessageInfo, ClientPart } from "../types/message"
import { partHasRenderableText } from "../types/message"
import type { MessageRecord } from "../stores/message-v2/types"
import { compactSession } from "../stores/session-actions"
import { clearCompactionSuggestion } from "../stores/session-compaction"
import MessagePart from "./message-part"
interface MessageItemProps {
@@ -125,6 +127,27 @@ interface MessageItemProps {
return null
}
const isContextError = () => {
const info = props.messageInfo
if (!info) return false
const errorMessage = (info as any).error?.data?.message || (info as any).error?.message || ""
return (
errorMessage.includes("maximum context length") ||
errorMessage.includes("context_length_exceeded") ||
errorMessage.includes("token count exceeds") ||
errorMessage.includes("token limit")
)
}
const handleCompact = async () => {
try {
clearCompactionSuggestion(props.instanceId, props.sessionId)
await compactSession(props.instanceId, props.sessionId)
} catch (error) {
console.error("Failed to compact session:", error)
}
}
const hasContent = () => {
if (errorMessage() !== null) {
return true
@@ -138,6 +161,19 @@ interface MessageItemProps {
return !hasContent() && info && info.role === "assistant" && info.time.completed !== undefined && info.time.completed === 0
}
const isStreaming = () => {
return props.record.status === "streaming"
}
const currentTokenCount = () => {
if (!isStreaming()) return null
const textParts = props.parts.filter(p => p.type === "text")
return textParts.reduce((sum, p) => {
const text = (p as { text?: string }).text || ""
return sum + text.length
}, 0)
}
const handleRevert = () => {
if (props.onRevert && isUser()) {
props.onRevert(props.record.id)
@@ -185,7 +221,7 @@ interface MessageItemProps {
const modelID = info.modelID || ""
const providerID = info.providerID || ""
if (modelID && providerID) return `${providerID}/${modelID}`
return modelID
return modelID || "unknown"
}
const agentMeta = () => {
@@ -202,6 +238,20 @@ interface MessageItemProps {
return segments.join(" • ")
}
const modelBadge = () => {
if (isUser()) return null
const model = modelIdentifier()
if (!model) return null
return (
<span class="message-model-badge" title={`Model: ${model}`}>
<svg class="h-3 w-3 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
<span class="text-xs font-medium text-zinc-400">{model}</span>
</span>
)
}
return (
<div class={containerClass()}>
@@ -259,6 +309,11 @@ interface MessageItemProps {
</Show>
</button>
</Show>
<Show when={modelBadge()}>
{(badge) => (
<span class="ml-2">{badge()}</span>
)}
</Show>
<time class="message-timestamp" dateTime={timestampIso()}>{timestamp()}</time>
</div>
@@ -266,13 +321,45 @@ interface MessageItemProps {
<div class="pt-1 whitespace-pre-wrap break-words leading-[1.1]">
<Show when={isStreaming()}>
<div class="message-streaming-indicator">
<span class="streaming-status">
<span class="streaming-pulse"></span>
<span class="streaming-text">Thinking</span>
</span>
<Show when={currentTokenCount() !== null}>
{(count) => (
<span class="streaming-tokens">
<span class="streaming-token-count">{count()}</span>
<span class="streaming-token-label">tokens</span>
</span>
)}
</Show>
</div>
</Show>
<Show when={props.isQueued && isUser()}>
<div class="message-queued-badge">QUEUED</div>
</Show>
<Show when={errorMessage()}>
<div class="message-error-block"> {errorMessage()}</div>
<div class="message-error-block">
<div class="flex items-start gap-2">
<span> {errorMessage()}</span>
<Show when={isContextError()}>
<button
onClick={handleCompact}
class="compact-button"
title="Compact session to reduce context usage"
>
<svg class="h-4 w-4 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v16l6-6-6 6M4 20l6-6 6-6" />
</svg>
Compact
</button>
</Show>
</div>
</div>
</Show>
<Show when={isGenerating()}>

View File

@@ -7,6 +7,9 @@ import { getSessionInfo } from "../stores/sessions"
import { messageStoreBus } from "../stores/message-v2/bus"
import { useScrollCache } from "../lib/hooks/use-scroll-cache"
import type { InstanceMessageStore } from "../stores/message-v2/instance-store"
import { getSessionStatus } from "../stores/session-status"
import { compactSession } from "../stores/session-actions"
import { clearCompactionSuggestion, getCompactionSuggestion } from "../stores/session-compaction"
const SCROLL_SCOPE = "session"
const SCROLL_SENTINEL_MARGIN_PX = 48
@@ -51,6 +54,10 @@ export default function MessageSection(props: MessageSectionProps) {
contextAvailableTokens: null,
},
)
const isCompacting = createMemo(() => getSessionStatus(props.instanceId, props.sessionId) === "compacting")
const compactionSuggestion = createMemo(() =>
getCompactionSuggestion(props.instanceId, props.sessionId),
)
const tokenStats = createMemo(() => {
const usage = usageSnapshot()
@@ -747,6 +754,30 @@ export default function MessageSection(props: MessageSectionProps) {
<div class="message-stream-container">
<div class={`message-layout${hasTimelineSegments() ? " message-layout--with-timeline" : ""}`}>
<div class="message-stream-shell" ref={setShellElement}>
<Show when={isCompacting()}>
<div class="compaction-banner" role="status" aria-live="polite">
<span class="spinner compaction-banner-spinner" aria-hidden="true" />
<span>Compacting context</span>
</div>
</Show>
<Show when={!isCompacting() && compactionSuggestion()}>
<div class="compaction-suggestion" role="status" aria-live="polite">
<div class="compaction-suggestion-text">
<span class="compaction-suggestion-label">Compact suggested</span>
<span class="compaction-suggestion-message">{compactionSuggestion()!.reason}</span>
</div>
<button
type="button"
class="compaction-suggestion-action"
onClick={() => {
clearCompactionSuggestion(props.instanceId, props.sessionId)
void compactSession(props.instanceId, props.sessionId)
}}
>
Compact now
</button>
</div>
</Show>
<div class="message-stream" ref={setContainerRef} onScroll={handleScroll} onMouseUp={handleStreamMouseUp}>
<div ref={setTopSentinel} aria-hidden="true" style={{ height: "1px" }} />
<Show when={!props.loading && messageIds().length === 0}>

View File

@@ -4,6 +4,7 @@ import { providers, fetchProviders } from "../stores/sessions"
import { ChevronDown } from "lucide-solid"
import type { Model } from "../types/session"
import { getLogger } from "../lib/logger"
import { getUserScopedKey } from "../lib/user-storage"
const log = getLogger("session")
const OPENCODE_ZEN_OFFLINE_STORAGE_KEY = "opencode-zen-offline-models"
@@ -40,7 +41,7 @@ export default function ModelSelector(props: ModelSelectorProps) {
const readOfflineModels = () => {
if (typeof window === "undefined") return new Set<string>()
try {
const raw = window.localStorage.getItem(OPENCODE_ZEN_OFFLINE_STORAGE_KEY)
const raw = window.localStorage.getItem(getUserScopedKey(OPENCODE_ZEN_OFFLINE_STORAGE_KEY))
const parsed = raw ? JSON.parse(raw) : []
return new Set(Array.isArray(parsed) ? parsed.filter((id) => typeof id === "string") : [])
} catch {
@@ -57,7 +58,7 @@ export default function ModelSelector(props: ModelSelectorProps) {
if (typeof window === "undefined") return
const handleCustom = () => refreshOfflineModels()
const handleStorage = (event: StorageEvent) => {
if (event.key === OPENCODE_ZEN_OFFLINE_STORAGE_KEY) {
if (event.key === getUserScopedKey(OPENCODE_ZEN_OFFLINE_STORAGE_KEY)) {
refreshOfflineModels()
}
}

View File

@@ -1169,6 +1169,12 @@ export default function PromptInput(props: PromptInputProps) {
</div>
<div class="prompt-input-actions">
<Show when={props.isSessionBusy}>
<div class="thinking-indicator" aria-live="polite">
<span class="thinking-spinner" aria-hidden="true" />
<span>Thinking</span>
</div>
</Show>
<button
type="button"
class="stop-button"

View File

@@ -2,6 +2,8 @@ import { Component, createSignal, onMount, Show } from 'solid-js'
import toast from 'solid-toast'
import { Button } from '@suid/material'
import { Cloud, CheckCircle, XCircle, Loader } from 'lucide-solid'
import { instances } from '../../stores/instances'
import { fetchProviders } from '../../stores/session-api'
interface OllamaCloudConfig {
enabled: boolean
@@ -12,9 +14,11 @@ interface OllamaCloudConfig {
interface OllamaCloudModelsResponse {
models: Array<{
name: string
size: string
digest: string
modified_at: string
model?: string
size?: string | number
digest?: string
modified_at?: string
details?: any
}>
}
@@ -25,14 +29,20 @@ const OllamaCloudSettings: Component = () => {
const [connectionStatus, setConnectionStatus] = createSignal<'idle' | 'testing' | 'connected' | 'failed'>('idle')
const [models, setModels] = createSignal<string[]>([])
const [isLoadingModels, setIsLoadingModels] = createSignal(false)
const [hasStoredApiKey, setHasStoredApiKey] = createSignal(false)
// Load config on mount
onMount(async () => {
try {
const response = await fetch('http://localhost:6149/api/ollama/config')
const response = await fetch('/api/ollama/config')
if (response.ok) {
const data = await response.json()
setConfig(data.config)
const maskedKey = typeof data.config?.apiKey === "string" && /^\*+$/.test(data.config.apiKey)
setHasStoredApiKey(Boolean(data.config?.apiKey) && maskedKey)
setConfig({
...data.config,
apiKey: maskedKey ? "" : data.config?.apiKey,
})
}
} catch (error) {
console.error('Failed to load Ollama config:', error)
@@ -47,10 +57,15 @@ const OllamaCloudSettings: Component = () => {
const saveConfig = async () => {
setIsLoading(true)
try {
const response = await fetch('http://localhost:6149/api/ollama/config', {
const payload: OllamaCloudConfig = { ...config() }
if (!payload.apiKey && hasStoredApiKey()) {
delete payload.apiKey
}
const response = await fetch('/api/ollama/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config())
body: JSON.stringify(payload)
})
if (response.ok) {
@@ -58,6 +73,16 @@ const OllamaCloudSettings: Component = () => {
duration: 3000,
icon: <CheckCircle class="w-4 h-4 text-green-500" />
})
// Refresh providers for all instances so models appear in selector
const instanceList = Array.from(instances().values())
for (const instance of instanceList) {
try {
await fetchProviders(instance.id)
} catch (error) {
console.error(`Failed to refresh providers for instance ${instance.id}:`, error)
}
}
} else {
throw new Error('Failed to save config')
}
@@ -74,22 +99,22 @@ const OllamaCloudSettings: Component = () => {
const testConnection = async () => {
setIsTesting(true)
setConnectionStatus('testing')
try {
const response = await fetch('http://localhost:6149/api/ollama/test', {
const response = await fetch('/api/ollama/test', {
method: 'POST'
})
if (response.ok) {
const data = await response.json()
setConnectionStatus(data.connected ? 'connected' : 'failed')
if (data.connected) {
toast.success('Successfully connected to Ollama Cloud', {
duration: 3000,
icon: <CheckCircle class="w-4 h-4 text-green-500" />
})
// Load models after successful connection
loadModels()
} else {
@@ -115,13 +140,32 @@ const OllamaCloudSettings: Component = () => {
const loadModels = async () => {
setIsLoadingModels(true)
try {
const response = await fetch('http://localhost:6149/api/ollama/models/cloud')
const response = await fetch('/api/ollama/models')
if (response.ok) {
const data: OllamaCloudModelsResponse = await response.json()
setModels(data.models.map(model => model.name))
const data = await response.json()
// Handle different response formats
if (data.models && Array.isArray(data.models)) {
setModels(data.models.map((model: any) => model.name || model.model || 'unknown'))
if (data.models.length > 0) {
toast.success(`Loaded ${data.models.length} models`, { duration: 2000 })
}
} else {
console.warn('Unexpected models response format:', data)
setModels([])
}
} else {
const errorData = await response.json().catch(() => ({}))
toast.error(`Failed to load models: ${errorData.error || response.statusText}`, {
duration: 5000,
icon: <XCircle class="w-4 h-4 text-red-500" />
})
}
} catch (error) {
console.error('Failed to load models:', error)
toast.error('Failed to load models - network error', {
duration: 5000,
icon: <XCircle class="w-4 h-4 text-red-500" />
})
} finally {
setIsLoadingModels(false)
}
@@ -164,12 +208,13 @@ const OllamaCloudSettings: Component = () => {
<label class="block font-medium mb-2">API Key</label>
<input
type="password"
placeholder="Enter your Ollama Cloud API key"
placeholder={hasStoredApiKey() ? "API key stored (leave empty to keep)" : "Enter your Ollama Cloud API key"}
value={config().apiKey || ''}
onChange={(e) => handleConfigChange('apiKey', e.target.value)}
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
disabled={!config().enabled}
/>
<p class="text-xs text-gray-500 mt-1">Get your API key from <a href="https://ollama.com/settings/keys" target="_blank" class="text-blue-500 underline">ollama.com/settings/keys</a></p>
</div>
{/* Endpoint */}
@@ -183,6 +228,7 @@ const OllamaCloudSettings: Component = () => {
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
disabled={!config().enabled}
/>
<p class="text-xs text-gray-500 mt-1">Default: https://ollama.com (for local Ollama use: http://localhost:11434)</p>
</div>
{/* Test Connection */}
@@ -196,7 +242,7 @@ const OllamaCloudSettings: Component = () => {
{getStatusIcon()}
{isTesting() ? 'Testing...' : 'Test Connection'}
</Button>
<Show when={connectionStatus() === 'connected'}>
<span class="text-green-600 text-sm">Connected successfully</span>
</Show>
@@ -208,8 +254,8 @@ const OllamaCloudSettings: Component = () => {
{/* Available Models */}
<Show when={models().length > 0}>
<div>
<label class="block font-medium mb-2">Available Cloud Models</label>
<div class="grid grid-cols-1 gap-2">
<label class="block font-medium mb-2">Available Models</label>
<div class="grid grid-cols-1 gap-2 max-h-48 overflow-y-auto">
{models().map(model => (
<div class="p-3 border border-gray-200 rounded-md bg-gray-50">
<code class="text-sm font-mono">{model}</code>
@@ -236,4 +282,4 @@ const OllamaCloudSettings: Component = () => {
)
}
export default OllamaCloudSettings
export default OllamaCloudSettings

View File

@@ -3,6 +3,8 @@ import toast from 'solid-toast'
import { Button } from '@suid/material'
import { User, CheckCircle, XCircle, Loader, LogOut, ExternalLink } from 'lucide-solid'
import { useQwenOAuth } from '../../lib/integrations/qwen-oauth'
import { instances } from '../../stores/instances'
import { fetchProviders } from '../../stores/session-api'
interface QwenUser {
id: string
@@ -17,7 +19,7 @@ interface QwenUser {
}
const QwenCodeSettings: Component = () => {
const { isAuthenticated, user, isLoading, signIn, signOut, createApiClient } = useQwenOAuth()
const { isAuthenticated, user, isLoading, signIn, signOut, tokenInfo } = useQwenOAuth()
const [isSigningOut, setIsSigningOut] = createSignal(false)
const handleSignIn = async () => {
@@ -27,6 +29,13 @@ const QwenCodeSettings: Component = () => {
duration: 3000,
icon: <CheckCircle class="w-4 h-4 text-green-500" />
})
for (const instance of instances().values()) {
try {
await fetchProviders(instance.id)
} catch (error) {
console.error(`Failed to refresh providers for instance ${instance.id}:`, error)
}
}
} catch (error) {
toast.error('Failed to authenticate with Qwen Code', {
duration: 5000,
@@ -59,6 +68,32 @@ const QwenCodeSettings: Component = () => {
return `${user.limits.requests_per_day} requests/day, ${user.limits.requests_per_minute}/min`
}
const formatTokenExpiry = () => {
const token = tokenInfo()
if (!token) return "Token not available"
const createdAt = token.created_at > 1e12 ? Math.floor(token.created_at / 1000) : token.created_at
const expiresAt = (createdAt + token.expires_in) * 1000
const remainingMs = Math.max(0, expiresAt - Date.now())
const remainingMin = Math.floor(remainingMs / 60000)
return `${remainingMin} min remaining`
}
const tokenStatus = () => {
const token = tokenInfo()
if (!token) return "Unknown"
const createdAt = token.created_at > 1e12 ? Math.floor(token.created_at / 1000) : token.created_at
const expiresAt = (createdAt + token.expires_in) * 1000
return Date.now() < expiresAt ? "Active" : "Expired"
}
const tokenId = () => {
const token = tokenInfo()
if (!token?.access_token) return "Unavailable"
const value = token.access_token
if (value.length <= 12) return value
return `${value.slice(0, 6)}...${value.slice(-4)}`
}
return (
<div class="space-y-6 p-6">
<div class="flex items-center gap-2 mb-4">
@@ -128,6 +163,16 @@ const QwenCodeSettings: Component = () => {
{formatRemainingRequests(user()!)}
</span>
</Show>
<span class="text-xs text-green-600 dark:text-green-400">
{formatTokenExpiry()}
</span>
</div>
<div class="flex items-center gap-2 mt-2 text-xs text-green-700 dark:text-green-300">
<span class="font-semibold">Token ID:</span>
<span class="font-mono">{tokenId()}</span>
<span class="px-2 py-0.5 rounded-full bg-green-200 dark:bg-green-800 text-green-800 dark:text-green-200">
{tokenStatus()}
</span>
</div>
</div>
</div>
@@ -188,4 +233,4 @@ const QwenCodeSettings: Component = () => {
)
}
export default QwenCodeSettings
export default QwenCodeSettings

View File

@@ -137,7 +137,7 @@ const ZAISettings: Component = () => {
<div class="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4 mb-4">
<h3 class="font-semibold text-blue-900 dark:text-blue-100 mb-2">GLM Coding Plan</h3>
<p class="text-sm text-blue-800 dark:text-blue-200">
Z.AI provides access to Claude models through their GLM Coding Plan. Get your API key from the{' '}
Z.AI provides access to GLM-4.7, GLM-4.6, GLM-4.5, and other GLM models through their PaaS/v4 API. Get your API key from the{' '}
<a
href="https://z.ai/manage-apikey/apikey-list"
target="_blank"
@@ -182,12 +182,11 @@ const ZAISettings: Component = () => {
</p>
</div>
{/* Endpoint */}
<div>
<label class="block font-medium mb-2">Endpoint</label>
<input
type="text"
placeholder="https://api.z.ai/api/anthropic"
placeholder="https://api.z.ai/api/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"

View File

@@ -29,6 +29,7 @@ const TOOL_CALL_CACHE_SCOPE = "tool-call"
const TOOL_SCROLL_SENTINEL_MARGIN_PX = 48
const TOOL_SCROLL_INTENT_WINDOW_MS = 600
const TOOL_SCROLL_INTENT_KEYS = new Set(["ArrowUp", "ArrowDown", "PageUp", "PageDown", "Home", "End", " ", "Spacebar"])
const FILE_CHANGE_EVENT = "opencode:workspace-files-changed"
function makeRenderCacheKey(
toolCallId?: string | null,
@@ -304,6 +305,7 @@ export default function ToolCall(props: ToolCallProps) {
let toolCallRootRef: HTMLDivElement | undefined
let scrollContainerRef: HTMLDivElement | undefined
let detachScrollIntentListeners: (() => void) | undefined
let lastFileEventKey = ""
let pendingScrollFrame: number | null = null
let pendingAnchorScroll: number | null = null
@@ -493,6 +495,19 @@ export default function ToolCall(props: ToolCallProps) {
})
})
createEffect(() => {
const state = toolState()
if (!state || state.status !== "completed") return
const tool = toolName()
if (!["write", "edit", "patch"].includes(tool)) return
const key = `${toolCallIdentifier()}:${tool}:${state.status}`
if (key === lastFileEventKey) return
lastFileEventKey = key
if (typeof window !== "undefined") {
window.dispatchEvent(new CustomEvent(FILE_CHANGE_EVENT, { detail: { instanceId: props.instanceId } }))
}
})
createEffect(() => {
const activeKey = activePermissionKey()
if (!activeKey) return