Files
OpsPilot/frontend/dashboard.html
T
wushenghuaandClaude Opus 4.7 4d68eccedc refactor: show tasks instead of projects in sidebar
The left sidebar now lists individual tasks (sorted by creation time)
instead of project cards. Each task shows the task title prominently
with the project name as subtitle, along with step progress and
session status. Selecting a task shows its step timeline in the
center panel.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 18:23:40 +08:00

933 lines
44 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>OpsPilot - Task Control Center</title>
<style>
:root {
--bg: #f6f8fb; --bg-card: #ffffff; --bg-hover: #f0f2f6;
--border: #e2e6ed; --border-light: #eef0f4;
--text: #1a1d2e; --text-secondary: #6b7185; --text-muted: #9ba0b2;
--accent: #4f6ef7; --accent-light: #eef1fd;
--green: #22c55e; --green-bg: #e6f7ed;
--amber: #f59e0b; --amber-bg: #fffbeb; --amber-border: #fcd34d;
--red: #ef4444; --red-bg: #fde8e8;
--purple: #8b5cf6; --purple-bg: #f3f0ff;
--terminal-bg: #0d1117; --terminal-text: #c9d1d9;
--radius: 8px; --radius-sm: 5px;
--shadow: 0 1px 3px rgba(0,0,0,0.04);
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; background: var(--bg); color: var(--text); height: 100vh; display: flex; flex-direction: column; }
/* Header */
.header { background: var(--bg-card); border-bottom: 1px solid var(--border); padding: 0 20px; height: 48px; display: flex; align-items: center; justify-content: space-between; flex-shrink: 0; }
.header-left { display: flex; align-items: center; gap: 12px; }
.logo { font-weight: 700; font-size: 15px; letter-spacing: -0.3px; }
.logo span { color: var(--accent); }
.dot { width: 7px; height: 7px; border-radius: 50%; display: inline-block; }
.dot.live { background: var(--green); animation: pulse 2s infinite; }
.dot.dead { background: var(--red); animation: none; }
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:0.4} }
.header-right { display: flex; align-items: center; gap: 16px; font-size: 11px; color: var(--text-secondary); }
.header-right strong { color: var(--text); }
/* Main 3-column layout with resizable panels */
.main { display: flex; flex: 1; overflow: hidden; }
.col { display: flex; flex-direction: column; overflow: hidden; }
.col-left { width: 240px; flex-shrink: 0; }
.col-center { flex: 1; min-width: 200px; }
.col-right { width: 420px; min-width: 300px; flex-shrink: 0; }
/* Draggable splitter */
.splitter {
width: 5px; background: var(--border); cursor: col-resize; flex-shrink: 0;
position: relative; transition: background 0.15s;
}
.splitter:hover, .splitter.dragging { background: var(--accent); }
.splitter::after {
content: ''; position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);
width: 3px; height: 24px; border-radius: 2px; background: transparent; transition: background 0.15s;
}
.splitter:hover::after, .splitter.dragging::after { background: rgba(255,255,255,0.6); }
.col-header { padding: 12px 16px; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-muted); border-bottom: 1px solid var(--border-light); flex-shrink: 0; display: flex; justify-content: space-between; align-items: center; }
.col-body { flex: 1; overflow-y: auto; padding: 8px; }
/*── Left: Projects ──*/
.project-item {
padding: 10px 12px; border-radius: var(--radius-sm); cursor: pointer;
border: 1px solid transparent; margin-bottom: 4px; transition: all 0.15s;
}
.project-item:hover { background: var(--bg-hover); border-color: var(--border); }
.project-item.active { background: var(--accent-light); border-color: var(--accent); }
.project-item .pname { font-weight: 600; font-size: 12px; }
.project-item .pmeta { font-size: 10px; color: var(--text-muted); margin-top: 2px; }
.project-item .ptask { font-size: 10px; color: var(--accent); margin-top: 1px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.project-item.needs-attention { border-color: var(--amber); box-shadow: 0 0 0 2px rgba(245,158,11,0.2); animation: attentionPulse 2s infinite; }
@keyframes attentionPulse { 0%,100% { box-shadow: 0 0 0 2px rgba(245,158,11,0.2); } 50% { box-shadow: 0 0 0 4px rgba(245,158,11,0.35); } }
.status-tag { display: inline-block; font-size: 9px; padding: 1px 6px; border-radius: 8px; font-weight: 500; }
.tag-running { background: var(--accent-light); color: var(--accent); }
.tag-completed { background: var(--green-bg); color: var(--green); }
.tag-queued { background: #f3f4f6; color: var(--text-muted); }
.tag-warning { background: var(--amber-bg); color: var(--amber); }
/*── Center: Task Steps ──*/
.task-empty { text-align: center; padding: 60px 20px; color: var(--text-muted); }
.task-empty h3 { font-size: 16px; margin-bottom: 8px; color: var(--text-secondary); }
.task-empty p { font-size: 12px; line-height: 1.6; }
.task-header { padding: 4px 0 12px; border-bottom: 1px solid var(--border-light); margin-bottom: 8px; }
.task-title { font-size: 15px; font-weight: 600; }
.task-status-line { font-size: 11px; color: var(--text-muted); margin-top: 4px; }
.step-list { display: flex; flex-direction: column; gap: 0; }
.step-item { display: flex; align-items: flex-start; gap: 12px; padding: 12px 8px; position: relative; }
.step-item:not(:last-child)::before {
content: ''; position: absolute; left: 19px; top: 36px; bottom: -4px;
width: 2px; background: var(--border);
}
.step-item.completed:not(:last-child)::before { background: var(--green); }
.step-item.running:not(:last-child)::before { background: var(--accent); }
.step-icon {
width: 28px; height: 28px; border-radius: 50%; display: flex; align-items: center;
justify-content: center; font-size: 12px; flex-shrink: 0; position: relative; z-index: 1;
border: 2px solid var(--border); background: var(--bg-card); color: var(--text-muted);
}
.step-item.completed .step-icon { border-color: var(--green); background: var(--green-bg); color: var(--green); }
.step-item.running .step-icon { border-color: var(--accent); background: var(--accent-light); color: var(--accent); animation: stepPulse 1.5s infinite; }
.step-item.failed .step-icon { border-color: var(--red); background: var(--red-bg); color: var(--red); }
@keyframes stepPulse { 0%,100% { box-shadow: 0 0 0 0 rgba(79,110,247,0.3) } 50% { box-shadow: 0 0 0 6px rgba(79,110,247,0) } }
.step-info { flex: 1; min-width: 0; }
.step-name { font-size: 12px; font-weight: 500; }
.step-item.completed .step-name { color: var(--text-secondary); text-decoration: line-through; text-decoration-color: var(--green); }
.step-item.running .step-name { color: var(--accent); font-weight: 600; }
.step-duration { font-size: 10px; color: var(--text-muted); margin-top: 2px; }
.step-item.running .step-duration { color: var(--accent); }
.step-running-indicator { display: inline-block; width: 6px; height: 6px; border-radius: 50%; background: var(--accent); margin-left: 6px; animation: pulse 1.5s infinite; }
/*── Right: Terminal + Actions ──*/
.terminal-wrap { flex: 1; display: flex; flex-direction: column; min-height: 0; }
.terminal-box {
flex: 1; background: var(--terminal-bg); color: var(--terminal-text);
font-family: 'SF Mono', 'Menlo', monospace; font-size: 11px; line-height: 1.45;
padding: 10px; white-space: pre-wrap; overflow-y: auto; border-radius: var(--radius-sm);
min-height: 150px;
}
.prompt-bar {
background: var(--amber-bg); border: 1px solid var(--amber-border); border-radius: var(--radius-sm);
padding: 10px 14px; margin-bottom: 8px; animation: flashBorder 2s ease-in-out;
}
@keyframes flashBorder { 0%,100%{border-color:var(--amber-border)} 50%{border-color:var(--amber)} }
.prompt-question { font-size: 11px; font-weight: 600; color: #92400e; margin-bottom: 6px; }
.prompt-btns { display: flex; gap: 6px; flex-wrap: wrap; }
.prompt-btn {
padding: 5px 14px; border-radius: 4px; border: none; font-size: 11px; font-weight: 600;
font-family: inherit; cursor: pointer; transition: all 0.15s;
}
.pbtn-yes { background: var(--green); color: #fff; }
.pbtn-allow { background: var(--purple); color: #fff; }
.pbtn-no { background: var(--red); color: #fff; }
.pbtn-esc { background: #e5e7eb; color: var(--text-secondary); }
.action-row { display: flex; gap: 6px; margin-top: 8px; flex-wrap: wrap; }
.act-btn {
padding: 5px 12px; border: 1px solid var(--border); border-radius: 4px;
background: var(--bg-card); color: var(--text-secondary); cursor: pointer;
font-size: 11px; font-family: inherit; transition: all 0.15s;
}
.act-btn:hover { border-color: var(--accent); color: var(--accent); background: var(--accent-light); }
.act-btn.primary { background: var(--accent); color: #fff; border-color: var(--accent); }
.act-btn.primary:hover { background: #3d5de7; }
.task-input-row { display: flex; gap: 6px; margin-top: 8px; }
.task-input-row input {
flex: 1; padding: 6px 10px; border: 1px solid var(--border); border-radius: 4px;
font-size: 11px; font-family: inherit; background: var(--bg); color: var(--text);
}
.task-input-row input:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 2px var(--accent-light); }
/* Toast */
.toast { position: fixed; bottom: 24px; right: 24px; background: var(--text); color: #fff; padding: 10px 18px; border-radius: var(--radius-sm); font-size: 12px; z-index: 300; animation: slideIn 0.2s ease-out; }
@keyframes slideIn { from { transform: translateY(10px); opacity: 0; } to { transform: translateY(0); opacity: 1; } }
/* Scrollbar */
::-webkit-scrollbar { width: 4px; }
::-webkit-scrollbar-thumb { background: #d1d5db; border-radius: 2px; }
</style>
</head>
<body>
<div class="header">
<div class="header-left">
<div class="logo"><span>OpsPilot</span></div>
<span><span class="dot live" id="live-dot"></span> <span id="conn-text" style="font-size:11px;color:var(--text-muted);">Connecting...</span></span>
<span id="update-banner" style="display:none;font-size:10px;background:#fef3c7;color:#92400e;padding:3px 10px;border-radius:4px;cursor:pointer;" onclick="runGitPull()" title="Click to git pull">
⬆ Update available · <span id="update-count"></span> commits behind
</span>
</div>
<div class="header-right">
<span>🖥 <strong id="stat-agents">0</strong> Agents</span>
<span>📁 <strong id="stat-projects">0</strong> Projects</span>
<span>📋 <strong id="stat-tasks">0</strong> Tasks</span>
<span id="stat-time">--:--:--</span>
</div>
</div>
<div class="main">
<!-- LEFT: Projects -->
<div class="col col-left">
<div class="col-header">
📁 Projects
<button class="act-btn" style="font-size:10px;padding:3px 8px;" onclick="showNewProject()">+ New</button>
</div>
<div class="col-body" id="project-list">
<div style="text-align:center;padding:40px 16px;color:var(--text-muted);font-size:12px;">暂无项目</div>
</div>
</div>
<div class="splitter" id="splitter-1"></div>
<!-- CENTER: Task Steps -->
<div class="col col-center">
<div class="col-header">
📋 Task Progress
<div style="display:flex;align-items:center;gap:8px;">
<span style="font-size:10px;font-weight:400;color:var(--text-muted);" id="task-badge"></span>
<button id="btn-close-task" onclick="deselectProject()" style="display:none;background:none;border:1px solid var(--border);border-radius:4px;cursor:pointer;font-size:14px;padding:0 6px;color:var(--text-muted);line-height:20px;" title="关闭"></button>
</div>
</div>
<div class="col-body" id="task-detail">
<div class="task-empty">
<h3>选择一个项目</h3>
<p>在左侧项目列表中点击一个项目,<br>查看任务执行进度和步骤详情。</p>
</div>
</div>
</div>
<div class="splitter" id="splitter-2"></div>
<!-- RIGHT: Terminal + Controls -->
<div class="col col-right">
<div class="col-header">
🖥 Terminal & Controls
<span style="font-size:10px;font-weight:400;color:var(--text-muted);" id="term-label"></span>
</div>
<div class="col-body" style="display:flex;flex-direction:column;">
<div id="prompt-area"></div>
<div class="terminal-box" id="terminal">选择项目后显示终端输出...</div>
<div class="action-row" id="action-bar">
<button class="act-btn" onclick="confirmAgent('escape')">Esc</button>
<button class="act-btn" onclick="confirmAgent('enter')">↵ 回车</button>
<button class="act-btn" onclick="refreshTerminal()">↻ 刷新终端</button>
<span id="confirm-btns" style="display:none;display:flex;gap:6px;">
<button class="act-btn" onclick="confirmAgent('yes')" style="background:var(--green);color:#fff;border-color:var(--green);">✅ 确认(1)</button>
<button class="act-btn" onclick="confirmAgent('yes_allow_all')" style="background:var(--purple);color:#fff;border-color:var(--purple);">🔓 全部允许(2)</button>
</span>
</div>
<div class="task-input-row">
<input type="text" id="agent-prompt" placeholder="输入指令发送给 Claude Code..."
onkeydown="if(event.key==='Enter')sendPrompt()">
<button class="act-btn primary" onclick="sendPrompt()">发送</button>
</div>
</div>
</div>
</div>
<!-- New Task Modal -->
<div id="task-modal-overlay" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.3);z-index:200;align-items:center;justify-content:center;" onclick="if(event.target===this)closeTaskModal()">
<div style="background:var(--bg-card);border-radius:12px;padding:24px;width:500px;box-shadow:0 20px 60px rgba(0,0,0,0.15);">
<h3 style="font-size:16px;margin-bottom:12px;" id="task-modal-title">派发新任务</h3>
<div style="margin-bottom:12px;">
<label style="font-size:11px;color:var(--text-muted);">任务描述(Claude Code 会自动拆分为步骤执行)</label>
<textarea id="new-task-desc-input" rows="4" placeholder="例如:在现有项目里加一个用户登录功能,支持邮箱和密码注册..." style="width:100%;padding:10px;border:1px solid var(--border);border-radius:6px;font-size:13px;font-family:inherit;margin-top:4px;resize:vertical;"></textarea>
</div>
<div style="display:flex;gap:8px;justify-content:flex-end;">
<button class="act-btn" onclick="closeTaskModal()">取消</button>
<button class="act-btn primary" onclick="submitNewTask()">派发任务</button>
</div>
</div>
</div>
<!-- New Project Modal -->
<div id="modal-overlay" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.3);z-index:200;align-items:center;justify-content:center;">
<div style="background:var(--bg-card);border-radius:12px;padding:24px;width:440px;box-shadow:0 20px 60px rgba(0,0,0,0.15);">
<h3 style="font-size:16px;margin-bottom:16px;">新建项目 + 派发任务</h3>
<div style="margin-bottom:10px;">
<label style="font-size:11px;color:var(--text-muted);">项目名称</label>
<input type="text" id="new-proj-name" placeholder="例如:Odineye" style="width:100%;padding:8px;border:1px solid var(--border);border-radius:4px;font-size:13px;font-family:inherit;margin-top:4px;">
</div>
<div style="margin-bottom:10px;">
<label style="font-size:11px;color:var(--text-muted);">项目路径
<span style="color:var(--accent);cursor:pointer;float:right;" onclick="browsePath('')">📂 浏览目录</span>
</label>
<div id="path-breadcrumb" style="font-size:10px;color:var(--text-muted);margin:4px 0;min-height:16px;"></div>
<div style="display:flex;gap:6px;align-items:center;">
<input type="text" id="new-proj-path" placeholder="选择或输入路径..." style="flex:1;padding:8px;border:1px solid var(--border);border-radius:4px;font-size:12px;font-family:inherit;">
<label style="font-size:10px;color:var(--text-muted);white-space:nowrap;display:flex;align-items:center;gap:3px;cursor:pointer;">
<input type="checkbox" id="use-existing-dir" onchange="onExistingDirToggle()"> 使用此目录
</label>
</div>
<div id="path-picker" style="max-height:160px;overflow-y:auto;border:1px solid var(--border);border-radius:4px;margin-top:4px;display:none;background:var(--bg-card);"></div>
</div>
<div style="margin-bottom:16px;">
<label style="font-size:11px;color:var(--text-muted);">任务描述(Claude Code 会自动拆分为步骤)</label>
<textarea id="new-task-desc" rows="3" placeholder="例如:按照 SPEC.md 构建完整的 Odin's Eye 项目..." style="width:100%;padding:8px;border:1px solid var(--border);border-radius:4px;font-size:12px;font-family:inherit;margin-top:4px;resize:vertical;"></textarea>
</div>
<div style="display:flex;gap:8px;justify-content:flex-end;">
<button class="act-btn" onclick="closeModal()">取消</button>
<button class="act-btn primary" onclick="createProjectAndTask()">创建项目并启动</button>
</div>
</div>
</div>
<script>
let ws, state = {}, selectedTask = null;
const WS = `ws://${location.host}/ws`;
function connect() {
ws = new WebSocket(WS);
ws.onopen = () => {
document.getElementById('conn-text').textContent = 'Connected';
document.getElementById('live-dot').className = 'dot live';
};
ws.onmessage = (e) => { state = JSON.parse(e.data); render(); };
ws.onclose = () => {
document.getElementById('conn-text').textContent = 'Disconnected';
document.getElementById('live-dot').className = 'dot dead';
setTimeout(connect, 3000);
};
}
async function api(method, path, body) {
const opts = { method, headers: {'Content-Type': 'application/json'} };
if (body) opts.body = JSON.stringify(body);
return (await fetch(path, opts)).json();
}
// ── Render ──
function render() {
const projects = state.projects || {};
const agents = state.agents || {};
const tasks = state.tasks || {};
const terminals = state.terminals || {};
document.getElementById('stat-agents').textContent = Object.keys(agents).length;
document.getElementById('stat-projects').textContent = Object.keys(projects).length;
document.getElementById('stat-tasks').textContent = Object.keys(tasks).length;
renderTaskList(projects, agents, tasks);
if (selectedTask) renderTaskDetail(projects, tasks, agents, terminals);
}
function renderTaskList(projects, agents, tasks) {
const container = document.getElementById('project-list');
const taskList = Object.values(tasks).sort((a,b) => (b.created_at||'').localeCompare(a.created_at||''));
if (taskList.length === 0) {
container.innerHTML = '<div style="text-align:center;padding:40px 16px;color:var(--text-muted);font-size:12px;">No tasks yet</div>';
return;
}
// Detect which projects need attention
const terminals = state.terminals || {};
const needingAttention = new Set();
Object.entries(projects).forEach(([name, p]) => {
const projAgents = (p.agents || []).map(n => (state.agents||{})[n]).filter(Boolean);
const sessionName = projAgents[0]?.tmux_session || name.toLowerCase().replace(/\s+/g, '-');
if (detectPrompt(terminals[sessionName]?.content)) needingAttention.add(name);
});
container.innerHTML = taskList.map(task => {
const p = projects[task.project] || {};
const totalSteps = task.steps?.length || 0;
const completedSteps = task.steps?.filter(s => s.status === 'completed').length || 0;
let tagClass = 'tag-queued', tagText = 'Queued';
if (task.status === 'running') { tagClass = 'tag-running'; tagText = `${completedSteps}/${totalSteps}`; }
else if (task.status === 'completed') { tagClass = 'tag-completed'; tagText = 'Done'; }
if (needingAttention.has(task.project)) { tagClass = 'tag-warning'; tagText = '⚠ Confirm'; }
const isActive = task.id === selectedTask;
// Session status (per project)
const projAgents2 = (p.agents || []).map(n => (state.agents||{})[n]).filter(Boolean);
const tmuxRunning = projAgents2.some(a => a.tmux?.running);
const sessionDot = tmuxRunning ? '<span style="color:#3fb950;" title="tmux online">●</span>' : '<span style="color:#f85149;" title="tmux offline">●</span>';
const isRunningNow = task.status === 'running';
const statusDot = isRunningNow ? '<span class="step-running-indicator" style="margin-left:0;margin-right:4px;"></span>' : '';
return `<div class="project-item ${isActive ? 'active' : ''} ${needingAttention.has(task.project) && !isActive ? 'needs-attention' : ''}" onclick="selectTask('${task.id}')">
<div class="pname">${sessionDot} ${statusDot}${escapeHtml(task.title.substring(0, 40))}${task.title.length>40?'...':''} <span class="status-tag ${tagClass}">${tagText}</span></div>
<div class="ptask">📂 ${escapeHtml(task.project)} · ${completedSteps}/${totalSteps} steps</div>
<div class="pmeta">🕐 ${task.created_at ? new Date(task.created_at).toLocaleString('zh-CN') : ''} ${task.completed_at ? '· ✅ ' + new Date(task.completed_at).toLocaleString('zh-CN') : ''}</div>
</div>`;
}).join('');
}
function renderTaskDetail(projects, tasks, agents, terminals) {
const activeTask = tasks[selectedTask];
if (!activeTask) return;
const project = projects[activeTask.project] || {};
const projectName = activeTask.project;
const totalSteps = activeTask.steps?.length || 0;
const currentStep = activeTask.current_step || 0;
const completedSteps = activeTask.steps?.filter(s => s.status === 'completed').length || 0;
const pct = totalSteps > 0 ? Math.round((completedSteps / totalSteps) * 100) : 0;
document.getElementById('task-badge').textContent = `${completedSteps}/${totalSteps} · ${pct}%`;
let stepHtml = '';
if (activeTask.status === 'running' || activeTask.status === 'completed') {
stepHtml = (activeTask.steps || []).map((step, i) => {
let icon = '○', cls = '';
if (step.status === 'completed') { icon = '✓'; cls = 'completed'; }
else if (step.status === 'running') { icon = '◉'; cls = 'running'; }
else if (step.status === 'failed') { icon = '✕'; cls = 'failed'; }
let durationStr = '';
if (step.status === 'running' && step.started_at) {
const elapsed = Math.floor((Date.now() - new Date(step.started_at).getTime()) / 1000);
durationStr = `⏱ 运行中 · ${formatDuration(elapsed)}`;
} else if (step.duration_seconds > 0) {
durationStr = `⏱ ${formatDuration(step.duration_seconds)}`;
} else if (step.status === 'pending') {
durationStr = '等待中';
}
return `<div class="step-item ${cls}">
<div class="step-icon">${icon}</div>
<div class="step-info">
<div class="step-name">${escapeHtml(step.name)}${step.status === 'running' ? '<span class="step-running-indicator"></span>' : ''}</div>
<div class="step-duration">${durationStr}</div>
</div>
</div>`;
}).join('');
} else {
// Task queued - show steps without animation
stepHtml = (activeTask.steps || []).map((step, i) => `
<div class="step-item">
<div class="step-icon">○</div>
<div class="step-info">
<div class="step-name">${escapeHtml(step.name)}</div>
<div class="step-duration">等待开始</div>
</div>
</div>
`).join('');
}
const statusLabel = activeTask.status === 'running' ? '🔄 执行中' : activeTask.status === 'completed' ? '✅ 已完成' : '⏳ 等待中';
document.getElementById('task-detail').innerHTML = `
<div class="task-header">
<div class="task-title">${escapeHtml(activeTask.title)}</div>
<div class="task-status-line">
${statusLabel} · ${totalSteps} 个步骤 ·
创建于 ${new Date(activeTask.created_at).toLocaleString('zh-CN')}
${activeTask.completed_at ? ` · 完成于 ${new Date(activeTask.completed_at).toLocaleString('zh-CN')}` : ''}
</div>
${activeTask.description ? `<div style="margin-top:6px;padding:8px 10px;background:#f0f4ff;border-left:3px solid var(--accent);border-radius:4px;font-size:11px;color:var(--text-secondary);line-height:1.5;">
💬 <strong>下发指令:</strong>${escapeHtml(activeTask.description)}
</div>` : ''}
</div>
<div class="step-list">${stepHtml}</div>
${activeTask.status === 'completed' ? '<div style="text-align:center;padding:16px;color:var(--green);font-size:13px;font-weight:600;">🎉 任务全部完成!</div>' : ''}
`;
// Terminal + session info (declare before use)
const projAgents = (project.agents || []).map(n => agents[n]).filter(Boolean);
const sessionName = projAgents[0]?.tmux_session || selectedTask.toLowerCase().replace(/\s+/g, '-');
document.getElementById('term-label').textContent = sessionName;
const termContent = terminals[sessionName]?.content || 'No terminal data';
document.getElementById('terminal').textContent = termContent;
// Session status bar
const sessionRunning = projAgents.some(a => a.tmux?.running);
const claudeActive = termContent.includes('') && !termContent.includes('Welcome back');
let sessionHtml = '<div style="margin-top:12px;border-top:1px solid var(--border-light);padding-top:10px;display:flex;align-items:center;gap:10px;flex-wrap:wrap;">';
sessionHtml += '<span style="font-size:10px;display:flex;align-items:center;gap:4px;">';
sessionHtml += '<span style="color:' + (sessionRunning ? '#3fb950' : '#f85149') + ';">●</span> tmux: ' + (sessionRunning ? 'Online' : 'Offline');
sessionHtml += '</span>';
sessionHtml += '<span style="font-size:10px;display:flex;align-items:center;gap:4px;">';
sessionHtml += '<span style="color:' + (claudeActive ? '#3fb950' : '#8b949e') + ';">●</span> Claude: ' + (claudeActive ? 'Active' : (sessionRunning ? 'Idle' : 'Not started'));
sessionHtml += '</span>';
sessionHtml += '<span style="flex:1;"></span>';
sessionHtml += '<button class="act-btn primary" onclick="showNewTaskModal()">📋 New Task</button>';
sessionHtml += '<button class="act-btn" onclick="resumeSession()">🔄 Resume</button>';
sessionHtml += '<input type="text" id="resume-session-id" placeholder="Session ID" style="width:100px;padding:3px 6px;border:1px solid var(--border);border-radius:4px;font-size:10px;font-family:monospace;">';
sessionHtml += '<button class="act-btn danger" onclick="killSession()">⏹ Kill</button>';
sessionHtml += '</div>';
document.getElementById('task-detail').innerHTML += sessionHtml;
document.getElementById('terminal').scrollTop = document.getElementById('terminal').scrollHeight;
// Show/hide confirm buttons based on prompt detection
const confirmBtns = document.getElementById('confirm-btns');
const promptInfo = detectPrompt(termContent);
if (confirmBtns) confirmBtns.style.display = promptInfo ? 'flex' : 'none';
// Prompt detection
const promptArea = document.getElementById('prompt-area');
if (promptInfo) {
const hasAllowAll = termContent.includes('allow all edits') || termContent.includes('shift+tab');
promptArea.innerHTML = `<div class="prompt-bar">
<div class="prompt-question">⚠️ ${escapeHtml(promptInfo.question)}</div>
<div class="prompt-btns">
<button class="prompt-btn pbtn-yes" onclick="confirmAgent('yes')">✅ 确认</button>
${hasAllowAll ? '<button class="prompt-btn pbtn-allow" onclick="confirmAgent(\'yes_allow_all\')">🔓 全部允许</button>' : ''}
<button class="prompt-btn pbtn-no" onclick="confirmAgent('no')">❌ 拒绝</button>
<button class="prompt-btn pbtn-esc" onclick="confirmAgent('escape')">Esc</button>
</div>
</div>`;
} else {
promptArea.innerHTML = '';
}
}
// ── Actions ──
function selectTask(taskId) {
if (selectedTask === taskId) {
deselectTask();
return;
}
selectedTask = taskId;
document.getElementById('btn-close-task').style.display = 'inline-block';
render();
if (ws) ws.send('ping');
}
function deselectTask() {
selectedTask = null;
document.getElementById('btn-close-task').style.display = 'none';
document.getElementById('task-detail').innerHTML = `
<div class="task-empty">
<h3>Select a task</h3>
<p>Click a task in the list to view<br>its progress and step details.</p>
</div>`;
document.getElementById('task-badge').textContent = '';
document.getElementById('terminal').textContent = 'Select a task to view terminal...';
document.getElementById('prompt-area').innerHTML = '';
document.getElementById('confirm-btns').style.display = 'none';
renderTaskList(state.projects||{}, state.agents||{}, state.tasks||{});
}
function getSelectedProjectName() {
if (!selectedTask) return '';
const task = state.tasks?.[selectedTask];
return task?.project || '';
}
function showNewProject() {
document.getElementById('modal-overlay').style.display = 'flex';
document.getElementById('new-proj-name').focus();
document.getElementById('use-existing-dir').checked = false;
// Default path
const defaultPath = '/Users/wusumac/Documents/Projects';
document.getElementById('new-proj-path').value = defaultPath;
}
function closeModal() {
document.getElementById('modal-overlay').style.display = 'none';
document.getElementById('path-picker').style.display = 'none';
}
async function browsePath(dirPath) {
const path = dirPath || document.getElementById('new-proj-path').value || '/Users/wusumac/Documents/Projects';
const r = await fetch(`/api/fs/list?path=${encodeURIComponent(path)}`);
const d = await r.json();
if (d.error) { toast('无法访问该目录'); return; }
document.getElementById('new-proj-path').value = d.path;
// Breadcrumb
const parts = d.path.split('/').filter(Boolean);
let bcHtml = '';
let built = '';
parts.forEach((p, i) => {
built += '/' + p;
const isLast = i === parts.length - 1;
bcHtml += isLast
? `<span style="color:var(--text);">/${p}</span>`
: `<span style="cursor:pointer;color:var(--accent);" onclick="browsePath('${built}')">/${p}</span>`;
});
document.getElementById('path-breadcrumb').innerHTML = bcHtml || '/';
// Folder list
const picker = document.getElementById('path-picker');
picker.style.display = 'block';
let html = '';
// Parent directory ".." entry (only if not at root)
if (d.path !== '/' && d.parent) {
html += `<div style="padding:6px 10px;cursor:pointer;font-size:12px;display:flex;align-items:center;gap:6px;border-bottom:1px solid var(--border-light);color:var(--accent);font-weight:500;"
onclick="browsePath('${escapeHtml(d.parent)}')"
onmouseover="this.style.background='var(--accent-light)'"
onmouseout="this.style.background=''">
📂 ..
</div>`;
}
if (d.dirs.length === 0 && !html) {
html += '<div style="padding:8px;font-size:11px;color:var(--text-muted);text-align:center;">此目录下没有子文件夹</div>';
} else {
html += d.dirs.map(dir => `
<div style="padding:6px 10px;cursor:pointer;font-size:12px;display:flex;align-items:center;gap:6px;border-bottom:1px solid var(--border-light);"
ondblclick="browsePath('${escapeHtml(dir.path)}')"
onclick="selectDir('${escapeHtml(dir.path)}', '${escapeHtml(dir.name)}')"
onmouseover="this.style.background='var(--bg-hover)'"
onmouseout="this.style.background=''">
📁 ${escapeHtml(dir.name)}
</div>
`).join('');
}
picker.innerHTML = html;
}
function selectDir(path, name) {
document.getElementById('new-proj-path').value = path;
document.getElementById('new-proj-name').value = name;
document.getElementById('use-existing-dir').checked = true;
browsePath(path);
}
function onExistingDirToggle() {
// Visual feedback only - logic handled in createProjectAndTask
}
async function createProjectAndTask() {
const name = document.getElementById('new-proj-name').value.trim();
const path = document.getElementById('new-proj-path').value.trim();
const desc = document.getElementById('new-task-desc').value.trim();
if (!name) return;
// Auto-append project name unless "use existing" is checked
const useExisting = document.getElementById('use-existing-dir').checked;
const baseDir = path || '/Users/wusumac/Documents/Projects';
const projectDir = useExisting ? baseDir : (baseDir.endsWith(name) ? baseDir : `${baseDir}/${name}`);
document.getElementById('new-proj-path').value = projectDir;
// Create project
await api('POST', '/api/projects', {
name, path: projectDir, description: desc || name,
kanban_status: 'in_progress', agents: [`${name.toLowerCase()}-dev`]
});
// Create agent
const sessionName = name.toLowerCase().replace(/\s+/g, '-');
await api('POST', '/api/agents', {
name: `${name.toLowerCase()}-dev`, project: name,
tmux_session: sessionName, tmux_window: '0',
status: 'starting', task: desc || 'New task'
});
// Create tmux session
await api('POST', '/api/tmux/session', {
name: sessionName, project_dir: projectDir,
prompt: desc || 'Hello'
});
// Create task
const stepNames = desc ? extractStepsFromDescription(desc) : ['Analyze requirements', 'Design architecture', 'Implement core', 'Test', 'Deliver'];
const taskResp = await api('POST', '/api/tasks', {
project: name, title: desc || `Build ${name}`,
description: desc, steps: stepNames
});
const taskId = taskResp.task?.id;
// Start the task and select it
if (taskId) {
setTimeout(async () => {
await api('POST', `/api/tasks/${taskId}/start`);
if (ws) ws.send('ping');
}, 1000);
}
closeModal();
selectedTask = taskId || '';
toast('Project "' + name + '" created');
if (ws) ws.send('ping');
}
function extractStepsFromDescription(desc) {
// Simple heuristic: split by numbered items or newlines
const lines = desc.split('\n').filter(l => l.trim());
if (lines.length >= 3 && lines.every(l => /^\d+[\.\)]/.test(l.trim()))) {
return lines.map(l => l.replace(/^\d+[\.\)]\s*/, '').trim());
}
// Default: generate reasonable steps
const keywords = desc.toLowerCase();
if (keywords.includes('build') || keywords.includes('构建')) {
return ['分析需求和设计架构', '搭建数据模型和存储层', '实现核心业务逻辑', '构建 API 和前端界面', '集成测试和部署'];
}
return ['需求分析', '架构设计', '核心实现', '测试验证', '交付'];
}
async function sendPrompt() {
const input = document.getElementById('agent-prompt');
const prompt = input.value.trim();
if (!prompt || !selectedTask) return;
const projectName = getSelectedProjectName();
const project = state.projects?.[projectName];
const agents = state.agents || {};
const projAgents = (project?.agents || []).map(n => agents[n]).filter(Boolean);
const session = projAgents[0]?.tmux_session || projectName.toLowerCase().replace(/\s+/g, '-');
await api('POST', '/api/tmux/send', { session, window: '0', keys: prompt });
input.value = '';
toast('Command sent');
setTimeout(refreshTerminal, 2000);
}
async function confirmAgent(action) {
const projectName = getSelectedProjectName();
const project = state.projects?.[projectName];
const agents = state.agents || {};
const projAgents = (project?.agents || []).map(n => agents[n]).filter(Boolean);
const session = projAgents[0]?.tmux_session || projectName.toLowerCase().replace(/\s+/g, '-') || 'odineye';
await api('POST', '/api/tmux/confirm', { session, action });
toast(action === 'yes' || action === 'yes_allow_all' ? 'Confirmed' : action === 'no' ? 'Denied' : 'Done');
setTimeout(refreshTerminal, 2000);
}
async function refreshTerminal() { if (ws) ws.send('ping'); }
function detectPrompt(text) {
if (!text) return null;
const patterns = [
/Do you want to proceed\?/,
/Do you want to create/,
/Do you want to (run|execute|install|delete|remove|modify|overwrite)/,
/Is this a project you created/,
/Quick safety check/,
];
for (const p of patterns) {
if (p.test(text)) {
const lines = text.split('\n');
const qIdx = lines.findIndex(l => p.test(l));
return { question: (lines[qIdx] || '').trim(), detected: true };
}
}
return null;
}
function formatDuration(seconds) {
if (seconds < 60) return `${seconds}s`;
const m = Math.floor(seconds / 60);
const s = seconds % 60;
return `${m}m ${s}s`;
}
function escapeHtml(t) {
if (!t) return '';
const d = document.createElement('div');
d.textContent = t;
return d.innerHTML;
}
function toast(msg) {
const el = document.createElement('div');
el.className = 'toast'; el.textContent = msg;
document.body.appendChild(el);
setTimeout(() => el.remove(), 2500);
}
// ── Session & Task management ──
function showNewTaskModal() {
if (!selectedTask) return;
document.getElementById('task-modal-title').textContent = 'New Task → ' + getSelectedProjectName();
document.getElementById('task-modal-overlay').style.display = 'flex';
document.getElementById('new-task-desc-input').focus();
}
function closeTaskModal() {
document.getElementById('task-modal-overlay').style.display = 'none';
}
async function submitNewTask() {
const desc = document.getElementById('new-task-desc-input').value.trim();
if (!desc || !selectedTask) return;
const projectName = getSelectedProjectName();
const project = state.projects?.[projectName];
const agents = state.agents || {};
const projAgents = (project?.agents || []).map(n => agents[n]).filter(Boolean);
const session = projAgents[0]?.tmux_session || projectName.toLowerCase().replace(/\s+/g, '-');
const stepNames = extractStepsFromDescription(desc);
const r = await api('POST', '/api/tasks', { project: projectName, title: desc, description: desc, steps: stepNames });
const taskId = r.task?.id;
await api('POST', '/api/tmux/send', { session, window: '0', keys: desc });
if (projAgents[0]) {
await api('POST', `/api/agents/${projAgents[0].name}/status`, { status: 'running', task: desc });
}
if (taskId) {
setTimeout(async () => { await api('POST', `/api/tasks/${taskId}/start`); selectedTask = taskId; if (ws) ws.send('ping'); }, 1000);
}
closeTaskModal();
document.getElementById('new-task-desc-input').value = '';
toast('New task assigned to ' + projectName);
if (ws) ws.send('ping');
}
async function resumeSession() {
if (!selectedTask) return;
const projectName = getSelectedProjectName();
const project = state.projects?.[projectName];
const agents = state.agents || {};
const projAgents = (project?.agents || []).map(n => agents[n]).filter(Boolean);
const session = projAgents[0]?.tmux_session || projectName.toLowerCase().replace(/\s+/g, '-');
const path = project?.path || '';
const resumeId = document.getElementById('resume-session-id')?.value?.trim() || '';
const r = await fetch(`/api/claude/sessions?project_dir=${encodeURIComponent(path)}`);
const data = await r.json();
const sessions = data.sessions || [];
if (resumeId) {
await api('POST', '/api/tmux/resume', { session, project_dir: path, resume_id: resumeId });
toast('Session resumed: ' + resumeId);
setTimeout(refreshTerminal, 2000);
} else if (sessions.length > 0) {
showSessionPicker(sessions, session, path);
} else {
await api('POST', '/api/tmux/resume', { session, project_dir: path, resume_id: '' });
toast('No history, starting fresh Claude');
setTimeout(refreshTerminal, 2000);
}
if (ws) ws.send('ping');
}
function showSessionPicker(sessions, sessionName, projectPath) {
const pickerHtml = `
<div class="prompt-bar" style="background:#eef1fd;border-color:var(--accent);">
<div class="prompt-question" style="color:var(--accent);">选择要恢复的 Claude Code 会话:</div>
<div style="max-height:180px;overflow-y:auto;margin-bottom:8px;">
${sessions.slice(0, 10).map(s => `
<div style="padding:6px 10px;cursor:pointer;font-size:11px;border-bottom:1px solid #e2e6ed;display:flex;justify-content:space-between;align-items:center;"
onclick="selectSession('${s.id}')"
onmouseover="this.style.background='#fff'"
onmouseout="this.style.background=''">
<span style="font-family:monospace;color:var(--accent);">${s.id.substring(0,12)}...</span>
<span style="font-size:10px;color:var(--text-muted);">${s.status} · ${s.updated_at ? new Date(s.updated_at).toLocaleString('zh-CN') : ''}</span>
</div>
`).join('')}
</div>
<button class="prompt-btn" style="background:var(--accent);color:#fff;border:none;" onclick="document.getElementById('prompt-area').innerHTML='';selectSession('')">启动新会话</button>
<button class="prompt-btn pbtn-esc" onclick="document.getElementById('prompt-area').innerHTML='';">取消</button>
</div>
`;
document.getElementById('prompt-area').innerHTML = pickerHtml;
}
async function selectSession(sessionId) {
if (!selectedTask) return;
const project = state.projects?.[getSelectedProjectName()];
const agents = state.agents || {};
const projAgents = (project?.agents || []).map(n => agents[n]).filter(Boolean);
const session = projAgents[0]?.tmux_session || selectedTask.toLowerCase().replace(/\s+/g, '-');
const path = project?.path || '';
document.getElementById('prompt-area').innerHTML = '';
if (sessionId) {
document.getElementById('resume-session-id').value = sessionId;
}
await api('POST', '/api/tmux/resume', { session, project_dir: path, resume_id: sessionId });
toast(sessionId ? `恢复会话 ${sessionId.substring(0,12)}...` : '启动新会话');
setTimeout(refreshTerminal, 2000);
if (ws) ws.send('ping');
}
async function killSession() {
if (!selectedTask) return;
if (!confirm('确定要关闭此项目的 tmux 会话吗?任务进度会保留。')) return;
const project = state.projects?.[getSelectedProjectName()];
const agents = state.agents || {};
const projAgents = (project?.agents || []).map(n => agents[n]).filter(Boolean);
const session = projAgents[0]?.tmux_session || selectedTask.toLowerCase().replace(/\s+/g, '-');
await api('POST', '/api/tmux/kill', { session });
toast(`会话 ${session} 已关闭`);
if (ws) ws.send('ping');
}
// ── Splitter drag-to-resize ──
function initSplitter(splitterId, targetSelector, minWidth) {
const splitter = document.getElementById(splitterId);
if (!splitter) return;
let dragging = false, startX, startWidth;
splitter.addEventListener('mousedown', (e) => {
dragging = true;
startX = e.clientX;
const target = document.querySelector(targetSelector);
startWidth = target ? target.offsetWidth : 300;
splitter.classList.add('dragging');
document.body.style.cursor = 'col-resize';
document.body.style.userSelect = 'none';
});
document.addEventListener('mousemove', (e) => {
if (!dragging) return;
const dx = startX - e.clientX;
const target = document.querySelector(targetSelector);
if (!target) return;
// For left-column targets, reverse the drag direction
if (targetSelector === '.col-left') {
const newWidth = Math.max(minWidth, startWidth - dx);
target.style.width = newWidth + 'px';
} else {
const newWidth = Math.max(minWidth, startWidth + dx);
target.style.width = newWidth + 'px';
}
});
document.addEventListener('mouseup', () => {
if (!dragging) return;
dragging = false;
splitter.classList.remove('dragging');
document.body.style.cursor = '';
document.body.style.userSelect = '';
});
}
// splitter-1: drag to resize col-left (min 160px)
initSplitter('splitter-1', '.col-left', 160);
// splitter-2: drag to resize col-right (min 300px)
initSplitter('splitter-2', '.col-right', 300);
// ── Version check ──
async function checkForUpdates() {
try {
const r = await fetch('/api/version');
const v = await r.json();
if (v.update_available) {
document.getElementById('update-banner').style.display = 'inline';
document.getElementById('update-count').textContent = v.commits_behind;
}
} catch(e) {}
}
async function runGitPull() {
toast('Pulling latest code...');
try {
const r = await api('POST', '/api/git/pull');
toast(r.message || 'Updated! Please restart the server.');
document.getElementById('update-banner').style.display = 'none';
} catch(e) {
toast('Pull failed. Run manually: cd OpsPilot && git pull');
}
}
// Init
connect();
checkForUpdates(); // Only check on startup
setInterval(() => { if (ws && ws.readyState === WebSocket.OPEN) ws.send('ping'); }, 8000);
setInterval(() => {
document.getElementById('stat-time').textContent = new Date().toLocaleTimeString('zh-CN');
}, 1000);
</script>
</body>
</html>