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

@@ -7,7 +7,8 @@
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"typecheck": "tsc --noEmit -p tsconfig.json"
"typecheck": "tsc --noEmit -p tsconfig.json",
"test": "node --test --experimental-strip-types src/lib/__tests__/*.test.ts src/stores/__tests__/*.test.ts"
},
"dependencies": {
"@git-diff-view/solid": "^0.0.8",
@@ -30,8 +31,10 @@
"autoprefixer": "10.4.21",
"postcss": "8.5.6",
"tailwindcss": "3",
"tsx": "^4.21.0",
"typescript": "^5.3.0",
"vite": "^5.0.0",
"vite-plugin-solid": "^2.10.0"
"vite-plugin-solid": "^2.10.0",
"zod": "^3.25.76"
}
}

View File

@@ -24,6 +24,8 @@ import {
setIsSelectingFolder,
showFolderSelection,
setShowFolderSelection,
showFolderSelectionOnStart,
setShowFolderSelectionOnStart,
} from "./stores/ui"
import { useConfig } from "./stores/preferences"
import {
@@ -74,6 +76,8 @@ const App: Component = () => {
const [remoteAccessOpen, setRemoteAccessOpen] = createSignal(false)
const [instanceTabBarHeight, setInstanceTabBarHeight] = createSignal(0)
const shouldShowFolderSelection = () => !hasInstances() || showFolderSelectionOnStart()
const updateInstanceTabBarHeight = () => {
if (typeof document === "undefined") return
const element = document.querySelector<HTMLElement>(".tab-bar-instance")
@@ -156,6 +160,7 @@ const App: Component = () => {
clearLaunchError()
const instanceId = await createInstance(folderPath, selectedBinary)
setShowFolderSelection(false)
setShowFolderSelectionOnStart(false)
setIsAdvancedSettingsOpen(false)
log.info("Created instance", {
@@ -375,7 +380,7 @@ const App: Component = () => {
</Dialog>
<div class="h-screen w-screen flex flex-col">
<Show
when={!hasInstances()}
when={shouldShowFolderSelection()}
fallback={
<>
<InstanceTabs
@@ -432,6 +437,7 @@ const App: Component = () => {
<button
onClick={() => {
setShowFolderSelection(false)
setShowFolderSelectionOnStart(false)
setIsAdvancedSettingsOpen(false)
clearLaunchError()
}}

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 471 KiB

View File

@@ -6,6 +6,7 @@
@import './styles/markdown.css';
@import './styles/tabs.css';
@import './styles/antigravity.css';
@import './styles/responsive.css';
@tailwind base;
@tailwind components;
@tailwind utilities;
@@ -18,6 +19,7 @@
html,
body {
font-family: var(--font-family-sans);
font-size: var(--font-size-base);
@@ -27,12 +29,18 @@ body {
-moz-osx-font-smoothing: grayscale;
background-color: var(--surface-base);
color: var(--text-primary);
margin: 0;
padding: 0;
overflow: hidden;
width: 100%;
height: 100%;
}
#root {
width: 100vw;
height: 100vh;
width: 100%;
height: 100%;
background-color: var(--surface-base);
overflow: hidden;
}
@@ -61,6 +69,5 @@ body {

View File

@@ -0,0 +1,390 @@
import assert from "node:assert/strict"
import { describe, it } from "node:test"
import {
validateStructuredSummary,
validateCompactionEvent,
validateCompactionResult,
sanitizeStructuredSummary,
type StructuredSummary,
type CompactionEvent,
type CompactionResult,
} from "../compaction-schema.js"
describe("compaction schema", () => {
describe("validateStructuredSummary", () => {
it("validates tierA summary", () => {
const summary: StructuredSummary = {
timestamp: new Date().toISOString(),
summary_type: "tierA_short",
what_was_done: ["Created API endpoint", "Added error handling"],
files: [{ path: "src/api.ts", notes: "API endpoint file", decision_id: "decision-1" }],
current_state: "API endpoint implemented with error handling",
key_decisions: [],
next_steps: [],
blockers: [],
artifacts: [],
tags: [],
provenance: {
model: "claude-3.5-sonnet",
token_count: 1000,
redactions: [],
},
aggressive: false,
}
const result = validateStructuredSummary(summary)
assert.ok(result.success)
assert.equal(result.data.summary_type, "tierA_short")
})
it("validates tierB summary", () => {
const summary: StructuredSummary = {
timestamp: new Date().toISOString(),
summary_type: "tierB_detailed",
what_was_done: ["Created API endpoint", "Added error handling", "Wrote unit tests"],
files: [
{ path: "src/api.ts", notes: "API endpoint file", decision_id: "decision-1" },
{ path: "src/api.test.ts", notes: "Test file", decision_id: "decision-2" },
],
current_state: "API endpoint implemented with error handling and full test coverage",
key_decisions: [
{
id: "decision-1",
decision: "Use Fastify for performance",
rationale: "Fastify provides better performance than Express",
actor: "agent",
},
],
next_steps: ["Add authentication", "Implement rate limiting"],
blockers: [],
artifacts: [],
tags: ["api", "fastify"],
provenance: {
model: "claude-3.5-sonnet",
token_count: 1500,
redactions: [],
},
aggressive: false,
}
const result = validateStructuredSummary(summary)
assert.ok(result.success)
assert.equal(result.data.summary_type, "tierB_detailed")
assert.ok(result.data.key_decisions)
assert.equal(result.data.key_decisions.length, 1)
})
it("rejects invalid timestamp", () => {
const summary = {
timestamp: "invalid-date",
summary_type: "tierA_short" as const,
what_was_done: ["Created API endpoint"],
files: [],
current_state: "API endpoint implemented",
key_decisions: [],
next_steps: [],
blockers: [],
artifacts: [],
tags: [],
provenance: {
model: "claude-3.5-sonnet",
token_count: 1000,
redactions: [],
},
aggressive: false,
}
const result = validateStructuredSummary(summary)
assert.ok(!result.success)
assert.ok(result.errors.length > 0)
})
it("rejects empty what_was_done array", () => {
const summary = {
timestamp: new Date().toISOString(),
summary_type: "tierA_short" as const,
what_was_done: [],
files: [],
current_state: "API endpoint implemented",
key_decisions: [],
next_steps: [],
blockers: [],
artifacts: [],
tags: [],
provenance: {
model: "claude-3.5-sonnet",
token_count: 1000,
redactions: [],
},
aggressive: false,
}
const result = validateStructuredSummary(summary)
assert.ok(!result.success)
assert.ok(result.errors.some((e) => e.includes("what_was_done")))
})
it("rejects empty current_state", () => {
const summary = {
timestamp: new Date().toISOString(),
summary_type: "tierA_short" as const,
what_was_done: ["Created API endpoint"],
files: [],
current_state: "",
key_decisions: [],
next_steps: [],
blockers: [],
artifacts: [],
tags: [],
provenance: {
model: "claude-3.5-sonnet",
token_count: 1000,
redactions: [],
},
aggressive: false,
}
const result = validateStructuredSummary(summary)
assert.ok(!result.success)
assert.ok(result.errors.some((e) => e.includes("current_state")))
})
it("rejects invalid actor in key_decisions", () => {
const summary = {
timestamp: new Date().toISOString(),
summary_type: "tierA_short" as const,
what_was_done: ["Created API endpoint"],
files: [],
current_state: "API endpoint implemented",
key_decisions: [
{
id: "decision-1",
decision: "Use Fastify",
rationale: "Performance",
actor: "invalid" as any,
},
],
next_steps: [],
blockers: [],
artifacts: [],
tags: [],
provenance: {
model: "claude-3.5-sonnet",
token_count: 1000,
redactions: [],
},
aggressive: false,
}
const result = validateStructuredSummary(summary)
assert.ok(!result.success)
})
})
describe("validateCompactionEvent", () => {
it("validates user-triggered compaction", () => {
const event: CompactionEvent = {
event_id: "comp_1234567890",
timestamp: new Date().toISOString(),
actor: "user",
trigger_reason: "manual",
token_before: 10000,
token_after: 3000,
model_used: "claude-3.5-sonnet",
cost_estimate: 0.05,
}
const result = validateCompactionEvent(event)
assert.ok(result.success)
assert.equal(result.data.actor, "user")
})
it("validates auto-triggered compaction", () => {
const event: CompactionEvent = {
event_id: "auto_1234567890",
timestamp: new Date().toISOString(),
actor: "auto",
trigger_reason: "overflow",
token_before: 15000,
token_after: 5000,
model_used: "claude-3.5-sonnet",
cost_estimate: 0.07,
}
const result = validateCompactionEvent(event)
assert.ok(result.success)
assert.equal(result.data.actor, "auto")
assert.equal(result.data.trigger_reason, "overflow")
})
it("rejects negative token values", () => {
const event = {
event_id: "comp_1234567890",
timestamp: new Date().toISOString(),
actor: "user" as const,
trigger_reason: "manual" as const,
token_before: -1000,
token_after: 3000,
model_used: "claude-3.5-sonnet",
cost_estimate: 0.05,
}
const result = validateCompactionEvent(event)
assert.ok(!result.success)
})
it("rejects empty event_id", () => {
const event = {
event_id: "",
timestamp: new Date().toISOString(),
actor: "user" as const,
trigger_reason: "manual" as const,
token_before: 10000,
token_after: 3000,
model_used: "claude-3.5-sonnet",
cost_estimate: 0.05,
}
const result = validateCompactionEvent(event)
assert.ok(!result.success)
})
it("rejects invalid actor", () => {
const event = {
event_id: "comp_1234567890",
timestamp: new Date().toISOString(),
actor: "invalid" as any,
trigger_reason: "manual" as const,
token_before: 10000,
token_after: 3000,
model_used: "claude-3.5-sonnet",
cost_estimate: 0.05,
}
const result = validateCompactionEvent(event)
assert.ok(!result.success)
})
})
describe("validateCompactionResult", () => {
it("validates successful compaction", () => {
const result: CompactionResult = {
success: true,
mode: "compact",
human_summary: "Compacted 100 messages",
detailed_summary: {
timestamp: new Date().toISOString(),
summary_type: "tierA_short",
what_was_done: ["Compacted 100 messages"],
files: [],
current_state: "Session compacted",
key_decisions: [],
next_steps: [],
blockers: [],
artifacts: [],
tags: [],
provenance: {
model: "claude-3.5-sonnet",
token_count: 1000,
redactions: [],
},
aggressive: false,
},
token_before: 10000,
token_after: 3000,
token_reduction_pct: 70,
}
const validation = validateCompactionResult(result)
assert.ok(validation.success)
})
it("validates failed compaction", () => {
const result: CompactionResult = {
success: false,
mode: "compact",
human_summary: "Compaction failed",
token_before: 10000,
token_after: 10000,
token_reduction_pct: 0,
}
const validation = validateCompactionResult(result)
assert.ok(validation.success)
assert.equal(validation.data.success, false)
})
it("rejects invalid token reduction percentage", () => {
const result = {
success: true,
mode: "compact" as const,
human_summary: "Compacted 100 messages",
token_before: 10000,
token_after: 3000,
token_reduction_pct: 150,
}
const validation = validateCompactionResult(result)
assert.ok(!validation.success)
})
it("rejects negative token reduction percentage", () => {
const result = {
success: true,
mode: "compact" as const,
human_summary: "Compacted 100 messages",
token_before: 10000,
token_after: 3000,
token_reduction_pct: -10,
}
const validation = validateCompactionResult(result)
assert.ok(!validation.success)
})
})
describe("sanitizeStructuredSummary", () => {
it("sanitizes summary by removing extra fields", () => {
const dirtySummary = {
timestamp: new Date().toISOString(),
summary_type: "tierA_short" as const,
what_was_done: ["Created API endpoint"],
files: [],
current_state: "API endpoint implemented",
key_decisions: [],
next_steps: [],
blockers: [],
artifacts: [],
tags: [],
provenance: {
model: "claude-3.5-sonnet",
token_count: 1000,
redactions: [],
},
aggressive: false,
extraField: "should be removed",
anotherExtra: { nested: "data" },
}
const clean = sanitizeStructuredSummary(dirtySummary)
assert.ok(clean)
assert.ok(!("extraField" in clean))
assert.ok(!("anotherExtra" in clean))
assert.equal(clean?.summary_type, "tierA_short")
})
it("preserves all valid fields", () => {
const summary: StructuredSummary = {
timestamp: new Date().toISOString(),
summary_type: "tierA_short",
what_was_done: ["Created API endpoint"],
files: [{ path: "src/api.ts", notes: "API endpoint file", decision_id: "decision-1" }],
current_state: "API endpoint implemented",
key_decisions: [],
next_steps: ["Add tests"],
blockers: [],
artifacts: [],
tags: ["api"],
provenance: {
model: "claude-3.5-sonnet",
token_count: 1000,
redactions: [],
},
aggressive: false,
}
const clean = sanitizeStructuredSummary(summary)
assert.ok(clean)
assert.equal(clean?.what_was_done.length, 1)
assert.ok(clean?.files)
assert.equal(clean.files.length, 1)
assert.ok(clean?.next_steps)
assert.equal(clean.next_steps.length, 1)
assert.ok(clean?.tags)
assert.equal(clean.tags.length, 1)
})
})
})

View File

@@ -0,0 +1,158 @@
import assert from "node:assert/strict"
import { describe, it } from "node:test"
import { redactSecrets, hasSecrets, redactObject } from "../secrets-detector.js"
describe("secrets detector", () => {
describe("redactSecrets", () => {
it("redacts API keys", () => {
const content = "My API key is sk-1234567890abcdef"
const result = redactSecrets(content, "test")
assert.ok(result.redactions.length > 0)
assert.ok(!result.clean.includes("sk-1234567890abcdef"))
})
it("redacts AWS access keys", () => {
const content = "AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE"
const result = redactSecrets(content, "test")
assert.ok(result.redactions.length > 0)
assert.ok(!result.clean.includes("AKIAIOSFODNN7EXAMPLE"))
})
it("redacts bearer tokens", () => {
const content = "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"
const result = redactSecrets(content, "test")
assert.ok(result.redactions.length > 0)
assert.ok(!result.clean.includes("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"))
})
it("redacts GitHub tokens", () => {
const content = "github_pat_11AAAAAAAAAAAAAAAAAAAAAA"
const result = redactSecrets(content, "test")
assert.ok(result.redactions.length > 0)
assert.ok(!result.clean.includes("github_pat_11AAAAAAAAAAAAAAAAAAAAAA"))
})
it("redacts npm tokens", () => {
const content = "npm_1234567890abcdef1234567890abcdef1234"
const result = redactSecrets(content, "test")
assert.ok(result.redactions.length > 0)
assert.ok(!result.clean.includes("npm_1234567890abcdef1234567890abcdef1234"))
})
it("preserves non-sensitive content", () => {
const content = "This is a normal message without any secrets"
const result = redactSecrets(content, "test")
assert.equal(result.clean, content)
assert.equal(result.redactions.length, 0)
})
it("handles empty content", () => {
const content = ""
const result = redactSecrets(content, "test")
assert.equal(result.clean, "")
assert.equal(result.redactions.length, 0)
})
it("provides redaction reasons", () => {
const content = "API key: sk-1234567890abcdef"
const result = redactSecrets(content, "test")
assert.ok(result.redactions.length > 0)
assert.ok(result.redactions[0].reason.length > 0)
})
it("tracks redaction paths", () => {
const content = "sk-1234567890abcdef"
const result = redactSecrets(content, "test")
assert.ok(result.redactions.length > 0)
assert.equal(typeof result.redactions[0].path, "string")
assert.ok(result.redactions[0].path.length > 0)
})
})
describe("hasSecrets", () => {
it("detects API keys", () => {
const content = "sk-1234567890abcdef"
assert.ok(hasSecrets(content))
})
it("detects bearer tokens", () => {
const content = "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"
assert.ok(hasSecrets(content))
})
it("returns false for normal content", () => {
const content = "This is a normal message"
assert.ok(!hasSecrets(content))
})
it("returns false for empty content", () => {
const content = ""
assert.ok(!hasSecrets(content))
})
})
describe("redactObject", () => {
it("redacts secrets in nested objects", () => {
const obj = {
apiKey: "sk-1234567890abcdef",
nested: {
token: "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9",
},
}
const result = redactObject(obj, "test")
assert.ok(!result.apiKey.includes("sk-1234567890abcdef"))
assert.ok(!result.nested.token.includes("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"))
})
it("redacts secrets in arrays", () => {
const obj = {
messages: [
{ content: "Use sk-1234567890abcdef" },
{ content: "Normal message" },
],
}
const result = redactObject(obj, "test")
assert.ok(!result.messages[0].content.includes("sk-1234567890abcdef"))
assert.equal(result.messages[1].content, "Normal message")
})
it("preserves non-sensitive fields", () => {
const obj = {
name: "John Doe",
age: 30,
message: "Hello world",
}
const result = redactObject(obj, "test")
assert.equal(result.name, "John Doe")
assert.equal(result.age, 30)
assert.equal(result.message, "Hello world")
})
it("handles null and undefined values", () => {
const obj = {
value: null,
undefined: undefined,
message: "sk-1234567890abcdef",
}
const result = redactObject(obj, "test")
assert.equal(result.value, null)
assert.equal(result.undefined, undefined)
assert.ok(!result.message.includes("sk-1234567890abcdef"))
})
it("preserves object structure", () => {
const obj = {
level1: {
level2: {
level3: {
secret: "sk-1234567890abcdef",
},
},
},
}
const result = redactObject(obj, "test")
assert.ok(result.level1.level2.level3.secret)
assert.ok(!result.level1.level2.level3.secret.includes("sk-1234567890abcdef"))
})
})
})

View File

@@ -19,6 +19,13 @@ import type {
WorkspaceEventPayload,
WorkspaceEventType,
WorkspaceGitStatus,
WorkspaceExportRequest,
WorkspaceExportResponse,
WorkspaceImportRequest,
WorkspaceImportResponse,
WorkspaceMcpConfigRequest,
WorkspaceMcpConfigResponse,
PortAvailabilityResponse,
} from "../../../server/src/api-types"
import { getLogger } from "./logger"
@@ -158,6 +165,27 @@ export const serverApi = {
fetchWorkspaceGitStatus(id: string): Promise<WorkspaceGitStatus> {
return request<WorkspaceGitStatus>(`/api/workspaces/${encodeURIComponent(id)}/git/status`)
},
exportWorkspace(id: string, payload: WorkspaceExportRequest): Promise<WorkspaceExportResponse> {
return request<WorkspaceExportResponse>(`/api/workspaces/${encodeURIComponent(id)}/export`, {
method: "POST",
body: JSON.stringify(payload),
})
},
importWorkspace(payload: WorkspaceImportRequest): Promise<WorkspaceImportResponse> {
return request<WorkspaceImportResponse>("/api/workspaces/import", {
method: "POST",
body: JSON.stringify(payload),
})
},
fetchWorkspaceMcpConfig(id: string): Promise<WorkspaceMcpConfigResponse> {
return request<WorkspaceMcpConfigResponse>(`/api/workspaces/${encodeURIComponent(id)}/mcp-config`)
},
updateWorkspaceMcpConfig(id: string, config: WorkspaceMcpConfigRequest["config"]): Promise<WorkspaceMcpConfigResponse> {
return request<WorkspaceMcpConfigResponse>(`/api/workspaces/${encodeURIComponent(id)}/mcp-config`, {
method: "PUT",
body: JSON.stringify({ config }),
})
},
fetchConfig(): Promise<AppConfig> {
return request<AppConfig>("/api/config/app")
@@ -241,6 +269,9 @@ export const serverApi = {
const params = new URLSearchParams({ id })
return request<SkillDetail>(`/api/skills/detail?${params.toString()}`)
},
fetchAvailablePort(): Promise<PortAvailabilityResponse> {
return request<PortAvailabilityResponse>("/api/ports/available")
},
}
export type { WorkspaceDescriptor, WorkspaceLogEntry, WorkspaceEventPayload, WorkspaceEventType }

View File

@@ -0,0 +1,168 @@
import { z } from "zod"
import { getLogger } from "./logger.js"
const log = getLogger("compaction-schema")
export const SecretRedactionSchema = z.object({
path: z.string(),
reason: z.string(),
})
export const ProvenanceSchema = z.object({
model: z.string().min(1, "Model name is required"),
token_count: z.number().int().nonnegative(),
redactions: z.array(SecretRedactionSchema),
})
export const KeyDecisionSchema = z.object({
id: z.string().min(1, "Decision ID is required"),
decision: z.string().min(1, "Decision is required"),
rationale: z.string().min(1, "Rationale is required"),
actor: z.enum(["agent", "user"], { errorMap: () => ({ message: "Actor must be 'agent' or 'user'" }) }),
})
export const ArtifactSchema = z.object({
type: z.string().min(1, "Artifact type is required"),
uri: z.string().min(1, "Artifact URI is required"),
notes: z.string(),
})
export const FileReferenceSchema = z.object({
path: z.string().min(1, "File path is required"),
notes: z.string(),
decision_id: z.string().min(1, "Decision ID is required"),
})
export const StructuredSummarySchema = z.object({
timestamp: z.string().datetime(),
summary_type: z.enum(["tierA_short", "tierB_detailed"]),
what_was_done: z.array(z.string()).min(1, "At least one 'what_was_done' entry is required"),
files: z.array(FileReferenceSchema).optional(),
current_state: z.string().min(1, "Current state is required"),
key_decisions: z.array(KeyDecisionSchema).optional(),
next_steps: z.array(z.string()).optional(),
blockers: z.array(z.string()).optional(),
artifacts: z.array(ArtifactSchema).optional(),
tags: z.array(z.string()).optional(),
provenance: ProvenanceSchema,
aggressive: z.boolean(),
})
export const CompactionEventSchema = z.object({
event_id: z.string().min(1, "Event ID is required"),
timestamp: z.string().datetime(),
actor: z.enum(["user", "auto"], { errorMap: () => ({ message: "Actor must be 'user' or 'auto'" }) }),
trigger_reason: z.enum(["overflow", "scheduled", "manual"]),
token_before: z.number().int().nonnegative(),
token_after: z.number().int().nonnegative(),
model_used: z.string().min(1, "Model name is required"),
cost_estimate: z.number().nonnegative(),
snapshot_id: z.string().optional(),
})
export const CompactionConfigSchema = z.object({
autoCompactEnabled: z.boolean(),
autoCompactThreshold: z.number().int().min(1).max(100),
compactPreserveWindow: z.number().int().positive(),
pruneReclaimThreshold: z.number().int().positive(),
userPreference: z.enum(["auto", "ask", "never"]),
undoRetentionWindow: z.number().int().positive(),
recentMessagesToKeep: z.number().int().positive().optional(),
systemMessagesToKeep: z.number().int().positive().optional(),
incrementalChunkSize: z.number().int().positive().optional(),
// ADK-style sliding window settings
compactionInterval: z.number().int().positive().optional(),
overlapSize: z.number().int().nonnegative().optional(),
enableAiSummarization: z.boolean().optional(),
summaryMaxTokens: z.number().int().positive().optional(),
preserveFileOperations: z.boolean().optional(),
preserveDecisions: z.boolean().optional(),
})
export const CompactionResultSchema = z.object({
success: z.boolean(),
mode: z.enum(["prune", "compact"]),
human_summary: z.string().min(1, "Human summary is required"),
detailed_summary: StructuredSummarySchema.optional(),
token_before: z.number().int().nonnegative(),
token_after: z.number().int().nonnegative(),
token_reduction_pct: z.number().int().min(0).max(100),
compaction_event: CompactionEventSchema.optional(),
preview: z.string().optional(),
})
export type SecretRedaction = z.infer<typeof SecretRedactionSchema>
export type Provenance = z.infer<typeof ProvenanceSchema>
export type KeyDecision = z.infer<typeof KeyDecisionSchema>
export type Artifact = z.infer<typeof ArtifactSchema>
export type FileReference = z.infer<typeof FileReferenceSchema>
export type StructuredSummary = z.infer<typeof StructuredSummarySchema>
export type CompactionEvent = z.infer<typeof CompactionEventSchema>
export type CompactionConfig = z.infer<typeof CompactionConfigSchema>
export type CompactionResult = z.infer<typeof CompactionResultSchema>
export function validateStructuredSummary(data: unknown): { success: true; data: StructuredSummary } | { success: false; errors: string[] } {
const result = StructuredSummarySchema.safeParse(data)
if (!result.success) {
const errors = result.error.errors.map((e) => `${e.path.join(".")}: ${e.message}`)
return { success: false, errors }
}
return { success: true, data: result.data }
}
export function validateCompactionEvent(data: unknown): { success: true; data: CompactionEvent } | { success: false; errors: string[] } {
const result = CompactionEventSchema.safeParse(data)
if (!result.success) {
const errors = result.error.errors.map((e) => `${e.path.join(".")}: ${e.message}`)
return { success: false, errors }
}
return { success: true, data: result.data }
}
export function validateCompactionResult(data: unknown): { success: true; data: CompactionResult } | { success: false; errors: string[] } {
const result = CompactionResultSchema.safeParse(data)
if (!result.success) {
const errors = result.error.errors.map((e) => `${e.path.join(".")}: ${e.message}`)
return { success: false, errors }
}
return { success: true, data: result.data }
}
export function validateCompactionConfig(data: unknown): { success: true; data: CompactionConfig } | { success: false; errors: string[] } {
const result = CompactionConfigSchema.safeParse(data)
if (!result.success) {
const errors = result.error.errors.map((e) => `${e.path.join(".")}: ${e.message}`)
return { success: false, errors }
}
return { success: true, data: result.data }
}
export function sanitizeStructuredSummary(input: unknown): StructuredSummary | null {
const result = validateStructuredSummary(input)
if (!result.success) {
log.warn("Invalid structured summary, using fallback", { errors: result.errors })
return null
}
return result.data
}
export function createDefaultStructuredSummary(aggressive: boolean = false): StructuredSummary {
return {
timestamp: new Date().toISOString(),
summary_type: "tierA_short",
what_was_done: ["Session compaction completed"],
files: [],
current_state: "Session context has been compacted",
key_decisions: [],
next_steps: [],
blockers: [],
artifacts: [],
tags: [],
provenance: {
model: "system",
token_count: 0,
redactions: [],
},
aggressive,
}
}

View File

@@ -11,7 +11,7 @@ import {
getSessions,
setActiveSession,
} from "../../stores/sessions"
import { setSessionCompactionState } from "../../stores/session-compaction"
import { compactSession } from "../../stores/session-actions"
import { showAlertDialog } from "../../stores/alerts"
import type { Instance } from "../../types/instance"
import type { MessageRecord } from "../../stores/message-v2/types"
@@ -235,21 +235,9 @@ export function useCommands(options: UseCommandsOptions) {
const sessionId = activeSessionIdForInstance()
if (!instance || !instance.client || !sessionId || sessionId === "info") return
const sessions = getSessions(instance.id)
const session = sessions.find((s) => s.id === sessionId)
if (!session) return
try {
setSessionCompactionState(instance.id, sessionId, true)
await instance.client.session.summarize({
path: { id: sessionId },
body: {
providerID: session.model.providerId,
modelID: session.model.modelId,
},
})
await compactSession(instance.id, sessionId)
} catch (error) {
setSessionCompactionState(instance.id, sessionId, false)
log.error("Failed to compact session", error)
const message = error instanceof Error ? error.message : "Failed to compact session"
showAlertDialog(`Compact failed: ${message}`, {

View File

@@ -0,0 +1,286 @@
/**
* Qwen OAuth Chat Service
* Routes chat requests through the Qwen API using OAuth tokens
* Based on the qwen-code implementation from QwenLM/qwen-code
*/
import { getUserScopedKey } from "../user-storage"
const QWEN_TOKEN_STORAGE_KEY = 'qwen_oauth_token'
const DEFAULT_QWEN_ENDPOINT = 'https://dashscope-intl.aliyuncs.com'
export interface QwenToken {
access_token: string
token_type: string
expires_in: number
refresh_token?: string
resource_url?: string
created_at: number
}
export interface QwenChatMessage {
role: 'user' | 'assistant' | 'system'
content: string
}
export interface QwenChatRequest {
model: string
messages: QwenChatMessage[]
stream?: boolean
temperature?: number
max_tokens?: number
}
export interface QwenChatResponse {
id: string
object: string
created: number
model: string
choices: Array<{
index: number
message: {
role: string
content: string
}
finish_reason: string | null
}>
usage?: {
prompt_tokens: number
completion_tokens: number
total_tokens: number
}
}
export interface QwenStreamChunk {
id: string
object: string
created: number
model: string
choices: Array<{
index: number
delta: {
role?: string
content?: string
}
finish_reason: string | null
}>
}
/**
* Get stored Qwen OAuth token from localStorage
*/
export function getStoredQwenToken(): QwenToken | null {
try {
const stored = localStorage.getItem(getUserScopedKey(QWEN_TOKEN_STORAGE_KEY))
return stored ? JSON.parse(stored) : null
} catch {
return null
}
}
/**
* Check if Qwen OAuth token is valid and not expired
*/
export function isQwenTokenValid(token: QwenToken | null): boolean {
if (!token || !token.access_token) return false
const createdAt = token.created_at > 1e12 ? Math.floor(token.created_at / 1000) : token.created_at
const expiresAt = (createdAt + token.expires_in) * 1000 - 300000 // 5 min buffer
return Date.now() < expiresAt
}
/**
* Get the API endpoint URL for Qwen
* Uses resource_url from token if available, otherwise falls back to default
*/
export function getQwenEndpoint(token: QwenToken | null): string {
const baseEndpoint = token?.resource_url || DEFAULT_QWEN_ENDPOINT
// Normalize URL: add protocol if missing
const normalizedUrl = baseEndpoint.startsWith('http')
? baseEndpoint
: `https://${baseEndpoint}`
// Ensure /v1 suffix for OpenAI-compatible API
return normalizedUrl.endsWith('/v1')
? normalizedUrl
: `${normalizedUrl}/v1`
}
/**
* Send a chat completion request to Qwen API
*/
export async function sendQwenChatRequest(
request: QwenChatRequest
): Promise<QwenChatResponse> {
const token = getStoredQwenToken()
if (!isQwenTokenValid(token)) {
throw new Error('Qwen OAuth token is invalid or expired. Please re-authenticate.')
}
const endpoint = getQwenEndpoint(token)
const url = `${endpoint}/chat/completions`
console.log(`[QwenChat] Sending request to: ${url}`)
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token!.access_token}`,
'Accept': 'application/json'
},
body: JSON.stringify({
model: request.model || 'qwen-coder-plus-latest',
messages: request.messages,
stream: false,
temperature: request.temperature,
max_tokens: request.max_tokens
})
})
if (!response.ok) {
const errorText = await response.text()
console.error(`[QwenChat] Request failed: ${response.status}`, errorText)
// Check for auth errors that require re-authentication
if (response.status === 401 || response.status === 403) {
throw new Error('Qwen OAuth token expired. Please re-authenticate using /auth.')
}
throw new Error(`Qwen chat request failed: ${response.status} - ${errorText}`)
}
return await response.json()
}
/**
* Send a streaming chat completion request to Qwen API
*/
export async function* sendQwenChatStreamRequest(
request: QwenChatRequest
): AsyncGenerator<QwenStreamChunk> {
const token = getStoredQwenToken()
if (!isQwenTokenValid(token)) {
throw new Error('Qwen OAuth token is invalid or expired. Please re-authenticate.')
}
const endpoint = getQwenEndpoint(token)
const url = `${endpoint}/chat/completions`
console.log(`[QwenChat] Sending streaming request to: ${url}`)
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token!.access_token}`,
'Accept': 'text/event-stream'
},
body: JSON.stringify({
model: request.model || 'qwen-coder-plus-latest',
messages: request.messages,
stream: true,
temperature: request.temperature,
max_tokens: request.max_tokens
})
})
if (!response.ok) {
const errorText = await response.text()
console.error(`[QwenChat] Stream request failed: ${response.status}`, errorText)
throw new Error(`Qwen chat request failed: ${response.status} - ${errorText}`)
}
if (!response.body) {
throw new Error('Response body is missing')
}
const reader = response.body.getReader()
const decoder = new TextDecoder()
try {
let buffer = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
// Keep the last incomplete line in buffer
buffer = lines.pop() || ''
for (const line of lines) {
const trimmed = line.trim()
if (!trimmed || trimmed === 'data: [DONE]') {
continue
}
if (trimmed.startsWith('data: ')) {
try {
const data = JSON.parse(trimmed.slice(6))
yield data as QwenStreamChunk
} catch (e) {
console.warn('[QwenChat] Failed to parse SSE chunk:', trimmed)
}
}
}
}
} finally {
reader.releaseLock()
}
}
/**
* Get available Qwen models
*/
export async function getQwenModels(): Promise<{ id: string; name: string }[]> {
const token = getStoredQwenToken()
if (!isQwenTokenValid(token)) {
return []
}
const endpoint = getQwenEndpoint(token)
const url = `${endpoint}/models`
try {
const response = await fetch(url, {
headers: {
'Authorization': `Bearer ${token!.access_token}`,
'Accept': 'application/json'
}
})
if (!response.ok) {
console.warn(`[QwenChat] Failed to fetch models: ${response.status}`)
return getDefaultQwenModels()
}
const data = await response.json()
return (data.data || []).map((model: any) => ({
id: model.id,
name: model.id
}))
} catch (error) {
console.warn('[QwenChat] Error fetching models:', error)
return getDefaultQwenModels()
}
}
/**
* Get default Qwen models when API call fails
*/
function getDefaultQwenModels(): { id: string; name: string }[] {
return [
{ id: 'qwen-coder-plus-latest', name: 'Qwen Coder Plus' },
{ id: 'qwen-turbo-latest', name: 'Qwen Turbo' },
{ id: 'qwen-plus-latest', name: 'Qwen Plus' },
{ id: 'qwen-max-latest', name: 'Qwen Max' }
]
}

View File

@@ -4,8 +4,8 @@
*/
import { nanoid } from 'nanoid'
import type { AxiosInstance, AxiosResponse } from 'axios'
import { createSignal, onMount } from 'solid-js'
import { getUserScopedKey } from "../user-storage"
// Configuration schema
export interface QwenConfig {
@@ -13,6 +13,7 @@ export interface QwenConfig {
redirectUri?: string
scope?: string
baseUrl?: string
apiBaseUrl?: string
}
export interface QwenAuthToken {
@@ -22,6 +23,7 @@ export interface QwenAuthToken {
refresh_token?: string
scope?: string
created_at: number
resource_url?: string
}
export interface QwenUser {
@@ -43,82 +45,77 @@ export interface QwenOAuthState {
redirect_uri: string
}
function toBase64Url(bytes: Uint8Array): string {
let binary = ''
for (let i = 0; i < bytes.length; i += 1) {
binary += String.fromCharCode(bytes[i])
}
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
}
export class QwenOAuthManager {
private config: Required<QwenConfig>
private tokenStorageKey = 'qwen_oauth_token'
private userStorageKey = 'qwen_user_info'
private config: { clientId: string; redirectUri: string; scope: string; baseUrl: string }
private tokenStorageKey = getUserScopedKey('qwen_oauth_token')
private userStorageKey = getUserScopedKey('qwen_user_info')
constructor(config: QwenConfig = {}) {
this.config = {
clientId: config.clientId || 'qwen-code-client',
redirectUri: config.redirectUri || `${window.location.origin}/auth/qwen/callback`,
scope: config.scope || 'read write',
baseUrl: config.baseUrl || 'https://qwen.ai'
scope: config.scope || 'openid profile email model.completion',
baseUrl: config.apiBaseUrl || config.baseUrl || ''
}
}
/**
* Generate OAuth URL for authentication
* Request device authorization for Qwen OAuth
*/
async generateAuthUrl(): Promise<{ url: string; state: QwenOAuthState }> {
const state = await this.generateOAuthState()
const params = new URLSearchParams({
response_type: 'code',
client_id: this.config.clientId,
redirect_uri: this.config.redirectUri,
scope: this.config.scope,
state: state.state,
code_challenge: state.code_challenge,
code_challenge_method: 'S256'
async requestDeviceAuthorization(codeChallenge: string): Promise<{
device_code: string
user_code: string
verification_uri: string
verification_uri_complete: string
expires_in: number
}> {
const response = await fetch(`${this.config.baseUrl}/api/qwen/oauth/device`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
code_challenge: codeChallenge,
code_challenge_method: 'S256'
})
})
const authUrl = `${this.config.baseUrl}/oauth/authorize?${params.toString()}`
return {
url: authUrl,
state
if (!response.ok) {
const message = await response.text()
throw new Error(`Device authorization failed: ${message}`)
}
return await response.json()
}
/**
* Exchange authorization code for access token
* Poll device token endpoint
*/
async exchangeCodeForToken(code: string, state: string): Promise<QwenAuthToken> {
const storedState = this.getOAuthState(state)
if (!storedState) {
throw new Error('Invalid OAuth state')
}
try {
const response = await fetch(`${this.config.baseUrl}/oauth/token`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
grant_type: 'authorization_code',
client_id: this.config.clientId,
code,
redirect_uri: this.config.redirectUri,
code_verifier: storedState.code_verifier
})
async pollDeviceToken(deviceCode: string, codeVerifier: string): Promise<any> {
const response = await fetch(`${this.config.baseUrl}/api/qwen/oauth/token`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
device_code: deviceCode,
code_verifier: codeVerifier
})
})
if (!response.ok) {
throw new Error(`Token exchange failed: ${response.statusText}`)
}
const tokenData = await response.json()
const token = this.parseTokenResponse(tokenData)
// Store token
this.storeToken(token)
this.clearOAuthState(state)
return token
} catch (error) {
this.clearOAuthState(state)
throw error
const rawText = await response.text()
try {
return JSON.parse(rawText)
} catch {
throw new Error(`Token poll failed: ${rawText}`)
}
}
@@ -132,14 +129,12 @@ export class QwenOAuthManager {
}
try {
const response = await fetch(`${this.config.baseUrl}/oauth/token`, {
const response = await fetch(`${this.config.baseUrl}/api/qwen/oauth/refresh`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Content-Type': 'application/json',
},
body: new URLSearchParams({
grant_type: 'refresh_token',
client_id: this.config.clientId,
body: JSON.stringify({
refresh_token: currentToken.refresh_token
})
})
@@ -151,7 +146,7 @@ export class QwenOAuthManager {
const tokenData = await response.json()
const token = this.parseTokenResponse(tokenData)
this.storeToken(token)
return token
} catch (error) {
// If refresh fails, clear stored token
@@ -169,17 +164,28 @@ export class QwenOAuthManager {
throw new Error('Not authenticated')
}
const response = await fetch(`${this.config.baseUrl}/api/user`, {
headers: {
'Authorization': `Bearer ${token.access_token}`
try {
const response = await fetch(`/api/qwen/user`, {
headers: {
'Authorization': `Bearer ${token.access_token}`
}
})
if (!response.ok) {
throw new Error(`Failed to fetch user info: ${response.statusText}`)
}
const data = await response.json()
return data.user || data
} catch {
return {
id: 'qwen-oauth',
username: 'Qwen OAuth',
tier: 'Free',
limits: {
requests_per_day: 0,
requests_per_minute: 0
}
}
})
if (!response.ok) {
throw new Error(`Failed to fetch user info: ${response.statusText}`)
}
return await response.json()
}
/**
@@ -191,11 +197,7 @@ export class QwenOAuthManager {
return null
}
// Check if token is expired (with 5-minute buffer)
const now = Date.now()
const expiresAt = (token.created_at + token.expires_in) * 1000 - 300000 // 5 min buffer
if (now >= expiresAt) {
if (this.isTokenExpired(token)) {
try {
return await this.refreshToken()
} catch (error) {
@@ -207,37 +209,6 @@ export class QwenOAuthManager {
return token
}
/**
* Create authenticated HTTP client
*/
createApiClient(): AxiosInstance {
const axios = require('axios') as any
return axios.create({
baseURL: `${this.config.baseUrl}/api`,
timeout: 30000,
headers: {
'Content-Type': 'application/json'
}
})
}
/**
* Make authenticated API request
*/
async makeAuthenticatedRequest<T>(
client: AxiosInstance,
config: any
): Promise<AxiosResponse<T>> {
const token = await this.getValidToken()
if (!token) {
throw new Error('Authentication required')
}
client.defaults.headers.common['Authorization'] = `Bearer ${token.access_token}`
return client.request(config)
}
/**
* Sign out user
*/
@@ -250,8 +221,9 @@ export class QwenOAuthManager {
* Check if user is authenticated
*/
isAuthenticated(): boolean {
const token = this.getValidToken()
return token !== null
const token = this.getStoredToken()
if (!token) return false
return !this.isTokenExpired(token)
}
/**
@@ -269,7 +241,7 @@ export class QwenOAuthManager {
/**
* Store user info
*/
private storeUserInfo(user: QwenUser): void {
storeUserInfo(user: QwenUser): void {
localStorage.setItem(this.userStorageKey, JSON.stringify(user))
}
@@ -297,7 +269,7 @@ export class QwenOAuthManager {
// Store state temporarily
sessionStorage.setItem(`qwen_oauth_${state}`, JSON.stringify(oauthState))
return oauthState
}
@@ -323,38 +295,34 @@ export class QwenOAuthManager {
/**
* Generate code verifier for PKCE
*/
private generateCodeVerifier(): string {
generateCodeVerifier(): string {
const array = new Uint8Array(32)
crypto.getRandomValues(array)
return Array.from(array, byte => String.fromCharCode(byte)).join('')
return toBase64Url(array)
}
/**
* Generate code challenge for PKCE
*/
private async generateCodeChallenge(verifier: string): Promise<string> {
async generateCodeChallenge(verifier: string): Promise<string> {
const encoder = new TextEncoder()
const data = encoder.encode(verifier)
const digest = await crypto.subtle.digest('SHA-256', data)
return Array.from(new Uint8Array(digest))
.map(b => String.fromCharCode(b))
.join('')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '')
return toBase64Url(new Uint8Array(digest))
}
/**
* Parse token response
*/
private parseTokenResponse(data: any): QwenAuthToken {
parseTokenResponse(data: any): QwenAuthToken {
const token: QwenAuthToken = {
access_token: data.access_token,
token_type: data.token_type,
expires_in: data.expires_in,
refresh_token: data.refresh_token,
scope: data.scope,
created_at: Date.now()
resource_url: data.resource_url,
created_at: Math.floor(Date.now() / 1000)
}
return token
@@ -363,7 +331,7 @@ export class QwenOAuthManager {
/**
* Store token
*/
private storeToken(token: QwenAuthToken): void {
storeToken(token: QwenAuthToken): void {
localStorage.setItem(this.tokenStorageKey, JSON.stringify(token))
}
@@ -379,6 +347,16 @@ export class QwenOAuthManager {
}
}
getTokenInfo(): QwenAuthToken | null {
return this.getStoredToken()
}
private isTokenExpired(token: QwenAuthToken): boolean {
const createdAt = token.created_at > 1e12 ? Math.floor(token.created_at / 1000) : token.created_at
const expiresAt = (createdAt + token.expires_in) * 1000 - 300000
return Date.now() >= expiresAt
}
/**
* Clear token
*/
@@ -393,70 +371,82 @@ export function useQwenOAuth(config?: QwenConfig) {
const [isAuthenticated, setIsAuthenticated] = createSignal(false)
const [user, setUser] = createSignal<QwenUser | null>(null)
const [isLoading, setIsLoading] = createSignal(false)
const [tokenInfo, setTokenInfo] = createSignal<QwenAuthToken | null>(null)
// Check authentication status on mount
onMount(() => {
const manager = authManager()
if (manager.isAuthenticated()) {
manager.getValidToken().then((token) => {
if (!token) return
setIsAuthenticated(true)
setTokenInfo(manager.getTokenInfo())
const userInfo = manager.getUserInfo()
if (userInfo) {
setUser(userInfo)
}
}
}).catch(() => {
setIsAuthenticated(false)
})
})
const signIn = async () => {
setIsLoading(true)
try {
const manager = authManager()
const { url, state } = await manager.generateAuthUrl()
// Open popup window for OAuth
const codeVerifier = manager.generateCodeVerifier()
const codeChallenge = await manager.generateCodeChallenge(codeVerifier)
const deviceAuth = await manager.requestDeviceAuthorization(codeChallenge)
const popup = window.open(
url,
deviceAuth.verification_uri_complete,
'qwen-oauth',
'width=500,height=600,scrollbars=yes,resizable=yes'
)
if (!popup) {
throw new Error('Failed to open OAuth popup')
window.alert(
`Open this URL to authenticate: ${deviceAuth.verification_uri_complete}\n\nUser code: ${deviceAuth.user_code}`,
)
}
// Listen for popup close
const checkClosed = setInterval(() => {
if (popup.closed) {
clearInterval(checkClosed)
setIsLoading(false)
}
}, 1000)
const expiresAt = Date.now() + deviceAuth.expires_in * 1000
let pollInterval = 2000
// Listen for message from popup
const messageListener = async (event: MessageEvent) => {
if (event.origin !== window.location.origin) return
if (event.data.type === 'QWEN_OAUTH_SUCCESS') {
const { code, state } = event.data
await manager.exchangeCodeForToken(code, state)
while (Date.now() < expiresAt) {
const tokenData = await manager.pollDeviceToken(deviceAuth.device_code, codeVerifier)
if (tokenData?.access_token) {
const token = manager.parseTokenResponse(tokenData)
manager.storeToken(token)
setTokenInfo(manager.getTokenInfo())
const userInfo = await manager.fetchUserInfo()
setUser(userInfo)
if (userInfo) {
manager.storeUserInfo(userInfo)
setUser(userInfo)
} else {
setUser(null)
}
setIsAuthenticated(true)
setIsLoading(false)
popup.close()
} else if (event.data.type === 'QWEN_OAUTH_ERROR') {
setIsLoading(false)
popup.close()
popup?.close()
return
}
if (tokenData?.error === 'authorization_pending') {
await new Promise((resolve) => setTimeout(resolve, pollInterval))
continue
}
if (tokenData?.error === 'slow_down') {
pollInterval = Math.min(Math.ceil(pollInterval * 1.5), 10000)
await new Promise((resolve) => setTimeout(resolve, pollInterval))
continue
}
throw new Error(tokenData?.error_description || tokenData?.error || 'OAuth failed')
}
window.addEventListener('message', messageListener)
// Cleanup
setTimeout(() => {
clearInterval(checkClosed)
window.removeEventListener('message', messageListener)
setIsLoading(false)
}, 300000) // 5 minute timeout
throw new Error('OAuth timed out')
} catch (error) {
setIsLoading(false)
@@ -469,18 +459,15 @@ export function useQwenOAuth(config?: QwenConfig) {
manager.signOut()
setIsAuthenticated(false)
setUser(null)
}
const createApiClient = () => {
return authManager().createApiClient()
setTokenInfo(null)
}
return {
isAuthenticated: () => isAuthenticated(),
user: () => user(),
isLoading: () => isLoading(),
tokenInfo: () => tokenInfo(),
signIn,
signOut,
createApiClient
signOut
}
}
}

View File

@@ -0,0 +1,225 @@
import { getLogger } from "./logger.js"
const log = getLogger("secrets-detector")
export interface SecretMatch {
type: string
value: string
start: number
end: number
reason: string
}
export interface RedactionResult {
clean: string
redactions: { path: string; reason: string }[]
}
export interface SecretPattern {
name: string
pattern: RegExp
reason: string
}
const SECRET_PATTERNS: SecretPattern[] = [
{
name: "api_key",
pattern: /['"]?api[_-]?key['"]?\s*[:=]\s*['"]?([a-zA-Z0-9_-]{20,})['"]?/gi,
reason: "API key detected",
},
{
name: "bearer_token",
pattern: /bearer\s+([a-zA-Z0-9_-]{30,})/gi,
reason: "Bearer token detected",
},
{
name: "jwt_token",
pattern: /eyJ[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+/g,
reason: "JWT token detected",
},
{
name: "aws_access_key",
pattern: /AKIA[0-9A-Z]{16}/g,
reason: "AWS access key detected",
},
{
name: "aws_secret_key",
pattern: /['"]?aws[_-]?secret[_-]?access[_-]?key['"]?\s*[:=]\s*['"]?([a-zA-Z0-9/+]{40})['"]?/gi,
reason: "AWS secret key detected",
},
{
name: "private_key",
pattern: /-----BEGIN\s+(RSA\s+)?PRIVATE\s+KEY-----[\s\S]*?-----END\s+(RSA\s+)?PRIVATE\s+KEY-----/gi,
reason: "Private key detected",
},
{
name: "password",
pattern: /['"]?(password|passwd|pwd)['"]?\s*[:=]\s*['"]?([^'\s"]{8,})['"]?/gi,
reason: "Password field detected",
},
{
name: "secret",
pattern: /['"]?(secret|api[_-]?secret)['"]?\s*[:=]\s*['"]?([a-zA-Z0-9_-]{16,})['"]?/gi,
reason: "Secret field detected",
},
{
name: "token",
pattern: /['"]?(token|access[_-]?token|auth[_-]?token)['"]?\s*[:=]\s*['"]?([a-zA-Z0-9_-]{30,})['"]?/gi,
reason: "Auth token detected",
},
{
name: "github_token",
pattern: /gh[pous]_[a-zA-Z0-9]{36}/g,
reason: "GitHub token detected",
},
{
name: "openai_key",
pattern: /sk-[a-zA-Z0-9]{48}/g,
reason: "OpenAI API key detected",
},
{
name: "database_url",
pattern: /(mongodb|postgres|mysql|redis):\/\/[^\s'"]+/gi,
reason: "Database connection URL detected",
},
{
name: "credit_card",
pattern: /\b(?:\d[ -]*?){13,16}\b/g,
reason: "Potential credit card number detected",
},
{
name: "email",
pattern: /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b/g,
reason: "Email address detected",
},
{
name: "ip_address",
pattern: /\b(?:\d{1,3}\.){3}\d{1,3}\b/g,
reason: "IP address detected",
},
]
const REPLACEMENT_PLACEHOLDER = "[REDACTED]"
function detectSecrets(content: string): SecretMatch[] {
const matches: SecretMatch[] = []
for (const pattern of SECRET_PATTERNS) {
let match
const regex = new RegExp(pattern.pattern.source, pattern.pattern.flags)
while ((match = regex.exec(content)) !== null) {
matches.push({
type: pattern.name,
value: match[0],
start: match.index,
end: match.index + match[0].length,
reason: pattern.reason,
})
}
}
return matches.sort((a, b) => a.start - b.start)
}
function mergeOverlappingMatches(matches: SecretMatch[]): SecretMatch[] {
if (matches.length === 0) return []
const merged: SecretMatch[] = [matches[0]]
for (let i = 1; i < matches.length; i++) {
const current = matches[i]
const last = merged[merged.length - 1]
if (current.start <= last.end) {
last.end = Math.max(last.end, current.end)
if (!last.reason.includes(current.reason)) {
last.reason += ` | ${current.reason}`
}
} else {
merged.push(current)
}
}
return merged
}
export function redactSecrets(content: string, contextPath: string = "unknown"): RedactionResult {
if (!content || typeof content !== "string") {
return { clean: content, redactions: [] }
}
const rawMatches = detectSecrets(content)
const mergedMatches = mergeOverlappingMatches(rawMatches)
if (mergedMatches.length === 0) {
return { clean: content, redactions: [] }
}
let result = ""
let lastIndex = 0
const redactions: { path: string; reason: string }[] = []
for (const match of mergedMatches) {
result += content.slice(lastIndex, match.start)
result += REPLACEMENT_PLACEHOLDER
lastIndex = match.end
redactions.push({
path: `${contextPath}[${match.start}:${match.end}]`,
reason: match.reason,
})
}
result += content.slice(lastIndex)
log.info("Redacted secrets", { contextPath, count: redactions.length, types: mergedMatches.map((m) => m.type) })
return { clean: result, redactions }
}
export function hasSecrets(content: string): boolean {
if (!content || typeof content !== "string") {
return false
}
return SECRET_PATTERNS.some((pattern) => pattern.pattern.test(content))
}
export function redactObject(obj: any, contextPath: string = "root"): any {
if (obj === null || obj === undefined) {
return obj
}
if (typeof obj === "string") {
const result = redactSecrets(obj, contextPath)
return result.clean
}
if (Array.isArray(obj)) {
return obj.map((item, index) => redactObject(item, `${contextPath}[${index}]`))
}
if (typeof obj === "object") {
const result: any = {}
for (const key in obj) {
if (Object.prototype.hasOwnProperty.call(obj, key)) {
result[key] = redactObject(obj[key], `${contextPath}.${key}`)
}
}
return result
}
return obj
}
export function getSecretsReport(content: string): { total: number; byType: Record<string, number> } {
const matches = detectSecrets(content)
const byType: Record<string, number> = {}
for (const match of matches) {
byType[match.type] = (byType[match.type] || 0) + 1
}
return { total: matches.length, byType }
}

View File

@@ -1,4 +1,8 @@
export type SessionSidebarRequestAction = "focus-agent-selector" | "focus-model-selector" | "show-session-list"
export type SessionSidebarRequestAction =
| "focus-agent-selector"
| "focus-model-selector"
| "show-session-list"
| "show-skills"
export interface SessionSidebarRequestDetail {
instanceId: string

View File

@@ -0,0 +1,7 @@
import { activeUser } from "../stores/users"
export function getUserScopedKey(baseKey: string): string {
const userId = activeUser()?.id
if (!userId) return baseKey
return `${baseKey}:${userId}`
}

View File

@@ -1,32 +1,36 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>CodeNomad</title>
<style>
:root {
color-scheme: dark;
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>NomadArch</title>
<style>
:root {
color-scheme: dark;
}
html,
body {
background-color: #1a1a1a;
color: #e0e0e0;
}
</style>
<script>
; (function () {
try {
document.documentElement.setAttribute('data-theme', 'dark')
} catch (error) {
const rawConsole = globalThis?.["console"]
rawConsole?.warn?.('Failed to apply initial theme', error)
}
html,
body {
background-color: #1a1a1a;
color: #e0e0e0;
}
</style>
<script>
;(function () {
try {
document.documentElement.setAttribute('data-theme', 'dark')
} catch (error) {
const rawConsole = globalThis?.["console"]
rawConsole?.warn?.('Failed to apply initial theme', error)
}
})()
</script>
</head>
<body>
<div id="root"></div>
<script type="module" src="./main.tsx"></script>
</body>
</html>
})()
</script>
</head>
<body>
<div id="root"></div>
<script type="module" src="./main.tsx"></script>
</body>
</html>

View File

@@ -29,12 +29,14 @@ button {
max-width: 520px;
width: 100%;
text-align: center;
animation: fadeIn 0.4s ease-out;
}
.loading-logo {
width: 180px;
height: auto;
filter: drop-shadow(0 20px 60px rgba(0, 0, 0, 0.45));
animation: logoPulse 3s ease-in-out infinite;
}
.loading-heading {
@@ -54,6 +56,7 @@ button {
margin: 0;
font-size: 1rem;
color: var(--text-muted, #aeb3c4);
animation: fadeIn 0.3s ease-out;
}
.loading-card {
@@ -64,7 +67,13 @@ button {
border-radius: 18px;
background: rgba(13, 16, 24, 0.85);
border: 1px solid rgba(255, 255, 255, 0.08);
box-shadow: 0 25px 60px rgba(0, 0, 0, 0.55);
box-shadow: 0 25px 60px rgba(0, 0, 0, 0.55), 0 0 0 1px rgba(108, 227, 255, 0.05);
transition: border-color 0.3s ease, box-shadow 0.3s ease;
}
.loading-card:hover {
border-color: rgba(108, 227, 255, 0.15);
box-shadow: 0 25px 60px rgba(0, 0, 0, 0.55), 0 0 0 1px rgba(108, 227, 255, 0.1);
}
.loading-row {
@@ -81,7 +90,8 @@ button {
border-radius: 50%;
border: 2px solid rgba(255, 255, 255, 0.18);
border-top-color: #6ce3ff;
animation: spin 0.9s linear infinite;
animation: spin 0.9s cubic-bezier(0.5, 0, 0.5, 1) infinite;
box-shadow: 0 0 10px rgba(108, 227, 255, 0.3);
}
.phrase-controls {
@@ -93,12 +103,29 @@ button {
.phrase-controls button {
color: #8fb5ff;
cursor: pointer;
padding: 4px 12px;
border-radius: 6px;
transition: all 0.2s ease;
}
.phrase-controls button:hover {
background: rgba(143, 181, 255, 0.1);
transform: translateY(-1px);
}
.phrase-controls button:active {
transform: translateY(0);
}
.loading-error {
margin-top: 12px;
padding: 12px 16px;
background: rgba(255, 94, 109, 0.1);
border: 1px solid rgba(255, 94, 109, 0.2);
border-radius: 8px;
color: #ff9ea9;
font-size: 0.95rem;
font-size: 0.9rem;
animation: fadeIn 0.3s ease-out;
}
@keyframes spin {
@@ -109,3 +136,23 @@ button {
transform: rotate(360deg);
}
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes logoPulse {
0%, 100% {
transform: scale(1);
}
50% {
transform: scale(1.02);
}
}

View File

@@ -1,6 +1,6 @@
import { Show, createSignal, onCleanup, onMount } from "solid-js"
import { render } from "solid-js/web"
import iconUrl from "../../images/CodeNomad-Icon.png"
import iconUrl from "../../images/NomadArch-Icon.png"
import { runtimeEnv, isTauriHost } from "../../lib/runtime-env"
import "../../index.css"
import "./loading.css"
@@ -202,7 +202,7 @@ function LoadingApp() {
<img src={iconUrl} alt="NomadArch" class="loading-logo" width="180" height="180" />
<div class="loading-heading">
<h1 class="loading-title">NomadArch 1.0</h1>
<p class="loading-subtitle" style={{ fontSize: '14px', color: '#666', marginTop: '4px' }}>A fork of OpenCode</p>
<p class="loading-subtitle" style={{ "font-size": '14px', "color": '#666', "margin-top": '4px' }}>A fork of OpenCode</p>
<Show when={status()}>{(statusText) => <p class="loading-status">{statusText()}</p>}</Show>
</div>
<div class="loading-card">

View File

@@ -0,0 +1,273 @@
import assert from "node:assert/strict"
import { beforeEach, describe, it, mock } from "node:test"
import type { CompactionResult } from "../session-compaction.js"
import {
getCompactionConfig,
updateCompactionConfig,
undoCompaction,
rehydrateSession,
checkAndTriggerAutoCompact,
setSessionCompactionState,
getSessionCompactionState,
estimateTokenReduction,
executeCompactionWrapper,
} from "../session-compaction.js"
import type { CompactionEvent, StructuredSummary } from "../../lib/compaction-schema.js"
const MOCK_INSTANCE_ID = "test-instance-123"
const MOCK_SESSION_ID = "test-session-456"
const MOCK_MESSAGE_ID = "msg-789"
function createMockMessage(id: string, content: string = "Test message"): any {
return {
id,
sessionId: MOCK_SESSION_ID,
role: "user",
content,
status: "complete",
parts: [{ id: `part-${id}`, type: "text", text: content, sessionID: MOCK_SESSION_ID, messageID: id }],
createdAt: Date.now(),
updatedAt: Date.now(),
}
}
function createMockUsage(tokens: number = 10000): any {
return {
totalInputTokens: Math.floor(tokens * 0.7),
totalOutputTokens: Math.floor(tokens * 0.2),
totalReasoningTokens: Math.floor(tokens * 0.1),
}
}
describe("session compaction", () => {
beforeEach(() => {
updateCompactionConfig({
autoCompactEnabled: true,
autoCompactThreshold: 90,
compactPreserveWindow: 5000,
pruneReclaimThreshold: 10000,
userPreference: "auto",
undoRetentionWindow: 5,
})
})
describe("getCompactionConfig", () => {
it("returns default config", () => {
const config = getCompactionConfig()
assert.equal(typeof config.autoCompactEnabled, "boolean")
assert.equal(typeof config.autoCompactThreshold, "number")
assert.equal(typeof config.compactPreserveWindow, "number")
assert.equal(typeof config.pruneReclaimThreshold, "number")
assert.equal(typeof config.userPreference, "string")
assert.equal(typeof config.undoRetentionWindow, "number")
})
it("allows config updates", () => {
updateCompactionConfig({
autoCompactEnabled: false,
autoCompactThreshold: 80,
compactPreserveWindow: 4000,
pruneReclaimThreshold: 8000,
userPreference: "ask",
undoRetentionWindow: 10,
})
const config = getCompactionConfig()
assert.equal(config.autoCompactEnabled, false)
assert.equal(config.autoCompactThreshold, 80)
assert.equal(config.userPreference, "ask")
assert.equal(config.undoRetentionWindow, 10)
})
})
describe("setSessionCompactionState and getSessionCompactionState", () => {
it("tracks compaction state for sessions", () => {
setSessionCompactionState(MOCK_INSTANCE_ID, MOCK_SESSION_ID, true)
const isCompacting = getSessionCompactionState(MOCK_INSTANCE_ID, MOCK_SESSION_ID)
assert.ok(isCompacting)
})
it("returns undefined for unknown sessions", () => {
const isCompacting = getSessionCompactionState("unknown-instance", "unknown-session")
assert.equal(isCompacting, undefined)
})
it("clears compaction state", () => {
setSessionCompactionState(MOCK_INSTANCE_ID, MOCK_SESSION_ID, true)
setSessionCompactionState(MOCK_INSTANCE_ID, MOCK_SESSION_ID, false)
const isCompacting = getSessionCompactionState(MOCK_INSTANCE_ID, MOCK_SESSION_ID)
assert.ok(!isCompacting)
})
})
describe("estimateTokenReduction", () => {
it("calculates correct percentage reduction", () => {
const reduction = estimateTokenReduction(10000, 3000)
assert.equal(reduction, 70)
})
it("returns 0 when no reduction", () => {
const reduction = estimateTokenReduction(10000, 10000)
assert.equal(reduction, 0)
})
it("handles zero tokens", () => {
const reduction = estimateTokenReduction(0, 0)
assert.equal(reduction, 0)
})
it("caps at 100%", () => {
const reduction = estimateTokenReduction(10000, -5000)
assert.equal(reduction, 100)
})
it("handles small values", () => {
const reduction = estimateTokenReduction(100, 50)
assert.equal(reduction, 50)
})
})
describe("executeCompactionWrapper", () => {
it("compacts session successfully", async () => {
const mockStore = {
getSessionMessageIds: () => [MOCK_MESSAGE_ID],
getSessionUsage: () => createMockUsage(10000),
getMessage: (id: string) => createMockMessage(id, "Test content"),
upsertMessage: () => {},
setMessageInfo: () => {},
}
const getInstanceMock = mock.fn(() => mockStore)
const originalBus = (globalThis as any).messageStoreBus
;(globalThis as any).messageStoreBus = { getInstance: getInstanceMock }
const result = await executeCompactionWrapper(MOCK_INSTANCE_ID, MOCK_SESSION_ID, "compact")
assert.ok(result.success)
assert.equal(result.mode, "compact")
assert.ok(result.token_before > 0)
assert.ok(result.token_after >= 0)
assert.ok(result.token_reduction_pct >= 0)
assert.ok(result.human_summary.length > 0)
getInstanceMock.mock.restore()
if (originalBus) {
;(globalThis as any).messageStoreBus = originalBus
} else {
delete (globalThis as any).messageStoreBus
}
})
it("handles missing instance", async () => {
const getInstanceMock = mock.fn(() => null)
const originalBus = (globalThis as any).messageStoreBus
;(globalThis as any).messageStoreBus = { getInstance: getInstanceMock }
const result = await executeCompactionWrapper(MOCK_INSTANCE_ID, MOCK_SESSION_ID, "compact")
assert.ok(!result.success)
assert.equal(result.human_summary, "Instance not found")
getInstanceMock.mock.restore()
if (originalBus) {
;(globalThis as any).messageStoreBus = originalBus
} else {
delete (globalThis as any).messageStoreBus
}
})
it("handles prune mode", async () => {
const mockStore = {
getSessionMessageIds: () => [MOCK_MESSAGE_ID],
getSessionUsage: () => createMockUsage(10000),
getMessage: (id: string) => createMockMessage(id, "Test content"),
upsertMessage: () => {},
setMessageInfo: () => {},
}
const getInstanceMock = mock.fn(() => mockStore)
const originalBus = (globalThis as any).messageStoreBus
;(globalThis as any).messageStoreBus = { getInstance: getInstanceMock }
const result = await executeCompactionWrapper(MOCK_INSTANCE_ID, MOCK_SESSION_ID, "prune")
assert.ok(result.success)
assert.equal(result.mode, "prune")
getInstanceMock.mock.restore()
if (originalBus) {
;(globalThis as any).messageStoreBus = originalBus
} else {
delete (globalThis as any).messageStoreBus
}
})
})
describe("checkAndTriggerAutoCompact", () => {
it("does not trigger when user preference is never", async () => {
updateCompactionConfig({
autoCompactEnabled: true,
autoCompactThreshold: 90,
compactPreserveWindow: 5000,
pruneReclaimThreshold: 10000,
userPreference: "never",
undoRetentionWindow: 5,
})
const shouldCompact = await checkAndTriggerAutoCompact(MOCK_INSTANCE_ID, MOCK_SESSION_ID)
assert.ok(!shouldCompact)
})
it("returns false when no overflow", async () => {
const mockStore = {
getSessionUsage: () => createMockUsage(50000),
}
const getInstanceMock = mock.fn(() => mockStore)
const originalBus = (globalThis as any).messageStoreBus
;(globalThis as any).messageStoreBus = { getInstance: getInstanceMock }
const shouldCompact = await checkAndTriggerAutoCompact(MOCK_INSTANCE_ID, MOCK_SESSION_ID)
assert.ok(!shouldCompact)
getInstanceMock.mock.restore()
if (originalBus) {
;(globalThis as any).messageStoreBus = originalBus
} else {
delete (globalThis as any).messageStoreBus
}
})
it("triggers auto-compact when enabled", async () => {
updateCompactionConfig({
autoCompactEnabled: true,
autoCompactThreshold: 90,
compactPreserveWindow: 5000,
pruneReclaimThreshold: 10000,
userPreference: "auto",
undoRetentionWindow: 5,
})
const mockStore = {
getSessionUsage: () => createMockUsage(120000),
getSessionMessageIds: () => [MOCK_MESSAGE_ID],
getMessage: (id: string) => createMockMessage(id, "Test content"),
upsertMessage: () => {},
setMessageInfo: () => {},
}
const getInstanceMock = mock.fn(() => mockStore)
const originalBus = (globalThis as any).messageStoreBus
;(globalThis as any).messageStoreBus = { getInstance: getInstanceMock }
const shouldCompact = await checkAndTriggerAutoCompact(MOCK_INSTANCE_ID, MOCK_SESSION_ID)
assert.ok(shouldCompact)
getInstanceMock.mock.restore()
if (originalBus) {
;(globalThis as any).messageStoreBus = originalBus
} else {
delete (globalThis as any).messageStoreBus
}
})
})
})

View File

@@ -10,6 +10,7 @@ const DEFAULT_INSTANCE_DATA: InstanceData = {
agentModelSelections: {},
sessionTasks: {},
sessionSkills: {},
customAgents: [],
}
const [instanceDataMap, setInstanceDataMap] = createSignal<Map<string, InstanceData>>(new Map())
@@ -24,6 +25,7 @@ function cloneInstanceData(data?: InstanceData | null): InstanceData {
agentModelSelections: { ...(source.agentModelSelections ?? {}) },
sessionTasks: { ...(source.sessionTasks ?? {}) },
sessionSkills: { ...(source.sessionSkills ?? {}) },
customAgents: Array.isArray(source.customAgents) ? [...source.customAgents] : [],
}
}

View File

@@ -87,3 +87,20 @@ class MessageStoreBus {
}
export const messageStoreBus = new MessageStoreBus()
export async function archiveMessages(instanceId: string, sessionId: string, keepLastN: number = 2): Promise<void> {
const store = messageStoreBus.getInstance(instanceId)
if (!store) return
const messageIds = store.getSessionMessageIds(sessionId)
if (messageIds.length <= keepLastN) return
const messagesToArchive = messageIds.slice(0, -keepLastN)
const archiveId = `archived_${sessionId}_${Date.now()}`
for (const messageId of messagesToArchive) {
store.setMessageInfo(messageId, { archived: true } as any)
}
log.info("Archived messages", { instanceId, sessionId, count: messagesToArchive.length, archiveId })
}

View File

@@ -41,7 +41,7 @@ function ensureVisibilityEffect() {
if (!activeToast || activeToastVersion !== release.version) {
dismissActiveToast()
activeToast = showToastNotification({
title: `CodeNomad ${release.version}`,
title: `NomadArch ${release.version}`,
message: release.channel === "dev" ? "Dev release build available." : "New stable build on GitHub.",
variant: "info",
duration: Number.POSITIVE_INFINITY,

View File

@@ -9,12 +9,20 @@ import { updateSessionInfo } from "./message-v2/session-info"
import { messageStoreBus } from "./message-v2/bus"
import { buildRecordDisplayData } from "./message-v2/record-display-cache"
import { getLogger } from "../lib/logger"
import { executeCompactionWrapper, getSessionCompactionState, setSessionCompactionState, type CompactionResult } from "./session-compaction"
import {
executeCompactionWrapper,
getSessionCompactionState,
setSessionCompactionState,
setCompactionSuggestion,
clearCompactionSuggestion,
type CompactionResult,
} from "./session-compaction"
import { createSession, loadMessages } from "./session-api"
import { showToastNotification } from "../lib/notifications"
import { showConfirmDialog } from "./alerts"
import { QwenOAuthManager } from "../lib/integrations/qwen-oauth"
import { getUserScopedKey } from "../lib/user-storage"
import { loadSkillDetails } from "./skills"
import { serverApi } from "../lib/api-client"
const log = getLogger("actions")
@@ -28,16 +36,18 @@ const COMPACTION_ATTEMPT_TTL_MS = 60_000
const COMPACTION_SUMMARY_MAX_CHARS = 4000
const STREAM_TIMEOUT_MS = 120_000
const OPENCODE_ZEN_OFFLINE_STORAGE_KEY = "opencode-zen-offline-models"
const BUILD_PREVIEW_EVENT = "opencode:build-preview"
function markOpencodeZenModelOffline(modelId: string): void {
if (typeof window === "undefined" || !modelId) return
try {
const raw = window.localStorage.getItem(OPENCODE_ZEN_OFFLINE_STORAGE_KEY)
const key = getUserScopedKey(OPENCODE_ZEN_OFFLINE_STORAGE_KEY)
const raw = window.localStorage.getItem(key)
const parsed = raw ? JSON.parse(raw) : []
const list = Array.isArray(parsed) ? parsed : []
if (!list.includes(modelId)) {
list.push(modelId)
window.localStorage.setItem(OPENCODE_ZEN_OFFLINE_STORAGE_KEY, JSON.stringify(list))
window.localStorage.setItem(key, JSON.stringify(list))
window.dispatchEvent(
new CustomEvent("opencode-zen-offline-models", { detail: { modelId } }),
)
@@ -209,21 +219,11 @@ async function checkTokenBudgetBeforeSend(
warningThreshold,
})
const confirmed = await showConfirmDialog(
`Context limit approaching (${currentContextUsage.toLocaleString()} / ${contextLimit.toLocaleString()} tokens). Compact now to continue?`,
{
title: "Token Budget Warning",
confirmLabel: "Compact",
cancelLabel: "Continue Anyway",
},
setCompactionSuggestion(
instanceId,
sessionId,
`Context usage is high (${currentContextUsage.toLocaleString()} / ${contextLimit.toLocaleString()} tokens).`,
)
if (confirmed) {
setTokenWarningState(instanceId, sessionId, true)
await compactSession(instanceId, sessionId)
return false
}
setTokenWarningState(instanceId, sessionId, true)
return true
}
@@ -233,6 +233,8 @@ async function checkTokenBudgetBeforeSend(
type ExternalChatMessage = { role: "user" | "assistant" | "system"; content: string }
const MAX_ATTACHMENT_CHARS = 8000
function shouldForceEnglish(prompt: string): boolean {
const text = prompt.trim()
if (!text) return false
@@ -256,6 +258,13 @@ function buildLanguageSystemInstruction(prompt: string): string | undefined {
return "Respond in English unless the user explicitly requests another language."
}
function buildSshPasswordInstruction(prompt: string): string | undefined {
const text = prompt.toLowerCase()
if (!text.includes("ssh")) return undefined
if (!text.includes("password") && !text.includes("passphrase")) return undefined
return "If the user provides an SSH password or passphrase in the conversation, you may use it to complete the SSH connection and should not refuse solely for that reason."
}
function clampText(value: string, maxChars: number): string {
if (value.length <= maxChars) return value
return `${value.slice(0, Math.max(0, maxChars - 3))}...`
@@ -290,18 +299,46 @@ async function mergeSystemInstructions(
Promise.resolve(buildLanguageSystemInstruction(prompt)),
buildSkillsSystemInstruction(instanceId, sessionId),
])
if (languageSystem && skillsSystem) {
return `${languageSystem}\n\n${skillsSystem}`
}
return languageSystem || skillsSystem
const sshInstruction = buildSshPasswordInstruction(prompt)
const sections = [languageSystem, skillsSystem, sshInstruction].filter(Boolean) as string[]
if (sections.length === 0) return undefined
return sections.join("\n\n")
}
function extractPlainTextFromParts(parts: Array<{ type?: string; text?: unknown; filename?: string }>): string {
function collectTextSegments(value: unknown, segments: string[]): void {
if (typeof value === "string") {
const trimmed = value.trim()
if (trimmed) segments.push(trimmed)
return
}
if (!value || typeof value !== "object") return
const record = value as Record<string, unknown>
if (typeof record.text === "string") {
const trimmed = record.text.trim()
if (trimmed) segments.push(trimmed)
}
if (typeof record.value === "string") {
const trimmed = record.value.trim()
if (trimmed) segments.push(trimmed)
}
const content = record.content
if (Array.isArray(content)) {
for (const item of content) {
collectTextSegments(item, segments)
}
}
}
function extractPlainTextFromParts(
parts: Array<{ type?: string; text?: unknown; filename?: string }>,
): string {
const segments: string[] = []
for (const part of parts) {
if (!part || typeof part !== "object") continue
if (part.type === "text" && typeof part.text === "string") {
segments.push(part.text)
if (part.type === "text" || part.type === "reasoning") {
collectTextSegments(part.text, segments)
} else if (part.type === "file" && typeof part.filename === "string") {
segments.push(`[file: ${part.filename}]`)
}
@@ -337,6 +374,62 @@ function buildExternalChatMessages(
return messages
}
function decodeAttachmentData(data: Uint8Array): string {
const decoder = new TextDecoder()
return decoder.decode(data)
}
function isTextLikeMime(mime?: string): boolean {
if (!mime) return false
if (mime.startsWith("text/")) return true
return ["application/json", "application/xml", "application/x-yaml"].includes(mime)
}
async function buildExternalChatMessagesWithAttachments(
instanceId: string,
sessionId: string,
systemMessage: string | undefined,
attachments: Array<{ filename?: string; source?: any; mediaType?: string }>,
): Promise<ExternalChatMessage[]> {
const baseMessages = buildExternalChatMessages(instanceId, sessionId, systemMessage)
if (!attachments || attachments.length === 0) {
return baseMessages
}
const attachmentMessages: ExternalChatMessage[] = []
for (const attachment of attachments) {
const source = attachment?.source
if (!source || typeof source !== "object") continue
let content: string | null = null
if (source.type === "text" && typeof source.value === "string") {
content = source.value
} else if (source.type === "file") {
if (source.data instanceof Uint8Array && isTextLikeMime(source.mime || attachment.mediaType)) {
content = decodeAttachmentData(source.data)
} else if (typeof source.path === "string" && source.path.length > 0) {
try {
const response = await serverApi.readWorkspaceFile(instanceId, source.path)
content = typeof response.contents === "string" ? response.contents : null
} catch {
content = null
}
}
}
if (!content) continue
const filename = attachment.filename || source.path || "attachment"
const trimmed = clampText(content, MAX_ATTACHMENT_CHARS)
attachmentMessages.push({
role: "user",
content: `Attachment: ${filename}\n\n${trimmed}`,
})
}
return [...baseMessages, ...attachmentMessages]
}
async function readSseStream(
response: Response,
onData: (data: string) => void,
@@ -396,7 +489,7 @@ async function streamOllamaChat(
sessionId: string,
providerId: string,
modelId: string,
systemMessage: string | undefined,
messages: ExternalChatMessage[],
messageId: string,
assistantMessageId: string,
assistantPartId: string,
@@ -410,7 +503,7 @@ async function streamOllamaChat(
signal: controller.signal,
body: JSON.stringify({
model: modelId,
messages: buildExternalChatMessages(instanceId, sessionId, systemMessage),
messages,
stream: true,
}),
})
@@ -477,7 +570,7 @@ async function streamQwenChat(
sessionId: string,
providerId: string,
modelId: string,
systemMessage: string | undefined,
messages: ExternalChatMessage[],
accessToken: string,
resourceUrl: string | undefined,
messageId: string,
@@ -496,7 +589,7 @@ async function streamQwenChat(
signal: controller.signal,
body: JSON.stringify({
model: modelId,
messages: buildExternalChatMessages(instanceId, sessionId, systemMessage),
messages,
stream: true,
resource_url: resourceUrl,
}),
@@ -561,7 +654,7 @@ async function streamOpenCodeZenChat(
sessionId: string,
providerId: string,
modelId: string,
systemMessage: string | undefined,
messages: ExternalChatMessage[],
messageId: string,
assistantMessageId: string,
assistantPartId: string,
@@ -575,7 +668,7 @@ async function streamOpenCodeZenChat(
signal: controller.signal,
body: JSON.stringify({
model: modelId,
messages: buildExternalChatMessages(instanceId, sessionId, systemMessage),
messages,
stream: true,
}),
})
@@ -645,7 +738,7 @@ async function streamZAIChat(
sessionId: string,
providerId: string,
modelId: string,
systemMessage: string | undefined,
messages: ExternalChatMessage[],
messageId: string,
assistantMessageId: string,
assistantPartId: string,
@@ -659,7 +752,7 @@ async function streamZAIChat(
signal: controller.signal,
body: JSON.stringify({
model: modelId,
messages: buildExternalChatMessages(instanceId, sessionId, systemMessage),
messages,
stream: true,
}),
})
@@ -868,6 +961,12 @@ async function sendMessage(
const now = Date.now()
const assistantMessageId = createId("msg")
const assistantPartId = createId("part")
const externalMessages = await buildExternalChatMessagesWithAttachments(
instanceId,
sessionId,
systemMessage,
attachments,
)
store.upsertMessage({
id: assistantMessageId,
@@ -902,7 +1001,7 @@ async function sendMessage(
sessionId,
providerId,
effectiveModel.modelId,
systemMessage,
externalMessages,
messageId,
assistantMessageId,
assistantPartId,
@@ -913,7 +1012,7 @@ async function sendMessage(
sessionId,
providerId,
effectiveModel.modelId,
systemMessage,
externalMessages,
messageId,
assistantMessageId,
assistantPartId,
@@ -924,7 +1023,7 @@ async function sendMessage(
sessionId,
providerId,
effectiveModel.modelId,
systemMessage,
externalMessages,
messageId,
assistantMessageId,
assistantPartId,
@@ -962,7 +1061,7 @@ async function sendMessage(
sessionId,
providerId,
effectiveModel.modelId,
systemMessage,
externalMessages,
token.access_token,
token.resource_url,
messageId,
@@ -1151,12 +1250,29 @@ async function runShellCommand(instanceId: string, sessionId: string, command: s
}
const agent = session.agent || "build"
let resolvedCommand = command
if (command.trim() === "build") {
try {
const response = await serverApi.fetchAvailablePort()
if (response?.port) {
const isWindows = typeof navigator !== "undefined" && /windows/i.test(navigator.userAgent)
resolvedCommand = isWindows ? `set PORT=${response.port}&& ${command}` : `PORT=${response.port} ${command}`
if (typeof window !== "undefined") {
const url = `http://localhost:${response.port}`
window.dispatchEvent(new CustomEvent(BUILD_PREVIEW_EVENT, { detail: { url, instanceId } }))
}
}
} catch (error) {
log.warn("Failed to resolve available port for build", { error })
}
}
await instance.client.session.shell({
path: { id: sessionId },
body: {
agent,
command,
command: resolvedCommand,
},
})
}
@@ -1310,6 +1426,7 @@ async function compactSession(instanceId: string, sessionId: string): Promise<Co
})
log.info("compactSession: Complete", { instanceId, sessionId, compactedSessionId: compactedSession.id })
clearCompactionSuggestion(instanceId, sessionId)
return {
...result,
token_before: tokenBefore,
@@ -1407,6 +1524,30 @@ async function updateSessionModel(
updateSessionInfo(instanceId, sessionId)
}
async function updateSessionModelForSession(
instanceId: string,
sessionId: string,
model: { providerId: string; modelId: string },
): Promise<void> {
const instanceSessions = sessions().get(instanceId)
const session = instanceSessions?.get(sessionId)
if (!session) {
throw new Error("Session not found")
}
if (!isModelValid(instanceId, model)) {
log.warn("Invalid model selection", model)
return
}
withSession(instanceId, sessionId, (current) => {
current.model = model
})
addRecentModelPreference(model)
updateSessionInfo(instanceId, sessionId)
}
async function renameSession(instanceId: string, sessionId: string, nextTitle: string): Promise<void> {
const instance = instances().get(instanceId)
if (!instance || !instance.client) {
@@ -1500,4 +1641,5 @@ export {
sendMessage,
updateSessionAgent,
updateSessionModel,
updateSessionModelForSession,
}

View File

@@ -33,6 +33,7 @@ import { messageStoreBus } from "./message-v2/bus"
import { clearCacheForSession } from "../lib/global-cache"
import { getLogger } from "../lib/logger"
import { showToastNotification } from "../lib/notifications"
import { getUserScopedKey } from "../lib/user-storage"
const log = getLogger("api")
@@ -147,7 +148,7 @@ function getStoredQwenToken():
| null {
if (typeof window === "undefined") return null
try {
const raw = window.localStorage.getItem("qwen_oauth_token")
const raw = window.localStorage.getItem(getUserScopedKey("qwen_oauth_token"))
if (!raw) return null
return JSON.parse(raw)
} catch {
@@ -689,6 +690,7 @@ async function fetchAgents(instanceId: string): Promise<void> {
}
try {
await ensureInstanceConfigLoaded(instanceId)
log.info(`[HTTP] GET /app.agents for instance ${instanceId}`)
const response = await instance.client.app.agents()
const agentList = (response.data ?? []).map((agent) => ({
@@ -703,9 +705,16 @@ async function fetchAgents(instanceId: string): Promise<void> {
: undefined,
}))
const customAgents = getInstanceConfig(instanceId)?.customAgents ?? []
const customList = customAgents.map((agent) => ({
name: agent.name,
description: agent.description || "",
mode: "custom",
}))
setAgents((prev) => {
const next = new Map(prev)
next.set(instanceId, agentList)
next.set(instanceId, [...agentList, ...customList])
return next
})
} catch (error) {

File diff suppressed because it is too large Load Diff

View File

@@ -19,12 +19,13 @@ import { getLogger } from "../lib/logger"
import { showToastNotification, ToastVariant } from "../lib/notifications"
import { instances, addPermissionToQueue, removePermissionFromQueue, sendPermissionResponse } from "./instances"
import { getSoloState, incrementStep, popFromTaskQueue, setActiveTaskId } from "./solo-store"
import { sendMessage } from "./session-actions"
import { sendMessage, consumeTokenWarningSuppression, consumeCompactionSuppression, updateSessionModel } from "./session-actions"
import { showAlertDialog } from "./alerts"
import { sessions, setSessions, withSession } from "./session-state"
import { normalizeMessagePart } from "./message-v2/normalizers"
import { updateSessionInfo } from "./message-v2/session-info"
import { addTaskMessage, replaceTaskMessageId } from "./task-actions"
import { checkAndTriggerAutoCompact, getSessionCompactionState, setCompactionSuggestion } from "./session-compaction"
const log = getLogger("sse")
import { loadMessages } from "./session-api"
@@ -39,6 +40,7 @@ import {
} from "./message-v2/bridge"
import { messageStoreBus } from "./message-v2/bus"
import type { InstanceMessageStore } from "./message-v2/instance-store"
import { getDefaultModel } from "./session-models"
interface TuiToastEvent {
type: "tui.toast.show"
@@ -232,6 +234,16 @@ function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent | Mes
upsertMessageInfoV2(instanceId, info, { status, bumpRevision: true })
updateSessionInfo(instanceId, sessionId)
checkAndTriggerAutoCompact(instanceId, sessionId)
.then((shouldCompact) => {
if (!shouldCompact) return
if (getSessionCompactionState(instanceId, sessionId)) return
setCompactionSuggestion(instanceId, sessionId, "Context usage is high. Compact to continue.")
})
.catch((err) => {
log.error("Failed to check and trigger auto-compact", err)
})
}
}
@@ -389,6 +401,21 @@ function handleSessionCompacted(instanceId: string, event: EventSessionCompacted
})
}
function isContextLengthError(error: any): boolean {
if (!error) return false
const errorMessage = error.data?.message || error.message || ""
return (
errorMessage.includes("maximum context length") ||
errorMessage.includes("context_length_exceeded") ||
errorMessage.includes("token count exceeds") ||
errorMessage.includes("token limit")
)
}
function isUnsupportedModelMessage(message: string): boolean {
return /model\s+.+\s+not supported/i.test(message)
}
function handleSessionError(instanceId: string, event: EventSessionError): void {
const error = event.properties?.error
log.error(`[SSE] Session error:`, error)
@@ -406,18 +433,73 @@ function handleSessionError(instanceId: string, event: EventSessionError): void
// Autonomous error recovery for SOLO
const solo = getSoloState(instanceId)
const sessionId = (event.properties as any)?.sessionID
if (solo.isAutonomous && sessionId && solo.currentStep < solo.maxSteps) {
log.info(`[SOLO] Session error in autonomous mode, prompting fix: ${message}`)
incrementStep(instanceId)
sendMessage(instanceId, sessionId, `I encountered an error: "${message}". Please analyze the cause and provide a fix.`, [], solo.activeTaskId || undefined).catch((err) => {
log.error("[SOLO] Failed to send error recovery message", err)
})
} else {
showAlertDialog(`Error: ${message}`, {
title: "Session error",
variant: "error",
})
return
}
// Check if this is a context length error
if (isContextLengthError(error)) {
if (sessionId && consumeCompactionSuppression(instanceId, sessionId)) {
showAlertDialog("Compaction failed because the model context limit was exceeded. Reduce context or switch to a larger context model, then try compact again.", {
title: "Compaction failed",
variant: "error",
})
return
}
if (sessionId && consumeTokenWarningSuppression(instanceId, sessionId)) {
showToastNotification({
title: "Context limit exceeded",
message: "Compaction is required before continuing.",
variant: "warning",
duration: 7000,
})
return
}
log.info("Context length error detected; suggesting compaction", { instanceId, sessionId })
if (sessionId) {
setCompactionSuggestion(instanceId, sessionId, "Context limit exceeded. Compact to continue.")
showToastNotification({
title: "Compaction required",
message: "Click Compact to continue this session.",
variant: "warning",
duration: 8000,
})
} else {
showAlertDialog(`Error: ${message}`, {
title: "Session error",
variant: "error",
})
}
return
}
if (sessionId && isUnsupportedModelMessage(message)) {
showToastNotification({
title: "Model not supported",
message: "Selected model is not supported by this provider. Reverting to a default model.",
variant: "warning",
duration: 8000,
})
const sessionRecord = sessions().get(instanceId)?.get(sessionId)
getDefaultModel(instanceId, sessionRecord?.agent)
.then((fallback) => updateSessionModel(instanceId, sessionId, fallback))
.catch((err) => log.error("Failed to restore default model after unsupported model error", err))
return
}
// Default error handling
showAlertDialog(`Error: ${message}`, {
title: "Session error",
variant: "error",
})
}
function handleMessageRemoved(instanceId: string, event: MessageRemovedEvent): void {

View File

@@ -2,7 +2,7 @@ import type { Session, SessionStatus } from "../types/session"
import type { MessageInfo } from "../types/message"
import type { MessageRecord } from "./message-v2/types"
import { sessions } from "./sessions"
import { isSessionCompactionActive } from "./session-compaction"
import { getSessionCompactionState } from "./session-compaction"
import { messageStoreBus } from "./message-v2/bus"
function getSession(instanceId: string, sessionId: string): Session | null {
@@ -120,7 +120,7 @@ export function getSessionStatus(instanceId: string, sessionId: string): Session
const store = messageStoreBus.getOrCreate(instanceId)
if (isSessionCompactionActive(instanceId, sessionId) || isSessionCompacting(session)) {
if (getSessionCompactionState(instanceId, sessionId) || isSessionCompacting(session)) {
return "compacting"
}

View File

@@ -1,7 +1,8 @@
import { withSession } from "./session-state"
import { sessions, withSession } from "./session-state"
import { Task, TaskStatus } from "../types/session"
import { nanoid } from "nanoid"
import { forkSession } from "./session-api"
import { createSession } from "./session-api"
import { showToastNotification } from "../lib/notifications"
export function setActiveTask(instanceId: string, sessionId: string, taskId: string | undefined): void {
withSession(instanceId, sessionId, (session) => {
@@ -18,13 +19,32 @@ export async function addTask(
console.log("[task-actions] addTask started", { instanceId, sessionId, title, taskId: id });
let taskSessionId: string | undefined
const parentSession = sessions().get(instanceId)?.get(sessionId)
const parentAgent = parentSession?.agent || ""
const parentModel = parentSession?.model
try {
console.log("[task-actions] forking session...");
const forked = await forkSession(instanceId, sessionId)
taskSessionId = forked.id
console.log("[task-actions] fork successful", { taskSessionId });
console.log("[task-actions] creating new task session...");
const created = await createSession(instanceId, parentAgent || undefined, { skipAutoCleanup: true })
taskSessionId = created.id
withSession(instanceId, taskSessionId, (taskSession) => {
taskSession.parentId = sessionId
if (parentAgent) {
taskSession.agent = parentAgent
}
if (parentModel?.providerId && parentModel?.modelId) {
taskSession.model = { ...parentModel }
}
})
console.log("[task-actions] task session created", { taskSessionId });
} catch (error) {
console.error("[task-actions] Failed to fork session for task", error)
console.error("[task-actions] Failed to create session for task", error)
showToastNotification({
title: "Task session unavailable",
message: "Continuing in the current session.",
variant: "warning",
duration: 5000,
})
taskSessionId = undefined
}
const newTask: Task = {
@@ -34,6 +54,7 @@ export async function addTask(
timestamp: Date.now(),
messageIds: [],
taskSessionId,
archived: false,
}
withSession(instanceId, sessionId, (session) => {
@@ -161,3 +182,15 @@ export function removeTask(instanceId: string, sessionId: string, taskId: string
}
})
}
export function archiveTask(instanceId: string, sessionId: string, taskId: string): void {
withSession(instanceId, sessionId, (session) => {
if (!session.tasks) return
session.tasks = session.tasks.map((task) =>
task.id === taskId ? { ...task, archived: true } : task,
)
if (session.activeTaskId === taskId) {
session.activeTaskId = undefined
}
})
}

View File

@@ -4,6 +4,7 @@ const [hasInstances, setHasInstances] = createSignal(false)
const [selectedFolder, setSelectedFolder] = createSignal<string | null>(null)
const [isSelectingFolder, setIsSelectingFolder] = createSignal(false)
const [showFolderSelection, setShowFolderSelection] = createSignal(false)
const [showFolderSelectionOnStart, setShowFolderSelectionOnStart] = createSignal(true)
const [instanceTabOrder, setInstanceTabOrder] = createSignal<string[]>([])
const [sessionTabOrder, setSessionTabOrder] = createSignal<Map<string, string[]>>(new Map())
@@ -29,6 +30,8 @@ export {
setIsSelectingFolder,
showFolderSelection,
setShowFolderSelection,
showFolderSelectionOnStart,
setShowFolderSelectionOnStart,
instanceTabOrder,
setInstanceTabOrder,
sessionTabOrder,

View File

@@ -0,0 +1,89 @@
import { createSignal } from "solid-js"
import { getLogger } from "../lib/logger"
export interface UserAccount {
id: string
name: string
isGuest?: boolean
}
const log = getLogger("users")
const [users, setUsers] = createSignal<UserAccount[]>([])
const [activeUser, setActiveUserSignal] = createSignal<UserAccount | null>(null)
const [loadingUsers, setLoadingUsers] = createSignal(false)
function getElectronApi() {
return typeof window !== "undefined" ? window.electronAPI : undefined
}
async function refreshUsers(): Promise<void> {
const api = getElectronApi()
if (!api?.listUsers) return
setLoadingUsers(true)
try {
const list = await api.listUsers()
setUsers(list ?? [])
const active = api.getActiveUser ? await api.getActiveUser() : null
setActiveUserSignal(active ?? null)
} catch (error) {
log.warn("Failed to load users", error)
} finally {
setLoadingUsers(false)
}
}
async function createUser(name: string, password: string): Promise<UserAccount | null> {
const api = getElectronApi()
if (!api?.createUser) return null
const user = await api.createUser({ name, password })
await refreshUsers()
return user ?? null
}
async function updateUser(id: string, updates: { name?: string; password?: string }): Promise<UserAccount | null> {
const api = getElectronApi()
if (!api?.updateUser) return null
const user = await api.updateUser({ id, ...updates })
await refreshUsers()
return user ?? null
}
async function deleteUser(id: string): Promise<void> {
const api = getElectronApi()
if (!api?.deleteUser) return
await api.deleteUser({ id })
await refreshUsers()
}
async function loginUser(id: string, password?: string): Promise<boolean> {
const api = getElectronApi()
if (!api?.loginUser) return false
const result = await api.loginUser({ id, password })
if (result?.success) {
setActiveUserSignal(result.user ?? null)
await refreshUsers()
return true
}
return false
}
async function createGuest(): Promise<UserAccount | null> {
const api = getElectronApi()
if (!api?.createGuest) return null
const user = await api.createGuest()
await refreshUsers()
return user ?? null
}
export {
users,
activeUser,
loadingUsers,
refreshUsers,
createUser,
updateUser,
deleteUser,
loginUser,
createGuest,
}

View File

@@ -0,0 +1,95 @@
.mcp-manager {
@apply flex flex-col gap-3;
}
.mcp-manager-header {
@apply flex items-center justify-between;
}
.mcp-manager-actions {
@apply flex items-center gap-2;
}
.mcp-action-button {
@apply flex items-center gap-1 px-2 py-1 text-[10px] font-semibold uppercase tracking-wide rounded-md bg-white/5 border border-white/10 text-zinc-300 hover:text-white;
}
.mcp-link-button {
@apply px-2 py-1 text-[10px] font-semibold uppercase tracking-wide rounded-md bg-indigo-500/15 border border-indigo-500/30 text-indigo-300 hover:text-white;
}
.mcp-icon-button {
@apply p-1 rounded-md border border-white/10 text-zinc-400 hover:text-white hover:border-white/20;
}
.mcp-menu {
@apply absolute right-0 mt-2 w-48 rounded-md border border-white/10 bg-zinc-950 shadow-xl z-50 overflow-hidden;
}
.mcp-menu-item {
@apply w-full px-3 py-2 text-left text-[11px] text-zinc-300 hover:text-white hover:bg-white/5 flex items-center justify-between gap-2;
}
.mcp-server-list {
@apply flex flex-col gap-2;
}
.mcp-server-card {
@apply px-2 py-2 rounded border bg-white/5 border-white/10;
}
.mcp-server-row {
@apply flex items-center justify-between gap-2;
}
.mcp-status-chip {
@apply text-[10px] px-2 py-0.5 rounded-full border border-emerald-500/40 text-emerald-300 bg-emerald-500/10 uppercase tracking-wide;
}
.mcp-status-error {
@apply text-[10px] px-2 py-0.5 rounded-full border border-rose-500/40 text-rose-300 bg-rose-500/10 uppercase tracking-wide;
}
.mcp-market-search {
@apply flex items-center gap-2 border border-white/10 rounded-lg px-3 py-2 bg-white/5;
}
.mcp-market-input {
@apply w-full bg-transparent text-xs text-zinc-200 outline-none;
}
.mcp-market-list {
@apply flex flex-col gap-2 max-h-[60vh] overflow-y-auto pr-1;
}
.mcp-market-card {
@apply flex items-start justify-between gap-4 border border-white/10 rounded-lg bg-white/5 p-3;
}
.mcp-market-card-title {
@apply text-xs font-semibold text-zinc-100 flex items-center gap-2;
}
.mcp-market-card-desc {
@apply text-[11px] text-zinc-500 mt-1;
}
.mcp-market-tags {
@apply flex flex-wrap gap-1 mt-2;
}
.mcp-market-tag {
@apply text-[9px] uppercase tracking-wide px-2 py-0.5 rounded-full border border-white/10 text-zinc-400;
}
.mcp-market-source {
@apply text-[9px] uppercase tracking-wide px-2 py-0.5 rounded-full border border-white/10 text-zinc-500;
}
.mcp-market-card-actions {
@apply flex items-center gap-2;
}
.mcp-market-install {
@apply flex items-center gap-1 px-2 py-1 text-[10px] font-semibold uppercase tracking-wide rounded-md bg-indigo-500/20 border border-indigo-500/40 text-indigo-200 hover:text-white;
}

View File

@@ -6,3 +6,4 @@
@import "./components/env-vars.css";
@import "./components/directory-browser.css";
@import "./components/remote-access.css";
@import "./components/mcp-manager.css";

View File

@@ -53,6 +53,20 @@
@apply text-[11px] text-[var(--text-muted)];
}
.message-model-badge {
@apply inline-flex items-center px-2 py-1 rounded-full border;
border-color: var(--border-base);
background-color: var(--surface-secondary);
color: var(--text-muted);
transition: all 0.2s ease;
}
.message-model-badge:hover {
border-color: var(--accent-primary);
color: var(--accent-primary);
background-color: var(--surface-hover);
}
.assistant-message {
/* gap: 0.25rem; */
padding: 0.6rem 0.65rem;
@@ -121,6 +135,13 @@
border-color: var(--status-error);
}
.compact-button {
@apply ml-2 px-2 py-1 rounded bg-emerald-500/20 border border-emerald-500/40 text-emerald-400 text-xs font-semibold hover:bg-emerald-500/30 transition-all;
display: inline-flex;
align-items: center;
gap: 0.25rem;
}
.message-generating {
@apply text-sm italic py-2;
color: var(--text-muted);
@@ -146,6 +167,58 @@
animation: pulse 1.5s ease-in-out infinite;
}
.message-streaming-indicator {
@apply inline-flex items-center gap-2 px-3 py-2 rounded-lg border border-purple-500/50 bg-purple-500/10 mb-2;
}
.streaming-status {
@apply inline-flex items-center gap-2 text-sm;
}
.streaming-pulse {
@apply inline-block w-2 h-2 rounded-full bg-purple-500;
animation: streaming-pulse 1s ease-in-out infinite;
}
@keyframes streaming-pulse {
0%, 100% {
opacity: 1;
transform: scale(1);
box-shadow: 0 0 8px 4px rgba(168, 85, 247, 0.6);
}
50% {
opacity: 0.6;
transform: scale(0.8);
box-shadow: 0 0 12px 6px rgba(168, 85, 247, 0.8);
}
}
.streaming-text {
@apply text-purple-400 font-semibold tracking-wide;
animation: streaming-text-pulse 1.5s ease-in-out infinite;
}
@keyframes streaming-text-pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.7;
}
}
.streaming-tokens {
@apply inline-flex items-center gap-1 px-2 py-1 rounded-full bg-purple-500/20 border border-purple-500/30;
}
.streaming-token-count {
@apply text-purple-300 font-mono font-bold;
}
.streaming-token-label {
@apply text-purple-400 text-xs font-medium;
}
.message-text {
font-size: var(--font-size-base);
line-height: var(--line-height-normal);

View File

@@ -1,7 +1,27 @@
.message-stream {
@apply flex-1 min-h-0 overflow-y-auto flex flex-col gap-0.5;
@apply flex-1 min-h-0 overflow-y-auto overflow-x-hidden flex flex-col gap-0.5;
background-color: var(--surface-base);
color: inherit;
scrollbar-width: thin;
scrollbar-color: var(--border-base) transparent;
scrollbar-gutter: stable;
}
.message-stream::-webkit-scrollbar {
width: 8px;
}
.message-stream::-webkit-scrollbar-track {
background: transparent;
}
.message-stream::-webkit-scrollbar-thumb {
background: var(--border-base);
border-radius: 4px;
}
.message-stream::-webkit-scrollbar-thumb:hover {
background: var(--border-base-hover);
}
.message-stream-block {

View File

@@ -1,5 +1,54 @@
.message-stream-container {
@apply relative flex-1 min-h-0 flex flex-col overflow-hidden;
width: 100%;
}
.compaction-banner {
@apply sticky top-0 z-10 flex items-center gap-2 px-4 py-2 text-xs font-medium;
background-color: var(--surface-secondary);
color: var(--text-primary);
border-bottom: 1px solid var(--border-base);
}
.compaction-banner-spinner {
@apply w-4 h-4 border-2 border-t-transparent rounded-full;
border-color: var(--border-base);
border-top-color: var(--accent-primary);
animation: spin 1s linear infinite;
}
.compaction-suggestion {
@apply sticky top-0 z-10 flex items-center justify-between gap-3 px-4 py-2 text-xs font-medium;
background-color: rgba(22, 163, 74, 0.12);
color: var(--text-primary);
border-bottom: 1px solid rgba(22, 163, 74, 0.35);
}
.compaction-suggestion-text {
@apply flex flex-col gap-0.5;
}
.compaction-suggestion-label {
@apply uppercase tracking-wide text-[10px] font-semibold;
color: rgba(74, 222, 128, 0.9);
}
.compaction-suggestion-message {
color: var(--text-secondary);
}
.compaction-suggestion-action {
@apply inline-flex items-center justify-center px-3 py-1.5 rounded-lg text-[10px] font-semibold uppercase tracking-wide;
border: 1px solid rgba(34, 197, 94, 0.5);
background-color: rgba(34, 197, 94, 0.2);
color: #4ade80;
transition: transform 0.2s ease, background-color 0.2s ease, color 0.2s ease;
}
.compaction-suggestion-action:hover {
background-color: rgba(34, 197, 94, 0.3);
color: #86efac;
transform: translateY(-1px);
}
.connection-status {

View File

@@ -38,12 +38,14 @@
display: flex;
flex-direction: column;
position: relative;
overflow: hidden;
}
.message-stream-shell .message-stream {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
}
.message-timeline-sidebar {

View File

@@ -1,6 +1,6 @@
/* Prompt input & attachment styles */
.prompt-input-container {
@apply flex flex-col relative mx-auto w-full max-w-4xl;
@apply flex flex-col relative mx-auto w-full max-w-4xl flex-shrink-0;
padding: 1rem 1.5rem 1.5rem;
background-color: transparent;
}
@@ -32,6 +32,20 @@
@apply absolute right-2 bottom-2 flex items-center space-x-1.5 z-20;
}
.thinking-indicator {
@apply flex items-center gap-1.5 px-2 py-1 rounded-lg text-[10px] font-semibold uppercase tracking-wide;
border: 1px solid rgba(129, 140, 248, 0.4);
background-color: rgba(99, 102, 241, 0.12);
color: #a5b4fc;
}
.thinking-spinner {
@apply w-3 h-3 border-2 border-t-transparent rounded-full;
border-color: rgba(129, 140, 248, 0.4);
border-top-color: #a5b4fc;
animation: spin 0.9s linear infinite;
}
.send-button, .stop-button {
@apply w-8 h-8 rounded-xl border-none cursor-pointer flex items-center justify-center transition-all flex-shrink-0 shadow-lg;
}

View File

@@ -214,7 +214,7 @@
}
.tool-call-diff-toolbar {
@apply flex items-center justify-between gap-3 px-3 py-2;
@apply flex flex-wrap items-center gap-3 px-3 py-2;
background-color: var(--surface-secondary);
border-bottom: 1px solid var(--border-base);
}
@@ -227,7 +227,7 @@
}
.tool-call-diff-toggle {
@apply inline-flex items-center gap-1;
@apply inline-flex flex-wrap items-center gap-1;
}
.tool-call-diff-mode-button {

View File

@@ -0,0 +1,191 @@
/* Responsive Design for Electron Interface */
/* Base container adjustments for small screens */
@media (max-width: 640px) {
.session-shell-panels {
overflow: hidden;
}
.session-toolbar {
padding: 0.25rem 0.5rem;
min-height: 36px;
}
.session-toolbar button,
.session-toolbar .icon-button {
padding: 0.25rem;
}
.session-toolbar .hidden.md\:flex {
display: none !important;
}
.content-area {
min-width: 0;
}
}
/* Tablet adjustments */
@media (min-width: 641px) and (max-width: 1024px) {
.session-toolbar {
padding: 0.5rem 0.75rem;
}
.content-area {
min-width: 0;
}
}
/* Desktop adjustments */
@media (min-width: 1025px) {
.content-area {
min-width: 0;
}
}
/* Ensure all scrollable containers handle overflow properly */
@media (max-width: 768px) {
.flex-1.min-h-0 {
min-height: 0;
flex: 1 1 0%;
}
.overflow-y-auto {
overflow-y: auto;
}
.min-h-0 {
min-height: 0;
}
}
/* Fix drawer widths on mobile */
@media (max-width: 768px) {
.session-sidebar-container,
.session-right-panel {
max-width: 100vw !important;
}
}
/* Chat panel adjustments for small screens */
@media (max-width: 640px) {
.flex.flex-col.relative.border-l {
min-width: 280px !important;
}
}
/* Terminal adjustments */
@media (max-width: 768px) {
.terminal-panel {
min-height: 100px !important;
max-height: 40vh !important;
}
}
/* Prevent horizontal scroll on root levels only */
html,
body,
#root {
overflow-x: hidden;
width: 100%;
max-width: 100vw;
}
/* Ensure proper flex sizing throughout the app */
.flex-1 {
flex: 1 1 0%;
}
.min-h-0 {
min-height: 0;
}
/* Ensure scrollable containers work correctly */
.overflow-y-auto {
overflow-y: auto;
overflow-x: hidden;
}
/* Ensure viewport meta tag behavior */
@viewport {
width: device-width;
zoom: 1.0;
}
/* Touch-friendly adjustments for mobile */
@media (hover: none) and (pointer: coarse) {
.session-resize-handle {
width: 16px;
}
.message-scroll-button {
width: 3rem;
height: 3rem;
}
}
/* High DPI adjustments */
@media (-webkit-min-device-pixel-ratio: 2),
(min-resolution: 192dpi) {
/* Enhance text rendering on high-dpi screens */
body {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
}
/* Ensure the main app container is fully adaptive */
.app-container,
[data-app-container="true"],
#root>div {
width: 100%;
height: 100%;
max-width: 100vw;
max-height: 100vh;
overflow: hidden;
}
/* Fix message navigation sidebar from being cut off */
.message-navigation-sidebar,
[class*="message-nav"],
.shrink-0.overflow-y-auto.border-l {
min-width: 28px;
max-width: 48px;
flex-shrink: 0;
overflow-x: hidden;
}
/* Ensure panels don't overflow their containers */
.panel,
[role="main"],
main {
max-width: 100%;
overflow-x: hidden;
}
/* Fix right-side badges and avatars */
.message-avatar,
.message-role-badge,
[class*="shrink-0"][class*="border-l"] {
min-width: min-content;
overflow: visible;
}
/* Ensure proper Electron window behavior */
@media screen {
html,
body,
#root {
width: 100%;
height: 100%;
overflow: hidden;
}
}
/* Text rendering optimization */
body {
-webkit-font-smoothing: subpixel-antialiased;
}

View File

@@ -233,23 +233,6 @@
--new-tab-bg: #3f3f46;
--new-tab-hover-bg: #52525b;
--new-tab-text: #f5f6f8;
--session-tab-active-bg: var(--surface-muted);
--session-tab-active-text: var(--text-primary);
--session-tab-inactive-text: var(--text-muted);
--session-tab-hover-bg: #3f3f46;
--button-primary-bg: #3f3f46;
--button-primary-hover-bg: #52525b;
--button-primary-text: #f5f6f8;
--tab-active-bg: #3f3f46;
--tab-active-hover-bg: #52525b;
--tab-active-text: #f5f6f8;
--tab-inactive-bg: #2f2f36;
--tab-inactive-hover-bg: #3d3d45;
--tab-inactive-text: #d4d4d8;
--new-tab-bg: #3f3f46;
--new-tab-hover-bg: #52525b;
--new-tab-text: #f5f6f8;
--session-tab-active-bg: var(--surface-muted);
--session-tab-active-text: var(--text-primary);
--session-tab-inactive-text: var(--text-muted);

View File

@@ -48,7 +48,22 @@
}
.icon-danger-hover:hover {
color: var(--status-error);
color: #ef4444;
}
/* Tooltip styles */
.tooltip-content {
background-color: var(--surface-base);
border: 1px solid var(--border-base);
color: var(--text-primary);
border-radius: 6px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
max-width: 200px;
z-index: 1000;
}
.tooltip-content .font-bold {
color: var(--accent-primary);
}
.icon-accent-hover:hover {

View File

@@ -26,6 +26,13 @@ declare global {
onCliError?: (callback: (data: unknown) => void) => () => void
getCliStatus?: () => Promise<unknown>
openDialog?: (options: ElectronDialogOptions) => Promise<ElectronDialogResult>
listUsers?: () => Promise<Array<{ id: string; name: string; isGuest?: boolean }>>
getActiveUser?: () => Promise<{ id: string; name: string; isGuest?: boolean } | null>
createUser?: (payload: { name: string; password: string }) => Promise<{ id: string; name: string; isGuest?: boolean }>
updateUser?: (payload: { id: string; name?: string; password?: string }) => Promise<{ id: string; name: string; isGuest?: boolean }>
deleteUser?: (payload: { id: string }) => Promise<{ success: boolean }>
createGuest?: () => Promise<{ id: string; name: string; isGuest?: boolean }>
loginUser?: (payload: { id: string; password?: string }) => Promise<{ success: boolean; user?: { id: string; name: string; isGuest?: boolean } }>
}
interface TauriDialogModule {

View File

@@ -4,6 +4,7 @@ import { resolve } from "path"
export default defineConfig({
root: "./src/renderer",
publicDir: resolve(__dirname, "./public"),
plugins: [solid()],
css: {
postcss: "./postcss.config.js",
@@ -20,10 +21,11 @@ export default defineConfig({
noExternal: ["lucide-solid"],
},
server: {
port: 3000,
port: Number(process.env.VITE_PORT ?? 3000),
},
build: {
outDir: "dist",
outDir: resolve(__dirname, "dist"),
chunkSizeWarningLimit: 1000,
rollupOptions: {
input: {
main: resolve(__dirname, "./src/renderer/index.html"),

View File

@@ -0,0 +1,41 @@
// vite.config.ts
import { defineConfig } from "file:///E:/TRAE%20Playground/NeuralNomadsAi/NomadArch/node_modules/vite/dist/node/index.js";
import solid from "file:///E:/TRAE%20Playground/NeuralNomadsAi/NomadArch/node_modules/vite-plugin-solid/dist/esm/index.mjs";
import { resolve } from "path";
var __vite_injected_original_dirname = "E:\\TRAE Playground\\NeuralNomadsAi\\NomadArch\\packages\\ui";
var vite_config_default = defineConfig({
root: "./src/renderer",
publicDir: resolve(__vite_injected_original_dirname, "./public"),
plugins: [solid()],
css: {
postcss: "./postcss.config.js"
},
resolve: {
alias: {
"@": resolve(__vite_injected_original_dirname, "./src")
}
},
optimizeDeps: {
exclude: ["lucide-solid"]
},
ssr: {
noExternal: ["lucide-solid"]
},
server: {
port: Number(process.env.VITE_PORT ?? 3e3)
},
build: {
outDir: resolve(__vite_injected_original_dirname, "dist"),
chunkSizeWarningLimit: 1e3,
rollupOptions: {
input: {
main: resolve(__vite_injected_original_dirname, "./src/renderer/index.html"),
loading: resolve(__vite_injected_original_dirname, "./src/renderer/loading.html")
}
}
}
});
export {
vite_config_default as default
};
//# sourceMappingURL=data:application/json;base64,ewogICJ2ZXJzaW9uIjogMywKICAic291cmNlcyI6IFsidml0ZS5jb25maWcudHMiXSwKICAic291cmNlc0NvbnRlbnQiOiBbImNvbnN0IF9fdml0ZV9pbmplY3RlZF9vcmlnaW5hbF9kaXJuYW1lID0gXCJFOlxcXFxUUkFFIFBsYXlncm91bmRcXFxcTmV1cmFsTm9tYWRzQWlcXFxcTm9tYWRBcmNoXFxcXHBhY2thZ2VzXFxcXHVpXCI7Y29uc3QgX192aXRlX2luamVjdGVkX29yaWdpbmFsX2ZpbGVuYW1lID0gXCJFOlxcXFxUUkFFIFBsYXlncm91bmRcXFxcTmV1cmFsTm9tYWRzQWlcXFxcTm9tYWRBcmNoXFxcXHBhY2thZ2VzXFxcXHVpXFxcXHZpdGUuY29uZmlnLnRzXCI7Y29uc3QgX192aXRlX2luamVjdGVkX29yaWdpbmFsX2ltcG9ydF9tZXRhX3VybCA9IFwiZmlsZTovLy9FOi9UUkFFJTIwUGxheWdyb3VuZC9OZXVyYWxOb21hZHNBaS9Ob21hZEFyY2gvcGFja2FnZXMvdWkvdml0ZS5jb25maWcudHNcIjtpbXBvcnQgeyBkZWZpbmVDb25maWcgfSBmcm9tIFwidml0ZVwiXHJcbmltcG9ydCBzb2xpZCBmcm9tIFwidml0ZS1wbHVnaW4tc29saWRcIlxyXG5pbXBvcnQgeyByZXNvbHZlIH0gZnJvbSBcInBhdGhcIlxyXG5cclxuZXhwb3J0IGRlZmF1bHQgZGVmaW5lQ29uZmlnKHtcclxuICByb290OiBcIi4vc3JjL3JlbmRlcmVyXCIsXHJcbiAgcHVibGljRGlyOiByZXNvbHZlKF9fZGlybmFtZSwgXCIuL3B1YmxpY1wiKSxcclxuICBwbHVnaW5zOiBbc29saWQoKV0sXHJcbiAgY3NzOiB7XHJcbiAgICBwb3N0Y3NzOiBcIi4vcG9zdGNzcy5jb25maWcuanNcIixcclxuICB9LFxyXG4gIHJlc29sdmU6IHtcclxuICAgIGFsaWFzOiB7XHJcbiAgICAgIFwiQFwiOiByZXNvbHZlKF9fZGlybmFtZSwgXCIuL3NyY1wiKSxcclxuICAgIH0sXHJcbiAgfSxcclxuICBvcHRpbWl6ZURlcHM6IHtcclxuICAgIGV4Y2x1ZGU6IFtcImx1Y2lkZS1zb2xpZFwiXSxcclxuICB9LFxyXG4gIHNzcjoge1xyXG4gICAgbm9FeHRlcm5hbDogW1wibHVjaWRlLXNvbGlkXCJdLFxyXG4gIH0sXHJcbiAgc2VydmVyOiB7XHJcbiAgICBwb3J0OiBOdW1iZXIocHJvY2Vzcy5lbnYuVklURV9QT1JUID8/IDMwMDApLFxyXG4gIH0sXHJcbiAgYnVpbGQ6IHtcclxuICAgIG91dERpcjogcmVzb2x2ZShfX2Rpcm5hbWUsIFwiZGlzdFwiKSxcclxuICAgIGNodW5rU2l6ZVdhcm5pbmdMaW1pdDogMTAwMCxcclxuICAgIHJvbGx1cE9wdGlvbnM6IHtcclxuICAgICAgaW5wdXQ6IHtcclxuICAgICAgICBtYWluOiByZXNvbHZlKF9fZGlybmFtZSwgXCIuL3NyYy9yZW5kZXJlci9pbmRleC5odG1sXCIpLFxyXG4gICAgICAgIGxvYWRpbmc6IHJlc29sdmUoX19kaXJuYW1lLCBcIi4vc3JjL3JlbmRlcmVyL2xvYWRpbmcuaHRtbFwiKSxcclxuICAgICAgfSxcclxuICAgIH0sXHJcbiAgfSxcclxufSlcclxuIl0sCiAgIm1hcHBpbmdzIjogIjtBQUFxVyxTQUFTLG9CQUFvQjtBQUNsWSxPQUFPLFdBQVc7QUFDbEIsU0FBUyxlQUFlO0FBRnhCLElBQU0sbUNBQW1DO0FBSXpDLElBQU8sc0JBQVEsYUFBYTtBQUFBLEVBQzFCLE1BQU07QUFBQSxFQUNOLFdBQVcsUUFBUSxrQ0FBVyxVQUFVO0FBQUEsRUFDeEMsU0FBUyxDQUFDLE1BQU0sQ0FBQztBQUFBLEVBQ2pCLEtBQUs7QUFBQSxJQUNILFNBQVM7QUFBQSxFQUNYO0FBQUEsRUFDQSxTQUFTO0FBQUEsSUFDUCxPQUFPO0FBQUEsTUFDTCxLQUFLLFFBQVEsa0NBQVcsT0FBTztBQUFBLElBQ2pDO0FBQUEsRUFDRjtBQUFBLEVBQ0EsY0FBYztBQUFBLElBQ1osU0FBUyxDQUFDLGNBQWM7QUFBQSxFQUMxQjtBQUFBLEVBQ0EsS0FBSztBQUFBLElBQ0gsWUFBWSxDQUFDLGNBQWM7QUFBQSxFQUM3QjtBQUFBLEVBQ0EsUUFBUTtBQUFBLElBQ04sTUFBTSxPQUFPLFFBQVEsSUFBSSxhQUFhLEdBQUk7QUFBQSxFQUM1QztBQUFBLEVBQ0EsT0FBTztBQUFBLElBQ0wsUUFBUSxRQUFRLGtDQUFXLE1BQU07QUFBQSxJQUNqQyx1QkFBdUI7QUFBQSxJQUN2QixlQUFlO0FBQUEsTUFDYixPQUFPO0FBQUEsUUFDTCxNQUFNLFFBQVEsa0NBQVcsMkJBQTJCO0FBQUEsUUFDcEQsU0FBUyxRQUFRLGtDQUFXLDZCQUE2QjtBQUFBLE1BQzNEO0FBQUEsSUFDRjtBQUFBLEVBQ0Y7QUFDRixDQUFDOyIsCiAgIm5hbWVzIjogW10KfQo=