g3 console initial cut + error doesnt kill auto

This commit is contained in:
Dhanji Prasanna
2025-11-04 11:39:26 +11:00
parent 6913c5f72e
commit aaf918828f
53 changed files with 6796 additions and 23 deletions

View File

@@ -0,0 +1,10 @@
pre code.hljs{display:block;overflow-x:auto;padding:1em}code.hljs{padding:3px 5px}/*!
Theme: GitHub Dark
Description: Dark theme as seen on github.com
Author: github.com
Maintainer: @Hirse
Updated: 2021-05-15
Outdated base version: https://github.com/primer/github-syntax-dark
Current colors taken from GitHub's CSS
*/.hljs{color:#c9d1d9;background:#0d1117}.hljs-doctag,.hljs-keyword,.hljs-meta .hljs-keyword,.hljs-template-tag,.hljs-template-variable,.hljs-type,.hljs-variable.language_{color:#ff7b72}.hljs-title,.hljs-title.class_,.hljs-title.class_.inherited__,.hljs-title.function_{color:#d2a8ff}.hljs-attr,.hljs-attribute,.hljs-literal,.hljs-meta,.hljs-number,.hljs-operator,.hljs-selector-attr,.hljs-selector-class,.hljs-selector-id,.hljs-variable{color:#79c0ff}.hljs-meta .hljs-string,.hljs-regexp,.hljs-string{color:#a5d6ff}.hljs-built_in,.hljs-symbol{color:#ffa657}.hljs-code,.hljs-comment,.hljs-formula{color:#8b949e}.hljs-name,.hljs-quote,.hljs-selector-pseudo,.hljs-selector-tag{color:#7ee787}.hljs-subst{color:#c9d1d9}.hljs-section{color:#1f6feb;font-weight:700}.hljs-bullet{color:#f2cc60}.hljs-emphasis{color:#c9d1d9;font-style:italic}.hljs-strong{color:#c9d1d9;font-weight:700}.hljs-addition{color:#aff5b4;background-color:#033a16}.hljs-deletion{color:#ffdcd7;background-color:#67060c}

View File

@@ -0,0 +1,112 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>G3 Console</title>
<link rel="stylesheet" href="/styles/app.css">
<!-- Marked.js for Markdown rendering -->
<script src="/js/marked.min.js"></script>
<!-- Highlight.js for syntax highlighting -->
<link rel="stylesheet" href="/css/highlight-dark.min.css">
<script src="/js/highlight.min.js"></script>
</head>
<body class="dark">
<div id="app">
<header class="header">
<div class="header-content">
<h1 class="header-title">G3 Console</h1>
<div class="header-actions">
<button id="new-run-btn" class="btn btn-primary">+ New Run</button>
<button id="theme-toggle" class="btn btn-secondary">🌙</button>
</div>
</div>
</header>
<main class="main-content">
<div id="page-container"></div>
</main>
</div>
<!-- New Run Modal -->
<div id="new-run-modal" class="modal hidden">
<div class="modal-overlay"></div>
<div class="modal-content">
<div class="modal-header">
<h2>Launch New G3 Instance</h2>
<button id="modal-close" class="modal-close">&times;</button>
</div>
<div class="modal-body">
<form id="launch-form">
<div class="form-group">
<label for="prompt">Initial Prompt *</label>
<textarea id="prompt" name="prompt" rows="4" required
placeholder="Describe what you want g3 to build..."></textarea>
</div>
<div class="form-group">
<label for="workspace">Workspace Directory *</label>
<div class="input-with-button">
<input type="text" id="workspace" name="workspace" required />
<button type="button" id="browse-workspace" class="btn btn-secondary">Browse</button>
</div>
</div>
<div class="form-group">
<label for="g3-binary-path">G3 Binary Path</label>
<div class="input-with-button">
<input type="text" id="g3-binary-path" name="g3_binary_path" placeholder="g3 (default)" />
<button type="button" id="browse-binary" class="btn btn-secondary">Browse</button>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="provider">Provider</label>
<select id="provider" name="provider">
<option value="databricks">Databricks</option>
<option value="anthropic">Anthropic</option>
<option value="local">Local</option>
</select>
</div>
<div class="form-group">
<label for="model">Model</label>
<select id="model" name="model">
<option value="databricks-claude-sonnet-4-5">databricks-claude-sonnet-4-5</option>
<option value="databricks-meta-llama-3-1-405b-instruct">databricks-meta-llama-3-1-405b-instruct</option>
</select>
</div>
</div>
<div class="form-group">
<label>Execution Mode</label>
<div class="radio-group">
<label class="radio-label">
<input type="radio" name="mode" value="single" checked />
<span>Single-shot</span>
<small>Execute once and complete</small>
</label>
<label class="radio-label">
<input type="radio" name="mode" value="ensemble" />
<span>Coach+Player Ensemble</span>
<small>Autonomous mode with coach and player agents</small>
</label>
</div>
</div>
<div class="modal-footer">
<button type="button" id="cancel-launch" class="btn btn-secondary">Cancel</button>
<button type="submit" class="btn btn-primary">Start Instance</button>
</div>
</form>
</div>
</div>
</div>
<script src="/js/api.js"></script>
<script src="/js/state.js"></script>
<script src="/js/components.js"></script>
<script src="/js/router.js"></script>
<script src="/js/app.js"></script>
</body>
</html>

View File

@@ -0,0 +1,80 @@
// API client for G3 Console
const API_BASE = '/api';
const api = {
// Get all instances
async getInstances() {
const response = await fetch(`${API_BASE}/instances`);
if (!response.ok) throw new Error('Failed to fetch instances');
return response.json();
},
// Get single instance details
async getInstance(id) {
const response = await fetch(`${API_BASE}/instances/${id}`);
if (!response.ok) throw new Error('Failed to fetch instance');
return response.json();
},
// Get instance logs
async getInstanceLogs(id) {
const response = await fetch(`${API_BASE}/instances/${id}/logs`);
if (!response.ok) throw new Error('Failed to fetch logs');
return response.json();
},
// Launch new instance
async launchInstance(data) {
const response = await fetch(`${API_BASE}/instances/launch`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
if (!response.ok) {
// Try to extract error message from response
try {
const errorData = await response.json();
throw new Error(errorData.message || errorData.error || 'Failed to launch instance');
} catch (e) {
throw new Error(`Failed to launch instance (${response.status})`);
}
}
return response.json();
},
// Kill instance
async killInstance(id) {
const response = await fetch(`${API_BASE}/instances/${id}/kill`, {
method: 'POST'
});
if (!response.ok) throw new Error('Failed to kill instance');
return response.json();
},
// Restart instance
async restartInstance(id) {
const response = await fetch(`${API_BASE}/instances/${id}/restart`, {
method: 'POST'
});
if (!response.ok) throw new Error('Failed to restart instance');
return response.json();
},
// Get console state
async getState() {
const response = await fetch(`${API_BASE}/state`);
if (!response.ok) throw new Error('Failed to fetch state');
return response.json();
},
// Save console state
async saveState(state) {
const response = await fetch(`${API_BASE}/state`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(state)
});
if (!response.ok) throw new Error('Failed to save state');
return response.json();
}
};

View File

@@ -0,0 +1,313 @@
// Main application logic
// Global action handlers
window.handleKill = async function(id) {
if (!confirm('Are you sure you want to kill this instance?')) return;
// Find the button and show loading state
const button = event.target;
const originalText = button.textContent;
button.disabled = true;
button.innerHTML = '<span class="spinner" style="width: 1rem; height: 1rem; border-width: 2px; display: inline-block; vertical-align: middle;"></span> Terminating...';
try {
await api.killInstance(id);
// Show success state
button.innerHTML = '✓ Terminated';
button.classList.remove('btn-danger');
button.classList.add('btn-secondary');
// Refresh after a short delay
setTimeout(() => {
router.handleRoute(router.currentRoute);
}, 1000);
} catch (error) {
// Restore button state on error
button.disabled = false;
button.textContent = originalText;
alert('Failed to kill instance: ' + error.message);
}
};
window.handleRestart = async function(id) {
// Find the button and show loading state
const button = event.target;
const originalText = button.textContent;
button.disabled = true;
button.innerHTML = '<span class="spinner" style="width: 1rem; height: 1rem; border-width: 2px; display: inline-block; vertical-align: middle;"></span> Restarting...';
try {
await api.restartInstance(id);
// Show intermediate states
button.innerHTML = '<span class="spinner" style="width: 1rem; height: 1rem; border-width: 2px; display: inline-block; vertical-align: middle;"></span> Starting...';
// Wait a bit then show success
setTimeout(() => {
button.innerHTML = '✓ Running';
button.classList.remove('btn-primary');
button.classList.add('btn-success');
}, 1500);
// Refresh current view
setTimeout(() => {
router.handleRoute(router.currentRoute);
}, 2500);
} catch (error) {
// Restore button state on error
button.disabled = false;
button.textContent = originalText;
alert('Failed to kill instance: ' + error.message);
}
};
// Modal management
const modal = {
element: null,
init() {
this.element = document.getElementById('new-run-modal');
// Close button
document.getElementById('modal-close').addEventListener('click', () => this.close());
document.getElementById('cancel-launch').addEventListener('click', () => this.close());
// Close on overlay click
this.element.querySelector('.modal-overlay').addEventListener('click', () => this.close());
// Form submission
document.getElementById('launch-form').addEventListener('submit', (e) => {
e.preventDefault();
this.handleLaunch();
});
// File browser buttons - use HTML5 file input
document.getElementById('browse-workspace').addEventListener('click', () => {
this.browseDirectory('workspace');
});
document.getElementById('browse-binary').addEventListener('click', () => {
this.browseFile('g3-binary-path');
});
// Provider change updates model options
document.getElementById('provider').addEventListener('change', (e) => {
this.updateModelOptions(e.target.value);
});
},
browseDirectory(inputId) {
// Create a hidden file input with directory picker
const input = document.createElement('input');
input.type = 'file';
input.webkitdirectory = true;
input.directory = true;
input.multiple = false;
input.onchange = (e) => {
const files = e.target.files;
if (files.length > 0) {
// Get the directory path from the first file
const path = files[0].webkitRelativePath.split('/')[0];
// In browser context, we get relative path, so we need to construct full path
// For now, just use the directory name and let user adjust
document.getElementById(inputId).value = path;
}
};
input.click();
},
browseFile(inputId) {
// Create a hidden file input
const input = document.createElement('input');
input.type = 'file';
input.accept = '*';
input.onchange = (e) => {
const files = e.target.files;
if (files.length > 0) {
// Get the file name
// Note: For security reasons, browsers don't give us the full path
// User will need to type the full path manually
document.getElementById(inputId).value = files[0].name;
}
};
input.click();
},
open() {
// Load saved state
const form = document.getElementById('launch-form');
if (state.lastWorkspace) {
form.workspace.value = state.lastWorkspace;
}
if (state.g3BinaryPath) {
form.g3_binary_path.value = state.g3BinaryPath;
}
form.provider.value = state.lastProvider;
this.updateModelOptions(state.lastProvider);
form.model.value = state.lastModel;
this.element.classList.remove('hidden');
},
close() {
this.element.classList.add('hidden');
},
updateModelOptions(provider) {
const modelSelect = document.getElementById('model');
const models = {
databricks: [
{ value: 'databricks-claude-sonnet-4-5', label: 'databricks-claude-sonnet-4-5' },
{ value: 'databricks-meta-llama-3-1-405b-instruct', label: 'databricks-meta-llama-3-1-405b-instruct' }
],
anthropic: [
{ value: 'claude-3-5-sonnet-20241022', label: 'claude-3-5-sonnet-20241022' },
{ value: 'claude-3-opus-20240229', label: 'claude-3-opus-20240229' }
],
local: [
{ value: 'local-model', label: 'Local Model' }
]
};
modelSelect.innerHTML = '';
for (const model of models[provider] || []) {
const option = document.createElement('option');
option.value = model.value;
option.textContent = model.label;
modelSelect.appendChild(option);
}
},
async handleLaunch() {
const form = document.getElementById('launch-form');
const formData = new FormData(form);
const data = {
prompt: formData.get('prompt'),
workspace: formData.get('workspace'),
provider: formData.get('provider'),
model: formData.get('model'),
mode: formData.get('mode'),
g3_binary_path: formData.get('g3_binary_path') || null
};
try {
// Show loading state
const submitBtn = form.querySelector('button[type="submit"]');
const modalBody = this.element.querySelector('.modal-body');
submitBtn.disabled = true;
submitBtn.innerHTML = '<span class="spinner" style="width: 1rem; height: 1rem; border-width: 2px; display: inline-block; vertical-align: middle;"></span> Starting g3 instance...';
const response = await api.launchInstance(data);
// Show intermediate state
submitBtn.innerHTML = '<span class="spinner" style="width: 1rem; height: 1rem; border-width: 2px; display: inline-block; vertical-align: middle;"></span> Waiting for process...';
// Wait a bit to let the process start
await new Promise(resolve => setTimeout(resolve, 1500));
submitBtn.innerHTML = '✓ Instance started!';
// Save state
state.updateLaunchDefaults(
data.workspace,
data.provider,
data.model,
data.g3_binary_path
);
// Close modal and navigate home
this.close();
router.navigate('/');
// Reset form
form.reset();
submitBtn.disabled = false;
submitBtn.textContent = 'Start Instance';
} catch (error) {
// Display detailed error message in modal
const errorDiv = document.createElement('div');
errorDiv.className = 'error-message';
errorDiv.style.cssText = 'background: #fee; border: 1px solid #fcc; color: #c33; padding: 1rem; margin: 1rem 0; border-radius: 0.5rem;';
let errorMessage = 'Failed to launch instance';
if (error.message) {
errorMessage += ': ' + error.message;
}
// Check for specific error types
if (error.message && error.message.includes('400')) {
errorMessage = 'Invalid configuration. Please check that the g3 binary path exists and is executable, and that the workspace directory is valid.';
} else if (error.message && error.message.includes('500')) {
errorMessage = 'Server error while launching instance. Check console logs for details.';
}
errorDiv.textContent = errorMessage;
// Remove any existing error messages
const existingError = modalBody.querySelector('.error-message');
if (existingError) existingError.remove();
// Insert error message at the top of modal body
modalBody.insertBefore(errorDiv, modalBody.firstChild);
const submitBtn = form.querySelector('button[type="submit"]');
submitBtn.disabled = false;
submitBtn.textContent = 'Start Instance';
}
}
};
// Theme toggle
function initTheme() {
const themeToggle = document.getElementById('theme-toggle');
themeToggle.addEventListener('click', () => {
const newTheme = state.theme === 'dark' ? 'light' : 'dark';
state.setTheme(newTheme);
themeToggle.textContent = newTheme === 'dark' ? '🌙' : '☀️';
});
// Set initial theme
document.body.className = state.theme;
themeToggle.textContent = state.theme === 'dark' ? '🌙' : '☀️';
}
// Initialize app
async function init() {
// Prevent double initialization
if (window.g3Initialized) {
return;
}
window.g3Initialized = true;
// Load state
await state.load();
// Initialize theme
initTheme();
// Initialize modal
modal.init();
// New Run button
document.getElementById('new-run-btn').addEventListener('click', () => {
modal.open();
});
// Initialize router
router.init();
}
// Simplified initialization - call exactly once when DOM is ready
if (document.readyState === 'loading') {
// DOM still loading, wait for DOMContentLoaded
document.addEventListener('DOMContentLoaded', init, { once: true });
} else {
// DOM already loaded (interactive or complete), init immediately
init();
}

View File

@@ -0,0 +1,311 @@
// UI Components for G3 Console
const components = {
// Render status badge
statusBadge(status) {
const colors = {
running: 'badge-success',
completed: 'badge-success',
failed: 'badge-error',
idle: 'badge-warning',
terminated: 'badge-neutral'
};
return `<span class="badge ${colors[status] || 'badge-neutral'}">${status}</span>`;
},
// Render progress bar
progressBar(instance, stats) {
const duration = stats.duration_secs;
const estimated = duration * 1.5; // Simple estimation
const progress = Math.min((duration / estimated) * 100, 100);
// Check if this is ensemble mode with turn data
if (instance.instance_type === 'ensemble' && stats.turns && stats.turns.length > 0) {
return this.ensembleProgressBar(stats.turns, duration);
}
return `
<div class="progress-bar">
<div class="progress-fill" style="width: ${progress}%"></div>
<span class="progress-text">${Math.round(duration / 60)}m elapsed</span>
</div>
`;
},
// Render multi-segment progress bar for ensemble mode
ensembleProgressBar(turns, totalDuration) {
const colors = {
coach: '#3b82f6',
player: '#6b7280',
completed: '#10b981',
error: '#ef4444'
};
let segments = '';
for (const turn of turns) {
const percentage = (turn.duration_secs / totalDuration) * 100;
const color = colors[turn.agent] || colors.player;
const statusColor = turn.status === 'error' ? colors.error : color;
segments += `
<div class="progress-segment"
style="width: ${percentage}%; background-color: ${statusColor};"
title="${turn.agent}: ${turn.duration_secs}s - ${turn.status}">
</div>
`;
}
return `
<div class="progress-bar ensemble">
${segments}
<span class="progress-text">${Math.round(totalDuration / 60)}m elapsed</span>
</div>
`;
},
// Render instance panel
instancePanel(instance, stats, latestMessage) {
return `
<div class="instance-panel" data-id="${instance.id}" onclick="router.navigate('/instance/${instance.id}')">
<div class="panel-header">
<div class="panel-title">
<h3>${instance.workspace}</h3>
${this.statusBadge(instance.status)}
</div>
<div class="panel-meta">
<span class="meta-item">${instance.instance_type}</span>
<span class="meta-item">PID: ${instance.pid}</span>
<span class="meta-item">${new Date(instance.start_time).toLocaleString()}</span>
</div>
</div>
${this.progressBar(instance, stats)}
<div class="panel-stats">
<div class="stat-item">
<span class="stat-label">Tokens</span>
<span class="stat-value">${stats.total_tokens.toLocaleString()}</span>
</div>
<div class="stat-item">
<span class="stat-label">Tool Calls</span>
<span class="stat-value">${stats.tool_calls}</span>
</div>
<div class="stat-item">
<span class="stat-label">Errors</span>
<span class="stat-value">${stats.errors}</span>
</div>
<div class="stat-item">
<span class="stat-label">Duration</span>
<span class="stat-value">${Math.round(stats.duration_secs / 60)}m</span>
</div>
</div>
${latestMessage ? `
<div class="panel-message">
<strong>Latest:</strong> ${this.truncate(latestMessage, 100)}
</div>
` : ''}
<div class="panel-actions">
${instance.status === 'running' ? `
<button class="btn btn-danger btn-sm" onclick="event.stopPropagation(); handleKill('${instance.id}')">Kill</button>
` : ''}
${instance.status === 'terminated' ? `
<button class="btn btn-primary btn-sm" onclick="event.stopPropagation(); handleRestart('${instance.id}')">Restart</button>
` : ''}
<button class="btn btn-secondary btn-sm" onclick="event.stopPropagation(); router.navigate('/instance/${instance.id}')">View Details</button>
</div>
</div>
`;
},
// Render loading spinner
spinner(message = 'Loading...') {
return `
<div class="spinner-container">
<div class="spinner"></div>
<p>${message}</p>
</div>
`;
},
// Render error message
error(message) {
return `
<div class="error-message">
<strong>Error:</strong> ${message}
</div>
`;
},
// Render empty state
emptyState(message) {
return `
<div class="empty-state">
<p>${message}</p>
</div>
`;
},
// Truncate text
truncate(text, length) {
if (text.length <= length) return text;
return text.substring(0, length) + '...';
},
// Render chat message
chatMessage(message, agent = null) {
const agentClass = agent === 'coach' ? 'message-coach' : agent === 'player' ? 'message-player' : '';
return `
<div class="chat-message ${agentClass}">
${agent ? `<div class="message-agent">${agent}</div>` : ''}
<div class="message-content">${marked.parse(message)}</div>
</div>
`;
},
// Render tool call
toolCall(toolCall) {
const statusIcon = toolCall.success ? '✓' : '✗';
const statusClass = toolCall.success ? 'success' : 'error';
return `
<div class="tool-call" data-tool-id="${toolCall.id}">
<div class="tool-header" onclick="this.parentElement.classList.toggle('expanded')">
<span class="tool-name">🔧 ${toolCall.tool_name}</span>
<div class="tool-header-right">
${toolCall.execution_time_ms ? `<span class="tool-time">${toolCall.execution_time_ms}ms</span>` : ''}
<span class="tool-status ${statusClass}">${statusIcon}</span>
</div>
</div>
<div class="tool-details">
<div class="tool-section">
<strong>Parameters:</strong>
<pre><code class="language-json">${JSON.stringify(toolCall.parameters, null, 2)}</code></pre>
</div>
${toolCall.result ? `
<div class="tool-section">
<strong>Result:</strong>
<pre><code class="language-json">${JSON.stringify(toolCall.result, null, 2)}</code></pre>
</div>
` : ''}
${toolCall.error ? `
<div class="tool-section">
<strong>Error:</strong>
<pre><code class="language-text">${this.escapeHtml(toolCall.error)}</code></pre>
</div>
` : ''}
<div class="tool-meta">
<span>Timestamp: ${new Date(toolCall.timestamp).toLocaleString()}</span>
${toolCall.execution_time_ms ? `<span> • Duration: ${toolCall.execution_time_ms}ms</span>` : ''}
<span> • Status: ${toolCall.success ? 'Success' : 'Failed'}</span>
</div>
</div>
</div>
`;
},
// Render git status section
gitStatus(gitStatus) {
if (!gitStatus) {
return '<p class="text-muted">No git repository detected</p>';
}
return `
<div class="git-status">
<div class="git-header">
<span class="git-branch">📍 ${gitStatus.branch}</span>
<span class="git-changes">${gitStatus.uncommitted_changes} uncommitted changes</span>
</div>
${gitStatus.uncommitted_changes > 0 ? `
<div class="git-files">
${gitStatus.modified_files.length > 0 ? `
<div class="git-file-group">
<strong class="file-status modified">Modified:</strong>
<ul>
${gitStatus.modified_files.map(f => `<li>${f}</li>`).join('')}
</ul>
</div>
` : ''}
${gitStatus.added_files.length > 0 ? `
<div class="git-file-group">
<strong class="file-status added">Added:</strong>
<ul>
${gitStatus.added_files.map(f => `<li>${f}</li>`).join('')}
</ul>
</div>
` : ''}
${gitStatus.deleted_files.length > 0 ? `
<div class="git-file-group">
<strong class="file-status deleted">Deleted:</strong>
<ul>
${gitStatus.deleted_files.map(f => `<li>${f}</li>`).join('')}
</ul>
</div>
` : ''}
</div>
` : ''}
</div>
`;
},
// Render project files section
projectFiles(projectFiles) {
if (!projectFiles || (!projectFiles.requirements && !projectFiles.readme && !projectFiles.agents)) {
return '<p class="text-muted">No project files found</p>';
}
let html = '<div class="project-files">';
if (projectFiles.requirements) {
html += `
<div class="project-file">
<div class="file-header" onclick="this.parentElement.classList.toggle('expanded')">
<span class="file-name">📄 requirements.md</span>
<span class="file-toggle">▼</span>
</div>
<div class="file-content">
<pre><code>${this.escapeHtml(projectFiles.requirements)}</code></pre>
</div>
</div>
`;
}
if (projectFiles.readme) {
html += `
<div class="project-file">
<div class="file-header" onclick="this.parentElement.classList.toggle('expanded')">
<span class="file-name">📄 README.md</span>
<span class="file-toggle">▼</span>
</div>
<div class="file-content">
<pre><code>${this.escapeHtml(projectFiles.readme)}</code></pre>
</div>
</div>
`;
}
if (projectFiles.agents) {
html += `
<div class="project-file">
<div class="file-header" onclick="this.parentElement.classList.toggle('expanded')">
<span class="file-name">📄 AGENTS.md</span>
<span class="file-toggle">▼</span>
</div>
<div class="file-content">
<pre><code>${this.escapeHtml(projectFiles.agents)}</code></pre>
</div>
</div>
`;
}
html += '</div>';
return html;
},
escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
};

1213
crates/g3-console/web/js/highlight.min.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,167 @@
// Simple client-side router
const router = {
currentRoute: '/',
init() {
// Handle browser back/forward
window.addEventListener('popstate', () => {
this.handleRoute(window.location.pathname);
});
// Handle initial route
this.handleRoute(window.location.pathname);
},
navigate(path) {
window.history.pushState({}, '', path);
this.handleRoute(path);
},
async handleRoute(path) {
this.currentRoute = path;
const container = document.getElementById('page-container');
if (path === '/' || path === '') {
await this.renderHome(container);
} else if (path.startsWith('/instance/')) {
const id = path.split('/')[2];
await this.renderDetail(container, id);
} else {
container.innerHTML = components.error('Page not found');
}
},
async renderHome(container) {
container.innerHTML = components.spinner('Loading instances...');
try {
const instances = await api.getInstances();
if (instances.length === 0) {
container.innerHTML = components.emptyState(
'No running instances. Click "+ New Run" to start one.'
);
return;
}
let html = '<div class="instances-list">';
for (const instance of instances) {
// Use stats from API response
const stats = instance.stats || { total_tokens: 0, tool_calls: 0, errors: 0, duration_secs: 0 };
html += components.instancePanel(instance, stats, instance.latest_message);
}
html += '</div>';
container.innerHTML = html;
// Auto-refresh every 5 seconds
setTimeout(() => {
if (this.currentRoute === '/') {
this.renderHome(container);
}
}, 5000);
} catch (error) {
container.innerHTML = components.error(error.message);
}
},
async renderDetail(container, id) {
container.innerHTML = components.spinner('Loading instance details...');
try {
const instance = await api.getInstance(id);
const logs = await api.getInstanceLogs(id);
// Build detail view HTML
let html = `
<div class="detail-view">
<div class="detail-header">
<button class="btn btn-secondary" onclick="router.navigate('/')">&larr; Back</button>
<h2>${instance.workspace}</h2>
${components.statusBadge(instance.status)}
</div>
<div class="detail-stats">
<div class="stat-card">
<div class="stat-label">Tokens</div>
<div class="stat-value">${(instance.stats?.total_tokens || 0).toLocaleString()}</div>
</div>
<div class="stat-card">
<div class="stat-label">Tool Calls</div>
<div class="stat-value">${instance.stats?.tool_calls || 0}</div>
</div>
<div class="stat-card">
<div class="stat-label">Errors</div>
<div class="stat-value">${instance.stats?.errors || 0}</div>
</div>
<div class="stat-card">
<div class="stat-label">Duration</div>
<div class="stat-value">${Math.round((instance.stats?.duration_secs || 0) / 60)}m</div>
</div>
</div>
<div class="detail-section">
<h3>Git Status</h3>
${components.gitStatus(instance.git_status)}
</div>
<div class="detail-section">
<h3>Project Files</h3>
${components.projectFiles(instance.project_files)}
</div>
<div class="detail-content">
<h3>Tool Calls</h3>
<div class="tool-calls-section">
`;
// Render tool calls
if (logs && logs.tool_calls && logs.tool_calls.length > 0) {
for (const toolCall of logs.tool_calls) {
html += components.toolCall(toolCall);
}
} else {
html += '<p class="text-muted">No tool calls yet</p>';
}
html += `
</div>
<h3>Chat History</h3>
<div class="chat-messages">
`;
// Render messages from logs
if (logs && logs.messages && logs.messages.length > 0) {
for (const msg of logs.messages) {
html += components.chatMessage(msg.content, msg.agent);
}
} else {
html += '<p class="text-muted">No messages yet</p>';
}
html += `
</div>
</div>
</div>
</div>
`;
container.innerHTML = html;
// Apply syntax highlighting
document.querySelectorAll('pre code').forEach((block) => {
hljs.highlightElement(block);
});
// Auto-refresh every 3 seconds
setTimeout(() => {
if (this.currentRoute === `/instance/${id}`) {
this.renderDetail(container, id);
}
}, 3000);
} catch (error) {
container.innerHTML = components.error(error.message);
}
}
};

View File

@@ -0,0 +1,51 @@
// State management for G3 Console
const state = {
theme: 'dark',
lastWorkspace: null,
g3BinaryPath: null,
lastProvider: 'databricks',
lastModel: 'databricks-claude-sonnet-4-5',
async load() {
try {
const data = await api.getState();
this.theme = data.theme || 'dark';
this.lastWorkspace = data.last_workspace;
this.g3BinaryPath = data.g3_binary_path;
this.lastProvider = data.last_provider || 'databricks';
this.lastModel = data.last_model || 'databricks-claude-sonnet-4-5';
return data;
} catch (error) {
console.error('Failed to load state:', error);
return null;
}
},
async save() {
try {
await api.saveState({
theme: this.theme,
last_workspace: this.lastWorkspace,
g3_binary_path: this.g3BinaryPath,
last_provider: this.lastProvider,
last_model: this.lastModel
});
} catch (error) {
console.error('Failed to save state:', error);
}
},
setTheme(theme) {
this.theme = theme;
document.body.className = theme;
this.save();
},
updateLaunchDefaults(workspace, provider, model, binaryPath) {
this.lastWorkspace = workspace;
this.lastProvider = provider;
this.lastModel = model;
if (binaryPath) this.g3BinaryPath = binaryPath;
this.save();
}
};

View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>G3 Console</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

View File

@@ -0,0 +1,42 @@
import React, { useState } from 'react'
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom'
import Home from './pages/Home'
import Detail from './pages/Detail'
function App() {
const [theme, setTheme] = useState('dark')
React.useEffect(() => {
if (theme === 'dark') {
document.documentElement.classList.add('dark')
} else {
document.documentElement.classList.remove('dark')
}
}, [theme])
return (
<Router>
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
<header className="bg-white dark:bg-gray-800 shadow">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4 flex justify-between items-center">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">G3 Console</h1>
<button
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
className="px-4 py-2 rounded-lg bg-gray-200 dark:bg-gray-700 text-gray-900 dark:text-white hover:bg-gray-300 dark:hover:bg-gray-600"
>
{theme === 'dark' ? '☀️' : '🌙'}
</button>
</div>
</header>
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<Routes>
<Route path="/" element={<Home />} />
<Route path="/instance/:id" element={<Detail />} />
</Routes>
</main>
</div>
</Router>
)
}
export default App

View File

@@ -0,0 +1,71 @@
import React from 'react'
import { marked } from 'marked'
import hljs from 'highlight.js'
import 'highlight.js/styles/github-dark.css'
import ToolCall from './ToolCall'
function ChatView({ messages, toolCalls }) {
const renderMessage = (message) => {
const html = marked(message.content)
return (
<div
key={message.id}
className={`p-4 rounded-lg mb-4 ${
message.agent === 'coach'
? 'bg-blue-50 dark:bg-blue-900/20 border-l-4 border-blue-500'
: message.agent === 'player'
? 'bg-gray-50 dark:bg-gray-800 border-l-4 border-gray-500'
: 'bg-white dark:bg-gray-700'
}`}
>
<div className="flex items-center gap-2 mb-2">
<span className="text-xs font-semibold text-gray-600 dark:text-gray-400">
{message.agent.toUpperCase()}
</span>
<span className="text-xs text-gray-500 dark:text-gray-500">
{new Date(message.timestamp).toLocaleTimeString()}
</span>
</div>
<div
className="markdown prose dark:prose-invert max-w-none"
dangerouslySetInnerHTML={{ __html: html }}
/>
</div>
)
}
React.useEffect(() => {
// Highlight code blocks after render
document.querySelectorAll('pre code').forEach((block) => {
hljs.highlightElement(block)
})
}, [messages])
if (messages.length === 0 && toolCalls.length === 0) {
return (
<div className="text-center text-gray-600 dark:text-gray-400 py-8">
No messages yet
</div>
)
}
return (
<div className="space-y-4 max-h-[600px] overflow-y-auto">
{messages.map(renderMessage)}
{toolCalls.length > 0 && (
<div className="mt-6">
<h4 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
Tool Calls
</h4>
{toolCalls.map((toolCall) => (
<ToolCall key={toolCall.id} toolCall={toolCall} />
))}
</div>
)}
</div>
)
}
export default ChatView

View File

@@ -0,0 +1,62 @@
import React from 'react'
function GitStatus({ status }) {
return (
<div>
<h4 className="font-semibold text-gray-900 dark:text-white mb-2">Git Status</h4>
<div className="space-y-2">
<div className="text-sm">
<span className="text-gray-600 dark:text-gray-400">Branch:</span>
<span className="ml-2 font-mono text-gray-900 dark:text-white">{status.branch}</span>
</div>
<div className="text-sm">
<span className="text-gray-600 dark:text-gray-400">Uncommitted changes:</span>
<span className="ml-2 font-semibold text-gray-900 dark:text-white">
{status.uncommitted_changes}
</span>
</div>
{status.modified_files.length > 0 && (
<div>
<div className="text-xs font-semibold text-yellow-600 dark:text-yellow-400 mb-1">
Modified ({status.modified_files.length})
</div>
<ul className="text-xs text-gray-700 dark:text-gray-300 space-y-1">
{status.modified_files.map((file, i) => (
<li key={i} className="font-mono"> {file}</li>
))}
</ul>
</div>
)}
{status.added_files.length > 0 && (
<div>
<div className="text-xs font-semibold text-green-600 dark:text-green-400 mb-1">
Added ({status.added_files.length})
</div>
<ul className="text-xs text-gray-700 dark:text-gray-300 space-y-1">
{status.added_files.map((file, i) => (
<li key={i} className="font-mono"> {file}</li>
))}
</ul>
</div>
)}
{status.deleted_files.length > 0 && (
<div>
<div className="text-xs font-semibold text-red-600 dark:text-red-400 mb-1">
Deleted ({status.deleted_files.length})
</div>
<ul className="text-xs text-gray-700 dark:text-gray-300 space-y-1">
{status.deleted_files.map((file, i) => (
<li key={i} className="font-mono"> {file}</li>
))}
</ul>
</div>
)}
</div>
</div>
)
}
export default GitStatus

View File

@@ -0,0 +1,99 @@
import React from 'react'
import StatusBadge from './StatusBadge'
import ProgressBar from './ProgressBar'
function InstancePanel({ instance, onClick, onKill, onRestart }) {
const { instance: inst, stats, latest_message } = instance
const handleKill = (e) => {
e.stopPropagation()
if (window.confirm('Are you sure you want to kill this instance?')) {
onKill()
}
}
const handleRestart = (e) => {
e.stopPropagation()
onRestart()
}
return (
<div
onClick={onClick}
className="hero-card p-6 cursor-pointer"
>
<div className="flex justify-between items-start mb-4">
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
{inst.workspace.split('/').pop() || 'Unknown'}
</h3>
<StatusBadge status={inst.status} />
<span className="text-sm text-gray-600 dark:text-gray-400">
{inst.instance_type === 'ensemble' ? 'Coach + Player' : 'Single Agent'}
</span>
</div>
<div className="text-sm text-gray-600 dark:text-gray-400">
PID: {inst.pid} | Started: {new Date(inst.start_time).toLocaleTimeString()}
</div>
</div>
<div className="flex gap-2">
{inst.status === 'running' && (
<button
onClick={handleKill}
className="hero-button hero-button-danger text-sm"
>
Kill
</button>
)}
{inst.status === 'terminated' && (
<button
onClick={handleRestart}
className="hero-button hero-button-secondary text-sm"
>
Restart
</button>
)}
</div>
</div>
<ProgressBar
instanceType={inst.instance_type}
durationSecs={stats.duration_secs}
/>
<div className="grid grid-cols-3 gap-4 mt-4">
<div>
<div className="text-xs text-gray-600 dark:text-gray-400">Tokens</div>
<div className="text-lg font-semibold text-gray-900 dark:text-white">
{stats.total_tokens.toLocaleString()}
</div>
</div>
<div>
<div className="text-xs text-gray-600 dark:text-gray-400">Tool Calls</div>
<div className="text-lg font-semibold text-gray-900 dark:text-white">
{stats.tool_calls}
</div>
</div>
<div>
<div className="text-xs text-gray-600 dark:text-gray-400">Errors</div>
<div className="text-lg font-semibold text-gray-900 dark:text-white">
{stats.errors}
</div>
</div>
</div>
{latest_message && (
<div className="mt-4 text-sm text-gray-600 dark:text-gray-400 truncate">
<strong>Latest:</strong> {latest_message}
</div>
)}
<div className="mt-2 text-xs text-gray-500 dark:text-gray-500">
{inst.workspace}
</div>
</div>
)
}
export default InstancePanel

View File

@@ -0,0 +1,179 @@
import React, { useState } from 'react'
function NewRunModal({ onClose, onLaunch }) {
const [prompt, setPrompt] = useState('')
const [workspace, setWorkspace] = useState('')
const [provider, setProvider] = useState('databricks')
const [model, setModel] = useState('databricks-claude-sonnet-4-5')
const [mode, setMode] = useState('single')
const [g3BinaryPath, setG3BinaryPath] = useState('')
const [loading, setLoading] = useState(false)
const handleSubmit = async (e) => {
e.preventDefault()
setLoading(true)
const request = {
prompt,
workspace,
provider,
model,
mode,
g3_binary_path: g3BinaryPath || null,
}
await onLaunch(request)
setLoading(false)
}
const isValid = prompt.trim() && workspace.trim()
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="hero-card p-6 max-w-2xl w-full max-h-[90vh] overflow-y-auto">
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">
New Run
</h2>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Initial Prompt *
</label>
<textarea
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
placeholder="Describe what you want g3 to build..."
className="hero-input"
rows={4}
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Workspace Directory *
</label>
<input
type="text"
value={workspace}
onChange={(e) => setWorkspace(e.target.value)}
placeholder="/path/to/workspace"
className="hero-input"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
G3 Binary Path (optional)
</label>
<input
type="text"
value={g3BinaryPath}
onChange={(e) => setG3BinaryPath(e.target.value)}
placeholder="g3 (default) or /path/to/g3"
className="hero-input"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Provider
</label>
<select
value={provider}
onChange={(e) => setProvider(e.target.value)}
className="hero-input"
>
<option value="databricks">Databricks</option>
<option value="anthropic">Anthropic</option>
<option value="local">Local</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Model
</label>
<select
value={model}
onChange={(e) => setModel(e.target.value)}
className="hero-input"
>
{provider === 'databricks' && (
<>
<option value="databricks-claude-sonnet-4-5">Claude Sonnet 4.5</option>
<option value="databricks-meta-llama-3-1-405b-instruct">Llama 3.1 405B</option>
</>
)}
{provider === 'anthropic' && (
<>
<option value="claude-3-5-sonnet-20241022">Claude 3.5 Sonnet</option>
<option value="claude-3-opus-20240229">Claude 3 Opus</option>
</>
)}
{provider === 'local' && (
<option value="local-model">Local Model</option>
)}
</select>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Execution Mode
</label>
<div className="space-y-2">
<label className="flex items-center">
<input
type="radio"
value="single"
checked={mode === 'single'}
onChange={(e) => setMode(e.target.value)}
className="mr-2"
/>
<span className="text-gray-700 dark:text-gray-300">
Single-shot (one agent, one task)
</span>
</label>
<label className="flex items-center">
<input
type="radio"
value="ensemble"
checked={mode === 'ensemble'}
onChange={(e) => setMode(e.target.value)}
className="mr-2"
/>
<span className="text-gray-700 dark:text-gray-300">
Coach + Player Ensemble (autonomous mode)
</span>
</label>
</div>
</div>
<div className="flex justify-end gap-2 pt-4">
<button
type="button"
onClick={onClose}
className="hero-button hero-button-secondary"
disabled={loading}
>
Cancel
</button>
<button
type="submit"
className="hero-button hero-button-primary"
disabled={!isValid || loading}
>
{loading ? 'Starting...' : 'Start'}
</button>
</div>
</form>
</div>
</div>
)
}
export default NewRunModal

