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