fleetcode/renderer.ts
Amrit Subramanian 61d2defd93 unread indicator
2025-10-03 12:14:46 -04:00

954 lines
29 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { ipcRenderer } from "electron";
import { Terminal } from "xterm";
import { FitAddon } from "xterm-addon-fit";
interface SessionConfig {
projectDir: string;
parentBranch: string;
codingAgent: string;
skipPermissions: boolean;
}
interface PersistedSession {
id: string;
number: number;
name: string;
config: SessionConfig;
worktreePath: string;
createdAt: number;
}
interface Session {
id: string;
terminal: Terminal | null;
fitAddon: FitAddon | null;
element: HTMLDivElement | null;
name: string;
config: SessionConfig;
worktreePath: string;
hasActivePty: boolean;
hasUnreadActivity: boolean;
}
interface McpServer {
name: string;
connected?: boolean;
command?: string;
args?: string[];
env?: Record<string, string>;
url?: string;
type?: "stdio" | "sse";
}
const sessions = new Map<string, Session>();
let activeSessionId: string | null = null;
let mcpServers: McpServer[] = [];
function createTerminalUI(sessionId: string) {
const term = new Terminal({
cursorBlink: true,
fontSize: 14,
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
theme: {
background: "#000000",
foreground: "#ffffff",
},
});
const fitAddon = new FitAddon();
term.loadAddon(fitAddon);
const sessionElement = document.createElement("div");
sessionElement.className = "session-wrapper";
sessionElement.id = `session-${sessionId}`;
const container = document.getElementById("session-container");
if (container) {
container.appendChild(sessionElement);
}
term.open(sessionElement);
fitAddon.fit();
term.onData((data) => {
ipcRenderer.send("session-input", sessionId, data);
});
// Listen for bell character to mark unread activity
term.onBell(() => {
console.log(`Bell received for session ${sessionId}, activeSessionId: ${activeSessionId}`);
if (activeSessionId !== sessionId) {
console.log(`Marking session ${sessionId} as unread`);
markSessionAsUnread(sessionId);
}
});
// Handle resize
const resizeHandler = () => {
if (activeSessionId === sessionId) {
fitAddon.fit();
ipcRenderer.send("session-resize", sessionId, term.cols, term.rows);
}
};
window.addEventListener("resize", resizeHandler);
return { terminal: term, fitAddon, element: sessionElement };
}
function addSession(persistedSession: PersistedSession, hasActivePty: boolean) {
const session: Session = {
id: persistedSession.id,
terminal: null,
fitAddon: null,
element: null,
name: persistedSession.name,
config: persistedSession.config,
worktreePath: persistedSession.worktreePath,
hasActivePty,
hasUnreadActivity: false,
};
sessions.set(persistedSession.id, session);
// Add to sidebar
addToSidebar(persistedSession.id, persistedSession.name, hasActivePty);
// Only add tab if terminal is active
if (hasActivePty) {
addTab(persistedSession.id, persistedSession.name);
}
return session;
}
function activateSession(sessionId: string) {
const session = sessions.get(sessionId);
if (!session) return;
// If terminal UI doesn't exist yet, create it
if (!session.terminal) {
const ui = createTerminalUI(sessionId);
session.terminal = ui.terminal;
session.fitAddon = ui.fitAddon;
session.element = ui.element;
}
session.hasActivePty = true;
updateSessionState(sessionId, true);
// Add tab if it doesn't exist
if (!document.getElementById(`tab-${sessionId}`)) {
addTab(sessionId, session.name);
}
// Switch to this session
switchToSession(sessionId);
}
function updateSessionState(sessionId: string, isActive: boolean) {
const sidebarItem = document.getElementById(`sidebar-${sessionId}`);
const indicator = sidebarItem?.querySelector(".session-indicator");
if (indicator) {
if (isActive) {
indicator.classList.add("active");
} else {
indicator.classList.remove("active");
}
}
}
function addToSidebar(sessionId: string, name: string, hasActivePty: boolean) {
const list = document.getElementById("session-list");
if (!list) return;
const item = document.createElement("div");
item.id = `sidebar-${sessionId}`;
item.className = "session-list-item";
item.innerHTML = `
<div class="flex items-center space-x-2 flex-1 session-name-container">
<span class="session-indicator ${hasActivePty ? 'active' : ''}"></span>
<span class="truncate session-name-text" data-id="${sessionId}">${name}</span>
<input type="text" class="session-name-input hidden" data-id="${sessionId}" value="${name}" />
</div>
<button class="session-delete-btn" data-id="${sessionId}" title="Delete session">×</button>
`;
// Click on session name to edit
const nameText = item.querySelector(".session-name-text");
const nameInput = item.querySelector(".session-name-input") as HTMLInputElement;
nameText?.addEventListener("click", (e) => {
e.stopPropagation();
startEditingSessionName(sessionId);
});
// Handle input blur and enter key
nameInput?.addEventListener("blur", () => {
finishEditingSessionName(sessionId);
});
nameInput?.addEventListener("keydown", (e) => {
if (e.key === "Enter") {
finishEditingSessionName(sessionId);
} else if (e.key === "Escape") {
cancelEditingSessionName(sessionId);
}
});
item.addEventListener("click", (e) => {
const target = e.target as HTMLElement;
if (!target.classList.contains("session-delete-btn") &&
!target.classList.contains("session-name-text") &&
!target.classList.contains("session-name-input")) {
handleSessionClick(sessionId);
}
});
const deleteBtn = item.querySelector(".session-delete-btn");
deleteBtn?.addEventListener("click", (e) => {
e.stopPropagation();
deleteSession(sessionId);
});
list.appendChild(item);
}
function startEditingSessionName(sessionId: string) {
const sidebarItem = document.getElementById(`sidebar-${sessionId}`);
const nameText = sidebarItem?.querySelector(".session-name-text");
const nameInput = sidebarItem?.querySelector(".session-name-input") as HTMLInputElement;
if (nameText && nameInput) {
nameText.classList.add("hidden");
nameInput.classList.remove("hidden");
nameInput.focus();
nameInput.select();
}
}
function finishEditingSessionName(sessionId: string) {
const sidebarItem = document.getElementById(`sidebar-${sessionId}`);
const nameText = sidebarItem?.querySelector(".session-name-text");
const nameInput = sidebarItem?.querySelector(".session-name-input") as HTMLInputElement;
const session = sessions.get(sessionId);
if (nameText && nameInput && session) {
const newName = nameInput.value.trim();
if (newName && newName !== session.name) {
// Update session name
session.name = newName;
nameText.textContent = newName;
// Update tab name if exists
const tab = document.getElementById(`tab-${sessionId}`);
const tabName = tab?.querySelector(".tab-name");
if (tabName) {
tabName.textContent = newName;
}
// Save to backend
ipcRenderer.send("rename-session", sessionId, newName);
}
nameInput.classList.add("hidden");
nameText.classList.remove("hidden");
}
}
function cancelEditingSessionName(sessionId: string) {
const sidebarItem = document.getElementById(`sidebar-${sessionId}`);
const nameText = sidebarItem?.querySelector(".session-name-text");
const nameInput = sidebarItem?.querySelector(".session-name-input") as HTMLInputElement;
const session = sessions.get(sessionId);
if (nameText && nameInput && session) {
// Reset to original name
nameInput.value = session.name;
nameInput.classList.add("hidden");
nameText.classList.remove("hidden");
}
}
function handleSessionClick(sessionId: string) {
const session = sessions.get(sessionId);
if (!session) return;
if (session.hasActivePty) {
// Just switch to it
switchToSession(sessionId);
} else {
// Reopen the session
ipcRenderer.send("reopen-session", sessionId);
}
}
function addTab(sessionId: string, name: string) {
const tabsContainer = document.getElementById("tabs");
if (!tabsContainer) return;
const tab = document.createElement("div");
tab.id = `tab-${sessionId}`;
tab.className = "tab";
tab.innerHTML = `
<span class="tab-name">${name}</span>
<button class="tab-close-btn" data-id="${sessionId}">×</button>
`;
tab.addEventListener("click", (e) => {
if (!(e.target as HTMLElement).classList.contains("tab-close-btn")) {
switchToSession(sessionId);
}
});
const closeBtn = tab.querySelector(".tab-close-btn");
closeBtn?.addEventListener("click", (e) => {
e.stopPropagation();
closeSession(sessionId);
});
tabsContainer.appendChild(tab);
}
function markSessionAsUnread(sessionId: string) {
const session = sessions.get(sessionId);
if (!session) {
console.log(`[Unread] ERROR: Session ${sessionId} not found`);
return;
}
console.log(`[Unread] Setting hasUnreadActivity flag for session ${sessionId}`);
session.hasUnreadActivity = true;
// Add unread indicator to tab
const tab = document.getElementById(`tab-${sessionId}`);
console.log(`[Unread] Tab element for ${sessionId}:`, tab);
if (tab) {
console.log(`[Unread] Tab classes before:`, tab.className);
if (!tab.classList.contains("unread")) {
tab.classList.add("unread");
console.log(`[Unread] ✓ Added 'unread' class to tab ${sessionId}`);
console.log(`[Unread] Tab classes after:`, tab.className);
} else {
console.log(`[Unread] Tab ${sessionId} already has 'unread' class`);
}
} else {
console.log(`[Unread] ERROR: No tab element found for session ${sessionId}`);
}
}
function clearUnreadStatus(sessionId: string) {
const session = sessions.get(sessionId);
if (!session) return;
session.hasUnreadActivity = false;
// Remove unread indicator from tab
const tab = document.getElementById(`tab-${sessionId}`);
if (tab) {
tab.classList.remove("unread");
}
}
function switchToSession(sessionId: string) {
// Hide all sessions
sessions.forEach((session, id) => {
if (session.element) {
session.element.classList.remove("active");
}
document.getElementById(`tab-${id}`)?.classList.remove("active");
document.getElementById(`sidebar-${id}`)?.classList.remove("active");
});
// Show active session
const session = sessions.get(sessionId);
if (session && session.element && session.terminal && session.fitAddon) {
session.element.classList.add("active");
document.getElementById(`tab-${sessionId}`)?.classList.add("active");
document.getElementById(`sidebar-${sessionId}`)?.classList.add("active");
activeSessionId = sessionId;
// Clear unread status when switching to this session
clearUnreadStatus(sessionId);
// Focus and resize
session.terminal.focus();
setTimeout(() => {
if (session.fitAddon && session.terminal) {
session.fitAddon.fit();
ipcRenderer.send("session-resize", sessionId, session.terminal.cols, session.terminal.rows);
}
}, 0);
}
}
function closeSession(sessionId: string) {
const session = sessions.get(sessionId);
if (!session) return;
// Remove terminal UI
if (session.element) {
session.element.remove();
}
if (session.terminal) {
session.terminal.dispose();
}
// Remove tab
document.getElementById(`tab-${sessionId}`)?.remove();
// Update session state
session.terminal = null;
session.fitAddon = null;
session.element = null;
session.hasActivePty = false;
// Update UI indicator
updateSessionState(sessionId, false);
// Close PTY in main process
ipcRenderer.send("close-session", sessionId);
// Switch to another active session
if (activeSessionId === sessionId) {
const activeSessions = Array.from(sessions.values()).filter(s => s.hasActivePty);
if (activeSessions.length > 0) {
switchToSession(activeSessions[0].id);
} else {
activeSessionId = null;
}
}
}
function deleteSession(sessionId: string) {
const session = sessions.get(sessionId);
if (!session) return;
// Confirm deletion
if (!confirm(`Delete ${session.name}? This will remove the git worktree.`)) {
return;
}
// Remove from UI
if (session.element) {
session.element.remove();
}
if (session.terminal) {
session.terminal.dispose();
}
document.getElementById(`tab-${sessionId}`)?.remove();
document.getElementById(`sidebar-${sessionId}`)?.remove();
// Remove from sessions map
sessions.delete(sessionId);
// Delete in main process (handles worktree removal)
ipcRenderer.send("delete-session", sessionId);
// Switch to another session
if (activeSessionId === sessionId) {
const remainingSessions = Array.from(sessions.values()).filter(s => s.hasActivePty);
if (remainingSessions.length > 0) {
switchToSession(remainingSessions[0].id);
} else {
activeSessionId = null;
}
}
}
// Track idle timers per session to detect when output stops (Claude is done)
const sessionIdleTimers = new Map<string, NodeJS.Timeout>();
const IDLE_DELAY_MS = 500; // 0.5 seconds of no output = Claude is done
// Handle session output
ipcRenderer.on("session-output", (_event, sessionId: string, data: string) => {
const session = sessions.get(sessionId);
if (session && session.terminal) {
session.terminal.write(data);
// Only mark as unread if this is not the active session
if (activeSessionId !== sessionId && session.hasActivePty) {
console.log(`[Unread] Session ${sessionId} received output while inactive`);
// Clear any existing idle timer
const existingTimer = sessionIdleTimers.get(sessionId);
if (existingTimer) {
console.log(`[Unread] Clearing existing idle timer for session ${sessionId}`);
clearTimeout(existingTimer);
}
// Set a new timer - if no output for IDLE_DELAY_MS, mark as unread
const timer = setTimeout(() => {
console.log(`[Unread] ✓ No output for ${IDLE_DELAY_MS}ms - Claude is done! Marking session ${sessionId} as unread`);
markSessionAsUnread(sessionId);
sessionIdleTimers.delete(sessionId);
}, IDLE_DELAY_MS);
sessionIdleTimers.set(sessionId, timer);
console.log(`[Unread] Set idle timer for session ${sessionId} - will trigger in ${IDLE_DELAY_MS}ms if no more output`);
}
}
});
// Handle session created
ipcRenderer.on("session-created", (_event, sessionId: string, persistedSession: any) => {
const session = addSession(persistedSession, true);
activateSession(sessionId);
});
// Handle session reopened
ipcRenderer.on("session-reopened", (_event, sessionId: string) => {
activateSession(sessionId);
});
// Handle session deleted
ipcRenderer.on("session-deleted", (_event, sessionId: string) => {
const session = sessions.get(sessionId);
if (session) {
if (session.element) session.element.remove();
if (session.terminal) session.terminal.dispose();
document.getElementById(`tab-${sessionId}`)?.remove();
document.getElementById(`sidebar-${sessionId}`)?.remove();
sessions.delete(sessionId);
if (activeSessionId === sessionId) {
const remainingSessions = Array.from(sessions.values()).filter(s => s.hasActivePty);
if (remainingSessions.length > 0) {
switchToSession(remainingSessions[0].id);
} else {
activeSessionId = null;
}
}
}
});
// Load persisted sessions on startup
ipcRenderer.on("load-persisted-sessions", (_event, persistedSessions: PersistedSession[]) => {
persistedSessions.forEach(ps => {
addSession(ps, false);
});
});
// Modal handling
const modal = document.getElementById("config-modal");
const projectDirInput = document.getElementById("project-dir") as HTMLInputElement;
const parentBranchSelect = document.getElementById("parent-branch") as HTMLSelectElement;
const codingAgentSelect = document.getElementById("coding-agent") as HTMLSelectElement;
const skipPermissionsCheckbox = document.getElementById("skip-permissions") as HTMLInputElement;
const skipPermissionsGroup = skipPermissionsCheckbox?.parentElement?.parentElement;
const browseDirBtn = document.getElementById("browse-dir");
const cancelBtn = document.getElementById("cancel-session");
const createBtn = document.getElementById("create-session");
let selectedDirectory = "";
// Toggle skip permissions checkbox visibility based on coding agent
codingAgentSelect?.addEventListener("change", () => {
if (codingAgentSelect.value === "claude") {
skipPermissionsGroup?.classList.remove("hidden");
} else {
skipPermissionsGroup?.classList.add("hidden");
}
});
// New session button - opens modal
document.getElementById("new-session")?.addEventListener("click", async () => {
modal?.classList.remove("hidden");
// Load last used settings
const lastSettings = await ipcRenderer.invoke("get-last-settings");
if (lastSettings.projectDir) {
selectedDirectory = lastSettings.projectDir;
projectDirInput.value = lastSettings.projectDir;
// Load git branches for the last directory
const branches = await ipcRenderer.invoke("get-branches", lastSettings.projectDir);
parentBranchSelect.innerHTML = "";
if (branches.length === 0) {
parentBranchSelect.innerHTML = '<option value="">No git repository found</option>';
} else {
branches.forEach((branch: string) => {
const option = document.createElement("option");
option.value = branch;
option.textContent = branch;
if (branch === lastSettings.parentBranch) {
option.selected = true;
}
parentBranchSelect.appendChild(option);
});
}
}
// Set last used coding agent
if (lastSettings.codingAgent) {
codingAgentSelect.value = lastSettings.codingAgent;
}
// Set last used skip permissions setting and visibility
if (lastSettings.skipPermissions !== undefined) {
skipPermissionsCheckbox.checked = lastSettings.skipPermissions;
}
// Show/hide skip permissions based on coding agent
if (lastSettings.codingAgent === "codex") {
skipPermissionsGroup?.classList.add("hidden");
} else {
skipPermissionsGroup?.classList.remove("hidden");
}
});
// Browse directory
browseDirBtn?.addEventListener("click", async () => {
const dir = await ipcRenderer.invoke("select-directory");
if (dir) {
selectedDirectory = dir;
projectDirInput.value = dir;
// Load git branches
const branches = await ipcRenderer.invoke("get-branches", dir);
parentBranchSelect.innerHTML = "";
if (branches.length === 0) {
parentBranchSelect.innerHTML = '<option value="">No git repository found</option>';
} else {
branches.forEach((branch: string) => {
const option = document.createElement("option");
option.value = branch;
option.textContent = branch;
parentBranchSelect.appendChild(option);
});
}
}
});
// Cancel button
cancelBtn?.addEventListener("click", () => {
modal?.classList.add("hidden");
projectDirInput.value = "";
selectedDirectory = "";
parentBranchSelect.innerHTML = '<option value="">Loading branches...</option>';
});
// Create session button
createBtn?.addEventListener("click", () => {
if (!selectedDirectory) {
alert("Please select a project directory");
return;
}
const config: SessionConfig = {
projectDir: selectedDirectory,
parentBranch: parentBranchSelect.value,
codingAgent: codingAgentSelect.value,
skipPermissions: codingAgentSelect.value === "claude" ? skipPermissionsCheckbox.checked : false,
};
// Save settings for next time
ipcRenderer.send("save-settings", config);
// Create the session
ipcRenderer.send("create-session", config);
modal?.classList.add("hidden");
// Reset form
projectDirInput.value = "";
selectedDirectory = "";
parentBranchSelect.innerHTML = '<option value="">Loading branches...</option>';
codingAgentSelect.value = "claude";
});
// MCP Server management functions
async function loadMcpServers() {
const addMcpServerBtn = document.getElementById("add-mcp-server");
// Show loading spinner
if (addMcpServerBtn) {
addMcpServerBtn.innerHTML = '<span class="loading-spinner"></span>';
addMcpServerBtn.classList.add("pointer-events-none");
}
try {
const servers = await ipcRenderer.invoke("list-mcp-servers");
mcpServers = servers;
renderMcpServers();
} catch (error) {
console.error("Failed to load MCP servers:", error);
} finally {
// Restore button
if (addMcpServerBtn) {
addMcpServerBtn.innerHTML = '+';
addMcpServerBtn.classList.remove("pointer-events-none");
}
}
}
function renderMcpServers() {
const list = document.getElementById("mcp-server-list");
if (!list) return;
list.innerHTML = "";
mcpServers.forEach(server => {
const item = document.createElement("div");
item.className = "session-list-item";
const indicatorClass = server.connected ? "active" : "disconnected";
item.innerHTML = `
<div class="flex items-center space-x-2 flex-1">
<span class="session-indicator ${indicatorClass}"></span>
<span class="truncate">${server.name}</span>
</div>
<button class="session-delete-btn mcp-remove-btn" data-name="${server.name}" title="Remove server">×</button>
`;
// Click to show details
item.addEventListener("click", async (e) => {
const target = e.target as HTMLElement;
if (!target.classList.contains("mcp-remove-btn")) {
await showMcpServerDetails(server.name);
}
});
const removeBtn = item.querySelector(".mcp-remove-btn");
removeBtn?.addEventListener("click", async (e) => {
e.stopPropagation();
if (confirm(`Remove MCP server "${server.name}"?`)) {
try {
await ipcRenderer.invoke("remove-mcp-server", server.name);
await loadMcpServers();
} catch (error) {
alert(`Failed to remove server: ${error}`);
}
}
});
list.appendChild(item);
});
}
async function showMcpServerDetails(name: string) {
const detailsModal = document.getElementById("mcp-details-modal");
const detailsTitle = document.getElementById("mcp-details-title");
const detailsContent = document.getElementById("mcp-details-content");
// Show modal immediately with loading state
if (detailsTitle) {
detailsTitle.textContent = name;
}
if (detailsContent) {
detailsContent.innerHTML = '<div class="flex items-center justify-center py-8"><span class="loading-spinner" style="width: 24px; height: 24px; border-width: 3px;"></span></div>';
}
detailsModal?.classList.remove("hidden");
try {
const details = await ipcRenderer.invoke("get-mcp-server-details", name);
if (detailsContent) {
let html = "";
if (details.scope) {
html += `<div><strong>Scope:</strong> ${details.scope}</div>`;
}
if (details.status) {
html += `<div><strong>Status:</strong> ${details.status}</div>`;
}
if (details.type) {
html += `<div><strong>Type:</strong> ${details.type}</div>`;
}
if (details.url) {
html += `<div><strong>URL:</strong> ${details.url}</div>`;
}
if (details.command) {
html += `<div><strong>Command:</strong> ${details.command}</div>`;
}
if (details.args) {
html += `<div><strong>Args:</strong> ${details.args}</div>`;
}
detailsContent.innerHTML = html;
}
// Store current server name for remove button
const removeMcpDetailsBtn = document.getElementById("remove-mcp-details") as HTMLButtonElement;
if (removeMcpDetailsBtn) {
removeMcpDetailsBtn.dataset.serverName = name;
}
} catch (error) {
console.error("Failed to get server details:", error);
if (detailsContent) {
detailsContent.innerHTML = `<div class="text-red-400">Failed to load server details</div>`;
}
}
}
// MCP Modal handling
const mcpModal = document.getElementById("mcp-modal");
const mcpNameInput = document.getElementById("mcp-name") as HTMLInputElement;
const mcpTypeSelect = document.getElementById("mcp-type") as HTMLSelectElement;
const mcpCommandInput = document.getElementById("mcp-command") as HTMLInputElement;
const mcpArgsInput = document.getElementById("mcp-args") as HTMLInputElement;
const mcpEnvInput = document.getElementById("mcp-env") as HTMLTextAreaElement;
const mcpUrlInput = document.getElementById("mcp-url") as HTMLInputElement;
const mcpHeadersInput = document.getElementById("mcp-headers") as HTMLTextAreaElement;
const mcpAlwaysAllowInput = document.getElementById("mcp-always-allow") as HTMLInputElement;
const localFields = document.getElementById("local-fields");
const remoteFields = document.getElementById("remote-fields");
const cancelMcpBtn = document.getElementById("cancel-mcp");
const addMcpBtn = document.getElementById("add-mcp") as HTMLButtonElement;
// Toggle fields based on server type
mcpTypeSelect?.addEventListener("change", () => {
if (mcpTypeSelect.value === "local") {
localFields!.style.display = "block";
remoteFields!.style.display = "none";
} else {
localFields!.style.display = "none";
remoteFields!.style.display = "block";
}
});
// Add MCP server button - opens modal
document.getElementById("add-mcp-server")?.addEventListener("click", () => {
mcpModal?.classList.remove("hidden");
mcpNameInput.value = "";
mcpTypeSelect.value = "local";
mcpCommandInput.value = "";
mcpArgsInput.value = "";
mcpEnvInput.value = "";
mcpUrlInput.value = "";
mcpHeadersInput.value = "";
mcpAlwaysAllowInput.value = "";
localFields!.style.display = "block";
remoteFields!.style.display = "none";
});
// Cancel MCP button
cancelMcpBtn?.addEventListener("click", () => {
mcpModal?.classList.add("hidden");
});
// Add MCP button
addMcpBtn?.addEventListener("click", async () => {
const name = mcpNameInput.value.trim();
const serverType = mcpTypeSelect.value;
if (!name) {
alert("Please enter a server name");
return;
}
const config: any = {};
if (serverType === "local") {
config.type = "stdio";
const command = mcpCommandInput.value.trim();
const argsInput = mcpArgsInput.value.trim();
if (!command) {
alert("Please enter a command");
return;
}
config.command = command;
if (argsInput) {
config.args = argsInput.split(" ").filter(a => a.trim());
}
// Parse environment variables if provided
const envInput = mcpEnvInput.value.trim();
if (envInput) {
try {
config.env = JSON.parse(envInput);
} catch (error) {
alert("Invalid JSON for environment variables");
return;
}
}
} else {
// Remote server
config.type = "sse";
const url = mcpUrlInput.value.trim();
if (!url) {
alert("Please enter a server URL");
return;
}
config.url = url;
// Parse headers if provided
const headersInput = mcpHeadersInput.value.trim();
if (headersInput) {
try {
config.headers = JSON.parse(headersInput);
} catch (error) {
alert("Invalid JSON for headers");
return;
}
}
}
// Parse always allow tools
const alwaysAllowInput = mcpAlwaysAllowInput.value.trim();
if (alwaysAllowInput) {
config.alwaysAllow = alwaysAllowInput.split(",").map(t => t.trim()).filter(t => t);
}
// Show loading state
const originalText = addMcpBtn.textContent;
addMcpBtn.textContent = "Adding...";
addMcpBtn.disabled = true;
addMcpBtn.classList.add("opacity-50", "cursor-not-allowed");
try {
await ipcRenderer.invoke("add-mcp-server", name, config);
await loadMcpServers();
mcpModal?.classList.add("hidden");
} catch (error) {
console.error("Error adding server:", error);
alert(`Failed to add server: ${error}`);
} finally {
// Reset button state
addMcpBtn.textContent = originalText;
addMcpBtn.disabled = false;
addMcpBtn.classList.remove("opacity-50", "cursor-not-allowed");
}
});
// MCP Details Modal handling
const closeMcpDetailsBtn = document.getElementById("close-mcp-details");
const removeMcpDetailsBtn = document.getElementById("remove-mcp-details") as HTMLButtonElement;
const mcpDetailsModal = document.getElementById("mcp-details-modal");
closeMcpDetailsBtn?.addEventListener("click", () => {
mcpDetailsModal?.classList.add("hidden");
});
removeMcpDetailsBtn?.addEventListener("click", async () => {
const serverName = removeMcpDetailsBtn.dataset.serverName;
if (!serverName) return;
if (confirm(`Remove MCP server "${serverName}"?`)) {
try {
await ipcRenderer.invoke("remove-mcp-server", serverName);
mcpDetailsModal?.classList.add("hidden");
await loadMcpServers();
} catch (error) {
alert(`Failed to remove server: ${error}`);
}
}
});
// Listen for MCP server updates from main process
ipcRenderer.on("mcp-servers-updated", (_event, servers: McpServer[]) => {
mcpServers = servers;
renderMcpServers();
});
// Load MCP servers on startup
loadMcpServers();