diff --git a/.gitignore b/.gitignore index 071edfe3..551e01b4 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,12 @@ node_modules/ # Build output dist/ +# Frontend build artifacts (generated from TypeScript at build time) +src/dashboard/public/js/app.js +src/dashboard/public/js/app.js.map +src/dashboard/public/js/bridge.js +src/dashboard/public/js/bridge.js.map + # TypeScript build info (if enabled later) *.tsbuildinfo diff --git a/package.json b/package.json index 58fc328e..0f0fea22 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,9 @@ "scripts": { "postinstall": "npm rebuild better-sqlite3", "build": "npm run clean && tsc && npm run build:frontend", - "build:frontend": "esbuild src/dashboard/frontend/app.ts --bundle --outfile=src/dashboard/public/js/app.js --format=esm --target=es2022 --minify --sourcemap", + "build:frontend": "npm run build:frontend:dashboard && npm run build:frontend:bridge", + "build:frontend:dashboard": "esbuild src/dashboard/frontend/app.ts --bundle --outfile=src/dashboard/public/js/app.js --format=esm --target=es2022 --minify --sourcemap", + "build:frontend:bridge": "esbuild src/dashboard/frontend/bridge/app.ts --bundle --outfile=src/dashboard/public/js/bridge.js --format=esm --target=es2022 --minify --sourcemap", "postbuild": "cp -r src/dashboard/public dist/dashboard/ && chmod +x dist/cli/index.js", "dev": "tsc -w", "dev:local": "npm run build && npm link && echo '✓ agent-relay linked globally'", diff --git a/src/dashboard/frontend/app.ts b/src/dashboard/frontend/app.ts index 38d97206..abdbb4d5 100644 --- a/src/dashboard/frontend/app.ts +++ b/src/dashboard/frontend/app.ts @@ -32,12 +32,73 @@ import { } from './components.js'; import { state } from './state.js'; +/** + * Detect if we're viewing a project dashboard from bridge context + */ +function detectProjectContext(): { projectId: string | null; fromBridge: boolean } { + const pathname = window.location.pathname; + const match = pathname.match(/^\/project\/([^/]+)$/); + + if (match) { + return { projectId: decodeURIComponent(match[1]), fromBridge: true }; + } + + return { projectId: null, fromBridge: false }; +} + +/** + * Update the UI for project context (when accessed from bridge) + */ +async function setupProjectContext(projectId: string): Promise { + // Update workspace name to show project + const workspaceName = document.querySelector('.workspace-name'); + if (workspaceName) { + // Fetch project info + try { + const response = await fetch(`/api/project/${encodeURIComponent(projectId)}`); + if (response.ok) { + const project = await response.json(); + const nameSpan = workspaceName.querySelector(':not(.status-dot)'); + if (nameSpan && nameSpan.nodeType === Node.TEXT_NODE) { + nameSpan.textContent = project.name || projectId; + } else { + // Replace text content after status-dot + const textNodes = Array.from(workspaceName.childNodes).filter(n => n.nodeType === Node.TEXT_NODE); + textNodes.forEach(n => n.textContent = ''); + workspaceName.appendChild(document.createTextNode(' ' + (project.name || projectId))); + } + } + } catch { + // Fallback - just show project ID + } + } + + // Update bridge nav link to show "Back to Bridge" with back arrow + const bridgeLinkText = document.getElementById('bridge-link-text'); + const bridgeNavLink = document.getElementById('bridge-nav-link'); + if (bridgeLinkText) { + bridgeLinkText.textContent = '← Back to Bridge'; + } + if (bridgeNavLink) { + bridgeNavLink.classList.add('back-to-bridge'); + } + + // Add a subtle indicator that we're in project view + document.body.classList.add('project-view'); +} + /** * Initialize the dashboard application */ export function initApp(): void { const elements = initElements(); + // Check if we're in project context (from bridge) + const { projectId, fromBridge } = detectProjectContext(); + if (fromBridge && projectId) { + setupProjectContext(projectId); + } + // Subscribe to state changes subscribe(() => { updateConnectionStatus(); @@ -203,7 +264,10 @@ function setupEventListeners(elements: ReturnType): void { item.addEventListener('click', () => { const command = item.dataset.command; - if (command === 'broadcast') { + if (command === 'bridge') { + // Navigate to bridge view + window.location.href = '/bridge'; + } else if (command === 'broadcast') { // Pre-fill message input with @* for broadcast elements.messageInput.value = '@* '; elements.messageInput.focus(); @@ -215,6 +279,14 @@ function setupEventListeners(elements: ReturnType): void { }); }); + // Add Cmd/Ctrl+B shortcut for bridge navigation + document.addEventListener('keydown', (e: KeyboardEvent) => { + if ((e.ctrlKey || e.metaKey) && e.key === 'b') { + e.preventDefault(); + window.location.href = '/bridge'; + } + }); + // Initialize palette channel click handlers initPaletteChannels(); @@ -373,11 +445,204 @@ async function handleThreadSend(): Promise { elements.threadSendBtn.disabled = false; } +/** + * Spawn Agent Modal Management + */ +interface SpawnModalElements { + overlay: HTMLElement | null; + closeBtn: HTMLElement | null; + cancelBtn: HTMLElement | null; + submitBtn: HTMLElement | null; + nameInput: HTMLInputElement | null; + cliSelect: HTMLSelectElement | null; + modelInput: HTMLInputElement | null; + taskInput: HTMLTextAreaElement | null; +} + +function getSpawnModalElements(): SpawnModalElements { + return { + overlay: document.getElementById('spawn-modal-overlay'), + closeBtn: document.getElementById('spawn-modal-close'), + cancelBtn: document.getElementById('spawn-modal-cancel'), + submitBtn: document.getElementById('spawn-modal-submit'), + nameInput: document.getElementById('spawn-agent-name') as HTMLInputElement, + cliSelect: document.getElementById('spawn-agent-cli') as HTMLSelectElement, + modelInput: document.getElementById('spawn-agent-model') as HTMLInputElement, + taskInput: document.getElementById('spawn-agent-task') as HTMLTextAreaElement, + }; +} + +function openSpawnModal(): void { + const modal = getSpawnModalElements(); + if (modal.overlay) { + modal.overlay.classList.add('visible'); + modal.nameInput?.focus(); + } +} + +function closeSpawnModal(): void { + const modal = getSpawnModalElements(); + if (modal.overlay) { + modal.overlay.classList.remove('visible'); + // Clear form + if (modal.nameInput) modal.nameInput.value = ''; + if (modal.cliSelect) modal.cliSelect.value = 'claude'; + if (modal.modelInput) modal.modelInput.value = ''; + if (modal.taskInput) modal.taskInput.value = ''; + } +} + +async function handleSpawnAgent(): Promise { + const modal = getSpawnModalElements(); + + const name = modal.nameInput?.value.trim(); + const cli = modal.cliSelect?.value; + const model = modal.modelInput?.value.trim(); + const task = modal.taskInput?.value.trim(); + + if (!name) { + alert('Please enter an agent name'); + return; + } + + if (!cli) { + alert('Please select a CLI tool'); + return; + } + + // Disable submit button + if (modal.submitBtn) { + modal.submitBtn.textContent = 'Spawning...'; + (modal.submitBtn as HTMLButtonElement).disabled = true; + } + + try { + const response = await fetch('/api/agent/spawn', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name, + cli, + model: model || undefined, + task: task || undefined, + }), + }); + + const result = await response.json(); + + if (response.ok && result.success) { + closeSpawnModal(); + // Agent will appear in the list when it connects to the relay + } else { + alert(result.error || 'Failed to spawn agent'); + } + } catch (err) { + console.error('Failed to spawn agent:', err); + alert('Failed to spawn agent. Check console for details.'); + } finally { + if (modal.submitBtn) { + modal.submitBtn.textContent = 'Spawn Agent'; + (modal.submitBtn as HTMLButtonElement).disabled = false; + } + } +} + +/** + * Kill an agent + */ +async function killAgent(agentName: string): Promise { + if (!confirm(`Are you sure you want to kill agent "${agentName}"?`)) { + return; + } + + try { + const response = await fetch('/api/agent/kill', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: agentName }), + }); + + const result = await response.json(); + + if (!response.ok) { + alert(result.error || 'Failed to kill agent'); + } + // Agent will disappear from the list when it disconnects + } catch (err) { + console.error('Failed to kill agent:', err); + alert('Failed to kill agent. Check console for details.'); + } +} + +/** + * Set up spawn modal event listeners + */ +function setupSpawnModalListeners(): void { + const modal = getSpawnModalElements(); + + // Spawn button in sidebar + const spawnBtn = document.getElementById('spawn-agent-btn'); + if (spawnBtn) { + spawnBtn.addEventListener('click', (e) => { + e.stopPropagation(); + openSpawnModal(); + }); + } + + // Modal close buttons + modal.closeBtn?.addEventListener('click', closeSpawnModal); + modal.cancelBtn?.addEventListener('click', closeSpawnModal); + + // Modal submit + modal.submitBtn?.addEventListener('click', handleSpawnAgent); + + // Close on overlay click + modal.overlay?.addEventListener('click', (e) => { + if (e.target === modal.overlay) { + closeSpawnModal(); + } + }); + + // Close on Escape + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape' && modal.overlay?.classList.contains('visible')) { + closeSpawnModal(); + } + }); + + // Enter to submit (when name input is focused) + modal.nameInput?.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + handleSpawnAgent(); + } + }); +} + +/** + * Attach kill handlers to agent list items + * Called after agents are rendered + */ +export function attachKillHandlers(): void { + document.querySelectorAll('.agent-kill-btn').forEach((btn) => { + btn.addEventListener('click', (e) => { + e.stopPropagation(); + const agentName = btn.dataset.agent; + if (agentName) { + killAgent(agentName); + } + }); + }); +} + // Auto-initialize when DOM is ready if (typeof document !== 'undefined') { if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', initApp); + document.addEventListener('DOMContentLoaded', () => { + initApp(); + setupSpawnModalListeners(); + }); } else { initApp(); + setupSpawnModalListeners(); } } diff --git a/src/dashboard/frontend/bridge/app.ts b/src/dashboard/frontend/bridge/app.ts new file mode 100644 index 00000000..efa1c59f --- /dev/null +++ b/src/dashboard/frontend/bridge/app.ts @@ -0,0 +1,649 @@ +/** + * Bridge Dashboard Application Entry Point + */ + +import { subscribe, state, setProjects, setMessages, setConnected, setWebSocket, setSelectedProject, getUptimeString, getConnectedProjects, getAllAgents, getProject } from './state.js'; +import type { BridgeDOMElements } from './types.js'; +import { escapeHtml, formatTime } from '../utils.js'; + +let elements: BridgeDOMElements; + +/** + * Initialize DOM element references + */ +function initElements(): BridgeDOMElements { + return { + statusDot: document.getElementById('status-dot')!, + projectList: document.getElementById('project-list')!, + cardsGrid: document.getElementById('cards-grid')!, + emptyState: document.getElementById('empty-state')!, + messagesList: document.getElementById('messages-list')!, + searchBar: document.getElementById('search-bar')!, + paletteOverlay: document.getElementById('command-palette-overlay')!, + paletteSearch: document.getElementById('palette-search') as HTMLInputElement, + paletteResults: document.getElementById('palette-results')!, + paletteProjectsSection: document.getElementById('palette-projects-section')!, + paletteAgentsSection: document.getElementById('palette-agents-section')!, + channelName: document.getElementById('channel-name')!, + statAgents: document.getElementById('stat-agents')!, + statMessages: document.getElementById('stat-messages')!, + composerProject: document.getElementById('composer-project') as HTMLSelectElement, + composerAgent: document.getElementById('composer-agent') as HTMLSelectElement, + composerMessage: document.getElementById('composer-message') as HTMLInputElement, + composerSend: document.getElementById('composer-send') as HTMLButtonElement, + composerStatus: document.getElementById('composer-status')!, + uptime: document.getElementById('uptime')!, + }; +} + +/** + * Update connection status indicator + */ +function updateConnectionStatus(): void { + elements.statusDot.classList.toggle('offline', !state.isConnected); +} + +/** + * Render sidebar projects list + */ +function renderSidebarProjects(): void { + const { projects, selectedProjectId } = state; + + if (!projects || projects.length === 0) { + elements.projectList.innerHTML = '
  • No projects
  • '; + document.getElementById('project-count')!.textContent = '0'; + return; + } + + document.getElementById('project-count')!.textContent = String(projects.length); + + elements.projectList.innerHTML = projects.map((p) => ` +
  • + + ${escapeHtml(p.name || p.id)} + +
  • + `).join(''); +} + +/** + * Render project cards grid + */ +function renderProjectCards(): void { + const { projects, selectedProjectId } = state; + + if (!projects || projects.length === 0) { + elements.cardsGrid.innerHTML = ''; + elements.cardsGrid.appendChild(elements.emptyState); + elements.emptyState.style.display = 'flex'; + return; + } + + elements.emptyState.style.display = 'none'; + + elements.cardsGrid.innerHTML = projects.map((p) => { + const agents = p.agents || []; + const agentsHtml = agents.length > 0 + ? agents.map((a) => ` +
    + + ${escapeHtml(a.name)} + ${escapeHtml(a.cli || '')} +
    + `).join('') + : '
    No agents connected
    '; + + const isSelected = selectedProjectId === p.id; + return ` +
    +
    +
    +
    + + + +
    +
    +
    ${escapeHtml(p.name || p.id)}
    +
    ${escapeHtml(p.path || '')}
    +
    +
    +
    + + ${p.connected ? 'Online' : p.reconnecting ? 'Reconnecting...' : 'Offline'} +
    +
    + +
    +
    + Agents + ${agents.length} active +
    +
    + ${agentsHtml} +
    +
    + +
    + + +
    +
    + `; + }).join(''); +} + +/** + * Render messages list + */ +function renderMessages(): void { + const { messages } = state; + + if (!messages || messages.length === 0) { + elements.messagesList.innerHTML = '

    No messages yet

    '; + return; + } + + elements.messagesList.innerHTML = messages.slice(-50).reverse().map((m) => ` +
    +
    + ${escapeHtml(m.sourceProject || 'local')} + ${escapeHtml(m.from)} + + ${escapeHtml(m.to || '*')} + ${formatTime(m.timestamp)} +
    +
    ${escapeHtml(m.body || m.content || '')}
    +
    + `).join(''); +} + +/** + * Update stats display + */ +function updateStats(): void { + const allAgents = getAllAgents(); + elements.statAgents.textContent = String(allAgents.length); + elements.statMessages.textContent = String(state.messages.length); +} + +/** + * Update composer project options + */ +function updateComposerProjects(): void { + const connectedProjects = getConnectedProjects(); + const currentValue = elements.composerProject.value; + + elements.composerProject.innerHTML = '' + + connectedProjects.map((p) => + `` + ).join(''); + + // Restore selection if still valid + if (currentValue && connectedProjects.some((p) => p.id === currentValue)) { + elements.composerProject.value = currentValue; + } else if (state.selectedProjectId && connectedProjects.some((p) => p.id === state.selectedProjectId)) { + elements.composerProject.value = state.selectedProjectId; + updateComposerAgents(); + } +} + +/** + * Update composer agent options + */ +function updateComposerAgents(): void { + const projectId = elements.composerProject.value; + if (!projectId) { + elements.composerAgent.innerHTML = ''; + elements.composerAgent.disabled = true; + elements.composerMessage.disabled = true; + elements.composerSend.disabled = true; + return; + } + + const currentAgent = elements.composerAgent.value; + const project = getProject(projectId); + const agents = project?.agents || []; + + elements.composerAgent.innerHTML = '' + + '' + + '' + + agents.map((a) => + `` + ).join(''); + + elements.composerAgent.disabled = false; + + // Restore agent selection if still valid + if (currentAgent) { + const validAgents = ['*', 'lead', ...agents.map((a) => a.name)]; + if (validAgents.includes(currentAgent)) { + elements.composerAgent.value = currentAgent; + } + } +} + +/** + * Update composer state based on selections + */ +function updateComposerState(): void { + const hasProject = !!elements.composerProject.value; + const hasAgent = !!elements.composerAgent.value; + const hasMessage = elements.composerMessage.value.trim().length > 0; + + elements.composerMessage.disabled = !hasProject || !hasAgent; + elements.composerSend.disabled = !hasProject || !hasAgent || !hasMessage; +} + +/** + * Send message via bridge API + */ +async function sendBridgeMessage(): Promise { + const projectId = elements.composerProject.value; + const to = elements.composerAgent.value; + const message = elements.composerMessage.value.trim(); + + if (!projectId || !to || !message) return; + + elements.composerSend.disabled = true; + elements.composerStatus.textContent = 'Sending...'; + elements.composerStatus.className = 'composer-status'; + + try { + const response = await fetch('/api/bridge/send', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ projectId, to, message }), + }); + + const result = await response.json(); + + if (response.ok && result.success) { + elements.composerStatus.textContent = 'Message sent!'; + elements.composerStatus.className = 'composer-status success'; + elements.composerMessage.value = ''; + setTimeout(() => { + elements.composerStatus.textContent = ''; + elements.composerStatus.className = 'composer-status'; + }, 2000); + } else { + throw new Error(result.error || 'Failed to send'); + } + } catch (err) { + elements.composerStatus.textContent = (err as Error).message || 'Failed to send message'; + elements.composerStatus.className = 'composer-status error'; + } + + updateComposerState(); +} + +/** + * Update header for project selection + */ +function updateHeader(): void { + const { selectedProjectId } = state; + + if (selectedProjectId) { + const project = getProject(selectedProjectId); + if (project) { + elements.channelName.innerHTML = ` + ← All Projects + ${escapeHtml(project.name || project.id)} + `; + } + } else { + elements.channelName.textContent = 'All Projects'; + } +} + +/** + * Select a project + */ +function selectProject(projectId: string | null): void { + setSelectedProject(projectId); + + if (projectId) { + elements.composerProject.value = projectId; + updateComposerAgents(); + updateComposerState(); + } + + // Update card selection visually + document.querySelectorAll('.project-card').forEach((card) => { + card.classList.toggle('selected', (card as HTMLElement).dataset.projectId === projectId); + }); +} + +/** + * Open command palette + */ +function openPalette(): void { + elements.paletteOverlay.classList.add('visible'); + elements.paletteSearch.value = ''; + elements.paletteSearch.focus(); + updatePaletteResults(); +} + +/** + * Close command palette + */ +function closePalette(): void { + elements.paletteOverlay.classList.remove('visible'); +} + +/** + * Update palette search results + */ +function updatePaletteResults(): void { + const query = elements.paletteSearch.value.toLowerCase(); + const { projects } = state; + + // Update projects section + const filteredProjects = query + ? projects.filter((p) => (p.name || p.id).toLowerCase().includes(query)) + : projects; + + if (filteredProjects.length > 0) { + elements.paletteProjectsSection.innerHTML = ` +
    Open Project Dashboard
    + ${filteredProjects.map((p) => ` +
    +
    + + + + + +
    +
    +
    ${escapeHtml(p.name || p.id)}
    +
    ${p.connected ? 'Online' : 'Offline'} · ${(p.agents || []).length} agents · Click to open dashboard
    +
    +
    + +
    +
    + `).join('')} + `; + } else { + elements.paletteProjectsSection.innerHTML = '
    Open Project Dashboard
    '; + } + + // Update agents section + const allAgents = getAllAgents(); + const filteredAgents = query + ? allAgents.filter((a) => a.name.toLowerCase().includes(query)) + : allAgents; + + if (filteredAgents.length > 0) { + elements.paletteAgentsSection.innerHTML = ` +
    Message Agent
    + ${filteredAgents.map((a) => ` +
    +
    + + + + +
    +
    +
    ${escapeHtml(a.name)}
    +
    ${escapeHtml(a.projectName)} · ${escapeHtml(a.cli || 'unknown')}
    +
    +
    + `).join('')} + `; + } else { + elements.paletteAgentsSection.innerHTML = '
    Message Agent
    '; + } +} + +/** + * Set up event listeners + */ +function setupEventListeners(): void { + // Search bar opens palette + elements.searchBar.addEventListener('click', openPalette); + + // Palette overlay click closes + elements.paletteOverlay.addEventListener('click', (e) => { + if (e.target === elements.paletteOverlay) closePalette(); + }); + + // Palette search filtering + elements.paletteSearch.addEventListener('input', updatePaletteResults); + + // Keyboard shortcuts + document.addEventListener('keydown', (e) => { + // Cmd/Ctrl + K to open palette + if ((e.metaKey || e.ctrlKey) && e.key === 'k') { + e.preventDefault(); + if (elements.paletteOverlay.classList.contains('visible')) { + closePalette(); + } else { + openPalette(); + } + } + // Escape to close + if (e.key === 'Escape' && elements.paletteOverlay.classList.contains('visible')) { + closePalette(); + } + }); + + // Palette item clicks + elements.paletteResults.addEventListener('click', (e) => { + const item = (e.target as HTMLElement).closest('.palette-item') as HTMLElement | null; + if (!item) return; + + const command = item.dataset.command; + const projectId = item.dataset.project; + const agentName = item.dataset.agent; + const action = item.dataset.action; + + if (command === 'broadcast') { + closePalette(); + elements.composerMessage.focus(); + elements.composerStatus.textContent = 'Select a project and agent to send a message'; + } else if (command === 'refresh') { + closePalette(); + location.reload(); + } else if (command === 'go-dashboard') { + closePalette(); + window.location.href = '/'; + } else if (action === 'open-dashboard' && projectId) { + closePalette(); + window.location.href = `/project/${encodeURIComponent(projectId)}`; + } else if (agentName && projectId) { + closePalette(); + elements.composerProject.value = projectId; + updateComposerAgents(); + setTimeout(() => { + elements.composerAgent.value = agentName; + updateComposerState(); + elements.composerMessage.focus(); + }, 50); + } else if (projectId) { + closePalette(); + selectProject(projectId); + const card = document.querySelector(`.project-card[data-project-id="${projectId}"]`); + if (card) card.scrollIntoView({ behavior: 'smooth', block: 'center' }); + } + }); + + // Project card clicks + elements.cardsGrid.addEventListener('click', (e) => { + const target = e.target as HTMLElement; + + // Handle "Open Dashboard" button + const dashboardBtn = target.closest('[data-open-dashboard]') as HTMLElement | null; + if (dashboardBtn) { + e.stopPropagation(); + const projectId = dashboardBtn.dataset.openDashboard; + if (projectId) { + window.location.href = `/project/${encodeURIComponent(projectId)}`; + } + return; + } + + // Handle "Message Lead" button + const messageLeadBtn = target.closest('[data-message-lead]') as HTMLButtonElement | null; + if (messageLeadBtn && !messageLeadBtn.disabled) { + e.stopPropagation(); + const projectId = messageLeadBtn.dataset.messageLead; + if (projectId) { + elements.composerProject.value = projectId; + updateComposerAgents(); + setTimeout(() => { + elements.composerAgent.value = 'lead'; + updateComposerState(); + elements.composerMessage.focus(); + }, 50); + } + return; + } + + const card = target.closest('.project-card') as HTMLElement | null; + if (card) { + selectProject(card.dataset.projectId || null); + } + }); + + // Sidebar project clicks + elements.projectList.addEventListener('click', (e) => { + const target = e.target as HTMLElement; + + // Check if dashboard button was clicked + const dashboardBtn = target.closest('.project-dashboard-btn') as HTMLElement | null; + if (dashboardBtn) { + e.stopPropagation(); + const projectId = dashboardBtn.dataset.dashboardProject; + if (projectId) { + window.location.href = `/project/${encodeURIComponent(projectId)}`; + } + return; + } + + const item = target.closest('.project-item') as HTMLElement | null; + if (item) { + selectProject(item.dataset.projectId || null); + } + }); + + // Header back link + elements.channelName.addEventListener('click', (e) => { + const target = e.target as HTMLElement; + if (target.id === 'back-to-all' || target.classList.contains('back-link')) { + selectProject(null); + } + }); + + // Composer events + elements.composerProject.addEventListener('change', () => { + updateComposerAgents(); + updateComposerState(); + }); + + elements.composerAgent.addEventListener('change', updateComposerState); + elements.composerMessage.addEventListener('input', updateComposerState); + + elements.composerSend.addEventListener('click', sendBridgeMessage); + elements.composerMessage.addEventListener('keydown', (e) => { + if (e.key === 'Enter' && !e.shiftKey && !elements.composerSend.disabled) { + e.preventDefault(); + sendBridgeMessage(); + } + }); +} + +/** + * Connect to WebSocket + */ +function connect(): void { + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + const ws = new WebSocket(`${protocol}//${window.location.host}/ws/bridge`); + + ws.onopen = () => { + setConnected(true); + setWebSocket(ws); + }; + + ws.onclose = () => { + setConnected(false); + setWebSocket(null); + setTimeout(connect, 3000); + }; + + ws.onerror = () => { + setConnected(false); + }; + + ws.onmessage = (e) => { + try { + const data = JSON.parse(e.data); + setProjects(data.projects || []); + setMessages(data.messages || []); + } catch (err) { + console.error('[bridge] Parse error:', err); + } + }; +} + +/** + * Initialize the bridge application + */ +export function initBridgeApp(): void { + elements = initElements(); + + // Subscribe to state changes + subscribe(() => { + updateConnectionStatus(); + renderSidebarProjects(); + renderProjectCards(); + renderMessages(); + updateStats(); + updateComposerProjects(); + updateHeader(); + if (elements.composerProject.value) { + updateComposerAgents(); + updateComposerState(); + } + }); + + // Set up event listeners + setupEventListeners(); + + // Connect to WebSocket + connect(); + + // Update uptime periodically + setInterval(() => { + elements.uptime.textContent = `Uptime: ${getUptimeString()}`; + }, 1000); +} + +// Auto-initialize when DOM is ready +if (typeof document !== 'undefined') { + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', initBridgeApp); + } else { + initBridgeApp(); + } +} diff --git a/src/dashboard/frontend/bridge/state.ts b/src/dashboard/frontend/bridge/state.ts new file mode 100644 index 00000000..1385fb01 --- /dev/null +++ b/src/dashboard/frontend/bridge/state.ts @@ -0,0 +1,140 @@ +/** + * Bridge State Management + * Centralized state for the bridge dashboard + */ + +import type { BridgeState, Project, BridgeMessage } from './types.js'; + +type StateListener = () => void; + +// Bridge state +export const state: BridgeState = { + projects: [], + messages: [], + selectedProjectId: null, + isConnected: false, + ws: null, + connectionStart: null, +}; + +// State subscribers +const listeners: StateListener[] = []; + +/** + * Subscribe to state changes + */ +export function subscribe(listener: StateListener): () => void { + listeners.push(listener); + return () => { + const index = listeners.indexOf(listener); + if (index > -1) { + listeners.splice(index, 1); + } + }; +} + +/** + * Notify all listeners of state change + */ +function notifyListeners(): void { + listeners.forEach((listener) => { + try { + listener(); + } catch (err) { + console.error('[bridge-state] Listener error:', err); + } + }); +} + +/** + * Update projects + */ +export function setProjects(projects: Project[]): void { + state.projects = projects; + notifyListeners(); +} + +/** + * Update messages + */ +export function setMessages(messages: BridgeMessage[]): void { + state.messages = messages; + notifyListeners(); +} + +/** + * Set selected project + */ +export function setSelectedProject(projectId: string | null): void { + state.selectedProjectId = projectId; + notifyListeners(); +} + +/** + * Update connection status + */ +export function setConnected(connected: boolean): void { + state.isConnected = connected; + if (connected && !state.connectionStart) { + state.connectionStart = Date.now(); + } + notifyListeners(); +} + +/** + * Set WebSocket instance + */ +export function setWebSocket(ws: WebSocket | null): void { + state.ws = ws; +} + +/** + * Get all agents across all projects + */ +export function getAllAgents(): { name: string; projectId: string; projectName: string; cli?: string }[] { + const agents: { name: string; projectId: string; projectName: string; cli?: string }[] = []; + + state.projects.forEach((project) => { + (project.agents || []).forEach((agent) => { + agents.push({ + name: agent.name, + projectId: project.id, + projectName: project.name || project.id, + cli: agent.cli, + }); + }); + }); + + return agents; +} + +/** + * Get connected projects + */ +export function getConnectedProjects(): Project[] { + return state.projects.filter((p) => p.connected); +} + +/** + * Get project by ID + */ +export function getProject(projectId: string): Project | undefined { + return state.projects.find((p) => p.id === projectId); +} + +/** + * Get uptime formatted string + */ +export function getUptimeString(): string { + if (!state.connectionStart) return '--'; + + const ms = Date.now() - state.connectionStart; + const seconds = Math.floor(ms / 1000); + if (seconds < 60) return `${seconds}s`; + + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes}m`; + + const hours = Math.floor(minutes / 60); + return `${hours}h ${minutes % 60}m`; +} diff --git a/src/dashboard/frontend/bridge/types.ts b/src/dashboard/frontend/bridge/types.ts new file mode 100644 index 00000000..478ed205 --- /dev/null +++ b/src/dashboard/frontend/bridge/types.ts @@ -0,0 +1,70 @@ +/** + * Bridge Frontend Types + * Extends shared types from the main dashboard + */ + +import type { Agent, Message } from '../types.js'; + +export interface Project { + id: string; + name?: string; + path: string; + connected: boolean; + reconnecting?: boolean; + lead?: LeadInfo; + agents?: ProjectAgent[]; +} + +export interface LeadInfo { + name: string; + connected: boolean; +} + +export interface ProjectAgent extends Agent { + projectId: string; + projectName: string; +} + +export interface BridgeMessage extends Message { + sourceProject?: string; + targetProject?: string; + body?: string; // Alternative to content for bridge messages +} + +export interface BridgeData { + projects: Project[]; + messages: BridgeMessage[]; + connected: boolean; +} + +export interface BridgeState { + projects: Project[]; + messages: BridgeMessage[]; + selectedProjectId: string | null; + isConnected: boolean; + ws: WebSocket | null; + connectionStart: number | null; +} + +export interface BridgeDOMElements { + statusDot: HTMLElement; + projectList: HTMLElement; + cardsGrid: HTMLElement; + emptyState: HTMLElement; + messagesList: HTMLElement; + searchBar: HTMLElement; + paletteOverlay: HTMLElement; + paletteSearch: HTMLInputElement; + paletteResults: HTMLElement; + paletteProjectsSection: HTMLElement; + paletteAgentsSection: HTMLElement; + channelName: HTMLElement; + statAgents: HTMLElement; + statMessages: HTMLElement; + composerProject: HTMLSelectElement; + composerAgent: HTMLSelectElement; + composerMessage: HTMLInputElement; + composerSend: HTMLButtonElement; + composerStatus: HTMLElement; + uptime: HTMLElement; +} diff --git a/src/dashboard/frontend/components.ts b/src/dashboard/frontend/components.ts index 16d2383f..0749e487 100644 --- a/src/dashboard/frontend/components.ts +++ b/src/dashboard/frontend/components.ts @@ -2,7 +2,7 @@ * Dashboard UI Components */ -import type { Agent, Message, DOMElements, ChannelType, SpawnedAgent } from './types.js'; +import type { Agent, Message, DOMElements, ChannelType } from './types.js'; import { state, getFilteredMessages, setCurrentChannel, setCurrentThread, getThreadMessages, getThreadReplyCount } from './state.js'; import { escapeHtml, @@ -14,9 +14,6 @@ import { isAgentOnline, } from './utils.js'; -// Track spawned agents -let spawnedAgents: SpawnedAgent[] = []; - let elements: DOMElements; let paletteSelectedIndex = -1; @@ -53,14 +50,15 @@ export function initElements(): DOMElements { mentionAutocomplete: document.getElementById('mention-autocomplete')!, mentionAutocompleteList: document.getElementById('mention-autocomplete-list')!, // Spawn modal elements - spawnBtn: document.getElementById('spawn-btn') as HTMLButtonElement, + spawnAgentBtn: document.getElementById('spawn-agent-btn') as HTMLButtonElement, spawnModalOverlay: document.getElementById('spawn-modal-overlay')!, spawnModalClose: document.getElementById('spawn-modal-close') as HTMLButtonElement, - spawnNameInput: document.getElementById('spawn-name-input') as HTMLInputElement, - spawnCliInput: document.getElementById('spawn-cli-input') as HTMLInputElement, - spawnTaskInput: document.getElementById('spawn-task-input') as HTMLTextAreaElement, - spawnSubmitBtn: document.getElementById('spawn-submit-btn') as HTMLButtonElement, - spawnStatus: document.getElementById('spawn-status')!, + spawnAgentName: document.getElementById('spawn-agent-name') as HTMLInputElement, + spawnAgentCli: document.getElementById('spawn-agent-cli') as HTMLSelectElement, + spawnAgentModel: document.getElementById('spawn-agent-model') as HTMLInputElement, + spawnAgentTask: document.getElementById('spawn-agent-task') as HTMLTextAreaElement, + spawnModalCancel: document.getElementById('spawn-modal-cancel') as HTMLButtonElement, + spawnModalSubmit: document.getElementById('spawn-modal-submit') as HTMLButtonElement, }; return elements; } @@ -88,45 +86,29 @@ export function updateConnectionStatus(): void { */ export function renderAgents(): void { console.log('[UI] renderAgents called, agents:', state.agents.length, state.agents.map(a => a.name)); - - // Create a set of spawned agent names for quick lookup - const spawnedNames = new Set(spawnedAgents.map(a => a.name)); - const html = state.agents .map((agent) => { const online = isAgentOnline(agent.lastSeen || agent.lastActive); const presenceClass = online ? 'online' : ''; const isActive = state.currentChannel === agent.name; const needsAttentionClass = agent.needsAttention ? 'needs-attention' : ''; - const isSpawned = spawnedNames.has(agent.name); - - // Spawned icon SVG (play/launch icon) - const spawnedIcon = isSpawned ? ` - - - - ` : ''; - - // Release button for spawned agents - const releaseBtn = isSpawned ? ` - - ` : ''; return ` -
  • -
    +
  • +
    ${getInitials(agent.name)}
    ${escapeHtml(agent.name)} - ${spawnedIcon} ${agent.needsAttention ? 'Needs Input' : ''} - ${releaseBtn} +
    + +
  • `; }) @@ -136,11 +118,11 @@ export function renderAgents(): void { html || '
  • No agents connected
  • '; - // Add click handlers for agent selection + // Add click handlers elements.agentsList.querySelectorAll('.channel-item[data-agent]').forEach((item) => { item.addEventListener('click', (e) => { - // Don't select channel if clicking release button - if ((e.target as HTMLElement).closest('.release-btn')) { + // Don't navigate if clicking on action buttons + if ((e.target as HTMLElement).closest('.agent-actions')) { return; } const agentName = item.dataset.agent; @@ -150,19 +132,15 @@ export function renderAgents(): void { }); }); - // Add release button click handlers - elements.agentsList.querySelectorAll('.release-btn[data-release]').forEach((btn) => { - btn.addEventListener('click', async (e) => { - e.stopPropagation(); - const agentName = btn.dataset.release; - if (agentName && confirm(`Release agent "${agentName}"? This will terminate the agent.`)) { - await releaseAgent(agentName); - } - }); - }); - // Update command palette agents updatePaletteAgents(); + + // Attach kill handlers (dynamically imported to avoid circular dependency) + import('./app.js').then(({ attachKillHandlers }) => { + attachKillHandlers(); + }).catch(() => { + // Ignore if app.js not yet ready + }); } /** @@ -311,11 +289,11 @@ export function selectChannel(channel: ChannelType): void { if (prefixEl) prefixEl.textContent = '@'; } - // Update composer placeholder - DM mode doesn't require @mention + // Update composer placeholder with @mention format elements.messageInput.placeholder = channel === 'general' ? '@AgentName message... (or @* to broadcast)' - : `Message ${channel}... (@ not required)`; + : `@${channel} your message here...`; // Re-render messages renderMessages(); @@ -833,21 +811,19 @@ export function getCurrentMentionQuery(): string | null { return null; } -// ======================================== -// Spawn Modal Functions -// ======================================== +// Track spawned agents +let spawnedAgents: string[] = []; /** * Open the spawn agent modal */ export function openSpawnModal(): void { elements.spawnModalOverlay.classList.add('visible'); - elements.spawnNameInput.value = ''; - elements.spawnCliInput.value = 'claude'; - elements.spawnTaskInput.value = ''; - elements.spawnStatus.textContent = ''; - elements.spawnStatus.className = 'spawn-status'; - elements.spawnNameInput.focus(); + elements.spawnAgentName.value = ''; + elements.spawnAgentCli.value = 'claude'; + elements.spawnAgentModel.value = ''; + elements.spawnAgentTask.value = ''; + elements.spawnAgentName.focus(); } /** @@ -861,51 +837,43 @@ export function closeSpawnModal(): void { * Spawn a new agent via the API */ export async function spawnAgent(): Promise<{ success: boolean; error?: string }> { - const name = elements.spawnNameInput.value.trim(); - const cli = elements.spawnCliInput.value.trim() || 'claude'; - const task = elements.spawnTaskInput.value.trim(); + const name = elements.spawnAgentName.value.trim(); + const cli = elements.spawnAgentCli.value || 'claude'; + const model = elements.spawnAgentModel.value.trim(); + const task = elements.spawnAgentTask.value.trim(); if (!name) { - elements.spawnStatus.textContent = 'Agent name is required'; - elements.spawnStatus.className = 'spawn-status error'; return { success: false, error: 'Agent name is required' }; } - elements.spawnSubmitBtn.disabled = true; - elements.spawnStatus.textContent = 'Spawning agent...'; - elements.spawnStatus.className = 'spawn-status loading'; + elements.spawnModalSubmit.disabled = true; try { const response = await fetch('/api/spawn', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name, cli, task }), + body: JSON.stringify({ name, cli, model, task }), }); const result = await response.json(); if (response.ok && result.success) { - elements.spawnStatus.textContent = `Agent "${name}" spawned successfully!`; - elements.spawnStatus.className = 'spawn-status success'; - // Refresh spawned agents list await fetchSpawnedAgents(); // Close modal after brief delay setTimeout(() => { closeSpawnModal(); - }, 1000); + }, 500); return { success: true }; } else { throw new Error(result.error || 'Failed to spawn agent'); } } catch (err: any) { - elements.spawnStatus.textContent = err.message || 'Failed to spawn agent'; - elements.spawnStatus.className = 'spawn-status error'; return { success: false, error: err.message }; } finally { - elements.spawnSubmitBtn.disabled = false; + elements.spawnModalSubmit.disabled = false; } } @@ -918,7 +886,7 @@ export async function fetchSpawnedAgents(): Promise { const result = await response.json(); if (result.success && Array.isArray(result.agents)) { - spawnedAgents = result.agents; + spawnedAgents = result.agents.map((a: any) => a.name); // Re-render agents to show spawned status renderAgents(); } @@ -928,30 +896,8 @@ export async function fetchSpawnedAgents(): Promise { } /** - * Release a spawned agent - */ -export async function releaseAgent(name: string): Promise { - try { - const response = await fetch(`/api/spawned/${encodeURIComponent(name)}`, { - method: 'DELETE', - }); - - const result = await response.json(); - - if (result.success) { - // Refresh the list - await fetchSpawnedAgents(); - } else { - console.error('[UI] Failed to release agent:', result.error); - } - } catch (err) { - console.error('[UI] Failed to release agent:', err); - } -} - -/** - * Get spawned agents list + * Check if an agent is spawned */ -export function getSpawnedAgents(): SpawnedAgent[] { - return spawnedAgents; +export function isSpawnedAgent(name: string): boolean { + return spawnedAgents.includes(name); } diff --git a/src/dashboard/frontend/types.ts b/src/dashboard/frontend/types.ts index 0950ccec..dd4ac76c 100644 --- a/src/dashboard/frontend/types.ts +++ b/src/dashboard/frontend/types.ts @@ -93,14 +93,15 @@ export interface DOMElements { mentionAutocomplete: HTMLElement; mentionAutocompleteList: HTMLElement; // Spawn modal elements - spawnBtn: HTMLButtonElement; + spawnAgentBtn: HTMLButtonElement; spawnModalOverlay: HTMLElement; spawnModalClose: HTMLButtonElement; - spawnNameInput: HTMLInputElement; - spawnCliInput: HTMLInputElement; - spawnTaskInput: HTMLTextAreaElement; - spawnSubmitBtn: HTMLButtonElement; - spawnStatus: HTMLElement; + spawnAgentName: HTMLInputElement; + spawnAgentCli: HTMLSelectElement; + spawnAgentModel: HTMLInputElement; + spawnAgentTask: HTMLTextAreaElement; + spawnModalCancel: HTMLButtonElement; + spawnModalSubmit: HTMLButtonElement; } export interface SpawnedAgent { diff --git a/src/dashboard/public/bridge.html b/src/dashboard/public/bridge.html index 1f55accd..747cee17 100644 --- a/src/dashboard/public/bridge.html +++ b/src/dashboard/public/bridge.html @@ -12,43 +12,67 @@ Design Tokens (matching index.html) ======================================== */ :root { + /* Background colors */ --bg-workspace: #1a1d21; --bg-sidebar: #19171d; --bg-channel-active: #1164a3; --bg-channel-hover: rgba(255, 255, 255, 0.04); --bg-main: #222529; --bg-message-hover: rgba(255, 255, 255, 0.03); - --bg-input: rgba(255, 255, 255, 0.04); - --bg-modal: #2a2d32; --bg-card: #2a2d32; --bg-card-hover: #32363c; + + /* Text colors */ --text-primary: #d1d2d3; --text-secondary: #ababad; --text-muted: #8d8d8e; --text-channel: #bcabbc; --text-channel-active: #ffffff; --text-link: #1d9bd1; + + /* Accent colors */ --accent-primary: #1264a3; --accent-green: #2bac76; --accent-yellow: #e8a427; --accent-red: #e01e5a; --accent-purple: #7c3aed; + + /* Status colors */ --status-online: #2bac76; --status-away: #e8a427; --status-offline: #616061; + + /* Border colors */ --border-subtle: rgba(255, 255, 255, 0.1); --border-divider: rgba(255, 255, 255, 0.06); + + /* Dimensions */ --sidebar-width: 260px; --header-height: 49px; + + /* Typography */ --font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; --font-mono: 'JetBrains Mono', ui-monospace, monospace; + + /* Transitions */ --transition-fast: 0.1s ease; --transition-normal: 0.2s ease; - --shadow-modal: 0 18px 50px rgba(0, 0, 0, 0.6); } - *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } - html, body { height: 100%; overflow: hidden; } + /* ======================================== + Reset & Base + ======================================== */ + *, *::before, *::after { + box-sizing: border-box; + margin: 0; + padding: 0; + } + + html, body { + height: 100%; + overflow: hidden; + } + body { font-family: var(--font-family); font-size: 15px; @@ -58,7 +82,14 @@ -webkit-font-smoothing: antialiased; } - .app-container { display: flex; height: 100vh; width: 100vw; } + /* ======================================== + Layout Structure + ======================================== */ + .app-container { + display: flex; + height: 100vh; + width: 100vw; + } /* Sidebar */ .sidebar { @@ -70,6 +101,18 @@ border-right: 1px solid var(--border-divider); } + /* Main Content */ + .main-panel { + flex: 1; + display: flex; + flex-direction: column; + min-width: 0; + background: var(--bg-main); + } + + /* ======================================== + Sidebar Components + ======================================== */ .workspace-header { height: var(--header-height); padding: 0 16px; @@ -97,7 +140,48 @@ flex-shrink: 0; } - .workspace-name .status-dot.offline { background: var(--status-offline); } + .workspace-name .status-dot.offline { + background: var(--status-offline); + } + + /* Command Palette Trigger */ + .search-bar { + margin: 12px 12px 8px; + padding: 6px 12px; + background: rgba(255, 255, 255, 0.06); + border: 1px solid transparent; + border-radius: 6px; + display: flex; + align-items: center; + gap: 8px; + cursor: pointer; + transition: all var(--transition-fast); + } + + .search-bar:hover { + background: rgba(255, 255, 255, 0.1); + } + + .search-bar svg { + width: 16px; + height: 16px; + color: var(--text-muted); + } + + .search-bar span { + font-size: 13px; + color: var(--text-muted); + flex: 1; + } + + .search-bar kbd { + font-family: var(--font-family); + font-size: 11px; + color: var(--text-muted); + background: rgba(255, 255, 255, 0.1); + padding: 2px 6px; + border-radius: 4px; + } .sidebar-content { flex: 1; @@ -105,14 +189,17 @@ padding: 12px 0; } - .section { margin-bottom: 8px; } - .section-header { display: flex; align-items: center; justify-content: space-between; padding: 0 16px 0 12px; height: 26px; + cursor: pointer; + } + + .section-header:hover { + background: var(--bg-channel-hover); } .section-title { @@ -123,27 +210,24 @@ color: var(--text-channel); } - .section-title svg { width: 10px; height: 10px; opacity: 0.7; } + .section-title svg { + width: 10px; + height: 10px; + opacity: 0.7; + } - .section-add-btn { - width: 20px; - height: 20px; - border: none; - background: transparent; + .section-count { + font-size: 12px; color: var(--text-muted); - border-radius: 4px; - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - transition: all var(--transition-fast); + font-family: var(--font-mono); } - .section-add-btn:hover { background: rgba(255, 255, 255, 0.1); color: var(--text-primary); } - - .channel-list { list-style: none; padding: 4px 8px; } + .project-list { + list-style: none; + padding: 4px 8px; + } - .channel-item { + .project-item { display: flex; align-items: center; gap: 8px; @@ -151,12 +235,19 @@ border-radius: 6px; cursor: pointer; transition: background var(--transition-fast); - color: var(--text-channel); - font-size: 15px; } - .channel-item:hover { background: var(--bg-channel-hover); } - .channel-item.active { background: var(--bg-channel-active); color: var(--text-channel-active); } + .project-item:hover { + background: var(--bg-channel-hover); + } + + .project-item.active { + background: var(--bg-channel-active); + } + + .project-item.active .project-name { + color: var(--text-channel-active); + } .project-status-dot { width: 9px; @@ -166,37 +257,47 @@ flex-shrink: 0; } - .channel-item.connected .project-status-dot { background: var(--status-online); } - .channel-item.reconnecting .project-status-dot { background: var(--status-away); } + .project-item.connected .project-status-dot { + background: var(--status-online); + } - .channel-name { flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } + .project-name { + font-size: 15px; + color: var(--text-channel); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + flex: 1; + } - .agent-avatar { + .project-dashboard-btn { width: 24px; height: 24px; + border: none; + background: transparent; + color: var(--text-muted); + cursor: pointer; border-radius: 4px; display: flex; align-items: center; justify-content: center; - font-size: 11px; - font-weight: 600; - color: white; - flex-shrink: 0; - position: relative; + opacity: 0; + transition: all var(--transition-fast); } - .presence-indicator { - position: absolute; - bottom: -2px; - right: -2px; - width: 9px; - height: 9px; - border-radius: 50%; - background: var(--status-offline); - border: 2px solid var(--bg-sidebar); + .project-item:hover .project-dashboard-btn { + opacity: 1; } - .presence-indicator.online { background: var(--status-online); } + .project-dashboard-btn:hover { + background: rgba(255, 255, 255, 0.15); + color: var(--text-primary); + } + + .project-dashboard-btn svg { + width: 14px; + height: 14px; + } .sidebar-footer { padding: 12px 16px; @@ -215,18 +316,19 @@ transition: background var(--transition-fast); } - .nav-link:hover { background: var(--bg-channel-hover); } - .nav-link svg { width: 18px; height: 18px; opacity: 0.7; } + .nav-link:hover { + background: var(--bg-channel-hover); + } - /* Main Panel */ - .main-panel { - flex: 1; - display: flex; - flex-direction: column; - min-width: 0; - background: var(--bg-main); + .nav-link svg { + width: 18px; + height: 18px; + opacity: 0.7; } + /* ======================================== + Main Panel Header + ======================================== */ .channel-header { height: var(--header-height); padding: 0 20px; @@ -237,120 +339,97 @@ flex-shrink: 0; } - .channel-header-name { + .channel-info { display: flex; align-items: center; - gap: 4px; + gap: 12px; + } + + .channel-name { font-size: 18px; font-weight: 700; color: var(--text-primary); } - .channel-header-name .prefix { color: var(--text-muted); font-weight: 400; } - .channel-stats { display: flex; - gap: 12px; - font-size: 13px; - color: var(--text-muted); + gap: 16px; } .stat-badge { display: flex; align-items: center; - gap: 4px; + gap: 6px; + padding: 4px 10px; + background: rgba(255, 255, 255, 0.06); + border-radius: 12px; + font-size: 13px; + color: var(--text-secondary); } .stat-badge .value { font-weight: 600; - color: var(--text-secondary); + color: var(--text-primary); font-family: var(--font-mono); } - /* Messages Area - Dashboard Style */ - .messages-area { + /* ======================================== + Project Cards Grid + ======================================== */ + .cards-container { flex: 1; overflow-y: auto; padding: 20px; } - .messages-list { display: flex; flex-direction: column; gap: 1px; } - - .message { - display: flex; - gap: 12px; - padding: 8px 20px; - transition: background var(--transition-fast); + .cards-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); + gap: 16px; } - .message:hover { background: var(--bg-message-hover); } - - .message-avatar { - width: 36px; - height: 36px; - border-radius: 4px; - display: flex; - align-items: center; - justify-content: center; - font-size: 14px; - font-weight: 600; - color: white; - flex-shrink: 0; + .project-card { + background: var(--bg-card); + border: 1px solid var(--border-divider); + border-radius: 8px; + padding: 16px; + transition: all var(--transition-normal); } - .message-content { flex: 1; min-width: 0; } - - .message-header { - display: flex; - align-items: baseline; - gap: 8px; - margin-bottom: 4px; + .project-card:hover { + background: var(--bg-card-hover); + border-color: var(--border-subtle); } - .message-sender { font-weight: 700; color: var(--text-primary); } - .message-recipient { font-size: 13px; color: var(--text-muted); } - .message-recipient .target { color: var(--text-secondary); } - .message-timestamp { font-size: 12px; color: var(--text-muted); margin-left: auto; } - - .message-body { - font-size: 15px; - color: var(--text-primary); - line-height: 1.46668; - word-wrap: break-word; - white-space: pre-wrap; + .project-card.offline { + opacity: 0.6; } - .project-badge { - display: inline-block; - padding: 1px 5px; - background: var(--accent-purple); - color: white; - font-size: 11px; - border-radius: 3px; - margin-right: 4px; - font-weight: 500; + .project-card.selected { + border-color: var(--accent-primary); + background: var(--bg-card-hover); } - /* Overview Cards (for All Projects view) */ - .cards-container { flex: 1; overflow-y: auto; padding: 20px; } - - .cards-grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); - gap: 16px; + .project-card { + cursor: pointer; } - .project-card { - background: var(--bg-card); - border: 1px solid var(--border-divider); - border-radius: 8px; - padding: 16px; + /* Header with back link */ + .back-link { + color: var(--text-link); cursor: pointer; - transition: all var(--transition-normal); + font-size: 13px; + font-weight: 400; + margin-right: 8px; } - .project-card:hover { background: var(--bg-card-hover); border-color: var(--border-subtle); } - .project-card.offline { opacity: 0.6; } + .back-link:hover { + text-decoration: underline; + } + + .project-title { + color: var(--text-primary); + } .card-header { display: flex; @@ -359,7 +438,11 @@ margin-bottom: 12px; } - .card-title-group { display: flex; align-items: center; gap: 10px; } + .card-title-group { + display: flex; + align-items: center; + gap: 10px; + } .card-icon { width: 36px; @@ -372,9 +455,23 @@ color: white; } - .card-icon svg { width: 18px; height: 18px; } - .card-title { font-size: 16px; font-weight: 600; color: var(--text-primary); } - .card-path { font-size: 12px; font-family: var(--font-mono); color: var(--text-muted); margin-top: 2px; } + .card-icon svg { + width: 18px; + height: 18px; + } + + .card-title { + font-size: 16px; + font-weight: 600; + color: var(--text-primary); + } + + .card-path { + font-size: 12px; + font-family: var(--font-mono); + color: var(--text-muted); + margin-top: 2px; + } .card-status { display: flex; @@ -386,20 +483,67 @@ font-weight: 500; } - .card-status.online { background: rgba(43, 172, 118, 0.15); color: var(--status-online); } - .card-status.offline { background: rgba(97, 96, 97, 0.15); color: var(--status-offline); } - .card-status.reconnecting { background: rgba(232, 164, 39, 0.15); color: var(--status-away); } - .card-status .dot { width: 6px; height: 6px; background: currentColor; border-radius: 50%; } + .card-status.online { + background: rgba(43, 172, 118, 0.15); + color: var(--status-online); + } + + .card-status.offline { + background: rgba(97, 96, 97, 0.15); + color: var(--status-offline); + } + + .card-status.reconnecting { + background: rgba(232, 164, 39, 0.15); + color: var(--status-away); + } + + .card-status.reconnecting .dot { + animation: pulse-glow 1.5s ease-in-out infinite; + } + + .card-status .dot { + width: 6px; + height: 6px; + background: currentColor; + border-radius: 50%; + } + + /* Agent List in Card */ + .agents-section { + margin-top: 12px; + padding-top: 12px; + border-top: 1px solid var(--border-divider); + } + + .agents-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 8px; + } - .agents-section { margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--border-divider); } + .agents-label { + font-size: 12px; + font-weight: 600; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.5px; + } - .agents-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; } - .agents-label { font-size: 12px; font-weight: 600; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.5px; } - .agents-count { font-size: 12px; font-family: var(--font-mono); color: var(--text-muted); } + .agents-count { + font-size: 12px; + font-family: var(--font-mono); + color: var(--text-muted); + } - .card-agents-list { display: flex; flex-direction: column; gap: 6px; } + .agents-list { + display: flex; + flex-direction: column; + gap: 6px; + } - .card-agent-item { + .agent-item { display: flex; align-items: center; gap: 8px; @@ -408,9 +552,25 @@ border-radius: 6px; } - .card-agent-dot { width: 8px; height: 8px; background: var(--status-online); border-radius: 50%; } - .card-agent-name { font-size: 14px; font-weight: 500; color: var(--text-primary); } - .card-agent-cli { margin-left: auto; font-size: 12px; font-family: var(--font-mono); color: var(--text-muted); } + .agent-status-dot { + width: 8px; + height: 8px; + background: var(--status-online); + border-radius: 50%; + } + + .agent-name { + font-size: 14px; + font-weight: 500; + color: var(--text-primary); + } + + .agent-cli { + margin-left: auto; + font-size: 12px; + font-family: var(--font-mono); + color: var(--text-muted); + } .no-agents { padding: 12px; @@ -421,64 +581,234 @@ border-radius: 6px; } - /* Empty State */ - .empty-state { + .card-actions { display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - height: 100%; + gap: 8px; + margin-top: 12px; + padding-top: 12px; + border-top: 1px solid var(--border-divider); + } + + .card-action-btn { + flex: 1; + padding: 8px 12px; + background: rgba(255, 255, 255, 0.06); + border: 1px solid var(--border-subtle); + border-radius: 6px; + color: var(--text-secondary); + font-size: 13px; + font-weight: 500; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + transition: all var(--transition-fast); + } + + .card-action-btn:hover { + background: rgba(255, 255, 255, 0.1); + color: var(--text-primary); + border-color: var(--accent-primary); + } + + .card-action-btn.primary { + background: var(--accent-primary); + border-color: var(--accent-primary); + color: white; + } + + .card-action-btn.primary:hover { + background: #0d4f7a; + } + + .card-action-btn svg { + width: 14px; + height: 14px; + } + + /* Empty State */ + .empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; padding: 48px; text-align: center; } - .empty-state-icon { + .empty-icon { width: 64px; height: 64px; + background: var(--bg-card); + border-radius: 16px; + display: flex; + align-items: center; + justify-content: center; margin-bottom: 16px; color: var(--text-muted); } - .empty-state-title { font-size: 18px; font-weight: 600; color: var(--text-primary); margin-bottom: 8px; } - .empty-state-text { font-size: 14px; color: var(--text-muted); max-width: 300px; } + .empty-title { + font-size: 18px; + font-weight: 600; + color: var(--text-primary); + margin-bottom: 8px; + } - /* Composer */ - .message-composer { - padding: 16px 20px; - border-top: 1px solid var(--border-divider); - background: var(--bg-main); + .empty-text { + font-size: 14px; + color: var(--text-muted); + max-width: 300px; + margin-bottom: 16px; } - .composer-container { - background: var(--bg-input); - border: 1px solid var(--border-subtle); - border-radius: 8px; + .empty-code { + font-family: var(--font-mono); + font-size: 13px; + padding: 10px 14px; + background: var(--bg-card); + border-radius: 6px; + color: var(--accent-green); + } + + /* ======================================== + Messages Panel + ======================================== */ + .messages-panel { + width: 360px; + background: var(--bg-sidebar); + border-left: 1px solid var(--border-divider); + display: flex; + flex-direction: column; + flex-shrink: 0; + } + + .messages-header { + height: var(--header-height); + padding: 0 16px; + display: flex; + align-items: center; + justify-content: space-between; + border-bottom: 1px solid var(--border-divider); + } + + .messages-title { + font-size: 16px; + font-weight: 700; + color: var(--text-primary); + } + + .live-indicator { + display: flex; + align-items: center; + gap: 6px; + font-size: 12px; + color: var(--status-online); + } + + .live-indicator .dot { + width: 6px; + height: 6px; + background: var(--status-online); + border-radius: 50%; + animation: pulse 2s ease-in-out infinite; + } + + @keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.4; } + } + + .messages-list { + flex: 1; + overflow-y: auto; padding: 12px; } - .composer-input { - width: 100%; - background: transparent; - border: none; + .message-item { + padding: 10px 12px; + background: var(--bg-main); + border-radius: 8px; + margin-bottom: 8px; + } + + .message-item:hover { + background: var(--bg-card); + } + + .message-route { + display: flex; + align-items: center; + gap: 6px; + margin-bottom: 6px; + font-size: 12px; + } + + .route-tag { + padding: 2px 6px; + background: rgba(255, 255, 255, 0.08); + border-radius: 4px; + font-family: var(--font-mono); + color: var(--text-secondary); + } + + .route-agent { + font-weight: 500; color: var(--text-primary); - font-family: var(--font-family); - font-size: 15px; - resize: none; - outline: none; - min-height: 24px; - max-height: 200px; } - .composer-input::placeholder { color: var(--text-muted); } + .route-arrow { + color: var(--text-muted); + } + + .route-time { + margin-left: auto; + font-family: var(--font-mono); + color: var(--text-muted); + font-size: 11px; + } + + .message-body { + font-size: 13px; + color: var(--text-secondary); + line-height: 1.4; + word-wrap: break-word; + white-space: pre-wrap; + } + + .messages-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + color: var(--text-muted); + font-size: 13px; + } + + /* Message Composer */ + .message-composer { + padding: 12px 16px; + border-top: 1px solid var(--border-divider); + background: var(--bg-sidebar); + } - .composer-row { + .composer-header { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; } + .composer-label { + font-size: 12px; + color: var(--text-muted); + } + .composer-select { + flex: 1; padding: 6px 10px; font-size: 13px; font-family: var(--font-family); @@ -489,84 +819,304 @@ cursor: pointer; } - .composer-select:hover { border-color: var(--accent-primary); } - .composer-select:focus { outline: none; border-color: var(--accent-primary); } - .composer-select:disabled { opacity: 0.5; cursor: not-allowed; } + .composer-select:hover { + border-color: var(--accent-primary); + } - .composer-actions { + .composer-select:focus { + outline: none; + border-color: var(--accent-primary); + } + + .composer-select:disabled { + opacity: 0.5; + cursor: not-allowed; + } + + .composer-select option { + background: #222529; + color: #d1d2d3; + padding: 8px; + } + + .composer-select option:hover, + .composer-select option:checked { + background: #1164a3; + color: #fff; + } + + .composer-input-row { display: flex; - justify-content: space-between; - align-items: center; - margin-top: 8px; + gap: 8px; } - .composer-status { font-size: 12px; color: var(--text-muted); } - .composer-status.success { color: var(--status-online); } - .composer-status.error { color: var(--accent-red); } + .composer-input { + flex: 1; + padding: 8px 12px; + font-size: 14px; + font-family: var(--font-family); + color: var(--text-primary); + background: var(--bg-main); + border: 1px solid var(--border-subtle); + border-radius: 6px; + } + + .composer-input:hover:not(:disabled) { + border-color: var(--accent-primary); + } + + .composer-input:focus { + outline: none; + border-color: var(--accent-primary); + } + + .composer-input:disabled { + opacity: 0.5; + cursor: not-allowed; + } + + .composer-input::placeholder { + color: var(--text-muted); + } .composer-send { - padding: 6px 16px; - background: var(--accent-green); + padding: 8px 12px; + background: var(--accent-primary); color: white; border: none; - border-radius: 4px; - font-size: 13px; - font-weight: 600; + border-radius: 6px; cursor: pointer; - transition: background var(--transition-fast); + display: flex; + align-items: center; + justify-content: center; + transition: var(--transition-fast); + } + + .composer-send:hover:not(:disabled) { + background: #0d4f7a; + } + + .composer-send:disabled { + opacity: 0.5; + cursor: not-allowed; + } + + .composer-status { + font-size: 12px; + color: var(--text-muted); + margin-top: 6px; + min-height: 16px; + } + + .composer-status.success { + color: var(--status-online); + } + + .composer-status.error { + color: var(--accent-red); } - .composer-send:hover:not(:disabled) { background: #249966; } - .composer-send:disabled { opacity: 0.5; cursor: not-allowed; } + .messages-footer { + padding: 12px 16px; + border-top: 1px solid var(--border-divider); + display: flex; + justify-content: space-between; + font-size: 12px; + font-family: var(--font-mono); + color: var(--text-muted); + } - /* Spawn Modal */ - .spawn-modal-overlay { + /* ======================================== + Command Palette + ======================================== */ + .command-palette-overlay { position: fixed; inset: 0; background: rgba(0, 0, 0, 0.6); display: none; - align-items: center; + align-items: flex-start; justify-content: center; + padding-top: 15vh; z-index: 1000; } - .spawn-modal-overlay.visible { display: flex; } + .command-palette-overlay.visible { + display: flex; + } - .spawn-modal { - width: 480px; + .command-palette { + width: 600px; max-width: 90vw; - background: var(--bg-modal); - border-radius: 12px; - box-shadow: var(--shadow-modal); + background: var(--bg-sidebar); + border-radius: 8px; + box-shadow: 0 18px 50px rgba(0, 0, 0, 0.6); overflow: hidden; + animation: paletteSlideIn 0.15s ease-out; + } + + @keyframes paletteSlideIn { + from { + opacity: 0; + transform: translateY(-10px) scale(0.98); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } + } + + .palette-search { + padding: 16px; + border-bottom: 1px solid var(--border-divider); + } + + .palette-search-input { + width: 100%; + background: transparent; + border: none; + color: var(--text-primary); + font-family: var(--font-family); + font-size: 18px; + outline: none; + } + + .palette-search-input::placeholder { + color: var(--text-muted); + } + + .palette-results { + max-height: 400px; + overflow-y: auto; + } + + .palette-section { + padding: 8px 0; + } + + .palette-section-title { + padding: 8px 16px 4px; + font-size: 12px; + font-weight: 600; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.05em; + } + + .palette-item { + padding: 10px 16px; + display: flex; + align-items: center; + gap: 12px; + cursor: pointer; + transition: background var(--transition-fast); + } + + .palette-item:hover, + .palette-item.selected { + background: rgba(255, 255, 255, 0.06); + } + + .palette-item-icon { + width: 20px; + height: 20px; + color: var(--text-muted); + display: flex; + align-items: center; + justify-content: center; + } + + .palette-item-content { + flex: 1; + } + + .palette-item-title { + font-size: 14px; + color: var(--text-primary); + } + + .palette-item-subtitle { + font-size: 12px; + color: var(--text-muted); + } + + .palette-item-shortcut { + font-size: 12px; + color: var(--text-muted); + } + + .palette-item-shortcut kbd { + font-family: var(--font-family); + background: rgba(255, 255, 255, 0.1); + padding: 2px 6px; + border-radius: 3px; + } + + /* ======================================== + Scrollbars + ======================================== */ + ::-webkit-scrollbar { + width: 8px; + } + + ::-webkit-scrollbar-track { + background: transparent; + } + + ::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.1); + border-radius: 4px; + } + + ::-webkit-scrollbar-thumb:hover { + background: rgba(255, 255, 255, 0.2); + } + + /* ======================================== + Spawn Agent Modal + ======================================== */ + .modal-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.7); + display: none; + align-items: center; + justify-content: center; + z-index: 1100; + } + + .modal-overlay.visible { + display: flex; + } + + .modal { + background: var(--bg-sidebar); + border-radius: 12px; + box-shadow: 0 18px 50px rgba(0, 0, 0, 0.6); + width: 440px; + max-width: 90vw; animation: modalSlideIn 0.2s ease-out; } @keyframes modalSlideIn { - from { opacity: 0; transform: translateY(-20px) scale(0.95); } - to { opacity: 1; transform: translateY(0) scale(1); } + from { opacity: 0; transform: scale(0.95) translateY(-10px); } + to { opacity: 1; transform: scale(1) translateY(0); } } - .spawn-modal-header { - padding: 16px 20px; + .modal-header { + padding: 20px 24px 16px; border-bottom: 1px solid var(--border-divider); display: flex; align-items: center; justify-content: space-between; } - .spawn-modal-title { - display: flex; - align-items: center; - gap: 10px; + .modal-title { font-size: 18px; - font-weight: 600; + font-weight: 700; color: var(--text-primary); } - .spawn-modal-title svg { color: var(--accent-green); } - - .spawn-modal-close { + .modal-close { width: 32px; height: 32px; border: none; @@ -580,80 +1130,179 @@ transition: all var(--transition-fast); } - .spawn-modal-close:hover { background: rgba(255, 255, 255, 0.1); color: var(--text-primary); } + .modal-close:hover { + background: rgba(255, 255, 255, 0.1); + color: var(--text-primary); + } + + .modal-body { + padding: 20px 24px; + } + + .form-group { + margin-bottom: 16px; + } - .spawn-modal-body { padding: 20px; } + .form-group:last-child { + margin-bottom: 0; + } - .spawn-form-group { margin-bottom: 16px; } - .spawn-form-group:last-child { margin-bottom: 0; } - .spawn-form-group label { display: block; font-size: 13px; font-weight: 600; color: var(--text-secondary); margin-bottom: 6px; } + .form-label { + display: block; + font-size: 13px; + font-weight: 600; + color: var(--text-secondary); + margin-bottom: 6px; + } - .spawn-input, .spawn-textarea, .spawn-select { + .form-input { width: 100%; padding: 10px 12px; - background: var(--bg-input); + background: var(--bg-main); border: 1px solid var(--border-subtle); border-radius: 6px; color: var(--text-primary); font-family: var(--font-family); font-size: 14px; + outline: none; transition: border-color var(--transition-fast); } - .spawn-input:focus, .spawn-textarea:focus, .spawn-select:focus { outline: none; border-color: var(--accent-primary); } - .spawn-input::placeholder, .spawn-textarea::placeholder { color: var(--text-muted); } - .spawn-textarea { resize: vertical; min-height: 80px; line-height: 1.4; } - .spawn-hint { display: block; font-size: 12px; color: var(--text-muted); margin-top: 4px; } - .spawn-status { min-height: 20px; font-size: 13px; margin-top: 12px; } - .spawn-status.success { color: var(--accent-green); } - .spawn-status.error { color: var(--accent-red); } - .spawn-status.loading { color: var(--text-muted); } + .form-input:focus { + border-color: var(--accent-primary); + } - .spawn-modal-footer { - padding: 16px 20px; - border-top: 1px solid var(--border-divider); + .form-input::placeholder { + color: var(--text-muted); + } + + .form-select { + width: 100%; + padding: 10px 12px; + background: var(--bg-main); + border: 1px solid var(--border-subtle); + border-radius: 6px; + color: var(--text-primary); + font-family: var(--font-family); + font-size: 14px; + outline: none; + cursor: pointer; + appearance: none; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%23ababad' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 12px center; + } + + .form-select:focus { + border-color: var(--accent-primary); + } + + .form-hint { + font-size: 12px; + color: var(--text-muted); + margin-top: 4px; + } + + .modal-footer { + padding: 16px 24px 20px; display: flex; justify-content: flex-end; - gap: 10px; + gap: 12px; } - .spawn-cancel-btn { - padding: 8px 16px; - background: transparent; - border: 1px solid var(--border-subtle); + .btn { + padding: 10px 20px; + border: none; border-radius: 6px; - color: var(--text-secondary); + font-family: var(--font-family); font-size: 14px; - font-weight: 500; + font-weight: 600; cursor: pointer; transition: all var(--transition-fast); } - .spawn-cancel-btn:hover { background: rgba(255, 255, 255, 0.05); color: var(--text-primary); } + .btn-secondary { + background: transparent; + color: var(--text-secondary); + border: 1px solid var(--border-subtle); + } + + .btn-secondary:hover { + background: rgba(255, 255, 255, 0.05); + color: var(--text-primary); + } + + .btn-primary { + background: var(--accent-primary); + color: white; + } + + .btn-primary:hover { + background: #0b5d99; + } + + .btn-primary:disabled { + background: var(--border-subtle); + cursor: not-allowed; + } - .spawn-submit-btn { + /* Spawn button in card */ + .spawn-agent-btn { + padding: 8px 12px; + background: rgba(43, 172, 118, 0.15); + border: 1px solid rgba(43, 172, 118, 0.3); + border-radius: 6px; + color: var(--accent-green); + font-size: 13px; + font-weight: 500; + cursor: pointer; display: flex; align-items: center; + justify-content: center; gap: 6px; - padding: 8px 16px; - background: var(--accent-green); + transition: all var(--transition-fast); + } + + .spawn-agent-btn:hover { + background: rgba(43, 172, 118, 0.25); + border-color: var(--accent-green); + } + + .spawn-agent-btn svg { + width: 14px; + height: 14px; + } + + /* Kill button in agent item */ + .agent-kill-btn { + width: 20px; + height: 20px; border: none; - border-radius: 6px; - color: white; - font-size: 14px; - font-weight: 600; + background: transparent; + color: var(--text-muted); + border-radius: 4px; cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + opacity: 0; transition: all var(--transition-fast); + margin-left: auto; + } + + .agent-item:hover .agent-kill-btn { + opacity: 1; } - .spawn-submit-btn:hover:not(:disabled) { background: #249966; } - .spawn-submit-btn:disabled { opacity: 0.5; cursor: not-allowed; } + .agent-kill-btn:hover { + background: rgba(224, 30, 90, 0.2); + color: var(--accent-red); + } - /* Scrollbars */ - ::-webkit-scrollbar { width: 8px; } - ::-webkit-scrollbar-track { background: transparent; } - ::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, 0.1); border-radius: 4px; } - ::-webkit-scrollbar-thumb:hover { background: rgba(255, 255, 255, 0.2); } + .agent-kill-btn svg { + width: 12px; + height: 12px; + } @@ -663,59 +1312,38 @@
    - Bridge + Bridge Mode
    -