View File

@@ -0,0 +1,34 @@
import React from 'react'
function ProgressBar({ instanceType, durationSecs }) {
const formatDuration = (secs) => {
const hours = Math.floor(secs / 3600)
const minutes = Math.floor((secs % 3600) / 60)
const seconds = secs % 60
if (hours > 0) {
return `${hours}h ${minutes}m ${seconds}s`
} else if (minutes > 0) {
return `${minutes}m ${seconds}s`
} else {
return `${seconds}s`
}
}
return (
<div className="space-y-2">
<div className="flex justify-between text-sm text-gray-600 dark:text-gray-400">
<span>Duration: {formatDuration(durationSecs)}</span>
{instanceType === 'single' && <span>Running...</span>}
</div>
<div className="hero-progress">
<div
className="hero-progress-bar"
style={{ width: '100%' }}
/>
</div>
</div>
)
}
export default ProgressBar

View File

@@ -0,0 +1,28 @@
import React from 'react'
function StatusBadge({ status }) {
const getStatusClass = () => {
switch (status) {
case 'running':
return 'hero-badge hero-badge-success'
case 'completed':
return 'hero-badge hero-badge-success'
case 'failed':
return 'hero-badge hero-badge-error'
case 'idle':
return 'hero-badge hero-badge-warning'
case 'terminated':
return 'hero-badge hero-badge-error'
default:
return 'hero-badge hero-badge-info'
}
}
return (
<span className={getStatusClass()}>
{status.toUpperCase()}
</span>
)
}
export default StatusBadge

