mirror of
https://github.com/wu736139669/OpsPilot.git
synced 2026-08-05 06:23:35 +00:00
The session status bar JavaScript was accidentally placed inside the task-detail template literal. Moved it outside and used string concatenation instead of nested template literals. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
943 lines
44 KiB
HTML
943 lines
44 KiB
HTML
<!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 = {}, selectedProject = 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;
|
||
|
||
renderProjects(projects, agents, tasks);
|
||
if (selectedProject) renderTaskDetail(projects, tasks, agents, terminals);
|
||
}
|
||
|
||
function renderProjects(projects, agents, tasks) {
|
||
const container = document.getElementById('project-list');
|
||
const names = Object.keys(projects);
|
||
if (names.length === 0) {
|
||
container.innerHTML = '<div style="text-align:center;padding:40px 16px;color:var(--text-muted);font-size:12px;">暂无项目</div>';
|
||
return;
|
||
}
|
||
// Find active task per project
|
||
const projectActiveTask = {};
|
||
Object.values(tasks).forEach(t => {
|
||
const pn = t.project;
|
||
if (!projectActiveTask[pn] || t.status === 'running') projectActiveTask[pn] = t;
|
||
});
|
||
|
||
// Detect which projects need attention (pending confirmation in terminal)
|
||
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 = names.map(name => {
|
||
const p = projects[name];
|
||
const task = projectActiveTask[name];
|
||
let tagClass = 'tag-queued', tagText = '待命';
|
||
if (task) {
|
||
if (task.status === 'running') { tagClass = 'tag-running'; tagText = `步骤 ${(task.current_step||0)+1}/${task.steps?.length||0}`; }
|
||
else if (task.status === 'completed') { tagClass = 'tag-completed'; tagText = '完成'; }
|
||
}
|
||
// Override if needs user confirmation
|
||
if (needingAttention.has(name)) { tagClass = 'tag-warning'; tagText = '⚠ 需确认'; }
|
||
const isActive = name === selectedProject;
|
||
// Session status indicator
|
||
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 在线">●</span>' : '<span style="color:#f85149;" title="tmux 离线">●</span>';
|
||
|
||
return `<div class="project-item ${isActive ? 'active' : ''} ${needingAttention.has(name) && !isActive ? 'needs-attention' : ''}" onclick="selectProject('${escapeHtml(name)}')">
|
||
<div class="pname">${sessionDot} ${escapeHtml(name)} <span class="status-tag ${tagClass}">${tagText}</span></div>
|
||
<div class="ptask">${task ? escapeHtml(task.title) : '暂无任务'}</div>
|
||
<div class="pmeta">🤖 ${(p.agents||[]).length} agents · ${p.path ? '📁' : ''}</div>
|
||
</div>`;
|
||
}).join('');
|
||
}
|
||
|
||
function renderTaskDetail(projects, tasks, agents, terminals) {
|
||
const project = projects[selectedProject];
|
||
if (!project) return;
|
||
|
||
// Find active task for this project
|
||
const projectTasks = Object.values(tasks).filter(t => t.project === selectedProject);
|
||
const activeTask = projectTasks.find(t => t.status === 'running') || projectTasks.sort((a,b) => b.created_at?.localeCompare(a.created_at||''))[0];
|
||
|
||
if (!activeTask) {
|
||
document.getElementById('task-detail').innerHTML = `
|
||
<div class="task-empty">
|
||
<h3>📋 ${escapeHtml(selectedProject)} - 暂无任务</h3>
|
||
<p>该项目还没有分配任务。<br>在右侧发送指令给 Claude Code,或点击"新建项目"来派发任务。</p>
|
||
</div>`;
|
||
document.getElementById('task-badge').textContent = '无任务';
|
||
return;
|
||
}
|
||
|
||
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>' : ''}
|
||
`;
|
||
|
||
// Session status bar (append after main content)
|
||
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;
|
||
|
||
// Terminal
|
||
const projAgents = (project.agents || []).map(n => agents[n]).filter(Boolean);
|
||
const sessionName = projAgents[0]?.tmux_session || selectedProject.toLowerCase().replace(/\s+/g, '-');
|
||
document.getElementById('term-label').textContent = sessionName;
|
||
const termContent = terminals[sessionName]?.content || '暂无终端数据';
|
||
document.getElementById('terminal').textContent = termContent;
|
||
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 selectProject(name) {
|
||
if (selectedProject === name) {
|
||
// Clicking the active project deselects it
|
||
deselectProject();
|
||
return;
|
||
}
|
||
selectedProject = name;
|
||
document.getElementById('btn-close-task').style.display = 'inline-block';
|
||
render();
|
||
if (ws) ws.send('ping');
|
||
}
|
||
|
||
function deselectProject() {
|
||
selectedProject = null;
|
||
document.getElementById('btn-close-task').style.display = 'none';
|
||
document.getElementById('task-detail').innerHTML = `
|
||
<div class="task-empty">
|
||
<h3>选择一个项目</h3>
|
||
<p>在左侧项目列表中点击一个项目,<br>查看任务执行进度和步骤详情。</p>
|
||
</div>`;
|
||
document.getElementById('task-badge').textContent = '';
|
||
document.getElementById('terminal').textContent = '选择项目后显示终端输出...';
|
||
document.getElementById('prompt-area').innerHTML = '';
|
||
document.getElementById('confirm-btns').style.display = 'none';
|
||
renderProjects(state.projects||{}, state.agents||{}, state.tasks||{});
|
||
}
|
||
|
||
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 with steps - Claude Code will auto-generate steps
|
||
const stepNames = desc ? extractStepsFromDescription(desc) : ['Analyze requirements', 'Design architecture', 'Implement core', 'Test', 'Deliver'];
|
||
await api('POST', '/api/tasks', {
|
||
project: name, title: desc || `Build ${name}`,
|
||
description: desc, steps: stepNames
|
||
});
|
||
|
||
// Find the task and start it
|
||
setTimeout(async () => {
|
||
const r = await fetch('/api/state'); const s = await r.json();
|
||
const tasks = s.tasks || {};
|
||
const projTasks = Object.entries(tasks).filter(([id,t]) => t.project === name);
|
||
if (projTasks.length > 0) {
|
||
await api('POST', `/api/tasks/${projTasks[0][0]}/start`);
|
||
}
|
||
}, 1000);
|
||
|
||
closeModal();
|
||
selectedProject = name;
|
||
toast(`项目 "${name}" 已创建并启动`);
|
||
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 || !selectedProject) return;
|
||
|
||
const project = state.projects?.[selectedProject];
|
||
const agents = state.agents || {};
|
||
const projAgents = (project?.agents || []).map(n => agents[n]).filter(Boolean);
|
||
const session = projAgents[0]?.tmux_session || selectedProject.toLowerCase().replace(/\s+/g, '-');
|
||
|
||
await api('POST', '/api/tmux/send', { session, window: '0', keys: prompt });
|
||
input.value = '';
|
||
toast('指令已发送');
|
||
setTimeout(refreshTerminal, 2000);
|
||
}
|
||
|
||
async function confirmAgent(action) {
|
||
const project = state.projects?.[selectedProject];
|
||
const agents = state.agents || {};
|
||
const projAgents = (project?.agents || []).map(n => agents[n]).filter(Boolean);
|
||
const session = projAgents[0]?.tmux_session || selectedProject?.toLowerCase().replace(/\s+/g, '-') || 'odineye';
|
||
|
||
await api('POST', '/api/tmux/confirm', { session, action });
|
||
toast(action === 'yes' || action === 'yes_allow_all' ? '✅ 已确认' : action === 'no' ? '❌ 已拒绝' : '已操作');
|
||
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 (!selectedProject) return;
|
||
document.getElementById('task-modal-title').textContent = '派发新任务 → ' + selectedProject;
|
||
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 || !selectedProject) return;
|
||
|
||
const project = state.projects?.[selectedProject];
|
||
const agents = state.agents || {};
|
||
const projAgents = (project?.agents || []).map(n => agents[n]).filter(Boolean);
|
||
const session = projAgents[0]?.tmux_session || selectedProject.toLowerCase().replace(/\s+/g, '-');
|
||
|
||
const stepNames = extractStepsFromDescription(desc);
|
||
const r = await api('POST', '/api/tasks', { project: selectedProject, 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`); if (ws) ws.send('ping'); }, 1000);
|
||
}
|
||
|
||
closeTaskModal();
|
||
document.getElementById('new-task-desc-input').value = '';
|
||
toast(`新任务已派发给 ${selectedProject}`);
|
||
if (ws) ws.send('ping');
|
||
}
|
||
|
||
async function resumeSession() {
|
||
if (!selectedProject) return;
|
||
const project = state.projects?.[selectedProject];
|
||
const agents = state.agents || {};
|
||
const projAgents = (project?.agents || []).map(n => agents[n]).filter(Boolean);
|
||
const session = projAgents[0]?.tmux_session || selectedProject.toLowerCase().replace(/\s+/g, '-');
|
||
const path = project?.path || '';
|
||
const resumeId = document.getElementById('resume-session-id')?.value?.trim() || '';
|
||
|
||
// Fetch available sessions and show picker
|
||
const r = await fetch(`/api/claude/sessions?project_dir=${encodeURIComponent(path)}`);
|
||
const data = await r.json();
|
||
const sessions = data.sessions || [];
|
||
|
||
if (resumeId) {
|
||
// Specific ID provided
|
||
const resp = await api('POST', '/api/tmux/resume', { session, project_dir: path, resume_id: resumeId });
|
||
toast(`已恢复会话 ${resumeId}`);
|
||
setTimeout(refreshTerminal, 2000);
|
||
} else if (sessions.length > 0) {
|
||
// Show session picker in the terminal bar
|
||
showSessionPicker(sessions, session, path);
|
||
} else {
|
||
// No sessions, just start fresh
|
||
const resp = await api('POST', '/api/tmux/resume', { session, project_dir: path, resume_id: '' });
|
||
toast('无历史会话,启动新 Claude Code');
|
||
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 (!selectedProject) return;
|
||
const project = state.projects?.[selectedProject];
|
||
const agents = state.agents || {};
|
||
const projAgents = (project?.agents || []).map(n => agents[n]).filter(Boolean);
|
||
const session = projAgents[0]?.tmux_session || selectedProject.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 (!selectedProject) return;
|
||
if (!confirm('确定要关闭此项目的 tmux 会话吗?任务进度会保留。')) return;
|
||
|
||
const project = state.projects?.[selectedProject];
|
||
const agents = state.agents || {};
|
||
const projAgents = (project?.agents || []).map(n => agents[n]).filter(Boolean);
|
||
const session = projAgents[0]?.tmux_session || selectedProject.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>
|