restore: bring back all custom UI enhancements from checkpoint
Restored from commit 52be710 (checkpoint before qwen oauth + todo roller): Enhanced UI Features: - SMART FIX button with AI code analysis - APEX (Autonomous Programming EXecution) mode - SHIELD (Auto-approval) mode - MULTIX MODE multi-task pipeline interface - Live streaming token counter - Thinking indicator with bouncing dots animation Components restored: - packages/ui/src/components/chat/multi-task-chat.tsx - packages/ui/src/components/instance/instance-shell2.tsx - packages/ui/src/components/settings/OllamaCloudSettings.tsx - packages/ui/src/components/settings/QwenCodeSettings.tsx - packages/ui/src/stores/solo-store.ts - packages/ui/src/stores/task-actions.ts - packages/ui/src/stores/session-events.ts (autonomous mode) - packages/server/src/integrations/ollama-cloud.ts - packages/server/src/server/routes/ollama.ts - packages/server/src/server/routes/qwen.ts This ensures all custom features are preserved in source control.
This commit is contained in:
@@ -1,7 +1,9 @@
|
||||
import { Component } from "solid-js"
|
||||
import { Component, createSignal, Show } from "solid-js"
|
||||
import { Dialog } from "@kobalte/core/dialog"
|
||||
import OpenCodeBinarySelector from "./opencode-binary-selector"
|
||||
import EnvironmentVariablesEditor from "./environment-variables-editor"
|
||||
import OllamaCloudSettings from "./settings/OllamaCloudSettings"
|
||||
import QwenCodeSettings from "./settings/QwenCodeSettings"
|
||||
|
||||
interface AdvancedSettingsModalProps {
|
||||
open: boolean
|
||||
@@ -12,35 +14,84 @@ interface AdvancedSettingsModalProps {
|
||||
}
|
||||
|
||||
const AdvancedSettingsModal: Component<AdvancedSettingsModalProps> = (props) => {
|
||||
const [activeTab, setActiveTab] = createSignal("general")
|
||||
|
||||
return (
|
||||
<Dialog open={props.open} onOpenChange={(open) => !open && props.onClose()}>
|
||||
<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-5xl max-h-[90vh] flex flex-col overflow-hidden">
|
||||
<Dialog.Content class="modal-surface w-full max-w-6xl max-h-[90vh] flex flex-col overflow-hidden">
|
||||
<header class="px-6 py-4 border-b" style={{ "border-color": "var(--border-base)" }}>
|
||||
<Dialog.Title class="text-xl font-semibold text-primary">Advanced Settings</Dialog.Title>
|
||||
</header>
|
||||
|
||||
<div class="flex-1 overflow-y-auto p-6 space-y-6">
|
||||
<OpenCodeBinarySelector
|
||||
selectedBinary={props.selectedBinary}
|
||||
onBinaryChange={props.onBinaryChange}
|
||||
disabled={Boolean(props.isLoading)}
|
||||
isVisible={props.open}
|
||||
/>
|
||||
|
||||
<div class="panel">
|
||||
<div class="panel-header">
|
||||
<h3 class="panel-title">Environment Variables</h3>
|
||||
<p class="panel-subtitle">Applied whenever a new OpenCode instance starts</p>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<EnvironmentVariablesEditor disabled={Boolean(props.isLoading)} />
|
||||
</div>
|
||||
<div class="border-b" style={{ "border-color": "var(--border-base)" }}>
|
||||
<div class="flex w-full px-6">
|
||||
<button
|
||||
class={`px-4 py-2 text-sm font-medium border-b-2 border-transparent hover:border-gray-300 ${
|
||||
activeTab() === "general"
|
||||
? "border-blue-500 text-blue-600 dark:text-blue-400"
|
||||
: ""
|
||||
}`}
|
||||
onClick={() => setActiveTab("general")}
|
||||
>
|
||||
General
|
||||
</button>
|
||||
<button
|
||||
class={`px-4 py-2 text-sm font-medium border-b-2 border-transparent hover:border-gray-300 ${
|
||||
activeTab() === "ollama"
|
||||
? "border-blue-500 text-blue-600 dark:text-blue-400"
|
||||
: ""
|
||||
}`}
|
||||
onClick={() => setActiveTab("ollama")}
|
||||
>
|
||||
Ollama Cloud
|
||||
</button>
|
||||
<button
|
||||
class={`px-4 py-2 text-sm font-medium border-b-2 border-transparent hover:border-gray-300 ${
|
||||
activeTab() === "qwen"
|
||||
? "border-blue-500 text-blue-600 dark:text-blue-400"
|
||||
: ""
|
||||
}`}
|
||||
onClick={() => setActiveTab("qwen")}
|
||||
>
|
||||
Qwen Code
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
<Show when={activeTab() === "general"}>
|
||||
<div class="p-6 space-y-6">
|
||||
<OpenCodeBinarySelector
|
||||
selectedBinary={props.selectedBinary}
|
||||
onBinaryChange={props.onBinaryChange}
|
||||
disabled={Boolean(props.isLoading)}
|
||||
isVisible={props.open}
|
||||
/>
|
||||
|
||||
<div class="panel">
|
||||
<div class="panel-header">
|
||||
<h3 class="panel-title">Environment Variables</h3>
|
||||
<p class="panel-subtitle">Applied whenever a new OpenCode instance starts</p>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<EnvironmentVariablesEditor disabled={Boolean(props.isLoading)} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={activeTab() === "ollama"}>
|
||||
<OllamaCloudSettings />
|
||||
</Show>
|
||||
|
||||
<Show when={activeTab() === "qwen"}>
|
||||
<QwenCodeSettings />
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div class="px-6 py-4 border-t flex justify-end" style={{ "border-color": "var(--border-base)" }}>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
334
packages/ui/src/components/chat/multi-task-chat-backup.tsx
Normal file
334
packages/ui/src/components/chat/multi-task-chat-backup.tsx
Normal file
@@ -0,0 +1,334 @@
|
||||
import { createSignal, createMemo, Show, For, onMount } from "solid-js";
|
||||
import { sessions, withSession, setActiveSession } from "@/stores/session-state";
|
||||
import { instances } from "@/stores/instances";
|
||||
import { sendMessage } from "@/stores/session-actions";
|
||||
import { addTask, setActiveTask } from "@/stores/task-actions";
|
||||
import { messageStoreBus } from "@/stores/message-v2/bus";
|
||||
import MessageBlockList from "@/components/message-block-list";
|
||||
import {
|
||||
Command,
|
||||
Plus,
|
||||
CheckCircle2,
|
||||
MoreHorizontal,
|
||||
PanelRight,
|
||||
ListTodo,
|
||||
AtSign,
|
||||
Hash,
|
||||
Mic,
|
||||
ArrowUp,
|
||||
Terminal,
|
||||
FileCode2,
|
||||
ChevronRight,
|
||||
Loader2,
|
||||
AlertCircle,
|
||||
Clock,
|
||||
Code2,
|
||||
} from "lucide-solid";
|
||||
import type { Task, TaskStatus } from "@/types/session";
|
||||
import type { InstanceMessageStore } from "@/stores/message-v2/instance-store";
|
||||
|
||||
interface MultiTaskChatProps {
|
||||
instanceId: string;
|
||||
sessionId: string;
|
||||
}
|
||||
|
||||
type PanelView = "tasks" | "active";
|
||||
|
||||
export default function MultiTaskChat(props: MultiTaskChatProps) {
|
||||
const session = () => {
|
||||
const instanceSessions = sessions().get(props.instanceId);
|
||||
return instanceSessions?.get(props.sessionId);
|
||||
};
|
||||
const selectedTaskId = () => session()?.activeTaskId || null;
|
||||
const setSelectedTaskId = (id: string | null) => setActiveTask(props.instanceId, props.sessionId, id || undefined);
|
||||
const [isCreatingTask, setIsCreatingTask] = createSignal(false);
|
||||
const [chatInput, setChatInput] = createSignal("");
|
||||
const [isSending, setIsSending] = createSignal(false);
|
||||
let scrollContainer: HTMLDivElement | undefined;
|
||||
const [bottomSentinel, setBottomSentinel] = createSignal<HTMLDivElement | null>(null);
|
||||
|
||||
// Message store integration for chat display
|
||||
const messageStore = () => messageStoreBus.getOrCreate(props.instanceId);
|
||||
const messageIds = () => messageStore().getSessionMessageIds(props.sessionId);
|
||||
const lastAssistantIndex = () => {
|
||||
const ids = messageIds();
|
||||
const store = messageStore();
|
||||
for (let i = ids.length - 1; i >= 0; i--) {
|
||||
const msg = store.getMessage(ids[i]);
|
||||
if (msg?.role === "assistant") return i;
|
||||
}
|
||||
return -1;
|
||||
};
|
||||
|
||||
// Handle message sending with comprehensive error handling
|
||||
const handleSendMessage = async () => {
|
||||
const message = chatInput().trim();
|
||||
if (!message || isSending()) return;
|
||||
|
||||
const currentInstance = instances().get(props.instanceId);
|
||||
const instanceSessions = sessions().get(props.instanceId);
|
||||
const currentSession = instanceSessions?.get(props.sessionId);
|
||||
const sessionTasks = currentSession?.tasks || [];
|
||||
const selectedTask = sessionTasks.find((task: Task) => task.id === selectedTaskId());
|
||||
|
||||
if (!currentInstance || !currentSession) {
|
||||
console.error("[MultiTaskChat] Instance or session not available");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSending(true);
|
||||
try {
|
||||
const messageId = await sendMessage(
|
||||
props.instanceId,
|
||||
props.sessionId,
|
||||
message,
|
||||
[], // No attachments for now
|
||||
selectedTask?.id
|
||||
);
|
||||
|
||||
// Clear input after successful send
|
||||
setChatInput("");
|
||||
console.log("[MultiTaskChat] Message sent successfully:", messageId);
|
||||
} catch (error) {
|
||||
console.error("[MultiTaskChat] Failed to send message:", error);
|
||||
// TODO: Show toast notification to user
|
||||
} finally {
|
||||
setIsSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle keyboard shortcuts (Cmd/Ctrl+Enter to send)
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
handleSendMessage();
|
||||
}
|
||||
};
|
||||
|
||||
// Handle artifact opening via code streamer
|
||||
const handleArtifactOpen = (artifact: any) => {
|
||||
console.log("[MultiTaskChat] Opening artifact:", artifact);
|
||||
// TODO: Implement code streamer integration
|
||||
// For now, we'll log artifact and show a placeholder message
|
||||
console.log(`[MultiTaskChat] Would open ${artifact.name} (${artifact.type})`);
|
||||
// TODO: Show toast notification to user
|
||||
};
|
||||
|
||||
const tasks = () => {
|
||||
const instanceSessions = sessions().get(props.instanceId);
|
||||
const currentSession = instanceSessions?.get(props.sessionId);
|
||||
return currentSession?.tasks || [];
|
||||
};
|
||||
|
||||
const handleAddTask = () => {
|
||||
const taskTitle = `Task ${tasks().length + 1}`;
|
||||
addTask(props.instanceId, props.sessionId, taskTitle);
|
||||
};
|
||||
|
||||
const formatTimestamp = (timestamp: number) => {
|
||||
return new Date(timestamp).toLocaleTimeString();
|
||||
};
|
||||
|
||||
const selectedTask = () => {
|
||||
const instanceSessions = sessions().get(props.instanceId);
|
||||
const currentSession = instanceSessions?.get(props.sessionId);
|
||||
const sessionTasks = currentSession?.tasks || [];
|
||||
return sessionTasks.find(task => task.id === selectedTaskId());
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="h-full flex flex-col bg-[#0a0a0b]">
|
||||
{/* Header */}
|
||||
<div class="h-12 px-4 flex items-center justify-between bg-zinc-900/40 backdrop-blur-md border-b border-white/5 relative z-20">
|
||||
<div class="flex items-center space-x-3">
|
||||
<Show when={!selectedTaskId()} fallback={
|
||||
<div class="flex items-center bg-indigo-500/10 border border-indigo-500/20 rounded-md px-2 py-1 shadow-[0_0_15px_rgba(99,102,241,0.1)] transition-all hover:bg-indigo-500/15">
|
||||
<span class="text-[10px] font-black text-indigo-400 mr-2 tracking-tighter uppercase">MULTIX</span>
|
||||
<div class="bg-indigo-500 rounded-sm w-3.5 h-3.5 flex items-center justify-center p-[1px]">
|
||||
<div class="flex flex-col space-y-[1px] w-full items-center">
|
||||
<div class="flex space-x-[1px]">
|
||||
<div class="w-0.5 h-0.5 bg-black rounded-full" />
|
||||
<div class="w-0.5 h-0.5 bg-black rounded-full" />
|
||||
</div>
|
||||
<div class="w-full h-[0.5px] bg-black rounded-full" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}>
|
||||
<button
|
||||
onClick={() => setSelectedTaskId(null)}
|
||||
class="flex items-center space-x-2 text-zinc-400 hover:text-white transition-all duration-200 group active:scale-95"
|
||||
>
|
||||
<ChevronRight size={16} class="rotate-180 group-hover:-translate-x-0.5 transition-transform" />
|
||||
<span class="text-xs font-semibold tracking-tight">Pipeline</span>
|
||||
</button>
|
||||
</Show>
|
||||
|
||||
<Show when={selectedTaskId()}>
|
||||
<div class="flex items-center space-x-1.5 px-2 py-1 bg-zinc-800/50 rounded-lg border border-white/5">
|
||||
<ListTodo size={14} class="text-indigo-400" />
|
||||
<span class="text-[10px] font-bold text-zinc-400">{tasks().length}</span>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="flex items-center space-x-4">
|
||||
<button class="p-1.5 text-zinc-500 hover:text-zinc-200 transition-colors hover:bg-zinc-800/50 rounded-md active:scale-90">
|
||||
<Command size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSelectedTaskId(null)}
|
||||
class={`p-1.5 rounded-md transition-all duration-200 group ${
|
||||
selectedTaskId()
|
||||
? "bg-indigo-500/10 border-indigo-500/20 text-white"
|
||||
: "text-zinc-500 hover:text-white hover:bg-zinc-800/50"
|
||||
}`}
|
||||
>
|
||||
<PanelRight size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 relative overflow-hidden flex flex-col">
|
||||
<Show when={!selectedTaskId()}>
|
||||
{/* TASK LIST VIEW - CODEX 5.1 Styled */}
|
||||
<div class="flex-1 flex flex-col bg-zinc-900/20 animate-in fade-in slide-in-from-left-4 duration-300">
|
||||
<div class="p-6 space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="space-y-1">
|
||||
<h2 class="text-xl font-bold text-zinc-100 tracking-tight">Project Pipeline</h2>
|
||||
<p class="text-xs text-zinc-500">Manage and orchestrate agentic tasks</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleAddTask}
|
||||
class="px-3 py-1.5 bg-indigo-500 text-white rounded-xl flex items-center justify-center hover:bg-indigo-600 active:scale-[0.97] transition-all shadow-lg shadow-indigo-500/20 font-bold text-xs"
|
||||
>
|
||||
<Plus size={14} class="mr-2" strokeWidth={3} />
|
||||
New Task
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Task List */}
|
||||
<div class="space-y-3">
|
||||
<For each={tasks()}>
|
||||
{(task) => (
|
||||
<div
|
||||
onClick={() => setSelectedTaskId(task.id)}
|
||||
class={`p-4 rounded-xl border transition-all cursor-pointer ${
|
||||
task.id === selectedTaskId()
|
||||
? "bg-indigo-500/10 border-indigo-500/20"
|
||||
: "bg-zinc-800/40 border-white/5 hover:border-indigo-500/20 hover:bg-indigo-500/5"
|
||||
}`}
|
||||
>
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class={`w-8 h-8 rounded-lg flex items-center justify-center ${
|
||||
task.status === "completed"
|
||||
? "bg-emerald-500/10"
|
||||
: task.status === "in-progress"
|
||||
? "bg-amber-500/10"
|
||||
: "bg-zinc-700/50"
|
||||
}`}>
|
||||
{task.status === "completed" ? (
|
||||
<CheckCircle2 size={16} class="text-emerald-500" />
|
||||
) : task.status === "in-progress" ? (
|
||||
<Loader2 size={16} class="text-amber-500 animate-spin" />
|
||||
) : (
|
||||
<AlertCircle size={16} class="text-zinc-400" />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-white font-semibold text-sm">{task.title}</h3>
|
||||
<p class="text-zinc-400 text-xs">{formatTimestamp(task.timestamp)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<ChevronRight size={16} class="text-zinc-600" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={selectedTaskId()}>
|
||||
{/* TASK CHAT VIEW - When task is selected */}
|
||||
<div class="flex-1 flex flex-col relative animate-in fade-in slide-in-from-right-4 duration-300">
|
||||
<div class="flex-1 overflow-y-auto custom-scrollbar p-6 pb-32">
|
||||
<MessageBlockList
|
||||
instanceId={props.instanceId}
|
||||
sessionId={props.sessionId}
|
||||
store={messageStore}
|
||||
messageIds={messageIds}
|
||||
lastAssistantIndex={lastAssistantIndex}
|
||||
scrollContainer={() => scrollContainer}
|
||||
setBottomSentinel={setBottomSentinel}
|
||||
showThinking={() => true}
|
||||
thinkingDefaultExpanded={() => true}
|
||||
showUsageMetrics={() => true}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* CODEX 5.1 Chat Input Area */}
|
||||
<div class="absolute bottom-0 left-0 right-0 p-4 bg-gradient-to-t from-[#0a0a0b] via-[#0a0a0b]/95 to-transparent backdrop-blur-md">
|
||||
<div class="bg-zinc-900/80 border border-white/10 rounded-2xl shadow-2xl p-4 space-y-4 transition-all focus-within:border-indigo-500/40 focus-within:ring-4 focus-within:ring-indigo-500/5">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center space-x-2.5">
|
||||
<div class="w-5 h-5 rounded-full bg-gradient-to-br from-indigo-500 to-violet-600 flex items-center justify-center shadow-lg shadow-indigo-500/20">
|
||||
<AtSign size={10} class="text-white" />
|
||||
</div>
|
||||
<span class="text-[11px] font-bold text-zinc-400 tracking-tight">TASK ASSISTANT</span>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<span class="px-1.5 py-0.5 bg-zinc-800 text-[9px] font-black text-zinc-500 uppercase tracking-tighter rounded border border-white/5">
|
||||
{selectedTask()?.status}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="relative">
|
||||
<textarea
|
||||
value={chatInput()}
|
||||
onInput={(e) => setChatInput(e.currentTarget.value)}
|
||||
placeholder="Message assistant about this task..."
|
||||
class="w-full bg-transparent border-none focus:ring-0 text-sm text-zinc-200 placeholder-zinc-600 resize-none min-h-[44px] max-h-32 custom-scrollbar leading-relaxed disabled:opacity-50"
|
||||
onKeyDown={handleKeyDown}
|
||||
disabled={isSending()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between pt-3 border-t border-white/5">
|
||||
<div class="flex items-center space-x-4 text-zinc-500">
|
||||
<button class="hover:text-indigo-400 transition-colors active:scale-90"><Hash size={16} /></button>
|
||||
<button class="hover:text-indigo-400 transition-colors active:scale-90"><Mic size={16} /></button>
|
||||
<div class="w-px h-4 bg-zinc-800" />
|
||||
<span class="text-[10px] font-bold text-zinc-600 tracking-widest">CMD + ENTER</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleSendMessage}
|
||||
disabled={!chatInput().trim() || isSending()}
|
||||
class="px-4 py-1.5 bg-zinc-100 text-zinc-950 rounded-xl flex items-center justify-center hover:bg-white active:scale-[0.97] transition-all shadow-lg shadow-white/5 font-bold text-xs disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-zinc-100"
|
||||
>
|
||||
{isSending() ? (
|
||||
<>
|
||||
<div class="w-3 h-3 border-2 border-zinc-950 border-t-transparent rounded-full animate-spin mr-2" />
|
||||
Sending...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Execute
|
||||
<ArrowUp size={14} class="ml-2" strokeWidth={3} />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
503
packages/ui/src/components/chat/multi-task-chat.tsx
Normal file
503
packages/ui/src/components/chat/multi-task-chat.tsx
Normal file
@@ -0,0 +1,503 @@
|
||||
import { createSignal, Show, onMount, For, createMemo, createEffect } from "solid-js";
|
||||
import { sessions, withSession, setActiveSession } from "@/stores/session-state";
|
||||
import { instances } from "@/stores/instances";
|
||||
import { sendMessage } from "@/stores/session-actions";
|
||||
import { addTask, setActiveTask } from "@/stores/task-actions";
|
||||
import { messageStoreBus } from "@/stores/message-v2/bus";
|
||||
import MessageBlockList from "@/components/message-block-list";
|
||||
import { formatTokenTotal } from "@/lib/formatters";
|
||||
import { addToTaskQueue, getSoloState, setActiveTaskId, toggleAutonomous, toggleAutoApproval } from "@/stores/solo-store";
|
||||
import { getLogger } from "@/lib/logger";
|
||||
import {
|
||||
Command,
|
||||
Plus,
|
||||
CheckCircle2,
|
||||
PanelRight,
|
||||
ListTodo,
|
||||
AtSign,
|
||||
Hash,
|
||||
Mic,
|
||||
ArrowUp,
|
||||
Terminal,
|
||||
ChevronRight,
|
||||
Loader2,
|
||||
AlertCircle,
|
||||
X,
|
||||
Zap,
|
||||
Layers,
|
||||
Shield,
|
||||
Activity,
|
||||
} from "lucide-solid";
|
||||
import type { InstanceMessageStore } from "@/stores/message-v2/instance-store";
|
||||
import type { Task } from "@/types/session";
|
||||
|
||||
const log = getLogger("multix-chat");
|
||||
|
||||
interface MultiTaskChatProps {
|
||||
instanceId: string;
|
||||
sessionId: string;
|
||||
}
|
||||
|
||||
export default function MultiTaskChat(props: MultiTaskChatProps) {
|
||||
const selectedTaskId = () => session()?.activeTaskId || null;
|
||||
const setSelectedTaskId = (id: string | null) => setActiveTask(props.instanceId, props.sessionId, id || undefined);
|
||||
const [isSending, setIsSending] = createSignal(false);
|
||||
const [chatInput, setChatInput] = createSignal("");
|
||||
let scrollContainer: HTMLDivElement | undefined;
|
||||
const [bottomSentinel, setBottomSentinel] = createSignal<HTMLDivElement | null>(null);
|
||||
|
||||
// Scroll to bottom helper
|
||||
const scrollToBottom = () => {
|
||||
if (scrollContainer) {
|
||||
scrollContainer.scrollTop = scrollContainer.scrollHeight;
|
||||
}
|
||||
};
|
||||
|
||||
// Get current session and tasks
|
||||
const session = () => {
|
||||
const instanceSessions = sessions().get(props.instanceId);
|
||||
return instanceSessions?.get(props.sessionId);
|
||||
};
|
||||
|
||||
const tasks = () => session()?.tasks || [];
|
||||
const selectedTask = () => tasks().find(t => t.id === selectedTaskId());
|
||||
|
||||
// Message store integration
|
||||
const messageStore = () => messageStoreBus.getOrCreate(props.instanceId);
|
||||
const lastAssistantIndex = () => {
|
||||
const ids = filteredMessageIds();
|
||||
const store = messageStore();
|
||||
for (let i = ids.length - 1; i >= 0; i--) {
|
||||
const msg = store.getMessage(ids[i]);
|
||||
if (msg?.role === "assistant") return i;
|
||||
}
|
||||
return -1;
|
||||
};
|
||||
|
||||
// Filter messages based on selected task - use store's session messages for the task session
|
||||
const filteredMessageIds = () => {
|
||||
const task = selectedTask();
|
||||
if (!task) return []; // Show no messages in Pipeline view
|
||||
|
||||
// If task has a dedicated session, get messages from the store for that session
|
||||
if (task.taskSessionId) {
|
||||
const store = messageStore();
|
||||
return store.getSessionMessageIds(task.taskSessionId);
|
||||
}
|
||||
|
||||
// Fallback to task.messageIds for backward compatibility
|
||||
return task.messageIds || [];
|
||||
};
|
||||
|
||||
// Note: Auto-scroll is handled in two places:
|
||||
// 1. After sending a message (in handleSendMessage)
|
||||
// 2. During streaming (in the isAgentThinking effect below)
|
||||
// We intentionally don't scroll on message count change to let users scroll freely
|
||||
|
||||
// Token and status tracking
|
||||
const sessionUsage = createMemo(() => {
|
||||
const store = messageStore();
|
||||
return store.getSessionUsage(props.sessionId);
|
||||
});
|
||||
|
||||
const tokenStats = createMemo(() => {
|
||||
const usage = sessionUsage();
|
||||
return {
|
||||
used: usage?.actualUsageTokens ?? 0,
|
||||
total: usage?.totalCost ?? 0,
|
||||
};
|
||||
});
|
||||
|
||||
const activeTaskSessionId = createMemo(() => {
|
||||
const task = selectedTask();
|
||||
return task?.taskSessionId || props.sessionId;
|
||||
});
|
||||
|
||||
const solo = () => getSoloState(props.instanceId);
|
||||
|
||||
const isAgentThinking = createMemo(() => {
|
||||
// Show thinking while we're actively sending
|
||||
if (isSending()) return true;
|
||||
|
||||
const store = messageStore();
|
||||
|
||||
// Check for streaming in the specific task session
|
||||
const taskSessionId = activeTaskSessionId();
|
||||
const sessionRecord = store.state.sessions[taskSessionId];
|
||||
const sessionMessages = sessionRecord ? sessionRecord.messageIds : [];
|
||||
const isAnyStreaming = sessionMessages.some((id: string) => {
|
||||
const m = store.getMessage(id);
|
||||
return m?.role === "assistant" && (m.status === "streaming" || m.status === "sending");
|
||||
});
|
||||
|
||||
if (isAnyStreaming) return true;
|
||||
|
||||
// Also check the filtered message IDs (for tasks)
|
||||
const ids = filteredMessageIds();
|
||||
if (ids.length === 0) return false;
|
||||
const lastMsg = store.getMessage(ids[ids.length - 1]);
|
||||
return lastMsg?.role === "assistant" && (lastMsg.status === "streaming" || lastMsg.status === "sending");
|
||||
});
|
||||
|
||||
// Auto-scroll during streaming - must be after isAgentThinking is defined
|
||||
createEffect(() => {
|
||||
const streaming = isAgentThinking();
|
||||
if (!streaming) return;
|
||||
|
||||
// During streaming, scroll periodically to keep up with content
|
||||
const interval = setInterval(scrollToBottom, 300);
|
||||
return () => clearInterval(interval);
|
||||
});
|
||||
|
||||
const handleSendMessage = async () => {
|
||||
const message = chatInput().trim();
|
||||
if (!message || isSending()) return;
|
||||
|
||||
setIsSending(true);
|
||||
log.info("handleSendMessage started", {
|
||||
instanceId: props.instanceId,
|
||||
sessionId: props.sessionId,
|
||||
selectedTaskId: selectedTaskId(),
|
||||
messageLength: message.length
|
||||
});
|
||||
try {
|
||||
let taskId = selectedTaskId();
|
||||
let targetSessionId = props.sessionId;
|
||||
|
||||
// If no task selected, create one automatically
|
||||
if (!taskId) {
|
||||
log.info("No task selected, creating new task");
|
||||
const title = message.length > 30 ? message.substring(0, 27) + "..." : message;
|
||||
const result = await addTask(props.instanceId, props.sessionId, title);
|
||||
taskId = result.id;
|
||||
targetSessionId = result.taskSessionId || props.sessionId;
|
||||
|
||||
log.info("New task created", { taskId, targetSessionId });
|
||||
setSelectedTaskId(taskId);
|
||||
|
||||
// If autonomous mode is on, we might want to queue it or set it as active
|
||||
const s = solo();
|
||||
if (s.isAutonomous) {
|
||||
log.info("Autonomous mode active, setting active task or queuing");
|
||||
if (!s.activeTaskId) {
|
||||
setActiveTaskId(props.instanceId, taskId);
|
||||
} else {
|
||||
addToTaskQueue(props.instanceId, taskId);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const task = selectedTask();
|
||||
targetSessionId = task?.taskSessionId || props.sessionId;
|
||||
}
|
||||
|
||||
log.info("Target session identified", { targetSessionId, taskId });
|
||||
|
||||
const store = messageStore();
|
||||
log.info("Message store check before sending", {
|
||||
instanceId: props.instanceId,
|
||||
storeExists: !!store,
|
||||
messageCount: store?.getSessionMessageIds(targetSessionId).length
|
||||
});
|
||||
|
||||
await sendMessage(
|
||||
props.instanceId,
|
||||
targetSessionId,
|
||||
message,
|
||||
[],
|
||||
taskId || undefined
|
||||
);
|
||||
|
||||
log.info("sendMessage call completed");
|
||||
setChatInput("");
|
||||
|
||||
// Auto-scroll to bottom after sending
|
||||
setTimeout(scrollToBottom, 100);
|
||||
} catch (error) {
|
||||
log.error("handleSendMessage failed", error);
|
||||
console.error("[MultiTaskChat] Send failed:", error);
|
||||
} finally {
|
||||
setIsSending(false);
|
||||
log.info("handleSendMessage finished");
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
// Enter to submit, Shift+Enter for new line
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSendMessage();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<main class="h-full flex flex-col bg-[#0a0a0b] text-zinc-300 font-sans selection:bg-indigo-500/30">
|
||||
{/* Header */}
|
||||
<header class="h-14 px-4 flex items-center justify-between bg-zinc-900/60 backdrop-blur-xl border-b border-white/5 relative z-30 shrink-0">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="flex items-center bg-indigo-500/10 border border-indigo-500/20 rounded-lg px-2.5 py-1.5 shadow-[0_0_20px_rgba(99,102,241,0.1)]">
|
||||
<span class="text-[10px] font-black text-indigo-400 mr-2.5 tracking-tighter uppercase">MULTIX</span>
|
||||
<div class="bg-indigo-500 rounded-md w-4 h-4 flex items-center justify-center shadow-lg shadow-indigo-500/40">
|
||||
<Zap size={10} class="text-white fill-current" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Show when={selectedTaskId()}>
|
||||
<div class="flex items-center space-x-2 animate-in fade-in slide-in-from-left-2 duration-300">
|
||||
<ChevronRight size={14} class="text-zinc-600" />
|
||||
<div class="flex items-center space-x-2 px-2.5 py-1 bg-white/5 rounded-lg border border-white/5">
|
||||
<ListTodo size={14} class="text-indigo-400" />
|
||||
<span class="text-[11px] font-bold text-zinc-100 truncate max-w-[120px]">
|
||||
{selectedTask()?.title || "Active Task"}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setSelectedTaskId(null)}
|
||||
class="ml-1 p-0.5 hover:bg-white/10 rounded-md transition-colors text-zinc-500 hover:text-white"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-2">
|
||||
<button class="p-2 text-zinc-500 hover:text-white transition-all hover:bg-white/5 rounded-xl active:scale-90">
|
||||
<Command size={18} strokeWidth={2} />
|
||||
</button>
|
||||
<button class="p-2 text-zinc-500 hover:text-white transition-all hover:bg-white/5 rounded-xl active:scale-90">
|
||||
<PanelRight size={18} strokeWidth={2} />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Task Tabs (Horizontal Scroll) */}
|
||||
<Show when={tasks().length > 0}>
|
||||
<div class="flex items-center bg-[#0a0a0b] border-b border-white/5 px-2 py-2 space-x-1.5 overflow-x-auto custom-scrollbar-hidden no-scrollbar shrink-0">
|
||||
<button
|
||||
onClick={() => setSelectedTaskId(null)}
|
||||
class={`flex items-center space-x-2 px-3.5 py-2 rounded-xl text-[10px] font-black uppercase tracking-widest transition-all shrink-0 border ${!selectedTaskId()
|
||||
? "bg-indigo-500/15 text-indigo-400 border-indigo-500/30 shadow-[0_0_15px_rgba(99,102,241,0.1)]"
|
||||
: "text-zinc-500 hover:text-zinc-300 hover:bg-white/5 border-transparent"
|
||||
}`}
|
||||
>
|
||||
<Layers size={12} class={!selectedTaskId() ? "text-indigo-400" : "text-zinc-600"} />
|
||||
<span>Pipeline</span>
|
||||
</button>
|
||||
|
||||
<div class="w-px h-4 bg-white/10 shrink-0 mx-0.5" />
|
||||
|
||||
<div class="flex items-center space-x-1.5 overflow-x-auto no-scrollbar">
|
||||
<For each={tasks()}>
|
||||
{(task) => (
|
||||
<button
|
||||
onClick={() => setSelectedTaskId(task.id)}
|
||||
class={`flex items-center space-x-2 px-3.5 py-2 rounded-xl text-[10px] font-bold transition-all shrink-0 max-w-[160px] border group ${selectedTaskId() === task.id
|
||||
? "bg-white/10 text-zinc-100 border-white/20 shadow-xl shadow-black/20"
|
||||
: "text-zinc-500 hover:text-zinc-300 hover:bg-white/5 border-transparent"
|
||||
}`}
|
||||
>
|
||||
<div class={`w-2 h-2 rounded-full transition-all duration-500 ${task.status === "completed" ? "bg-emerald-500 shadow-[0_0_8px_rgba(16,185,129,0.4)]" :
|
||||
task.status === "interrupted" ? "bg-rose-500 shadow-[0_0_8px_rgba(244,63,94,0.4)]" :
|
||||
"bg-indigo-500 shadow-[0_0_8px_rgba(99,102,241,0.4)] animate-pulse"
|
||||
}`} />
|
||||
<span class="truncate">{task.title}</span>
|
||||
<Show when={selectedTaskId() === task.id}>
|
||||
<div class="ml-1 w-1 h-1 bg-indigo-400 rounded-full animate-ping" />
|
||||
</Show>
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
setChatInput("");
|
||||
setSelectedTaskId(null);
|
||||
}}
|
||||
class="flex items-center justify-center w-8 h-8 rounded-xl text-zinc-500 hover:text-indigo-400 hover:bg-indigo-500/10 transition-all shrink-0 ml-1 border border-transparent hover:border-indigo-500/20"
|
||||
title="New Task"
|
||||
>
|
||||
<Plus size={16} strokeWidth={3} />
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Main Content Area - min-h-0 is critical for flex containers with overflow */}
|
||||
<div class="flex-1 min-h-0 relative overflow-hidden flex flex-col">
|
||||
<div
|
||||
ref={scrollContainer}
|
||||
class="flex-1 min-h-0 overflow-y-auto overflow-x-hidden custom-scrollbar"
|
||||
>
|
||||
<Show when={!selectedTaskId()} fallback={
|
||||
<div class="p-3 pb-4 overflow-x-hidden">
|
||||
<MessageBlockList
|
||||
instanceId={props.instanceId}
|
||||
sessionId={activeTaskSessionId()}
|
||||
store={messageStore}
|
||||
messageIds={filteredMessageIds}
|
||||
lastAssistantIndex={lastAssistantIndex}
|
||||
showThinking={() => true}
|
||||
thinkingDefaultExpanded={() => true}
|
||||
showUsageMetrics={() => true}
|
||||
scrollContainer={() => scrollContainer}
|
||||
setBottomSentinel={setBottomSentinel}
|
||||
/>
|
||||
</div>
|
||||
}>
|
||||
{/* Pipeline View */}
|
||||
<div class="p-4 space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
||||
<div class="space-y-2">
|
||||
<h2 class="text-2xl font-black text-white tracking-tight leading-none">Pipeline</h2>
|
||||
<p class="text-xs font-medium text-zinc-500 uppercase tracking-[0.2em]">Agentic Orchestration</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-[10px] font-bold text-zinc-600 uppercase tracking-widest">Active Threads</span>
|
||||
<div class="h-px flex-1 bg-white/5 mx-4" />
|
||||
<span class="text-[10px] font-black text-indigo-400 bg-indigo-500/10 px-2 py-0.5 rounded border border-indigo-500/20">
|
||||
{tasks().length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-3">
|
||||
<For each={tasks()} fallback={
|
||||
<div class="group relative p-8 rounded-3xl border border-dashed border-white/5 bg-zinc-900/20 flex flex-col items-center justify-center text-center space-y-4 transition-all hover:bg-zinc-900/40 hover:border-white/10">
|
||||
<div class="w-12 h-12 rounded-2xl bg-white/5 flex items-center justify-center text-zinc-600 group-hover:text-indigo-400 group-hover:scale-110 transition-all duration-500">
|
||||
<Plus size={24} strokeWidth={1.5} />
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<p class="text-sm font-bold text-zinc-400">No active tasks</p>
|
||||
<p class="text-[11px] text-zinc-600">Send a message below to start a new thread</p>
|
||||
</div>
|
||||
</div>
|
||||
}>
|
||||
{(task) => (
|
||||
<button
|
||||
onClick={() => setSelectedTaskId(task.id)}
|
||||
class="group relative p-4 rounded-2xl border border-white/5 bg-zinc-900/40 hover:bg-zinc-800/60 hover:border-indigo-500/30 transition-all duration-300 text-left flex items-start space-x-4 active:scale-[0.98]"
|
||||
>
|
||||
<div class={`mt-1 w-2 h-2 rounded-full shadow-[0_0_10px_rgba(var(--color),0.5)] ${task.status === "completed" ? "bg-emerald-500 shadow-emerald-500/40" :
|
||||
task.status === "in-progress" ? "bg-indigo-500 shadow-indigo-500/40 animate-pulse" :
|
||||
"bg-zinc-600 shadow-zinc-600/20"
|
||||
}`} />
|
||||
<div class="flex-1 min-w-0 space-y-1">
|
||||
<p class="text-sm font-bold text-zinc-100 truncate group-hover:text-white transition-colors">
|
||||
{task.title}
|
||||
</p>
|
||||
<div class="flex items-center space-x-3 text-[10px] font-bold text-zinc-500 uppercase tracking-tight">
|
||||
<span>{new Date(task.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</span>
|
||||
<span class="w-1 h-1 rounded-full bg-zinc-800" />
|
||||
<span>{task.messageIds?.length || 0} messages</span>
|
||||
</div>
|
||||
</div>
|
||||
<ChevronRight size={16} class="text-zinc-700 group-hover:text-indigo-400 group-hover:translate-x-1 transition-all" />
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
{/* Chat Input Area - Fixed at bottom */}
|
||||
<div class="p-3 bg-[#0a0a0b] border-t border-white/5 shrink-0">
|
||||
<div class="w-full bg-zinc-900/80 border border-white/10 rounded-2xl shadow-lg p-3">
|
||||
{/* Header Row */}
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div class="flex items-center space-x-2">
|
||||
<div class="w-5 h-5 rounded-lg bg-gradient-to-br from-indigo-500 to-violet-600 flex items-center justify-center">
|
||||
<AtSign size={10} class="text-white" strokeWidth={3} />
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<span class="text-[10px] font-bold text-zinc-100 uppercase tracking-wide">
|
||||
{selectedTaskId() ? "Task Context" : "Global Pipeline"}
|
||||
</span>
|
||||
<span class="text-[9px] text-zinc-500 uppercase">
|
||||
{selectedTaskId() ? "MultiX Threaded" : "Auto-Task"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-2">
|
||||
<button
|
||||
onClick={() => toggleAutonomous(props.instanceId)}
|
||||
class={`px-2 py-0.5 rounded text-[9px] font-bold uppercase border ${solo().isAutonomous
|
||||
? "bg-indigo-500/20 border-indigo-500/40 text-indigo-400"
|
||||
: "bg-white/5 border-white/10 text-zinc-500"
|
||||
}`}
|
||||
>
|
||||
Auto
|
||||
</button>
|
||||
<button
|
||||
onClick={() => toggleAutoApproval(props.instanceId)}
|
||||
class={`px-2 py-0.5 rounded text-[9px] font-bold uppercase border ${solo().autoApproval
|
||||
? "bg-emerald-500/20 border-emerald-500/40 text-emerald-400"
|
||||
: "bg-white/5 border-white/10 text-zinc-500"
|
||||
}`}
|
||||
>
|
||||
Shield
|
||||
</button>
|
||||
<Show when={tokenStats().used > 0}>
|
||||
<div class="px-2 py-0.5 bg-emerald-500/10 rounded border border-emerald-500/20 text-[9px] font-bold text-emerald-400">
|
||||
{formatTokenTotal(tokenStats().used)}
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={isSending() || isAgentThinking()}>
|
||||
<div class="flex items-center space-x-1 px-2 py-0.5 bg-indigo-500/10 rounded border border-indigo-500/20">
|
||||
<Loader2 size={10} class="text-indigo-400 animate-spin" />
|
||||
<span class="text-[9px] font-bold text-indigo-400">{isAgentThinking() ? "Thinking" : "Sending"}</span>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Text Input */}
|
||||
<textarea
|
||||
value={chatInput()}
|
||||
onInput={(e) => setChatInput(e.currentTarget.value)}
|
||||
placeholder={selectedTaskId() ? "Send instruction to this task..." : "Type to create a new task and begin..."}
|
||||
class="w-full bg-transparent border-none focus:ring-0 focus:outline-none text-[13px] text-zinc-100 placeholder-zinc-600 resize-none min-h-[40px] max-h-32 leading-relaxed disabled:opacity-50"
|
||||
onKeyDown={handleKeyDown}
|
||||
disabled={isSending()}
|
||||
rows={1}
|
||||
/>
|
||||
|
||||
{/* Footer Row */}
|
||||
<div class="flex items-center justify-between pt-2 border-t border-white/5 mt-2">
|
||||
<div class="flex items-center space-x-3">
|
||||
<button class="text-zinc-600 hover:text-zinc-400 transition-colors p-1">
|
||||
<Hash size={14} />
|
||||
</button>
|
||||
<button class="text-zinc-600 hover:text-zinc-400 transition-colors p-1">
|
||||
<Mic size={14} />
|
||||
</button>
|
||||
<div class="w-px h-3 bg-zinc-800" />
|
||||
<div class="flex items-center space-x-1 text-zinc-600">
|
||||
<kbd class="px-1.5 py-0.5 bg-zinc-800 rounded text-[9px] font-bold border border-white/5">ENTER</kbd>
|
||||
<span class="text-[9px]">to send</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleSendMessage}
|
||||
disabled={!chatInput().trim() || isSending()}
|
||||
class="px-4 py-1.5 bg-indigo-500 hover:bg-indigo-400 text-white rounded-lg text-[11px] font-bold uppercase tracking-wide transition-all disabled:opacity-30 disabled:cursor-not-allowed flex items-center space-x-1.5"
|
||||
>
|
||||
<Show when={isSending()} fallback={
|
||||
<>
|
||||
<span>{selectedTaskId() ? "Update Task" : "Launch Task"}</span>
|
||||
<ArrowUp size={12} strokeWidth={3} />
|
||||
</>
|
||||
}>
|
||||
<Loader2 size={12} class="animate-spin" />
|
||||
</Show>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
52
packages/ui/src/components/instance/editor.tsx
Normal file
52
packages/ui/src/components/instance/editor.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import { Component, For, Show } from "solid-js"
|
||||
import { FileNode } from "./sidebar"
|
||||
|
||||
interface EditorProps {
|
||||
file: FileNode | null
|
||||
}
|
||||
|
||||
export const Editor: Component<EditorProps> = (props) => {
|
||||
return (
|
||||
<Show
|
||||
when={props.file}
|
||||
fallback={
|
||||
<div class="flex-1 flex items-center justify-center text-zinc-500 bg-[#0d0d0d]">
|
||||
<div class="text-center">
|
||||
<div class="mb-4 opacity-20 flex justify-center">
|
||||
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1">
|
||||
<path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z" />
|
||||
<polyline points="14 2 14 8 20 8" />
|
||||
</svg>
|
||||
</div>
|
||||
<p>Select a file to start editing</p>
|
||||
<p class="text-sm mt-2 opacity-60">Press Ctrl+P to search</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div class="flex-1 overflow-hidden flex flex-col bg-[#0d0d0d]">
|
||||
<div class="h-10 glass border-b border-white/5 flex items-center px-4 space-x-2 shrink-0">
|
||||
<span class="text-xs text-zinc-400 font-medium">{props.file?.name}</span>
|
||||
<span class="text-[10px] text-zinc-600 uppercase">{props.file?.language || "text"}</span>
|
||||
</div>
|
||||
<div class="flex-1 p-6 overflow-auto mono text-sm leading-relaxed">
|
||||
<pre class="text-zinc-300">
|
||||
<Show
|
||||
when={props.file?.content}
|
||||
fallback={<span class="italic text-zinc-600">// Empty file</span>}
|
||||
>
|
||||
<For each={props.file?.content?.split("\n")}>
|
||||
{(line, i) => (
|
||||
<div class="flex group">
|
||||
<span class="w-12 text-zinc-600 select-none text-right pr-4">{i() + 1}</span>
|
||||
<span class="whitespace-pre">{line}</span>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
@@ -34,6 +34,8 @@ import {
|
||||
getSessionFamily,
|
||||
getSessionInfo,
|
||||
setActiveSession,
|
||||
executeCustomCommand,
|
||||
runShellCommand,
|
||||
} from "../../stores/sessions"
|
||||
import { keyboardRegistry, type KeyboardShortcut } from "../../lib/keyboard-registry"
|
||||
import { messageStoreBus } from "../../stores/message-v2/bus"
|
||||
@@ -48,14 +50,25 @@ import InfoView from "../info-view"
|
||||
import InstanceServiceStatus from "../instance-service-status"
|
||||
import AgentSelector from "../agent-selector"
|
||||
import ModelSelector from "../model-selector"
|
||||
import ModelStatusSelector from "../model-status-selector"
|
||||
import CommandPalette from "../command-palette"
|
||||
import Kbd from "../kbd"
|
||||
import MultiTaskChat from "../chat/multi-task-chat"
|
||||
import { TodoListView } from "../tool-call/renderers/todo"
|
||||
import ContextUsagePanel from "../session/context-usage-panel"
|
||||
import SessionView from "../session/session-view"
|
||||
import { Sidebar, type FileNode } from "./sidebar"
|
||||
import { Editor } from "./editor"
|
||||
import { serverApi } from "../../lib/api-client"
|
||||
import { Sparkles, Layout as LayoutIcon, Terminal as TerminalIcon, Search, Loader2, Zap, Shield } from "lucide-solid"
|
||||
import { formatTokenTotal } from "../../lib/formatters"
|
||||
import { sseManager } from "../../lib/sse-manager"
|
||||
import { getLogger } from "../../lib/logger"
|
||||
import {
|
||||
getSoloState,
|
||||
toggleAutonomous,
|
||||
toggleAutoApproval,
|
||||
} from "../../stores/solo-store"
|
||||
import {
|
||||
SESSION_SIDEBAR_EVENT,
|
||||
type SessionSidebarRequestAction,
|
||||
@@ -128,7 +141,26 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
const [activeResizeSide, setActiveResizeSide] = createSignal<"left" | "right" | null>(null)
|
||||
const [resizeStartX, setResizeStartX] = createSignal(0)
|
||||
const [resizeStartWidth, setResizeStartWidth] = createSignal(0)
|
||||
const [rightPanelExpandedItems, setRightPanelExpandedItems] = createSignal<string[]>(["lsp", "mcp"])
|
||||
const [rightPanelExpandedItems, setRightPanelExpandedItems] = createSignal<string[]>(["lsp", "mcp", "plan"])
|
||||
const [currentFile, setCurrentFile] = createSignal<FileNode | null>(null)
|
||||
const [isSoloOpen, setIsSoloOpen] = createSignal(true)
|
||||
|
||||
// Handler to load file content when selected
|
||||
const handleFileSelect = async (file: FileNode) => {
|
||||
try {
|
||||
const response = await serverApi.readWorkspaceFile(props.instance.id, file.path)
|
||||
const language = file.name.split('.').pop() || 'text'
|
||||
setCurrentFile({
|
||||
...file,
|
||||
content: response.contents,
|
||||
language,
|
||||
})
|
||||
} catch (error) {
|
||||
log.error('Failed to read file content', error)
|
||||
// Still show the file but without content
|
||||
setCurrentFile(file)
|
||||
}
|
||||
}
|
||||
|
||||
const messageStore = createMemo(() => messageStoreBus.getOrCreate(props.instance.id))
|
||||
|
||||
@@ -326,6 +358,58 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
showCommandPalette(props.instance.id)
|
||||
}
|
||||
|
||||
const [isFixing, setIsFixing] = createSignal(false)
|
||||
const [isBuilding, setIsBuilding] = createSignal(false)
|
||||
|
||||
const handleSmartFix = async () => {
|
||||
const sessionId = activeSessionIdForInstance()
|
||||
if (!sessionId || sessionId === "info" || isFixing()) {
|
||||
return
|
||||
}
|
||||
|
||||
setIsFixing(true)
|
||||
try {
|
||||
// Smart Fix targets the active task if available, otherwise general fix
|
||||
const session = activeSessionForInstance()
|
||||
const activeTaskId = session?.activeTaskId
|
||||
const args = activeTaskId ? `task:${activeTaskId}` : ""
|
||||
|
||||
await executeCustomCommand(props.instance.id, sessionId, "fix", args)
|
||||
|
||||
// Auto-open right panel to show agent progress if it's not open
|
||||
if (!rightOpen()) {
|
||||
setRightOpen(true)
|
||||
measureDrawerHost()
|
||||
}
|
||||
} catch (error) {
|
||||
log.error("Failed to run Smart Fix command", error)
|
||||
} finally {
|
||||
setTimeout(() => setIsFixing(false), 2000) // Reset after delay
|
||||
}
|
||||
}
|
||||
|
||||
const handleBuild = async () => {
|
||||
const sessionId = activeSessionIdForInstance()
|
||||
if (!sessionId || sessionId === "info" || isBuilding()) {
|
||||
return
|
||||
}
|
||||
|
||||
setIsBuilding(true)
|
||||
try {
|
||||
await runShellCommand(props.instance.id, sessionId, "build")
|
||||
|
||||
// Auto-open right panel to show build logs if it's not open
|
||||
if (!rightOpen()) {
|
||||
setRightOpen(true)
|
||||
measureDrawerHost()
|
||||
}
|
||||
} catch (error) {
|
||||
log.error("Failed to run Build command", error)
|
||||
} finally {
|
||||
setTimeout(() => setIsBuilding(false), 2000) // Reset after delay
|
||||
}
|
||||
}
|
||||
|
||||
const customCommands = createMemo(() => buildCustomCommandEntries(props.instance.id, getInstanceCommands(props.instance.id)))
|
||||
|
||||
const instancePaletteCommands = createMemo(() => [...props.paletteCommands(), ...customCommands()])
|
||||
@@ -607,7 +691,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
})
|
||||
|
||||
type DrawerViewState = "pinned" | "floating-open" | "floating-closed"
|
||||
|
||||
|
||||
|
||||
const leftDrawerState = createMemo<DrawerViewState>(() => {
|
||||
if (leftPinned()) return "pinned"
|
||||
@@ -648,7 +732,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
|
||||
|
||||
|
||||
const pinLeftDrawer = () => {
|
||||
const pinLeftDrawer = () => {
|
||||
blurIfInside(leftDrawerContentEl())
|
||||
batch(() => {
|
||||
setLeftPinned(true)
|
||||
@@ -707,11 +791,13 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
if (state === "pinned") return
|
||||
if (state === "floating-closed") {
|
||||
setRightOpen(true)
|
||||
setIsSoloOpen(false)
|
||||
measureDrawerHost()
|
||||
return
|
||||
}
|
||||
blurIfInside(rightDrawerContentEl())
|
||||
setRightOpen(false)
|
||||
setIsSoloOpen(false)
|
||||
focusTarget(rightToggleButtonEl())
|
||||
measureDrawerHost()
|
||||
}
|
||||
@@ -757,90 +843,27 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
}
|
||||
|
||||
const LeftDrawerContent = () => (
|
||||
<div class="flex flex-col h-full min-h-0" ref={setLeftDrawerContentEl}>
|
||||
<div class="flex items-start justify-between gap-2 px-4 py-3 border-b border-base">
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="session-sidebar-title text-sm font-semibold uppercase text-primary">Sessions</span>
|
||||
<div class="session-sidebar-shortcuts">
|
||||
<Show when={keyboardShortcuts().length}>
|
||||
<KeyboardHint shortcuts={keyboardShortcuts()} separator=" " showDescription={false} />
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Show when={!isPhoneLayout()}>
|
||||
<IconButton
|
||||
size="small"
|
||||
color="inherit"
|
||||
aria-label={leftPinned() ? "Unpin left drawer" : "Pin left drawer"}
|
||||
onClick={() => (leftPinned() ? unpinLeftDrawer() : pinLeftDrawer())}
|
||||
>
|
||||
{leftPinned() ? <PushPinIcon fontSize="small" /> : <PushPinOutlinedIcon fontSize="small" />}
|
||||
</IconButton>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="session-sidebar flex flex-col flex-1 min-h-0">
|
||||
<SessionList
|
||||
instanceId={props.instance.id}
|
||||
sessions={activeSessions()}
|
||||
activeSessionId={activeSessionIdForInstance()}
|
||||
onSelect={handleSessionSelect}
|
||||
onClose={(id) => {
|
||||
const result = props.onCloseSession(id)
|
||||
if (result instanceof Promise) {
|
||||
void result.catch((error) => log.error("Failed to close session:", error))
|
||||
}
|
||||
}}
|
||||
onNew={() => {
|
||||
const result = props.onNewSession()
|
||||
if (result instanceof Promise) {
|
||||
void result.catch((error) => log.error("Failed to create session:", error))
|
||||
}
|
||||
}}
|
||||
showHeader={false}
|
||||
showFooter={false}
|
||||
/>
|
||||
|
||||
<Divider />
|
||||
<Show when={activeSessionForInstance()}>
|
||||
{(activeSession) => (
|
||||
<>
|
||||
<ContextUsagePanel instanceId={props.instance.id} sessionId={activeSession().id} />
|
||||
<div class="session-sidebar-controls px-4 py-4 border-t border-base flex flex-col gap-3">
|
||||
<AgentSelector
|
||||
instanceId={props.instance.id}
|
||||
sessionId={activeSession().id}
|
||||
currentAgent={activeSession().agent}
|
||||
onAgentChange={(agent) => props.handleSidebarAgentChange(activeSession().id, agent)}
|
||||
/>
|
||||
|
||||
<div class="sidebar-selector-hints" aria-hidden="true">
|
||||
<span class="hint sidebar-selector-hint sidebar-selector-hint--left">
|
||||
<Kbd shortcut="cmd+shift+a" />
|
||||
</span>
|
||||
<span class="hint sidebar-selector-hint sidebar-selector-hint--right">
|
||||
<Kbd shortcut="cmd+shift+m" />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<ModelSelector
|
||||
instanceId={props.instance.id}
|
||||
sessionId={activeSession().id}
|
||||
currentModel={activeSession().model}
|
||||
onModelChange={(model) => props.handleSidebarModelChange(activeSession().id, model)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
<Sidebar
|
||||
instanceId={props.instance.id}
|
||||
isOpen={leftOpen()}
|
||||
onFileSelect={handleFileSelect}
|
||||
sessions={Array.from(activeSessions().values())}
|
||||
activeSessionId={activeSessionIdForInstance() || undefined}
|
||||
onSessionSelect={handleSessionSelect}
|
||||
/>
|
||||
)
|
||||
|
||||
const RightDrawerContent = () => {
|
||||
const sessionId = activeSessionIdForInstance()
|
||||
|
||||
if (sessionId && sessionId !== "info") {
|
||||
return (
|
||||
<div class="flex flex-col h-full" ref={setRightDrawerContentEl}>
|
||||
<MultiTaskChat instanceId={props.instance.id} sessionId={sessionId} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const renderPlanSectionContent = () => {
|
||||
const sessionId = activeSessionIdForInstance()
|
||||
if (!sessionId || sessionId === "info") {
|
||||
@@ -1011,6 +1034,8 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
|
||||
|
||||
const renderRightPanel = () => {
|
||||
if (isSoloOpen()) return null; // MultiX Mode uses the main stream area
|
||||
|
||||
if (rightPinned()) {
|
||||
return (
|
||||
<Box
|
||||
@@ -1075,215 +1100,239 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
|
||||
const sessionLayout = (
|
||||
<div
|
||||
class="session-shell-panels flex flex-col flex-1 min-h-0 overflow-x-hidden"
|
||||
class="session-shell-panels flex flex-col flex-1 min-h-0 overflow-x-hidden relative bg-[#050505]"
|
||||
ref={(element) => {
|
||||
setDrawerHost(element)
|
||||
measureDrawerHost()
|
||||
}}
|
||||
>
|
||||
<AppBar position="sticky" color="default" elevation={0} class="border-b border-base">
|
||||
<Toolbar variant="dense" class="session-toolbar flex flex-wrap items-center gap-2 py-0 min-h-[40px]">
|
||||
<Show
|
||||
when={!isPhoneLayout()}
|
||||
fallback={
|
||||
<div class="flex flex-col w-full gap-1.5">
|
||||
<div class="flex flex-wrap items-center justify-between gap-2 w-full">
|
||||
<IconButton
|
||||
ref={setLeftToggleButtonEl}
|
||||
color="inherit"
|
||||
onClick={handleLeftAppBarButtonClick}
|
||||
aria-label={leftAppBarButtonLabel()}
|
||||
size="small"
|
||||
aria-expanded={leftDrawerState() !== "floating-closed"}
|
||||
disabled={leftDrawerState() === "pinned"}
|
||||
>
|
||||
{leftAppBarButtonIcon()}
|
||||
</IconButton>
|
||||
{/* Background Decorator - Antigravity Glows */}
|
||||
<div class="absolute top-[-10%] left-[-10%] w-[40%] h-[40%] bg-blue-600/10 blur-[120px] rounded-full pointer-events-none z-0" />
|
||||
<div class="absolute bottom-[-10%] right-[-10%] w-[30%] h-[30%] bg-purple-600/5 blur-[100px] rounded-full pointer-events-none z-0" />
|
||||
|
||||
<div class="flex flex-wrap items-center gap-1 justify-center">
|
||||
<button
|
||||
type="button"
|
||||
class="connection-status-button px-2 py-0.5 text-xs"
|
||||
onClick={handleCommandPaletteClick}
|
||||
aria-label="Open command palette"
|
||||
style={{ flex: "0 0 auto", width: "auto" }}
|
||||
>
|
||||
Command Palette
|
||||
</button>
|
||||
<span class="connection-status-shortcut-hint">
|
||||
<Kbd shortcut="cmd+shift+p" />
|
||||
</span>
|
||||
<span
|
||||
class={`status-indicator ${connectionStatusClass()}`}
|
||||
aria-label={`Connection ${connectionStatus()}`}
|
||||
>
|
||||
<span class="status-dot" />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<IconButton
|
||||
ref={setRightToggleButtonEl}
|
||||
color="inherit"
|
||||
onClick={handleRightAppBarButtonClick}
|
||||
aria-label={rightAppBarButtonLabel()}
|
||||
size="small"
|
||||
aria-expanded={rightDrawerState() !== "floating-closed"}
|
||||
disabled={rightDrawerState() === "pinned"}
|
||||
>
|
||||
{rightAppBarButtonIcon()}
|
||||
</IconButton>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center justify-center gap-2 pb-1">
|
||||
<div class="inline-flex items-center gap-1 rounded-full border border-base px-2 py-0.5 text-xs text-primary">
|
||||
<span class="uppercase text-[10px] tracking-wide text-primary/70">Used</span>
|
||||
<span class="font-semibold text-primary">{formattedUsedTokens()}</span>
|
||||
</div>
|
||||
<div class="inline-flex items-center gap-1 rounded-full border border-base px-2 py-0.5 text-xs text-primary">
|
||||
<span class="uppercase text-[10px] tracking-wide text-primary/70">Avail</span>
|
||||
<span class="font-semibold text-primary">{formattedAvailableTokens()}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div class="session-toolbar-left flex items-center gap-3 min-w-0">
|
||||
<IconButton
|
||||
ref={setLeftToggleButtonEl}
|
||||
color="inherit"
|
||||
onClick={handleLeftAppBarButtonClick}
|
||||
aria-label={leftAppBarButtonLabel()}
|
||||
size="small"
|
||||
aria-expanded={leftDrawerState() !== "floating-closed"}
|
||||
disabled={leftDrawerState() === "pinned"}
|
||||
>
|
||||
{leftAppBarButtonIcon()}
|
||||
</IconButton>
|
||||
|
||||
<Show when={!showingInfoView()}>
|
||||
<div class="inline-flex items-center gap-1 rounded-full border border-base px-2 py-0.5 text-xs text-primary">
|
||||
<span class="uppercase text-[10px] tracking-wide text-primary/70">Used</span>
|
||||
<span class="font-semibold text-primary">{formattedUsedTokens()}</span>
|
||||
</div>
|
||||
<div class="inline-flex items-center gap-1 rounded-full border border-base px-2 py-0.5 text-xs text-primary">
|
||||
<span class="uppercase text-[10px] tracking-wide text-primary/70">Avail</span>
|
||||
<span class="font-semibold text-primary">{formattedAvailableTokens()}</span>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="session-toolbar-center flex-1 flex items-center justify-center gap-2 min-w-[160px]">
|
||||
<button
|
||||
type="button"
|
||||
class="connection-status-button px-2 py-0.5 text-xs"
|
||||
onClick={handleCommandPaletteClick}
|
||||
aria-label="Open command palette"
|
||||
style={{ flex: "0 0 auto", width: "auto" }}
|
||||
>
|
||||
Command Palette
|
||||
</button>
|
||||
<span class="connection-status-shortcut-hint">
|
||||
<Kbd shortcut="cmd+shift+p" />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="session-toolbar-right flex items-center gap-3">
|
||||
<div class="connection-status-meta flex items-center gap-3">
|
||||
<Show when={connectionStatus() === "connected"}>
|
||||
<span class="status-indicator connected">
|
||||
<span class="status-dot" />
|
||||
<span class="status-text">Connected</span>
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={connectionStatus() === "connecting"}>
|
||||
<span class="status-indicator connecting">
|
||||
<span class="status-dot" />
|
||||
<span class="status-text">Connecting...</span>
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={connectionStatus() === "error" || connectionStatus() === "disconnected"}>
|
||||
<span class="status-indicator disconnected">
|
||||
<span class="status-dot" />
|
||||
<span class="status-text">Disconnected</span>
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
<IconButton
|
||||
ref={setRightToggleButtonEl}
|
||||
color="inherit"
|
||||
onClick={handleRightAppBarButtonClick}
|
||||
aria-label={rightAppBarButtonLabel()}
|
||||
size="small"
|
||||
aria-expanded={rightDrawerState() !== "floating-closed"}
|
||||
disabled={rightDrawerState() === "pinned"}
|
||||
>
|
||||
{rightAppBarButtonIcon()}
|
||||
</IconButton>
|
||||
<AppBar position="sticky" color="default" elevation={0} class="border-b border-white/5 bg-[#050505]/80 backdrop-blur-md z-20">
|
||||
<Toolbar variant="dense" class="session-toolbar flex flex-wrap items-center justify-between gap-2 py-0 min-h-[40px]">
|
||||
<div class="flex items-center space-x-4">
|
||||
<IconButton
|
||||
ref={setLeftToggleButtonEl}
|
||||
color="inherit"
|
||||
onClick={handleLeftAppBarButtonClick}
|
||||
aria-label={leftAppBarButtonLabel()}
|
||||
size="small"
|
||||
class="text-zinc-500 hover:text-zinc-200"
|
||||
>
|
||||
<MenuIcon fontSize="small" />
|
||||
</IconButton>
|
||||
<div class="flex items-center space-x-2">
|
||||
<div class="w-2.5 h-2.5 rounded-full bg-[#f87171] opacity-60" />
|
||||
<div class="w-2.5 h-2.5 rounded-full bg-[#fbbf24] opacity-60" />
|
||||
<div class="w-2.5 h-2.5 rounded-full bg-[#4ade80] opacity-60" />
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div class="hidden md:flex items-center bg-white/5 border border-white/5 rounded-full px-3 py-1 space-x-2 text-zinc-400 group hover:border-white/10 transition-all cursor-pointer" onClick={handleCommandPaletteClick}>
|
||||
<Search size={14} />
|
||||
<span class="text-[11px] min-w-[200px]">Search your project...</span>
|
||||
<div class="flex items-center space-x-1 opacity-40">
|
||||
<Kbd shortcut="cmd+shift+p" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-4">
|
||||
<Show when={activeSessionIdForInstance() && activeSessionIdForInstance() !== "info"}>
|
||||
<ModelStatusSelector
|
||||
instanceId={props.instance.id}
|
||||
sessionId={activeSessionIdForInstance()!}
|
||||
currentModel={activeSessionForInstance()?.model || { providerId: "", modelId: "" }}
|
||||
onModelChange={async (model) => {
|
||||
const sid = activeSessionIdForInstance()
|
||||
if (sid) await props.handleSidebarModelChange(sid, model)
|
||||
}}
|
||||
/>
|
||||
</Show>
|
||||
|
||||
{/* SmartX Mode Buttons (Integrated HUD) */}
|
||||
<div class="flex items-center bg-white/5 border border-white/5 rounded-full px-2 py-1 space-x-1">
|
||||
<button
|
||||
onClick={handleSmartFix}
|
||||
disabled={isFixing()}
|
||||
title="Smart Fix"
|
||||
class={`transition-all flex items-center space-x-1.5 px-2 py-1 rounded-full hover:bg-white/10 ${isFixing() ? "text-blue-500" : "text-zinc-400 hover:text-white"}`}
|
||||
>
|
||||
<Show when={isFixing()} fallback={<Sparkles size={14} class="text-blue-400" />}>
|
||||
<Loader2 size={14} class="animate-spin text-blue-400" />
|
||||
</Show>
|
||||
<span class="text-[10px] font-bold uppercase tracking-tight">
|
||||
{isFixing() ? "FIXING..." : "SMART FIX"}
|
||||
</span>
|
||||
</button>
|
||||
<div class="w-px h-3 bg-white/10" />
|
||||
<button
|
||||
onClick={handleBuild}
|
||||
disabled={isBuilding()}
|
||||
title="Build"
|
||||
class={`transition-all flex items-center space-x-1.5 px-2 py-1 rounded-full hover:bg-white/10 ${isBuilding() ? "text-indigo-500" : "text-zinc-400 hover:text-white"}`}
|
||||
>
|
||||
<Show when={isBuilding()} fallback={<TerminalIcon size={14} />}>
|
||||
<Loader2 size={14} class="animate-spin text-indigo-400" />
|
||||
</Show>
|
||||
<span class="text-[10px] font-bold uppercase tracking-tight">
|
||||
{isBuilding() ? "BUILDING..." : "BUILD"}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* SOLO 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="Toggle Autonomous Mode (SOLO)"
|
||||
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>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => toggleAutoApproval(props.instance.id)}
|
||||
title="Toggle Auto-Approval (SHIELD)"
|
||||
class={`flex items-center space-x-1.5 px-2 py-0.5 rounded-full transition-all ${getSoloState(props.instance.id).autoApproval
|
||||
? "bg-emerald-500/20 text-emerald-400 border border-emerald-500/30"
|
||||
: "text-zinc-500 hover:text-zinc-300"
|
||||
}`}
|
||||
>
|
||||
<Shield size={12} />
|
||||
<span class="text-[9px] font-black uppercase tracking-tighter">Shield</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
const newState = !(rightOpen() && isSoloOpen())
|
||||
setRightOpen(newState)
|
||||
setIsSoloOpen(newState)
|
||||
}}
|
||||
class={`flex items-center space-x-1.5 px-3 py-1 rounded-full text-[11px] font-bold transition-all ${(rightOpen() && isSoloOpen()) ? 'bg-blue-600/20 text-blue-400 border border-blue-500/30' : 'bg-white/5 text-zinc-400 border border-white/5'
|
||||
}`}
|
||||
>
|
||||
<span class={`w-1.5 h-1.5 bg-current rounded-full ${(rightOpen() && isSoloOpen()) ? 'animate-pulse' : ''}`} />
|
||||
<span>MULTIX MODE</span>
|
||||
</button>
|
||||
<IconButton
|
||||
ref={setRightToggleButtonEl}
|
||||
color="inherit"
|
||||
onClick={handleRightAppBarButtonClick}
|
||||
aria-label={rightAppBarButtonLabel()}
|
||||
size="small"
|
||||
class="text-zinc-500 hover:text-zinc-200"
|
||||
>
|
||||
{rightAppBarButtonIcon()}
|
||||
</IconButton>
|
||||
</div>
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
|
||||
<Box sx={{ display: "flex", flex: 1, minHeight: 0, overflowX: "hidden" }}>
|
||||
<Box sx={{ display: "flex", flex: 1, minHeight: 0, overflowX: "hidden", position: "relative", zIndex: 10 }}>
|
||||
{renderLeftPanel()}
|
||||
|
||||
<Box
|
||||
component="main"
|
||||
sx={{ flexGrow: 1, minHeight: 0, display: "flex", flexDirection: "column", overflowX: "hidden" }}
|
||||
class="content-area"
|
||||
class="content-area relative"
|
||||
>
|
||||
<Show
|
||||
when={showingInfoView()}
|
||||
fallback={
|
||||
<Show
|
||||
when={cachedSessionIds().length > 0 && activeSessionIdForInstance()}
|
||||
fallback={
|
||||
<div class="flex items-center justify-center h-full">
|
||||
<div class="text-center text-gray-500 dark:text-gray-400">
|
||||
<p class="mb-2">No session selected</p>
|
||||
<p class="text-sm">Select a session to view messages</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<For each={cachedSessionIds()}>
|
||||
{(sessionId) => {
|
||||
const isActive = () => activeSessionIdForInstance() === sessionId
|
||||
return (
|
||||
<div
|
||||
class="session-cache-pane flex flex-col flex-1 min-h-0"
|
||||
style={{ display: isActive() ? "flex" : "none" }}
|
||||
data-session-id={sessionId}
|
||||
aria-hidden={!isActive()}
|
||||
>
|
||||
<SessionView
|
||||
sessionId={sessionId}
|
||||
activeSessions={activeSessions()}
|
||||
instanceId={props.instance.id}
|
||||
instanceFolder={props.instance.folder}
|
||||
escapeInDebounce={props.escapeInDebounce}
|
||||
showSidebarToggle={showEmbeddedSidebarToggle()}
|
||||
onSidebarToggle={() => setLeftOpen(true)}
|
||||
forceCompactStatusLayout={showEmbeddedSidebarToggle()}
|
||||
isActive={isActive()}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
<div class="flex-1 flex overflow-hidden">
|
||||
<Editor file={currentFile()} />
|
||||
|
||||
<div class="flex-1 flex flex-col relative border-l border-white/5">
|
||||
<Show when={isSoloOpen()}>
|
||||
<div class="flex-1 flex flex-col min-h-0">
|
||||
<MultiTaskChat instanceId={props.instance.id} sessionId={activeSessionIdForInstance() || ""} />
|
||||
</div>
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<div class="info-view-pane flex flex-col flex-1 min-h-0 overflow-y-auto">
|
||||
<InfoView instanceId={props.instance.id} />
|
||||
|
||||
<div class="flex-1 flex flex-col relative"
|
||||
style={{ display: isSoloOpen() ? "none" : "flex" }}>
|
||||
<Show
|
||||
when={showingInfoView()}
|
||||
fallback={
|
||||
<Show
|
||||
when={cachedSessionIds().length > 0 && activeSessionIdForInstance()}
|
||||
fallback={
|
||||
<div class="flex items-center justify-center h-full">
|
||||
<div class="text-center text-zinc-500">
|
||||
<p class="mb-2">No session selected</p>
|
||||
<p class="text-sm">Select a session to view messages</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<For each={cachedSessionIds()}>
|
||||
{(sessionId) => {
|
||||
const isActive = () => activeSessionIdForInstance() === sessionId
|
||||
return (
|
||||
<div
|
||||
class="session-cache-pane flex flex-col flex-1 min-h-0"
|
||||
style={{ display: isActive() ? "flex" : "none" }}
|
||||
data-session-id={sessionId}
|
||||
aria-hidden={!isActive()}
|
||||
>
|
||||
<SessionView
|
||||
sessionId={sessionId}
|
||||
activeSessions={activeSessions()}
|
||||
instanceId={props.instance.id}
|
||||
instanceFolder={props.instance.folder}
|
||||
escapeInDebounce={props.escapeInDebounce}
|
||||
showSidebarToggle={showEmbeddedSidebarToggle()}
|
||||
onSidebarToggle={() => setLeftOpen(true)}
|
||||
forceCompactStatusLayout={showEmbeddedSidebarToggle()}
|
||||
isActive={isActive()}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<div class="info-view-pane flex flex-col flex-1 min-h-0 overflow-y-auto">
|
||||
<InfoView instanceId={props.instance.id} />
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
{/* Bottom Toolbar/Terminal Area */}
|
||||
<footer class="h-8 glass border-t border-white/5 flex items-center justify-between px-3 text-[10px] text-zinc-500 tracking-wide z-10 shrink-0">
|
||||
<div class="flex items-center space-x-4">
|
||||
<div class="flex items-center space-x-1.5 cursor-pointer hover:text-zinc-300">
|
||||
<TerminalIcon size={12} />
|
||||
<span>TERMINAL</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center space-x-4 uppercase font-bold">
|
||||
<div class="flex items-center space-x-1">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-green-500 shadow-[0_0_5px_rgba(34,197,94,0.5)]" />
|
||||
<span>Sync Active</span>
|
||||
</div>
|
||||
<Show when={activeSessionForInstance()}>
|
||||
{(session) => (
|
||||
<>
|
||||
<span class="hover:text-zinc-300 cursor-pointer">{session().model.modelId}</span>
|
||||
<span class="hover:text-zinc-300 cursor-pointer">{session().agent}</span>
|
||||
</>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
</footer>
|
||||
</Box>
|
||||
|
||||
{renderRightPanel()}
|
||||
</Box>
|
||||
|
||||
{/* Floating Action Buttons removed - Integrated into Header */}
|
||||
</div>
|
||||
)
|
||||
|
||||
|
||||
203
packages/ui/src/components/instance/sidebar.tsx
Normal file
203
packages/ui/src/components/instance/sidebar.tsx
Normal file
@@ -0,0 +1,203 @@
|
||||
import { Component, createSignal, For, Show, createEffect } from "solid-js"
|
||||
import {
|
||||
Files,
|
||||
Search,
|
||||
GitBranch,
|
||||
Play,
|
||||
Settings,
|
||||
ChevronRight,
|
||||
ChevronDown,
|
||||
Folder,
|
||||
User,
|
||||
FileCode,
|
||||
FileJson,
|
||||
FileText,
|
||||
Image as ImageIcon,
|
||||
} from "lucide-solid"
|
||||
import { serverApi } from "../../lib/api-client"
|
||||
|
||||
export interface FileNode {
|
||||
name: string
|
||||
type: "file" | "directory"
|
||||
path: string
|
||||
language?: string
|
||||
content?: string
|
||||
children?: FileNode[]
|
||||
}
|
||||
|
||||
interface SidebarProps {
|
||||
instanceId: string
|
||||
onFileSelect: (file: FileNode) => void
|
||||
isOpen: boolean
|
||||
sessions: any[] // Existing sessions to display in one of the tabs
|
||||
activeSessionId?: string
|
||||
onSessionSelect: (id: string) => void
|
||||
}
|
||||
|
||||
const getFileIcon = (fileName: string) => {
|
||||
if (fileName.endsWith(".tsx") || fileName.endsWith(".ts"))
|
||||
return <FileCode size={16} class="text-blue-400" />
|
||||
if (fileName.endsWith(".json")) return <FileJson size={16} class="text-yellow-400" />
|
||||
if (fileName.endsWith(".md")) return <FileText size={16} class="text-gray-400" />
|
||||
if (fileName.endsWith(".png") || fileName.endsWith(".jpg"))
|
||||
return <ImageIcon size={16} class="text-purple-400" />
|
||||
return <FileCode size={16} class="text-blue-300" />
|
||||
}
|
||||
|
||||
const FileTree: Component<{
|
||||
node: FileNode;
|
||||
depth: number;
|
||||
onSelect: (f: FileNode) => void;
|
||||
instanceId: string;
|
||||
}> = (props) => {
|
||||
const [isOpen, setIsOpen] = createSignal(props.depth === 0)
|
||||
const [children, setChildren] = createSignal<FileNode[]>([])
|
||||
const [isLoading, setIsLoading] = createSignal(false)
|
||||
|
||||
const handleClick = async () => {
|
||||
if (props.node.type === "directory") {
|
||||
const nextOpen = !isOpen()
|
||||
setIsOpen(nextOpen)
|
||||
|
||||
if (nextOpen && children().length === 0) {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const entries = await serverApi.listWorkspaceFiles(props.instanceId, props.node.path)
|
||||
setChildren(entries.map(e => ({
|
||||
name: e.name,
|
||||
type: e.type,
|
||||
path: e.path
|
||||
})))
|
||||
} catch (e) {
|
||||
console.error("Failed to list files", e)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
props.onSelect(props.node)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
onClick={handleClick}
|
||||
class={`flex items-center py-1 px-2 cursor-pointer hover:bg-white/5 text-zinc-400 text-sm transition-colors rounded ${props.depth > 0 ? "ml-2" : ""}`}
|
||||
>
|
||||
<span class="mr-1 w-4 flex justify-center">
|
||||
<Show when={props.node.type === "directory"}>
|
||||
<Show when={isOpen()} fallback={<ChevronRight size={14} />}>
|
||||
<ChevronDown size={14} />
|
||||
</Show>
|
||||
</Show>
|
||||
</span>
|
||||
<span class="mr-2">
|
||||
<Show
|
||||
when={props.node.type === "directory"}
|
||||
fallback={getFileIcon(props.node.name)}
|
||||
>
|
||||
<Folder size={14} class="text-blue-500/80" />
|
||||
</Show>
|
||||
</span>
|
||||
<span class={props.node.type === "directory" ? "font-medium" : ""}>{props.node.name}</span>
|
||||
<Show when={isLoading()}>
|
||||
<span class="ml-2 w-3 h-3 border border-blue-500/30 border-t-blue-500 rounded-full animate-spin" />
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={props.node.type === "directory" && isOpen()}>
|
||||
<div class="border-l border-white/5 ml-3">
|
||||
<For each={children()}>
|
||||
{(child) => <FileTree node={child} depth={props.depth + 1} onSelect={props.onSelect} instanceId={props.instanceId} />}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const Sidebar: Component<SidebarProps> = (props) => {
|
||||
const [activeTab, setActiveTab] = createSignal("files")
|
||||
const [rootFiles, setRootFiles] = createSignal<FileNode[]>([])
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<div
|
||||
class={`flex bg-[#111111] border-r border-white/5 transition-all duration-300 ease-in-out h-full ${props.isOpen ? "w-72" : "w-0 overflow-hidden"}`}
|
||||
>
|
||||
{/* Activity Bar */}
|
||||
<div class="w-14 border-r border-white/5 flex flex-col items-center py-4 space-y-6 shrink-0">
|
||||
<For
|
||||
each={[
|
||||
{ id: "files", icon: Files },
|
||||
{ id: "sessions", icon: User },
|
||||
{ id: "search", icon: Search },
|
||||
{ id: "git", icon: GitBranch },
|
||||
{ id: "debug", icon: Play },
|
||||
]}
|
||||
>
|
||||
{(item) => (
|
||||
<button
|
||||
onClick={() => setActiveTab(item.id)}
|
||||
class={`p-2 transition-all duration-200 relative ${activeTab() === item.id ? "text-white" : "text-zinc-500 hover:text-zinc-300"}`}
|
||||
>
|
||||
<item.icon size={22} strokeWidth={1.5} />
|
||||
<Show when={activeTab() === item.id}>
|
||||
<div class="absolute left-0 top-1/2 -translate-y-1/2 w-0.5 h-6 bg-blue-500 rounded-r-full shadow-[0_0_10px_rgba(59,130,246,0.5)]" />
|
||||
</Show>
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
<div class="mt-auto pb-2">
|
||||
<button class="text-zinc-500 hover:text-white transition-colors">
|
||||
<Settings size={22} strokeWidth={1.5} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Side Pane */}
|
||||
<div class="flex-1 flex flex-col py-3 min-w-0">
|
||||
<div class="px-4 mb-4 flex items-center justify-between">
|
||||
<h2 class="text-[10px] uppercase font-bold text-zinc-500 tracking-wider">
|
||||
{activeTab() === "files" ? "Explorer" : activeTab() === "sessions" ? "Sessions" : activeTab()}
|
||||
</h2>
|
||||
</div>
|
||||
<div class="flex-1 overflow-auto px-2">
|
||||
<Show when={activeTab() === "files"}>
|
||||
<For each={rootFiles()}>
|
||||
{(node) => <FileTree node={node} depth={0} onSelect={props.onFileSelect} instanceId={props.instanceId} />}
|
||||
</For>
|
||||
</Show>
|
||||
<Show when={activeTab() === "sessions"}>
|
||||
<div class="flex flex-col gap-1">
|
||||
<For each={props.sessions}>
|
||||
{(session) => (
|
||||
<div
|
||||
onClick={() => props.onSessionSelect(session.id)}
|
||||
class={`px-3 py-1.5 rounded cursor-pointer text-sm transition-colors ${props.activeSessionId === session.id ? 'bg-blue-600/20 text-blue-400 border border-blue-500/20' : 'text-zinc-400 hover:bg-white/5'}`}
|
||||
>
|
||||
{session.title || session.id.slice(0, 8)}
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
import { Index, type Accessor } from "solid-js"
|
||||
import { Index, type Accessor, createEffect } from "solid-js"
|
||||
import VirtualItem from "./virtual-item"
|
||||
import MessageBlock from "./message-block"
|
||||
import type { InstanceMessageStore } from "../stores/message-v2/instance-store"
|
||||
import { getLogger } from "../lib/logger"
|
||||
|
||||
const log = getLogger("multix-chat")
|
||||
|
||||
export function getMessageAnchorId(messageId: string) {
|
||||
return `message-anchor-${messageId}`
|
||||
@@ -28,6 +31,14 @@ interface MessageBlockListProps {
|
||||
}
|
||||
|
||||
export default function MessageBlockList(props: MessageBlockListProps) {
|
||||
createEffect(() => {
|
||||
const ids = props.messageIds();
|
||||
log.info("[MessageBlockList] messageIds changed", {
|
||||
count: ids.length,
|
||||
ids: ids.slice(-3) // Log last 3 for context
|
||||
});
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<Index each={props.messageIds()}>
|
||||
|
||||
95
packages/ui/src/components/model-status-selector.tsx
Normal file
95
packages/ui/src/components/model-status-selector.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import { createMemo, createSignal, For, Show } from "solid-js"
|
||||
import { providers, fetchProviders } from "../stores/sessions"
|
||||
import { ChevronDown, ShieldCheck, Cpu } from "lucide-solid"
|
||||
import type { Model, Provider } from "../types/session"
|
||||
import { Popover } from "@kobalte/core/popover"
|
||||
|
||||
interface ModelStatusSelectorProps {
|
||||
instanceId: string
|
||||
sessionId: string
|
||||
currentModel: { providerId: string; modelId: string }
|
||||
onModelChange: (model: { providerId: string; modelId: string }) => Promise<void>
|
||||
}
|
||||
|
||||
export default function ModelStatusSelector(props: ModelStatusSelectorProps) {
|
||||
const instanceProviders = () => providers().get(props.instanceId) || []
|
||||
const [isOpen, setIsOpen] = createSignal(false)
|
||||
|
||||
const currentProvider = createMemo(() =>
|
||||
instanceProviders().find(p => p.id === props.currentModel.providerId)
|
||||
)
|
||||
|
||||
const currentModel = createMemo(() =>
|
||||
currentProvider()?.models.find(m => m.id === props.currentModel.modelId)
|
||||
)
|
||||
|
||||
// Simple auth status check: if we have providers and the current provider is in the list, we consider it "authenticated"
|
||||
const isAuthenticated = createMemo(() => !!currentProvider())
|
||||
|
||||
return (
|
||||
<div class="flex items-center space-x-2">
|
||||
{/* Auth Status Indicator */}
|
||||
<div class="flex items-center bg-white/5 border border-white/5 rounded-full px-2 py-1 space-x-1.5 h-[26px]">
|
||||
<div class={`w-1.5 h-1.5 rounded-full transition-all duration-500 ${isAuthenticated() ? 'bg-emerald-500 shadow-[0_0_8px_rgba(16,185,129,0.4)]' : 'bg-rose-500 shadow-[0_0_8px_rgba(244,63,94,0.4)]'}`} />
|
||||
<span class="text-[9px] font-black uppercase tracking-widest text-zinc-500 whitespace-nowrap">
|
||||
{isAuthenticated() ? 'AUTHED' : 'NO AUTH'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Model Selector HUD */}
|
||||
<Popover open={isOpen()} onOpenChange={setIsOpen}>
|
||||
<Popover.Trigger class="flex items-center bg-white/5 border border-white/5 rounded-full px-3 py-1 space-x-2 text-zinc-400 hover:border-white/10 hover:bg-white/10 transition-all group h-[26px]">
|
||||
<Cpu size={12} class="text-indigo-400 shrink-0" />
|
||||
<div class="flex flex-col items-start leading-none">
|
||||
<span class="text-[8px] font-black text-zinc-500 uppercase tracking-widest">AI MODEL</span>
|
||||
<span class="text-[10px] font-bold text-zinc-200 truncate max-w-[100px]">
|
||||
{currentModel()?.name ?? currentProvider()?.name ?? "Select Model"}
|
||||
</span>
|
||||
</div>
|
||||
<ChevronDown size={10} class={`transition-transform duration-200 shrink-0 ${isOpen() ? 'rotate-180' : ''}`} />
|
||||
</Popover.Trigger>
|
||||
|
||||
<Popover.Portal>
|
||||
<Popover.Content class="z-[1000] min-w-[240px] bg-[#0c0c0d] border border-white/10 rounded-2xl shadow-2xl shadow-black/50 p-2 animate-in fade-in zoom-in-95 duration-200 origin-top">
|
||||
<div class="max-h-[400px] overflow-y-auto custom-scrollbar no-scrollbar">
|
||||
<For each={instanceProviders()}>
|
||||
{(provider) => (
|
||||
<div class="mb-2 last:mb-0">
|
||||
<div class="px-2 py-1 text-[9px] font-black text-zinc-600 uppercase tracking-widest flex items-center justify-between border-b border-white/5 mb-1">
|
||||
<span>{provider.name}</span>
|
||||
<Show when={provider.id === props.currentModel.providerId}>
|
||||
<ShieldCheck size={10} class="text-emerald-500/50" />
|
||||
</Show>
|
||||
</div>
|
||||
<div class="space-y-0.5">
|
||||
<For each={provider.models}>
|
||||
{(model) => (
|
||||
<button
|
||||
onClick={async () => {
|
||||
await props.onModelChange({ providerId: provider.id, modelId: model.id })
|
||||
setIsOpen(false)
|
||||
}}
|
||||
class={`w-full flex items-center justify-between px-2 py-2 rounded-lg text-[11px] transition-all border ${
|
||||
model.id === props.currentModel.modelId && provider.id === props.currentModel.providerId
|
||||
? 'bg-indigo-500/15 text-indigo-400 border-indigo-500/20'
|
||||
: 'text-zinc-400 hover:bg-white/5 border-transparent'
|
||||
}`}
|
||||
>
|
||||
<span class="font-bold">{model.name}</span>
|
||||
<Show when={model.id === props.currentModel.modelId && provider.id === props.currentModel.providerId}>
|
||||
<div class="w-1 h-1 bg-indigo-400 rounded-full animate-pulse" />
|
||||
</Show>
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Popover.Content>
|
||||
</Popover.Portal>
|
||||
</Popover>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1087,7 +1087,6 @@ export default function PromptInput(props: PromptInputProps) {
|
||||
</div>
|
||||
</Show>
|
||||
<div class="prompt-input-field-container">
|
||||
<div class="prompt-input-field">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
class={`prompt-input ${mode() === "shell" ? "shell-mode" : ""}`}
|
||||
@@ -1103,7 +1102,7 @@ export default function PromptInput(props: PromptInputProps) {
|
||||
onFocus={() => setIsFocused(true)}
|
||||
onBlur={() => setIsFocused(false)}
|
||||
disabled={props.disabled}
|
||||
rows={4}
|
||||
rows={1}
|
||||
style={attachments().length > 0 ? { "padding-top": "8px" } : {}}
|
||||
spellcheck={false}
|
||||
autocorrect="off"
|
||||
@@ -1166,7 +1165,6 @@ export default function PromptInput(props: PromptInputProps) {
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
239
packages/ui/src/components/settings/OllamaCloudSettings.tsx
Normal file
239
packages/ui/src/components/settings/OllamaCloudSettings.tsx
Normal file
@@ -0,0 +1,239 @@
|
||||
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'
|
||||
|
||||
interface OllamaCloudConfig {
|
||||
enabled: boolean
|
||||
apiKey?: string
|
||||
endpoint?: string
|
||||
}
|
||||
|
||||
interface OllamaCloudModelsResponse {
|
||||
models: Array<{
|
||||
name: string
|
||||
size: string
|
||||
digest: string
|
||||
modified_at: string
|
||||
}>
|
||||
}
|
||||
|
||||
const OllamaCloudSettings: Component = () => {
|
||||
const [config, setConfig] = createSignal<OllamaCloudConfig>({ enabled: false })
|
||||
const [isLoading, setIsLoading] = createSignal(false)
|
||||
const [isTesting, setIsTesting] = createSignal(false)
|
||||
const [connectionStatus, setConnectionStatus] = createSignal<'idle' | 'testing' | 'connected' | 'failed'>('idle')
|
||||
const [models, setModels] = createSignal<string[]>([])
|
||||
const [isLoadingModels, setIsLoadingModels] = createSignal(false)
|
||||
|
||||
// Load config on mount
|
||||
onMount(async () => {
|
||||
try {
|
||||
const response = await fetch('http://localhost:6149/api/ollama/config')
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
setConfig(data.config)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load Ollama config:', error)
|
||||
}
|
||||
})
|
||||
|
||||
const handleConfigChange = (field: keyof OllamaCloudConfig, value: any) => {
|
||||
setConfig(prev => ({ ...prev, [field]: value }))
|
||||
setConnectionStatus('idle')
|
||||
}
|
||||
|
||||
const saveConfig = async () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const response = await fetch('http://localhost:6149/api/ollama/config', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(config())
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
toast.success('Ollama Cloud configuration saved', {
|
||||
duration: 3000,
|
||||
icon: <CheckCircle class="w-4 h-4 text-green-500" />
|
||||
})
|
||||
} else {
|
||||
throw new Error('Failed to save config')
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Failed to save Ollama Cloud configuration', {
|
||||
duration: 5000,
|
||||
icon: <XCircle class="w-4 h-4 text-red-500" />
|
||||
})
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const testConnection = async () => {
|
||||
setIsTesting(true)
|
||||
setConnectionStatus('testing')
|
||||
|
||||
try {
|
||||
const response = await fetch('http://localhost:6149/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 {
|
||||
toast.error('Failed to connect to Ollama Cloud', {
|
||||
duration: 3000,
|
||||
icon: <XCircle class="w-4 h-4 text-red-500" />
|
||||
})
|
||||
}
|
||||
} else {
|
||||
throw new Error('Connection test failed')
|
||||
}
|
||||
} catch (error) {
|
||||
setConnectionStatus('failed')
|
||||
toast.error('Connection test failed', {
|
||||
duration: 3000,
|
||||
icon: <XCircle class="w-4 h-4 text-red-500" />
|
||||
})
|
||||
} finally {
|
||||
setIsTesting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const loadModels = async () => {
|
||||
setIsLoadingModels(true)
|
||||
try {
|
||||
const response = await fetch('http://localhost:6149/api/ollama/models/cloud')
|
||||
if (response.ok) {
|
||||
const data: OllamaCloudModelsResponse = await response.json()
|
||||
setModels(data.models.map(model => model.name))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load models:', error)
|
||||
} finally {
|
||||
setIsLoadingModels(false)
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusIcon = () => {
|
||||
switch (connectionStatus()) {
|
||||
case 'testing':
|
||||
return <Loader class="w-4 h-4 animate-spin" />
|
||||
case 'connected':
|
||||
return <CheckCircle class="w-4 h-4 text-green-500" />
|
||||
case 'failed':
|
||||
return <XCircle class="w-4 h-4 text-red-500" />
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="space-y-6 p-6">
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<Cloud class="w-6 h-6" />
|
||||
<h2 class="text-xl font-semibold">Ollama Cloud Integration</h2>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
{/* Enable/Disable Toggle */}
|
||||
<div class="flex items-center justify-between">
|
||||
<label class="font-medium">Enable Ollama Cloud</label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config().enabled}
|
||||
onChange={(e) => handleConfigChange('enabled', e.target.checked)}
|
||||
class="w-4 h-4"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* API Key */}
|
||||
<div>
|
||||
<label class="block font-medium mb-2">API Key</label>
|
||||
<input
|
||||
type="password"
|
||||
placeholder="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}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Endpoint */}
|
||||
<div>
|
||||
<label class="block font-medium mb-2">Endpoint</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="https://ollama.com"
|
||||
value={config().endpoint || ''}
|
||||
onChange={(e) => handleConfigChange('endpoint', 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}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Test Connection */}
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={testConnection}
|
||||
disabled={!config().enabled || isTesting()}
|
||||
class="flex items-center gap-2"
|
||||
>
|
||||
{getStatusIcon()}
|
||||
{isTesting() ? 'Testing...' : 'Test Connection'}
|
||||
</Button>
|
||||
|
||||
<Show when={connectionStatus() === 'connected'}>
|
||||
<span class="text-green-600 text-sm">Connected successfully</span>
|
||||
</Show>
|
||||
<Show when={connectionStatus() === 'failed'}>
|
||||
<span class="text-red-600 text-sm">Connection failed</span>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
{/* 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">
|
||||
{models().map(model => (
|
||||
<div class="p-3 border border-gray-200 rounded-md bg-gray-50">
|
||||
<code class="text-sm font-mono">{model}</code>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Save Configuration */}
|
||||
<div class="flex justify-end">
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={saveConfig}
|
||||
disabled={isLoading()}
|
||||
class="flex items-center gap-2"
|
||||
>
|
||||
{isLoading() ? <Loader class="w-4 h-4 animate-spin" /> : null}
|
||||
Save Configuration
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default OllamaCloudSettings
|
||||
191
packages/ui/src/components/settings/QwenCodeSettings.tsx
Normal file
191
packages/ui/src/components/settings/QwenCodeSettings.tsx
Normal file
@@ -0,0 +1,191 @@
|
||||
import { Component, createSignal, onMount, Show } from 'solid-js'
|
||||
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'
|
||||
|
||||
interface QwenUser {
|
||||
id: string
|
||||
username: string
|
||||
email?: string
|
||||
avatar?: string
|
||||
tier: string
|
||||
limits: {
|
||||
requests_per_day: number
|
||||
requests_per_minute: number
|
||||
}
|
||||
}
|
||||
|
||||
const QwenCodeSettings: Component = () => {
|
||||
const { isAuthenticated, user, isLoading, signIn, signOut, createApiClient } = useQwenOAuth()
|
||||
const [isSigningOut, setIsSigningOut] = createSignal(false)
|
||||
|
||||
const handleSignIn = async () => {
|
||||
try {
|
||||
await signIn()
|
||||
toast.success('Successfully authenticated with Qwen Code', {
|
||||
duration: 3000,
|
||||
icon: <CheckCircle class="w-4 h-4 text-green-500" />
|
||||
})
|
||||
} catch (error) {
|
||||
toast.error('Failed to authenticate with Qwen Code', {
|
||||
duration: 5000,
|
||||
icon: <XCircle class="w-4 h-4 text-red-500" />
|
||||
})
|
||||
console.error('Qwen OAuth error:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSignOut = () => {
|
||||
setIsSigningOut(true)
|
||||
try {
|
||||
signOut()
|
||||
toast.success('Successfully signed out from Qwen Code', {
|
||||
duration: 3000,
|
||||
icon: <CheckCircle class="w-4 h-4 text-green-500" />
|
||||
})
|
||||
} catch (error) {
|
||||
toast.error('Failed to sign out from Qwen Code', {
|
||||
duration: 5000,
|
||||
icon: <XCircle class="w-4 h-4 text-red-500" />
|
||||
})
|
||||
console.error('Qwen signout error:', error)
|
||||
} finally {
|
||||
setIsSigningOut(false)
|
||||
}
|
||||
}
|
||||
|
||||
const formatRemainingRequests = (user: QwenUser) => {
|
||||
return `${user.limits.requests_per_day} requests/day, ${user.limits.requests_per_minute}/min`
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="space-y-6 p-6">
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<User class="w-6 h-6" />
|
||||
<h2 class="text-xl font-semibold">Qwen Code Integration</h2>
|
||||
</div>
|
||||
|
||||
{/* Authentication Status */}
|
||||
<div class="space-y-4">
|
||||
<Show
|
||||
when={isAuthenticated()}
|
||||
fallback={
|
||||
/* Not Authenticated State */
|
||||
<div class="text-center py-8">
|
||||
<div class="mb-4">
|
||||
<User class="w-12 h-12 mx-auto text-gray-400" />
|
||||
<p class="mt-2 text-gray-600 dark:text-gray-400">
|
||||
Connect your Qwen Code account to access AI-powered coding assistance
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={handleSignIn}
|
||||
disabled={isLoading()}
|
||||
class="flex items-center gap-2 mx-auto"
|
||||
>
|
||||
{isLoading() ? <Loader class="w-4 h-4 animate-spin" /> : null}
|
||||
Connect Qwen Code Account
|
||||
</Button>
|
||||
|
||||
<div class="mt-4 text-sm text-gray-500">
|
||||
<p>Get 2,000 free requests per day with Qwen OAuth</p>
|
||||
<a
|
||||
href="https://qwen.ai"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-blue-600 hover:underline inline-flex items-center gap-1 mt-2"
|
||||
>
|
||||
<ExternalLink class="w-3 h-3" />
|
||||
Learn more about Qwen Code
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{/* Authenticated State */}
|
||||
<div class="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg p-4">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-12 h-12 bg-green-100 dark:bg-green-800 rounded-full flex items-center justify-center">
|
||||
<User class="w-6 h-6 text-green-600 dark:text-green-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-semibold text-green-900 dark:text-green-100">
|
||||
{user()?.username || 'Qwen User'}
|
||||
</h3>
|
||||
<p class="text-sm text-green-700 dark:text-green-300">
|
||||
{user()?.email}
|
||||
</p>
|
||||
<div class="flex items-center gap-2 mt-1">
|
||||
<span class="text-xs px-2 py-1 bg-green-200 dark:bg-green-800 text-green-800 dark:text-green-200 rounded-full">
|
||||
{user()?.tier || 'Free'} Tier
|
||||
</span>
|
||||
<Show when={user()}>
|
||||
<span class="text-xs text-green-600 dark:text-green-400">
|
||||
{formatRemainingRequests(user()!)}
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={handleSignOut}
|
||||
disabled={isSigningOut()}
|
||||
size="small"
|
||||
class="flex items-center gap-1"
|
||||
>
|
||||
<LogOut class="w-4 h-4" />
|
||||
{isSigningOut() ? 'Signing out...' : 'Sign Out'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Feature Information */}
|
||||
<div class="border-t pt-4">
|
||||
<h3 class="font-semibold mb-3">Available Features</h3>
|
||||
<div class="grid grid-cols-1 gap-3">
|
||||
<div class="p-3 border border-gray-200 dark:border-gray-700 rounded-md">
|
||||
<h4 class="font-medium text-sm">Code Understanding & Editing</h4>
|
||||
<p class="text-xs text-gray-600 dark:text-gray-400 mt-1">
|
||||
Query and edit large codebases beyond traditional context window limits
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="p-3 border border-gray-200 dark:border-gray-700 rounded-md">
|
||||
<h4 class="font-medium text-sm">Workflow Automation</h4>
|
||||
<p class="text-xs text-gray-600 dark:text-gray-400 mt-1">
|
||||
Automate operational tasks like handling pull requests and complex rebases
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="p-3 border border-gray-200 dark:border-gray-700 rounded-md">
|
||||
<h4 class="font-medium text-sm">Vision Model Support</h4>
|
||||
<p class="text-xs text-gray-600 dark:text-gray-400 mt-1">
|
||||
Automatically detect images and switch to vision-capable models for multimodal analysis
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Usage Tips */}
|
||||
<div class="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
|
||||
<h3 class="font-semibold text-blue-900 dark:text-blue-100 mb-2">Usage Tips</h3>
|
||||
<ul class="text-sm text-blue-800 dark:text-blue-200 space-y-1">
|
||||
<li>• Use <code class="bg-blue-100 dark:bg-blue-800 px-1 rounded">/compress</code> to compress conversation history</li>
|
||||
<li>• Use <code class="bg-blue-100 dark:bg-blue-800 px-1 rounded">/stats</code> to check token usage</li>
|
||||
<li>• Vision models automatically switch when images are detected</li>
|
||||
<li>• Configure behavior in <code class="bg-blue-100 dark:bg-blue-800 px-1 rounded">~/.qwen/settings.json</code></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default QwenCodeSettings
|
||||
Reference in New Issue
Block a user