View File

@@ -0,0 +1,70 @@
import React, { useState } from 'react'
function ToolCall({ toolCall }) {
const [expanded, setExpanded] = useState(false)
return (
<div className="bg-gray-100 dark:bg-gray-800 rounded-lg p-4 mb-3">
<div
className="flex justify-between items-center cursor-pointer"
onClick={() => setExpanded(!expanded)}
>
<div className="flex items-center gap-3">
<span className="font-mono text-sm font-semibold text-gray-900 dark:text-white">
{toolCall.tool_name}
</span>
{toolCall.success ? (
<span className="hero-badge hero-badge-success">SUCCESS</span>
) : (
<span className="hero-badge hero-badge-error">FAILED</span>
)}
{toolCall.execution_time_ms && (
<span className="text-xs text-gray-600 dark:text-gray-400">
{toolCall.execution_time_ms}ms
</span>
)}
</div>
<button className="text-gray-600 dark:text-gray-400">
{expanded ? '▼' : '▶'}
</button>
</div>
{expanded && (
<div className="mt-4 space-y-3">
<div>
<div className="text-xs font-semibold text-gray-600 dark:text-gray-400 mb-1">
Parameters
</div>
<pre className="text-xs bg-white dark:bg-gray-900 p-2 rounded overflow-x-auto">
{JSON.stringify(toolCall.parameters, null, 2)}
</pre>
</div>
{toolCall.result && (
<div>
<div className="text-xs font-semibold text-gray-600 dark:text-gray-400 mb-1">
Result
</div>
<pre className="text-xs bg-white dark:bg-gray-900 p-2 rounded overflow-x-auto">
{JSON.stringify(toolCall.result, null, 2)}
</pre>
</div>
)}
{toolCall.error && (
<div>
<div className="text-xs font-semibold text-red-600 dark:text-red-400 mb-1">
Error
</div>
<pre className="text-xs bg-red-50 dark:bg-red-900/20 p-2 rounded text-red-800 dark:text-red-200">
{toolCall.error}
</pre>
</div>
)}
</div>
)}
</div>
)
}
export default ToolCall

