Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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'",
Expand Down
269 changes: 267 additions & 2 deletions src/dashboard/frontend/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
// 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();
Expand Down Expand Up @@ -203,7 +264,10 @@ function setupEventListeners(elements: ReturnType<typeof getElements>): 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();
Expand All @@ -215,6 +279,14 @@ function setupEventListeners(elements: ReturnType<typeof getElements>): 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();

Expand Down Expand Up @@ -373,11 +445,204 @@ async function handleThreadSend(): Promise<void> {
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<void> {
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<void> {
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<HTMLElement>('.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();
}
}
Loading
Loading