View File

@@ -0,0 +1,10 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import './styles/hero-ui.css'
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)

View File

@@ -0,0 +1,167 @@
import React, { useState, useEffect } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import StatusBadge from '../components/StatusBadge'
import ChatView from '../components/ChatView'
import GitStatus from '../components/GitStatus'
import ProgressBar from '../components/ProgressBar'
function Detail() {
const { id } = useParams()
const navigate = useNavigate()
const [instance, setInstance] = useState(null)
const [logs, setLogs] = useState({ messages: [], tool_calls: [] })
const [loading, setLoading] = useState(true)
const fetchInstance = async () => {
try {
const response = await fetch(`/api/instances/${id}`)
if (response.ok) {
const data = await response.json()
setInstance(data)
}
} catch (error) {
console.error('Failed to fetch instance:', error)
}
}
const fetchLogs = async () => {
try {
const response = await fetch(`/api/instances/${id}/logs`)
if (response.ok) {
const data = await response.json()
setLogs(data)
}
} catch (error) {
console.error('Failed to fetch logs:', error)
} finally {
setLoading(false)
}
}
useEffect(() => {
fetchInstance()
fetchLogs()
const interval = setInterval(() => {
fetchInstance()
fetchLogs()
}, 5000)
return () => clearInterval(interval)
}, [id])
if (loading || !instance) {
return (
<div className="flex justify-center items-center h-64">
<div className="text-gray-600 dark:text-gray-400">Loading instance details...</div>
</div>
)
}
return (
<div>
<button
onClick={() => navigate('/')}
className="mb-4 text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300"
>
Back to instances
</button>
{/* Summary Section */}
<div className="hero-card p-6 mb-6">
<div className="flex justify-between items-start mb-4">
<div>
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-2">
Instance {instance.instance.id}
</h2>
<div className="flex items-center gap-2">
<StatusBadge status={instance.instance.status} />
<span className="text-sm text-gray-600 dark:text-gray-400">
{instance.instance.instance_type === 'ensemble' ? 'Coach + Player' : 'Single Agent'}
</span>
</div>
</div>
</div>
<ProgressBar
instanceType={instance.instance.instance_type}
durationSecs={instance.stats.duration_secs}
/>
<div className="grid grid-cols-3 gap-4 mt-4">
<div>
<div className="text-sm text-gray-600 dark:text-gray-400">Tokens</div>
<div className="text-2xl font-bold text-gray-900 dark:text-white">
{instance.stats.total_tokens.toLocaleString()}
</div>
</div>
<div>
<div className="text-sm text-gray-600 dark:text-gray-400">Tool Calls</div>
<div className="text-2xl font-bold text-gray-900 dark:text-white">
{instance.stats.tool_calls}
</div>
</div>
<div>
<div className="text-sm text-gray-600 dark:text-gray-400">Errors</div>
<div className="text-2xl font-bold text-gray-900 dark:text-white">
{instance.stats.errors}
</div>
</div>
</div>
<div className="mt-4 text-sm text-gray-600 dark:text-gray-400">
<div><strong>Workspace:</strong> {instance.instance.workspace}</div>
<div><strong>Provider:</strong> {instance.instance.provider || 'N/A'}</div>
<div><strong>Model:</strong> {instance.instance.model || 'N/A'}</div>
<div><strong>Started:</strong> {new Date(instance.instance.start_time).toLocaleString()}</div>
</div>
</div>
{/* Project Context Section */}
<div className="hero-card p-6 mb-6">
<h3 className="text-xl font-bold text-gray-900 dark:text-white mb-4">Project Context</h3>
{/* Project Files */}
<div className="space-y-4">
{instance.project_files.requirements && (
<div>
<h4 className="font-semibold text-gray-900 dark:text-white mb-2">requirements.md</h4>
<pre className="text-sm text-gray-700 dark:text-gray-300 whitespace-pre-wrap">
{instance.project_files.requirements}
</pre>
</div>
)}
{instance.project_files.readme && (
<div>
<h4 className="font-semibold text-gray-900 dark:text-white mb-2">README.md</h4>
<pre className="text-sm text-gray-700 dark:text-gray-300 whitespace-pre-wrap">
{instance.project_files.readme}
</pre>
</div>
)}
{instance.project_files.agents && (
<div>
<h4 className="font-semibold text-gray-900 dark:text-white mb-2">AGENTS.md</h4>
<pre className="text-sm text-gray-700 dark:text-gray-300 whitespace-pre-wrap">
{instance.project_files.agents}
</pre>
</div>
)}
</div>
{/* Git Status */}
{instance.git_status && (
<div className="mt-6">
<GitStatus status={instance.git_status} />
</div>
)}
</div>
{/* Chat View Section */}
<div className="hero-card p-6">
<h3 className="text-xl font-bold text-gray-900 dark:text-white mb-4">Chat History</h3>
<ChatView messages={logs.messages} toolCalls={logs.tool_calls} />
</div>
</div>
)
}
export default Detail

View File

@@ -0,0 +1,132 @@
import React, { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import InstancePanel from '../components/InstancePanel'
import NewRunModal from '../components/NewRunModal'
function Home() {
const [instances, setInstances] = useState([])
const [loading, setLoading] = useState(true)
const [showModal, setShowModal] = useState(false)
const navigate = useNavigate()
const fetchInstances = async () => {
try {
const response = await fetch('/api/instances')
if (response.ok) {
const data = await response.json()
setInstances(data)
}
} catch (error) {
console.error('Failed to fetch instances:', error)
} finally {
setLoading(false)
}
}
useEffect(() => {
fetchInstances()
const interval = setInterval(fetchInstances, 5000) // Poll every 5 seconds
return () => clearInterval(interval)
}, [])
const handleInstanceClick = (id) => {
navigate(`/instance/${id}`)
}
const handleKill = async (id) => {
try {
const response = await fetch(`/api/instances/${id}/kill`, {
method: 'POST',
})
if (response.ok) {
fetchInstances()
}
} catch (error) {
console.error('Failed to kill instance:', error)
}
}
const handleRestart = async (id) => {
try {
const response = await fetch(`/api/instances/${id}/restart`, {
method: 'POST',
})
if (response.ok) {
fetchInstances()
}
} catch (error) {
console.error('Failed to restart instance:', error)
}
}
const handleLaunch = async (request) => {
try {
const response = await fetch('/api/instances/launch', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(request),
})
if (response.ok) {
setShowModal(false)
setTimeout(fetchInstances, 2000) // Refresh after 2 seconds
}
} catch (error) {
console.error('Failed to launch instance:', error)
}
}
if (loading) {
return (
<div className="flex justify-center items-center h-64">
<div className="text-gray-600 dark:text-gray-400">Loading instances...</div>
</div>
)
}
return (
<div>
<div className="flex justify-between items-center mb-6">
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">
Running Instances ({instances.length})
</h2>
<button
onClick={() => setShowModal(true)}
className="hero-button hero-button-primary"
>
+ New Run
</button>
</div>
{instances.length === 0 ? (
<div className="hero-card p-8 text-center">
<p className="text-gray-600 dark:text-gray-400">
No running instances. Click "New Run" to start a g3 instance.
</p>
</div>
) : (
<div className="space-y-4">
{instances.map((instance) => (
<InstancePanel
key={instance.instance.id}
instance={instance}
onClick={() => handleInstanceClick(instance.instance.id)}
onKill={() => handleKill(instance.instance.id)}
onRestart={() => handleRestart(instance.instance.id)}
/>
))}
</div>
)}
{showModal && (
<NewRunModal
onClose={() => setShowModal(false)}
onLaunch={handleLaunch}
/>
)}
</div>
)
}
export default Home

View File

@@ -0,0 +1,113 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* Hero UI inspired styles */
.hero-card {
@apply bg-white dark:bg-gray-800 rounded-lg shadow-md hover:shadow-lg transition-shadow duration-200;
}
.hero-button {
@apply px-4 py-2 rounded-lg font-medium transition-colors duration-200;
}
.hero-button-primary {
@apply bg-blue-600 text-white hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600;
}
.hero-button-secondary {
@apply bg-gray-200 text-gray-900 hover:bg-gray-300 dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600;
}
.hero-button-danger {
@apply bg-red-600 text-white hover:bg-red-700 dark:bg-red-500 dark:hover:bg-red-600;
}
.hero-badge {
@apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium;
}
.hero-badge-success {
@apply bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200;
}
.hero-badge-error {
@apply bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200;
}
.hero-badge-warning {
@apply bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200;
}
.hero-badge-info {
@apply bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200;
}
.hero-input {
@apply w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white;
}
.hero-progress {
@apply w-full bg-gray-200 rounded-full h-2.5 dark:bg-gray-700;
}
.hero-progress-bar {
@apply bg-blue-600 h-2.5 rounded-full transition-all duration-300;
}
/* Code highlighting */
pre {
@apply bg-gray-100 dark:bg-gray-800 rounded-lg p-4 overflow-x-auto;
}
code {
@apply font-mono text-sm;
}
/* Markdown styles */
.markdown {
@apply prose dark:prose-invert max-w-none;
}
.markdown h1 {
@apply text-2xl font-bold mb-4;
}
.markdown h2 {
@apply text-xl font-bold mb-3;
}
.markdown h3 {
@apply text-lg font-bold mb-2;
}
.markdown p {
@apply mb-4;
}
.markdown ul {
@apply list-disc list-inside mb-4;
}
.markdown ol {
@apply list-decimal list-inside mb-4;
}
.markdown a {
@apply text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300;
}

View File

@@ -0,0 +1,822 @@
/* G3 Console Styles - Hero UI inspired */
:root {
--primary: #3b82f6;
--primary-hover: #2563eb;
--success: #10b981;
--warning: #f59e0b;
--error: #ef4444;
--neutral: #6b7280;
/* Light theme */
--bg-primary: #ffffff;
--bg-secondary: #f9fafb;
--bg-tertiary: #f3f4f6;
--text-primary: #111827;
--text-secondary: #6b7280;
--border: #e5e7eb;
--shadow: rgba(0, 0, 0, 0.1);
}
.dark {
--bg-primary: #111827;
--bg-secondary: #1f2937;
--bg-tertiary: #374151;
--text-primary: #f9fafb;
--text-secondary: #9ca3af;
--border: #374151;
--shadow: rgba(0, 0, 0, 0.3);
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background-color: var(--bg-secondary);
color: var(--text-primary);
line-height: 1.6;
}
/* Header */
.header {
background-color: var(--bg-primary);
border-bottom: 1px solid var(--border);
box-shadow: 0 1px 3px var(--shadow);
}
.header-content {
max-width: 1400px;
margin: 0 auto;
padding: 1rem 2rem;
display: flex;
justify-content: space-between;
align-items: center;
}
.header-title {
font-size: 1.5rem;
font-weight: 700;
color: var(--text-primary);
}
.header-actions {
display: flex;
gap: 1rem;
}
/* Main Content */
.main-content {
max-width: 1400px;
margin: 0 auto;
padding: 2rem;
}
/* Buttons */
.btn {
padding: 0.5rem 1rem;
border: none;
border-radius: 0.5rem;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-primary {
background-color: var(--primary);
color: white;
}
.btn-primary:hover:not(:disabled) {
background-color: var(--primary-hover);
}
.btn-secondary {
background-color: var(--bg-tertiary);
color: var(--text-primary);
}
.btn-secondary:hover:not(:disabled) {
background-color: var(--border);
}
.btn-danger {
background-color: var(--error);
color: white;
}
.btn-danger:hover:not(:disabled) {
background-color: #dc2626;
}
.btn-success {
background-color: var(--success);
color: white;
}
.btn-sm {
padding: 0.375rem 0.75rem;
font-size: 0.8125rem;
}
/* Badges */
.badge {
display: inline-block;
padding: 0.25rem 0.75rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
}
.badge-success {
background-color: rgba(16, 185, 129, 0.1);
color: var(--success);
}
.badge-warning {
background-color: rgba(245, 158, 11, 0.1);
color: var(--warning);
}
.badge-error {
background-color: rgba(239, 68, 68, 0.1);
color: var(--error);
}
.badge-neutral {
background-color: rgba(107, 114, 128, 0.1);
color: var(--neutral);
}
/* Instance Panel */
.instances-list {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.instance-panel {
background-color: var(--bg-primary);
border: 1px solid var(--border);
border-radius: 0.75rem;
padding: 1.5rem;
box-shadow: 0 1px 3px var(--shadow);
transition: all 0.2s;
cursor: pointer;
}
.instance-panel:hover {
box-shadow: 0 4px 6px var(--shadow);
transform: translateY(-2px);
}
.panel-header {
margin-bottom: 1rem;
}
.panel-title {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 0.5rem;
}
.panel-title h3 {
font-size: 1.125rem;
font-weight: 600;
color: var(--text-primary);
}
.panel-meta {
display: flex;
gap: 1rem;
flex-wrap: wrap;
}
.meta-item {
font-size: 0.875rem;
color: var(--text-secondary);
}
/* Progress Bar */
.progress-bar {
position: relative;
height: 2rem;
background-color: var(--bg-tertiary);
border-radius: 0.5rem;
overflow: hidden;
margin-bottom: 1rem;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, var(--primary), var(--primary-hover));
transition: width 0.3s;
}
/* Ensemble progress bar with segments */
.progress-bar.ensemble {
display: flex;
position: relative;
}
.progress-segment {
height: 100%;
transition: width 0.3s;
cursor: help;
}
.progress-segment:not(:last-child) {
border-right: 2px solid var(--bg-primary);
}
.progress-bar.ensemble .progress-text {
position: absolute;
z-index: 10;
}
.progress-text {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: 0.875rem;
font-weight: 600;
color: var(--text-primary);
}
/* Stats */
.panel-stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: 1rem;
margin-bottom: 1rem;
}
.stat-item {
display: flex;
flex-direction: column;
}
.stat-label {
font-size: 0.75rem;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.stat-value {
font-size: 1.5rem;
font-weight: 700;
color: var(--text-primary);
}
.panel-message {
padding: 0.75rem;
background-color: var(--bg-secondary);
border-radius: 0.5rem;
font-size: 0.875rem;
color: var(--text-secondary);
margin-bottom: 1rem;
}
.panel-actions {
display: flex;
gap: 0.5rem;
justify-content: flex-end;
}
/* Modal */
.modal {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
}
.modal.hidden {
display: none;
}
.modal-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
}
.modal-content {
position: relative;
background-color: var(--bg-primary);
border-radius: 1rem;
max-width: 600px;
width: 90%;
max-height: 90vh;
overflow-y: auto;
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.3);
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.5rem;
border-bottom: 1px solid var(--border);
}
.modal-header h2 {
font-size: 1.25rem;
font-weight: 600;
}
.modal-close {
background: none;
border: none;
font-size: 2rem;
color: var(--text-secondary);
cursor: pointer;
line-height: 1;
}
.modal-close:hover {
color: var(--text-primary);
}
.modal-body {
padding: 1.5rem;
}
.modal-footer {
display: flex;
gap: 0.75rem;
justify-content: flex-end;
margin-top: 1.5rem;
}
/* Form */
.form-group {
margin-bottom: 1.25rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-size: 0.875rem;
font-weight: 500;
color: var(--text-primary);
}
.form-group input,
.form-group textarea,
.form-group select {
width: 100%;
padding: 0.625rem;
border: 1px solid var(--border);
border-radius: 0.5rem;
background-color: var(--bg-secondary);
color: var(--text-primary);
font-size: 0.875rem;
}
.form-group input:focus,
.form-group textarea:focus,
.form-group select:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.input-with-button {
display: flex;
gap: 0.5rem;
}
.input-with-button input {
flex: 1;
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.radio-group {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.radio-label {
display: flex;
align-items: flex-start;
gap: 0.75rem;
padding: 0.75rem;
border: 1px solid var(--border);
border-radius: 0.5rem;
cursor: pointer;
transition: all 0.2s;
}
.radio-label:hover {
background-color: var(--bg-secondary);
}
.radio-label input[type="radio"] {
margin-top: 0.25rem;
}
.radio-label span {
display: block;
font-weight: 500;
}
.radio-label small {
display: block;
color: var(--text-secondary);
font-size: 0.8125rem;
margin-top: 0.25rem;
}
/* Spinner */
.spinner-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 3rem;
}
.spinner {
width: 3rem;
height: 3rem;
border: 3px solid var(--border);
border-top-color: var(--primary);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* Error & Empty States */
.error-message,
.empty-state {
padding: 2rem;
text-align: center;
color: var(--text-secondary);
}
.error-message {
color: var(--error);
}
/* Detail View */
.detail-view {
background-color: var(--bg-primary);
border-radius: 0.75rem;
padding: 1.5rem;
}
.detail-header {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 1.5rem;
padding-bottom: 1rem;
border-bottom: 1px solid var(--border);
}
.detail-header h2 {
flex: 1;
font-size: 1.5rem;
font-weight: 600;
}
.detail-stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 1rem;
margin-bottom: 2rem;
}
.stat-card {
background-color: var(--bg-secondary);
padding: 1rem;
border-radius: 0.5rem;
text-align: center;
}
.stat-card .stat-label {
font-size: 0.75rem;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 0.5rem;
}
.stat-card .stat-value {
font-size: 2rem;
font-weight: 700;
color: var(--text-primary);
}
/* Chat View */
.chat-view {
margin-top: 2rem;
}
.chat-view h3 {
font-size: 1.25rem;
font-weight: 600;
margin-bottom: 1rem;
}
.chat-messages {
display: flex;
flex-direction: column;
gap: 1rem;
}
.chat-message {
padding: 1rem;
background-color: var(--bg-secondary);
border-radius: 0.5rem;
border-left: 3px solid var(--neutral);
}
.chat-message.message-coach {
border-left-color: var(--primary);
}
.chat-message.message-player {
border-left-color: var(--neutral);
}
.message-agent {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
color: var(--text-secondary);
margin-bottom: 0.5rem;
}
.message-content {
color: var(--text-primary);
}
.message-content pre {
background-color: var(--bg-tertiary);
padding: 1rem;
border-radius: 0.5rem;
overflow-x: auto;
margin: 0.5rem 0;
}
.message-content code {
font-family: 'Monaco', 'Courier New', monospace;
font-size: 0.875rem;
}
/* Tool Call */
.tool-call {
background-color: var(--bg-tertiary);
border-radius: 0.5rem;
margin: 0.5rem 0;
overflow: hidden;
}
.tool-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem 1rem;
cursor: pointer;
background-color: var(--bg-secondary);
}
.tool-header:hover {
background-color: var(--bg-tertiary);
}
.tool-name {
font-family: 'Monaco', 'Courier New', monospace;
font-size: 0.875rem;
font-weight: 600;
}
.tool-status {
font-size: 1rem;
}
.tool-status.success {
color: var(--success);
}
.tool-status.error {
color: var(--error);
}
.tool-details {
display: none;
padding: 1rem;
}
.tool-call.expanded .tool-details {
display: block;
}
.tool-section {
margin-bottom: 1rem;
}
.tool-section strong {
display: block;
margin-bottom: 0.5rem;
font-size: 0.875rem;
}
.tool-section pre {
background-color: var(--bg-primary);
padding: 0.75rem;
border-radius: 0.375rem;
overflow-x: auto;
}
.tool-meta {
font-size: 0.8125rem;
color: var(--text-secondary);
}
.text-muted {
color: var(--text-secondary);
}
/* Git Status */
.git-status {
background-color: var(--bg-secondary);
border-radius: 0.5rem;
padding: 1rem;
}
.git-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.75rem;
}
.git-branch {
font-weight: 600;
color: var(--text-primary);
}
.git-changes {
font-size: 0.875rem;
color: var(--text-secondary);
}
.git-files {
margin-top: 1rem;
}
.git-file-group {
margin-bottom: 0.75rem;
}
.file-status {
display: block;
font-size: 0.875rem;
margin-bottom: 0.5rem;
}
.file-status.modified {
color: var(--warning);
}
.file-status.added {
color: var(--success);
}
.file-status.deleted {
color: var(--error);
}
.git-file-group ul {
list-style: none;
padding-left: 1rem;
}
.git-file-group li {
font-size: 0.875rem;
color: var(--text-secondary);
font-family: 'Monaco', 'Courier New', monospace;
}
/* Project Files */
.project-files {
display: flex;
flex-direction: column;
gap: 1rem;
}
.project-file {
background-color: var(--bg-secondary);
border-radius: 0.5rem;
overflow: hidden;
}
.project-file .file-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem 1rem;
cursor: pointer;
background-color: var(--bg-tertiary);
transition: background-color 0.2s;
}
.project-file .file-header:hover {
background-color: var(--border);
}
.file-name {
font-weight: 600;
font-size: 0.875rem;
}
.file-toggle {
transition: transform 0.2s;
}
.project-file.expanded .file-toggle {
transform: rotate(180deg);
}
.project-file .file-content {
display: none;
padding: 1rem;
max-height: 300px;
overflow-y: auto;
}
.project-file.expanded .file-content {
display: block;
}
.project-file .file-content pre {
margin: 0;
background-color: var(--bg-primary);
padding: 0.75rem;
border-radius: 0.375rem;
font-size: 0.8125rem;
}
/* Detail sections */
.detail-section {
margin-bottom: 2rem;
}
.detail-section h3 {
font-size: 1.25rem;
font-weight: 600;
margin-bottom: 1rem;
}
/* Tool calls section */
.tool-calls-section {
display: flex;
flex-direction: column;
gap: 0.75rem;
margin-bottom: 2rem;
}
.tool-header-right {
display: flex;
align-items: center;
gap: 0.75rem;
}
.tool-time {
font-size: 0.8125rem;
color: var(--text-secondary);
font-family: 'Monaco', 'Courier New', monospace;
}