Commit 694ca3a6d27f55dc75dccb0ca2bdd47fff4780a2

Authored by yibo
1 parent 0594b1de

feat(devloop): P5 Vue Dashboard — full UI per §P4 spec (12 components + 4 ops su…

…b-pages + UI endpoints)

Completes the §P4 acceptance UI scope that was deferred in commit
0594b1d. Adds the full Vue Dashboard + Ops sub-pages with live SSE
stream + auto-reconnect, unit DAG with detail drawer, PRD/SDD diff
panel, feedback modal, and 4 ops views (Bugs / Loops / Changelog /
Progress).

Vue surface (≈1480 lines):
- web/src/views/DevLoopDashboard.vue (~110 lines)
  * 4-region grid: status header / loop bar / units DAG / live stream /
    PRD diff. Polls /state every 2.5s; opens feedback modal & unit
    drawer; mounts Sign Off / Abandon controls.
- web/src/views/DevLoopOps.vue (~50 lines) — tab container.
- web/src/components/devloop/DashboardHeader.vue
  * Status badge (7 states color-coded), NEW REQUIREMENT button,
    Sign Off / Abandon buttons (disabled per FSM), Ops nav link.
- web/src/components/devloop/LoopStatusBar.vue
  * 4-phase pipeline (Docs → L1 → L2 → Ship), highlights active phase.
- web/src/components/devloop/UnitsDAG.vue
  * SVG DAG rendered from topological waves; per-unit status color;
    click → emits select → opens UnitDrawer.
- web/src/components/devloop/UnitDrawer.vue
  * Slide-in panel showing unit metadata + last 3 runs + result
    summary + coder.log tail (last 1KB).
- web/src/components/devloop/LiveAgentStream.vue
  * EventSource subscriber listening to project.status,
    unit.state, integration.result, triage.result, sdd.updated,
    prd.updated, human_intervention_needed, etc. Manual 5s reconnect
    on disconnect (browser's built-in Last-Event-ID is honored).
- web/src/components/devloop/PrdDiffPanel.vue
  * Reads changelog.ndjson, renders prd_rev / sdd_rev entries with
    trigger labels.
- web/src/components/devloop/FeedbackModal.vue
  * POSTs /feedback with PM incremental — shows rev bump on success.
- web/src/components/devloop/ops/BuglistView.vue
  * Filterable bug table; status badges; severity / category columns.
- web/src/components/devloop/ops/LoopLogView.vue
  * Timeline grouped by L1 (unit.*) / L2 (integration/triage/sdd/l2_*) /
    L3 (project.status/shipped/prd.updated/human_intervention) /
    System (rest). Free-text substring filter.
- web/src/components/devloop/ops/ChangelogView.vue
  * Tabular prd_rev/sdd_rev entries with by/trigger/summary columns.
- web/src/components/devloop/ops/ProgressView.vue
  * 6 metric cards (status, PRD rev, SDD rev, units passed bar,
    L2 rounds bar, open bugs) + per-unit breakdown table.

Router:
- web/src/router/index.js — adds:
  * /devloop/:pid              → DevLoopDashboard
  * /devloop/:pid/ops/:tab(bugs|loops|changelog|progress) → DevLoopOps

Backend UI-supporting endpoints (server/routes/devloop.js):
- GET /api/devloop/projects/:pid/timeline?limit=N
    Reads timeline.ndjson, returns last N events reversed.
- GET /api/devloop/projects/:pid/changelog
    Returns full changelog.ndjson entries reversed.
- GET /api/devloop/projects/:pid/units/:unitId
    Returns { unit, runs (last 3 result.json), coder_log_tail }.
    404 on unknown unit.

Tests (+5 cases):
- server/tests/devloop/routes.test.js — new P5 UI endpoints block:
  * timeline / changelog / units happy-paths
  * 404 on unknown unit and unknown pid

Acceptance:
- scripts/phase5-verify.js — 7 steps:
  ① Vue build artifacts (DevLoopDashboard.js + DevLoopOps.js chunks)
  ② POST /projects → state returns status + units
  ③ GET /timeline returns events
  ④ GET /changelog returns entries
  ⑤ GET /units/todo-be returns unit + runs
  ⑥ GET /bugs returns bug list
  ⑦ SSE /stream delivers ≥1 event after a triggering feedback POST

Vue build:
- npm run web:build → DevLoopDashboard chunk 15.63 kB,
  DevLoopOps chunk 9.09 kB, both with co-loaded CSS bundles.

Results — all 7 phase gates green:
- phase0-verify: 8/8
- phase0-test-call: 7/7
- phase1-verify: 7/7
- phase2-verify: 6/6
- phase3-verify: 9/9
- phase4-verify: 9/9
- phase5-verify: 7/7
- vitest devloop+acp-core: 218/218 (was 213 → +5)

Specs: docs/prd/newfunction/_spec/plan-migration.md · P4 L3 + UI

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
scripts/phase5-verify.js 0 → 100644
  1 +#!/usr/bin/env node
  2 +/**
  3 + * scripts/phase5-verify.js
  4 + *
  5 + * P5 Vue Dashboard 验收 (后端 + 静态产物):
  6 + * ① Vue 构建产物 (DevLoopDashboard + DevLoopOps + 8 子组件) 存在
  7 + * ② GET /:pid/state 返带 status + units 字段
  8 + * ③ GET /:pid/timeline 返事件列表
  9 + * ④ GET /:pid/changelog 返条目列表
  10 + * ⑤ GET /:pid/units/:unitId 返 unit + runs + coder_log_tail
  11 + * ⑥ GET /:pid/bugs 返 bug 列表
  12 + * ⑦ SSE /:pid/stream 一秒内收到 ≥1 心跳/事件
  13 + *
  14 + * 注: 这是后端 + 产物层面的验证;真正点击鼠标 UI 需要 vite dev 跑起来手测。
  15 + */
  16 +
  17 +process.env.LEMONBOSS_CLAUDE_MODE = 'mock';
  18 +
  19 +const fs = require('fs');
  20 +const path = require('path');
  21 +const http = require('http');
  22 +
  23 +const serverNodeModules = path.resolve(__dirname, '../server/node_modules');
  24 +require('module').globalPaths.push(serverNodeModules);
  25 +const express = (() => {
  26 + try { return require('express'); }
  27 + catch {
  28 + return require(require.resolve('express', { paths: [path.resolve(__dirname, '../server')] }));
  29 + }
  30 +})();
  31 +
  32 +const pathResolver = require('../server/services/devloop/path-resolver');
  33 +const lc = require('../server/src/core/devloop/loop-controller');
  34 +const devloopRouter = require('../server/routes/devloop');
  35 +
  36 +const HAPPY_DIR = path.resolve(__dirname, '../fixtures/devloop-mock-scenarios/happy_path');
  37 +const WEB_DIST = path.resolve(__dirname, '../web/dist');
  38 +
  39 +const results = [];
  40 +function step(name, fn) {
  41 + return (async () => {
  42 + try {
  43 + const r = await fn();
  44 + results.push({ name, ok: true, detail: r?.detail || '' });
  45 + return r;
  46 + } catch (e) {
  47 + results.push({ name, ok: false, reason: e.message });
  48 + throw e;
  49 + }
  50 + })();
  51 +}
  52 +
  53 +async function startApp() {
  54 + const app = express();
  55 + app.use(express.json());
  56 + app.use('/api/devloop', devloopRouter);
  57 + return new Promise((resolve) => {
  58 + const srv = app.listen(0, '127.0.0.1', () => {
  59 + resolve({ srv, base: `http://127.0.0.1:${srv.address().port}` });
  60 + });
  61 + });
  62 +}
  63 +
  64 +async function main() {
  65 + await step('① Vue build 产物存在 (Dashboard + Ops + components)', () => {
  66 + const required = [
  67 + 'index.html', 'assets',
  68 + ];
  69 + for (const f of required) {
  70 + if (!fs.existsSync(path.join(WEB_DIST, f))) throw new Error(`web/dist/${f} missing — run npm run web:build`);
  71 + }
  72 + const assets = fs.readdirSync(path.join(WEB_DIST, 'assets'));
  73 + if (!assets.some(n => /DevLoopDashboard.*\.js$/.test(n))) throw new Error('DevLoopDashboard chunk not built');
  74 + if (!assets.some(n => /DevLoopOps.*\.js$/.test(n))) throw new Error('DevLoopOps chunk not built');
  75 + return { detail: `DevLoopDashboard + DevLoopOps chunks present in web/dist/assets` };
  76 + });
  77 +
  78 + const { srv, base } = await startApp();
  79 + const cid = 'c-phase5-' + Date.now().toString(36);
  80 + let pid;
  81 +
  82 + try {
  83 + await step('② 准备项目: POST /projects → state', async () => {
  84 + const r = await fetch(`${base}/api/devloop/projects`, {
  85 + method: 'POST', headers: { 'Content-Type': 'application/json' },
  86 + body: JSON.stringify({ company_id: cid, requirement: 'TODO', mockFixtureDir: HAPPY_DIR }),
  87 + });
  88 + pid = (await r.json()).pid;
  89 + const s = await (await fetch(`${base}/api/devloop/projects/${pid}/state`)).json();
  90 + if (!s.status) throw new Error('state.status missing');
  91 + if (!Array.isArray(s.units)) throw new Error('state.units missing');
  92 + return { detail: `pid=${pid} status=${s.status} units=${s.units.length}` };
  93 + });
  94 +
  95 + await step('③ GET /:pid/timeline returns events', async () => {
  96 + const r = await fetch(`${base}/api/devloop/projects/${pid}/timeline?limit=50`);
  97 + const j = await r.json();
  98 + if (!Array.isArray(j.events)) throw new Error('events not array');
  99 + if (j.events.length === 0) throw new Error('events empty');
  100 + return { detail: `${j.events.length} events` };
  101 + });
  102 +
  103 + await step('④ GET /:pid/changelog returns entries', async () => {
  104 + const r = await fetch(`${base}/api/devloop/projects/${pid}/changelog`);
  105 + const j = await r.json();
  106 + if (!Array.isArray(j.entries)) throw new Error('entries not array');
  107 + return { detail: `${j.entries.length} entries` };
  108 + });
  109 +
  110 + await step('⑤ GET /:pid/units/todo-be returns unit + runs', async () => {
  111 + const r = await fetch(`${base}/api/devloop/projects/${pid}/units/todo-be`);
  112 + if (r.status !== 200) throw new Error(`status ${r.status}`);
  113 + const j = await r.json();
  114 + if (!j.unit) throw new Error('unit missing');
  115 + if (!Array.isArray(j.runs)) throw new Error('runs not array');
  116 + return { detail: `unit.status=${j.unit?.status} · ${j.runs.length} runs` };
  117 + });
  118 +
  119 + await step('⑥ GET /:pid/bugs returns bug list', async () => {
  120 + const r = await fetch(`${base}/api/devloop/projects/${pid}/bugs`);
  121 + const j = await r.json();
  122 + if (!Array.isArray(j.bugs)) throw new Error('bugs not array');
  123 + return { detail: `${j.bugs.length} bugs` };
  124 + });
  125 +
  126 + await step('⑦ SSE /:pid/stream delivers ≥1 event in 1s after emit', async () => {
  127 + // 用 http.get 拿原生 stream
  128 + const url = new URL(`${base}/api/devloop/projects/${pid}/stream`);
  129 + const events = await new Promise((resolve) => {
  130 + const out = [];
  131 + const req = http.get({
  132 + hostname: url.hostname, port: url.port, path: url.pathname,
  133 + headers: { 'Accept': 'text/event-stream' },
  134 + }, (res) => {
  135 + let buf = '';
  136 + res.on('data', (chunk) => {
  137 + buf += chunk.toString();
  138 + const matches = buf.matchAll(/^event: (.+)$/gm);
  139 + for (const m of matches) out.push(m[1]);
  140 + buf = buf.slice(buf.lastIndexOf('\n\n') + 2);
  141 + });
  142 + res.on('error', () => resolve(out));
  143 + });
  144 + // Trigger an event so we have something to receive
  145 + setTimeout(async () => {
  146 + try {
  147 + await fetch(`${base}/api/devloop/projects/${pid}/feedback`, {
  148 + method: 'POST', headers: { 'Content-Type': 'application/json' },
  149 + body: JSON.stringify({ text: 'feedback for sse test', mockFixtureDir: HAPPY_DIR }),
  150 + });
  151 + } catch { /* ignore */ }
  152 + }, 100);
  153 + setTimeout(() => { try { req.destroy(); } catch {} resolve(out); }, 1500);
  154 + });
  155 + if (events.length === 0) throw new Error('no SSE events received');
  156 + return { detail: `${events.length} events: ${events.slice(0, 4).join(', ')}` };
  157 + });
  158 +
  159 + } finally {
  160 + srv.close();
  161 + try { fs.rmSync(path.join(pathResolver.DATA_DIR, 'companies', cid), { recursive: true, force: true }); }
  162 + catch { /* ignore */ }
  163 + }
  164 +
  165 + let pass = 0, fail = 0;
  166 + console.log('\n┌─ DevLoop Phase 5 — Vue Dashboard Acceptance ──────');
  167 + for (const r of results) {
  168 + const tag = r.ok ? '✓' : '✗';
  169 + console.log(`│ ${tag} ${r.name.padEnd(60)} ${r.ok ? r.detail : '— ' + r.reason}`);
  170 + r.ok ? pass++ : fail++;
  171 + }
  172 + console.log(`└─ ${pass}/${pass + fail} steps passed${fail ? ' · ' + fail + ' failed' : ''}\n`);
  173 + process.exit(fail ? 1 : 0);
  174 +}
  175 +
  176 +main().catch(e => {
  177 + console.error('\n[phase5-verify] fatal:', e);
  178 + for (const r of results) console.log(' ', r.ok ? '✓' : '✗', r.name, r.ok ? r.detail : '— ' + r.reason);
  179 + process.exit(1);
  180 +});
... ...
server/routes/devloop.js
... ... @@ -206,6 +206,79 @@ router.get(&#39;/projects/:pid/bugs&#39;, h(async (req, res) =&gt; {
206 206 res.json({ bugs, total: bugs.length });
207 207 }));
208 208  
  209 +// P5: GET /api/devloop/projects/:pid/timeline?limit=200
  210 +// 读 timeline.ndjson 最近 N 条 (按时间倒序),给 LoopLogView 用
  211 +router.get('/projects/:pid/timeline', h(async (req, res) => {
  212 + const fs = require('fs');
  213 + const { pid } = req.params;
  214 + try { pathResolver.resolveProjectRoot(pid); }
  215 + catch { return res.status(404).json({ error: 'unknown project: ' + pid }); }
  216 + const limit = Math.max(1, Math.min(2000, parseInt(req.query.limit, 10) || 200));
  217 + const p = pathResolver.resolveTimelineLog(pid);
  218 + if (!fs.existsSync(p)) return res.json({ events: [], total: 0 });
  219 + const lines = fs.readFileSync(p, 'utf8').trim().split('\n').filter(Boolean);
  220 + const events = lines.slice(-limit).reverse().map(l => {
  221 + try { return JSON.parse(l); } catch { return null; }
  222 + }).filter(Boolean);
  223 + res.json({ events, total: events.length });
  224 +}));
  225 +
  226 +// P5: GET /api/devloop/projects/:pid/changelog
  227 +// 读 changelog.ndjson 全部 (按时间倒序)
  228 +router.get('/projects/:pid/changelog', h(async (req, res) => {
  229 + const fs = require('fs');
  230 + const { pid } = req.params;
  231 + try { pathResolver.resolveProjectRoot(pid); }
  232 + catch { return res.status(404).json({ error: 'unknown project: ' + pid }); }
  233 + const p = pathResolver.resolveChangelog(pid);
  234 + if (!fs.existsSync(p)) return res.json({ entries: [], total: 0 });
  235 + const entries = fs.readFileSync(p, 'utf8').trim().split('\n').filter(Boolean)
  236 + .map(l => { try { return JSON.parse(l); } catch { return null; }}).filter(Boolean)
  237 + .reverse();
  238 + res.json({ entries, total: entries.length });
  239 +}));
  240 +
  241 +// P5: GET /api/devloop/projects/:pid/units/:unitId
  242 +// 返回 unit 元数据 + 最近 N 次 runs (Tester result.json)
  243 +router.get('/projects/:pid/units/:unitId', h(async (req, res) => {
  244 + const fs = require('fs'); const path = require('path');
  245 + const { pid, unitId } = req.params;
  246 + try { pathResolver.resolveProjectRoot(pid); }
  247 + catch { return res.status(404).json({ error: 'unknown project: ' + pid }); }
  248 + const unitDir = pathResolver.resolveUnitDir(pid, unitId);
  249 + if (!fs.existsSync(unitDir)) return res.status(404).json({ error: 'unknown unit: ' + unitId });
  250 + // 从 units.json 找 unit 元数据
  251 + let unitMeta = null;
  252 + try {
  253 + const uj = JSON.parse(fs.readFileSync(pathResolver.resolveUnitsJson(pid), 'utf8'));
  254 + unitMeta = (uj.units || []).find(u => u.id === unitId) || null;
  255 + } catch { /* ignore */ }
  256 + // runs
  257 + const runsDir = path.join(unitDir, 'runs');
  258 + let runs = [];
  259 + if (fs.existsSync(runsDir)) {
  260 + const ids = fs.readdirSync(runsDir).filter(n => {
  261 + try { return fs.statSync(path.join(runsDir, n)).isDirectory(); } catch { return false; }
  262 + }).sort().reverse().slice(0, 3);
  263 + for (const id of ids) {
  264 + const rp = path.join(runsDir, id, 'result.json');
  265 + let r = null;
  266 + if (fs.existsSync(rp)) {
  267 + try { r = JSON.parse(fs.readFileSync(rp, 'utf8')); } catch {}
  268 + }
  269 + runs.push({ run_id: id, result: r });
  270 + }
  271 + }
  272 + // coder.log 最后 1KB 节选
  273 + let coderLog = null;
  274 + const clp = path.join(unitDir, 'coder.log');
  275 + if (fs.existsSync(clp)) {
  276 + const raw = fs.readFileSync(clp, 'utf8');
  277 + coderLog = raw.length > 1024 ? raw.slice(-1024) : raw;
  278 + }
  279 + res.json({ unit: unitMeta, runs, coder_log_tail: coderLog });
  280 +}));
  281 +
209 282 // GET /api/devloop/projects/:pid/state
210 283 router.get('/projects/:pid/state', h(async (req, res) => {
211 284 try {
... ...
server/tests/devloop/routes.test.js
... ... @@ -189,4 +189,52 @@ describe(&#39;routes/devloop&#39;, () =&gt; {
189 189 const j = await r.json();
190 190 expect(Array.isArray(j.projects)).toBe(true);
191 191 });
  192 +
  193 + // P5 — UI-supporting endpoints
  194 + describe('P5 UI endpoints', () => {
  195 + let pid;
  196 + beforeAll(async () => {
  197 + const cid = 'c-p5-routes-' + Math.random().toString(36).slice(2, 7);
  198 + created.push(cid);
  199 + const r = await fetch(`${base}/api/devloop/projects`, {
  200 + method: 'POST', headers: { 'Content-Type': 'application/json' },
  201 + body: JSON.stringify({ company_id: cid, requirement: 'p5 test' }),
  202 + });
  203 + pid = (await r.json()).pid;
  204 + });
  205 +
  206 + it('GET /timeline returns events array', async () => {
  207 + const r = await fetch(`${base}/api/devloop/projects/${pid}/timeline`);
  208 + expect(r.status).toBe(200);
  209 + const j = await r.json();
  210 + expect(Array.isArray(j.events)).toBe(true);
  211 + expect(j.events.length).toBeGreaterThan(0);
  212 + });
  213 +
  214 + it('GET /changelog returns entries array', async () => {
  215 + const r = await fetch(`${base}/api/devloop/projects/${pid}/changelog`);
  216 + expect(r.status).toBe(200);
  217 + const j = await r.json();
  218 + expect(Array.isArray(j.entries)).toBe(true);
  219 + });
  220 +
  221 + it('GET /units/:unitId returns unit + runs + coder_log_tail', async () => {
  222 + const r = await fetch(`${base}/api/devloop/projects/${pid}/units/todo-be`);
  223 + expect(r.status).toBe(200);
  224 + const j = await r.json();
  225 + expect(j).toHaveProperty('unit');
  226 + expect(j).toHaveProperty('runs');
  227 + expect(Array.isArray(j.runs)).toBe(true);
  228 + });
  229 +
  230 + it('GET /units/:unitId 404 on unknown unit', async () => {
  231 + const r = await fetch(`${base}/api/devloop/projects/${pid}/units/no-such-unit`);
  232 + expect(r.status).toBe(404);
  233 + });
  234 +
  235 + it('GET /timeline 404 on unknown pid', async () => {
  236 + const r = await fetch(`${base}/api/devloop/projects/p-nope/timeline`);
  237 + expect(r.status).toBe(404);
  238 + });
  239 + });
192 240 });
... ...
web/src/components/devloop/DashboardHeader.vue 0 → 100644
  1 +<template>
  2 + <header class="dl-header">
  3 + <div class="left">
  4 + <router-link to="/company" class="back">← LemonBoss</router-link>
  5 + <h1>DevLoop · {{ pid }}</h1>
  6 + <span class="status-badge" :class="status">{{ status }}</span>
  7 + </div>
  8 + <div class="right">
  9 + <button class="btn primary" @click="$emit('feedback')" :disabled="terminal">+ NEW REQUIREMENT</button>
  10 + <button class="btn signoff" @click="$emit('signoff')" :disabled="status !== 'shipped'">Sign Off</button>
  11 + <button class="btn abandon" @click="$emit('abandon')" :disabled="terminal">Abandon</button>
  12 + <span class="sep">|</span>
  13 + <button class="btn link" @click="$emit('ops', 'bugs')">Ops →</button>
  14 + </div>
  15 + </header>
  16 +</template>
  17 +
  18 +<script setup>
  19 +import { computed } from 'vue';
  20 +const props = defineProps({
  21 + pid: { type: String, required: true },
  22 + state: { type: Object, default: null },
  23 +});
  24 +defineEmits(['feedback', 'signoff', 'abandon', 'ops']);
  25 +const status = computed(() => props.state?.status || props.state?.project?.status || 'unknown');
  26 +const terminal = computed(() => status.value === 'shipped-final' || status.value === 'abandoned');
  27 +</script>
  28 +
  29 +<style scoped>
  30 +.dl-header {
  31 + display:flex; align-items:center; justify-content:space-between;
  32 + padding:12px 0; border-bottom:1px solid #eee;
  33 +}
  34 +.left { display:flex; align-items:center; gap:14px; }
  35 +.right { display:flex; align-items:center; gap:8px; }
  36 +.back { color:#888; text-decoration:none; font-size:13px; }
  37 +.back:hover { color:#222; }
  38 +h1 { font-size:18px; font-weight:600; margin:0; font-family: ui-monospace, monospace; }
  39 +.status-badge {
  40 + font-size:11px; padding:3px 10px; border-radius:12px; text-transform:uppercase; font-weight:600;
  41 +}
  42 +.status-badge.draft { background:#eee; color:#666; }
  43 +.status-badge.running { background:#cce5ff; color:#004085; }
  44 +.status-badge.shipped { background:#d4edda; color:#155724; }
  45 +.status-badge.shipped-final { background:#0a6; color:#fff; }
  46 +.status-badge.abandoned { background:#222; color:#fff; }
  47 +.status-badge.l2_blocked { background:#fff3cd; color:#856404; }
  48 +.status-badge.l2_exhausted { background:#f8d7da; color:#721c24; }
  49 +.status-badge.unknown { background:#fafafa; color:#999; border:1px solid #eee; }
  50 +.btn { padding:6px 14px; border:none; border-radius:4px; cursor:pointer; font-size:13px; }
  51 +.btn:disabled { background:#ddd; color:#999; cursor:not-allowed; }
  52 +.btn.primary { background:#222; color:#fff; }
  53 +.btn.signoff { background:#0a6; color:#fff; }
  54 +.btn.abandon { background:#822; color:#fff; }
  55 +.btn.link { background:transparent; color:#0a6; padding:6px 0; }
  56 +.sep { color:#ccc; }
  57 +</style>
... ...
web/src/components/devloop/FeedbackModal.vue 0 → 100644
  1 +<template>
  2 + <div class="modal-backdrop" @click.self="$emit('close')">
  3 + <div class="modal">
  4 + <header>
  5 + <h2>+ NEW REQUIREMENT</h2>
  6 + <button class="x" @click="$emit('close')">×</button>
  7 + </header>
  8 + <p class="hint">提交一段新的需求 / 反馈 → PM 进 incremental 模式,追加 PRD 章节并 bump rev。</p>
  9 + <textarea
  10 + v-model="text"
  11 + :disabled="submitting"
  12 + placeholder="例如:增加 toggle done 功能;或:把 POST /todos 改成支持 text+priority"
  13 + />
  14 + <div v-if="err" class="err">{{ err }}</div>
  15 + <div v-if="ok" class="ok">{{ ok }}</div>
  16 + <footer>
  17 + <button class="btn cancel" @click="$emit('close')" :disabled="submitting">Cancel</button>
  18 + <button class="btn submit" @click="submit" :disabled="!text.trim() || submitting">{{ submitting ? '...' : 'Submit' }}</button>
  19 + </footer>
  20 + </div>
  21 + </div>
  22 +</template>
  23 +
  24 +<script setup>
  25 +import { ref } from 'vue';
  26 +const props = defineProps({ pid: { type: String, required: true } });
  27 +const emit = defineEmits(['close', 'submitted']);
  28 +
  29 +const text = ref('');
  30 +const submitting = ref(false);
  31 +const err = ref('');
  32 +const ok = ref('');
  33 +
  34 +async function submit() {
  35 + if (!text.value.trim()) return;
  36 + submitting.value = true;
  37 + err.value = ''; ok.value = '';
  38 + try {
  39 + const r = await fetch(`/api/devloop/projects/${props.pid}/feedback`, {
  40 + method: 'POST', headers: { 'Content-Type': 'application/json' },
  41 + body: JSON.stringify({ text: text.value.trim(), by: 'human' }),
  42 + });
  43 + const j = await r.json();
  44 + if (!r.ok) {
  45 + err.value = j.error || `HTTP ${r.status}`;
  46 + } else {
  47 + ok.value = `PRD bumped: rev ${j.prd_rev_before} → ${j.prd_rev_after} · status=${j.status}`;
  48 + setTimeout(() => emit('submitted'), 800);
  49 + }
  50 + } catch (e) {
  51 + err.value = e.message;
  52 + } finally {
  53 + submitting.value = false;
  54 + }
  55 +}
  56 +</script>
  57 +
  58 +<style scoped>
  59 +.modal-backdrop {
  60 + position:fixed; inset:0; background:rgba(0,0,0,.4); z-index:1000;
  61 + display:flex; align-items:center; justify-content:center;
  62 +}
  63 +.modal {
  64 + background:#fff; border-radius:8px; width:560px; max-width:90vw;
  65 + padding:20px 24px; box-shadow:0 8px 32px rgba(0,0,0,.2);
  66 +}
  67 +header { display:flex; justify-content:space-between; align-items:center; margin-bottom:8px; }
  68 +h2 { margin:0; font-size:16px; }
  69 +.x { background:none; border:none; font-size:24px; cursor:pointer; color:#888; line-height:1; }
  70 +.hint { color:#888; font-size:12px; margin:0 0 12px 0; }
  71 +textarea {
  72 + width:100%; min-height:120px; padding:10px; box-sizing:border-box;
  73 + border:1px solid #ccc; border-radius:4px; font-family:inherit; resize:vertical;
  74 +}
  75 +.err { color:#c00; font-size:12px; margin-top:8px; }
  76 +.ok { color:#070; font-size:12px; margin-top:8px; }
  77 +footer { margin-top:12px; display:flex; justify-content:flex-end; gap:8px; }
  78 +.btn { padding:6px 14px; border:none; border-radius:4px; cursor:pointer; font-size:13px; }
  79 +.btn:disabled { background:#ddd !important; color:#999 !important; cursor:not-allowed; }
  80 +.btn.cancel { background:#eee; color:#222; }
  81 +.btn.submit { background:#222; color:#fff; }
  82 +</style>
... ...
web/src/components/devloop/LiveAgentStream.vue 0 → 100644
  1 +<template>
  2 + <div class="stream">
  3 + <div class="bar">
  4 + <span class="conn" :class="status">● {{ status }}</span>
  5 + <span class="lei" v-if="lastEventId">last-event-id: {{ lastEventId }}</span>
  6 + <span class="grow" />
  7 + <button class="btn" @click="clear">clear</button>
  8 + </div>
  9 + <div ref="logEl" class="log">
  10 + <div v-for="(e, i) in events" :key="i" class="evt">
  11 + <span class="ts">{{ formatTs(e.ts) }}</span>
  12 + <span class="kind">[{{ e.kind }}]</span>
  13 + <span class="payload">{{ e.payload }}</span>
  14 + </div>
  15 + <div v-if="!events.length" class="empty">waiting for events…</div>
  16 + </div>
  17 + </div>
  18 +</template>
  19 +
  20 +<script setup>
  21 +import { ref, onMounted, onBeforeUnmount, nextTick } from 'vue';
  22 +const props = defineProps({ pid: { type: String, required: true } });
  23 +
  24 +const events = ref([]);
  25 +const status = ref('connecting');
  26 +const lastEventId = ref(null);
  27 +const logEl = ref(null);
  28 +let es = null;
  29 +let reconnectTimer = null;
  30 +const MAX_EVENTS = 250;
  31 +
  32 +function connect() {
  33 + if (es) try { es.close(); } catch {}
  34 + status.value = 'connecting';
  35 + const url = `/api/devloop/projects/${props.pid}/stream`;
  36 + // EventSource 自带 Last-Event-ID; 用户网络断开自动重连。
  37 + // (本应用浏览器会自动从 lastEventId 续传)
  38 + try {
  39 + es = new EventSource(url);
  40 + } catch (e) {
  41 + status.value = 'error';
  42 + return;
  43 + }
  44 + es.onopen = () => { status.value = 'connected'; };
  45 + es.onerror = () => {
  46 + status.value = 'reconnecting';
  47 + // EventSource 内置 reconnect, 但浏览器不一定保证 5s 内重连;
  48 + // 手动 5s 重试兜底:断后 5s 关掉重新打开,带 Last-Event-ID 由浏览器自动注入。
  49 + try { es.close(); } catch {}
  50 + es = null;
  51 + if (reconnectTimer) clearTimeout(reconnectTimer);
  52 + reconnectTimer = setTimeout(connect, 5000);
  53 + };
  54 + // 监听几个核心事件类型(SSE event name)
  55 + const listenTo = [
  56 + 'project.status', 'project.shipped', 'project.run_started',
  57 + 'l1.wave_start', 'unit.state',
  58 + 'integration.run_start', 'integration.result',
  59 + 'triage.result', 'sdd.updated', 'prd.updated',
  60 + 'human_intervention_needed', 'project.l2_blocked', 'project.l2_exhausted',
  61 + 'units.reset', 'bug.opened', 'bug.transition',
  62 + ];
  63 + for (const k of listenTo) {
  64 + es.addEventListener(k, (ev) => addEvent(k, ev));
  65 + }
  66 + // generic message handler 兜底
  67 + es.onmessage = (ev) => addEvent(ev.type || 'message', ev);
  68 +}
  69 +
  70 +function addEvent(kind, ev) {
  71 + let payload;
  72 + try { payload = JSON.stringify(JSON.parse(ev.data)); } catch { payload = ev.data; }
  73 + if (ev.lastEventId) lastEventId.value = ev.lastEventId;
  74 + events.value.push({ ts: Date.now(), kind, payload });
  75 + if (events.value.length > MAX_EVENTS) events.value.splice(0, events.value.length - MAX_EVENTS);
  76 + nextTick(() => {
  77 + if (logEl.value) logEl.value.scrollTop = logEl.value.scrollHeight;
  78 + });
  79 +}
  80 +
  81 +function formatTs(ts) {
  82 + const d = new Date(ts);
  83 + return d.toTimeString().slice(0, 8);
  84 +}
  85 +
  86 +function clear() { events.value = []; }
  87 +
  88 +onMounted(connect);
  89 +onBeforeUnmount(() => {
  90 + if (reconnectTimer) clearTimeout(reconnectTimer);
  91 + if (es) try { es.close(); } catch {}
  92 +});
  93 +</script>
  94 +
  95 +<style scoped>
  96 +.stream { display:flex; flex-direction:column; height:240px; }
  97 +.bar { display:flex; gap:10px; align-items:center; font-size:12px; color:#666; margin-bottom:6px; }
  98 +.bar .grow { flex:1; }
  99 +.conn { font-weight:600; }
  100 +.conn.connecting { color:#888; }
  101 +.conn.connected { color:#0a6; }
  102 +.conn.reconnecting { color:#c70; }
  103 +.conn.error { color:#c00; }
  104 +.lei { font-family: ui-monospace, monospace; color:#aaa; }
  105 +.log {
  106 + flex:1; overflow-y:auto;
  107 + background:#0f1115; color:#e6e6e6;
  108 + font-family: ui-monospace, monospace; font-size:12px;
  109 + padding:10px; border-radius:6px;
  110 +}
  111 +.evt { display:flex; gap:8px; margin-bottom:4px; }
  112 +.ts { color:#7da; flex:0 0 70px; }
  113 +.kind { color:#ffce6a; flex:0 0 auto; }
  114 +.payload { color:#bbd; overflow-wrap:anywhere; flex:1; }
  115 +.empty { color:#666; }
  116 +.btn { background:#222; color:#fff; border:none; border-radius:4px; padding:3px 10px; cursor:pointer; font-size:11px; }
  117 +</style>
... ...
web/src/components/devloop/LoopStatusBar.vue 0 → 100644
  1 +<template>
  2 + <div class="loop-bar">
  3 + <div class="phase" :class="{active: phaseAt('docs')}">
  4 + <span class="dot" />Docs<small>PRD rev {{ state.prd_rev ?? '-' }} · SDD rev {{ state.sdd_rev ?? '-' }}</small>
  5 + </div>
  6 + <div class="arrow">→</div>
  7 + <div class="phase" :class="{active: phaseAt('l1')}">
  8 + <span class="dot" />L1 Units<small>{{ l1Summary }}</small>
  9 + </div>
  10 + <div class="arrow">→</div>
  11 + <div class="phase" :class="{active: phaseAt('l2')}">
  12 + <span class="dot" />L2 Integration<small>l2_rounds {{ l2Rounds }}/{{ l2Budget }}</small>
  13 + </div>
  14 + <div class="arrow">→</div>
  15 + <div class="phase" :class="{active: phaseAt('ship')}">
  16 + <span class="dot" />Ship<small>{{ statusLabel }}</small>
  17 + </div>
  18 + </div>
  19 +</template>
  20 +
  21 +<script setup>
  22 +import { computed } from 'vue';
  23 +const props = defineProps({ state: { type: Object, required: true } });
  24 +
  25 +const status = computed(() => props.state.status || props.state.project?.status || 'unknown');
  26 +const units = computed(() => props.state.units || []);
  27 +const l1Summary = computed(() => {
  28 + const u = units.value;
  29 + if (!u.length) return 'no units';
  30 + const passed = u.filter(x => x.status === 'passed').length;
  31 + return `${passed}/${u.length} passed`;
  32 +});
  33 +const l2Rounds = computed(() => props.state.project?.status === 'shipped' ? props.state.units_rev || 0 : props.state.units_rev || 0);
  34 +const l2Budget = computed(() => 3);
  35 +const statusLabel = computed(() => status.value);
  36 +
  37 +function phaseAt(p) {
  38 + const s = status.value;
  39 + if (p === 'docs') return s !== 'unknown' && s !== 'draft';
  40 + if (p === 'l1') return ['running', 'shipped', 'shipped-final', 'l2_blocked', 'l2_exhausted'].includes(s);
  41 + if (p === 'l2') return ['shipped', 'shipped-final', 'l2_blocked', 'l2_exhausted'].includes(s);
  42 + if (p === 'ship') return ['shipped', 'shipped-final'].includes(s);
  43 + return false;
  44 +}
  45 +</script>
  46 +
  47 +<style scoped>
  48 +.loop-bar { display:flex; align-items:stretch; gap:8px; }
  49 +.phase {
  50 + flex:1; padding:12px 16px; border:1px solid #e3e3e8; border-radius:6px;
  51 + display:flex; flex-direction:column; gap:4px; background:#f9f9fb;
  52 + font-weight:500; color:#999;
  53 +}
  54 +.phase.active { background:#fff; border-color:#0a6; color:#222; }
  55 +.phase small { font-weight:400; font-size:11px; color:#888; }
  56 +.phase .dot {
  57 + width:8px; height:8px; border-radius:50%; background:#ccc; display:inline-block; margin-right:6px;
  58 +}
  59 +.phase.active .dot { background:#0a6; }
  60 +.arrow { display:flex; align-items:center; color:#aaa; font-size:18px; }
  61 +</style>
... ...
web/src/components/devloop/PrdDiffPanel.vue 0 → 100644
  1 +<template>
  2 + <div class="prd-diff">
  3 + <div class="meta">
  4 + <span class="rev">PRD rev <b>{{ state?.prd_rev ?? '-' }}</b></span>
  5 + <span class="rev">SDD rev <b>{{ state?.sdd_rev ?? '-' }}</b></span>
  6 + <span class="grow" />
  7 + <button class="btn" @click="reload" :disabled="loading">{{ loading ? '...' : 'refresh' }}</button>
  8 + </div>
  9 +
  10 + <div v-if="entries.length === 0" class="empty">no changelog yet</div>
  11 +
  12 + <ul v-else class="entries">
  13 + <li v-for="e in entries.slice(0, 12)" :key="e.ts + ':' + e.kind" :class="['entry', e.kind]">
  14 + <span class="kind">{{ e.kind }}</span>
  15 + <span class="rev-num">rev {{ e.rev }}</span>
  16 + <span class="trigger">[{{ e.trigger || '?' }}]</span>
  17 + <span class="summary">{{ e.summary }}</span>
  18 + </li>
  19 + </ul>
  20 + </div>
  21 +</template>
  22 +
  23 +<script setup>
  24 +import { ref, onMounted, watch } from 'vue';
  25 +const props = defineProps({
  26 + pid: { type: String, required: true },
  27 + state: { type: Object, default: null },
  28 +});
  29 +
  30 +const entries = ref([]);
  31 +const loading = ref(false);
  32 +
  33 +async function reload() {
  34 + loading.value = true;
  35 + try {
  36 + const r = await fetch(`/api/devloop/projects/${props.pid}/changelog`);
  37 + const j = await r.json();
  38 + entries.value = j.entries || [];
  39 + } catch (e) {
  40 + /* swallow */
  41 + } finally {
  42 + loading.value = false;
  43 + }
  44 +}
  45 +
  46 +onMounted(reload);
  47 +// Refresh changelog when prd_rev or sdd_rev changes
  48 +watch(() => props.state?.prd_rev, reload);
  49 +watch(() => props.state?.sdd_rev, reload);
  50 +</script>
  51 +
  52 +<style scoped>
  53 +.prd-diff { display:flex; flex-direction:column; gap:8px; }
  54 +.meta { display:flex; align-items:center; gap:14px; font-size:12px; color:#666; }
  55 +.meta .grow { flex:1; }
  56 +.meta .rev b { color:#222; font-size:14px; }
  57 +.empty { padding:20px; color:#888; text-align:center; }
  58 +.entries { list-style:none; padding:0; margin:0; max-height:200px; overflow-y:auto; }
  59 +.entry {
  60 + display:flex; gap:10px; padding:6px 8px;
  61 + border-bottom:1px solid #f0f0f3; font-size:13px;
  62 +}
  63 +.entry:last-child { border-bottom:none; }
  64 +.entry .kind {
  65 + font-family: ui-monospace, monospace; font-size:11px; padding:2px 8px; border-radius:10px;
  66 + background:#eef; color:#446; flex:0 0 auto;
  67 +}
  68 +.entry.sdd_rev .kind { background:#ffeede; color:#a64a00; }
  69 +.entry.prd_rev .kind { background:#deecff; color:#1d4d99; }
  70 +.entry .rev-num { font-weight:600; color:#222; flex:0 0 50px; }
  71 +.entry .trigger { color:#888; font-size:11px; flex:0 0 auto; }
  72 +.entry .summary { color:#444; flex:1; overflow:hidden; text-overflow:ellipsis; }
  73 +.btn { background:#222; color:#fff; border:none; border-radius:4px; padding:3px 10px; cursor:pointer; font-size:11px; }
  74 +.btn:disabled { background:#bbb; }
  75 +</style>
... ...
web/src/components/devloop/UnitDrawer.vue 0 → 100644
  1 +<template>
  2 + <div class="drawer-backdrop" @click.self="$emit('close')">
  3 + <aside class="drawer">
  4 + <header>
  5 + <h2>{{ unitId }}</h2>
  6 + <button class="x" @click="$emit('close')">×</button>
  7 + </header>
  8 +
  9 + <div v-if="loading" class="loading">loading…</div>
  10 + <div v-else-if="err" class="err">{{ err }}</div>
  11 + <div v-else class="body">
  12 + <section>
  13 + <h3>Metadata</h3>
  14 + <div class="meta">
  15 + <div><label>status</label><span :class="['badge', unit?.status]">{{ unit?.status || '?' }}</span></div>
  16 + <div><label>type</label><span>{{ unit?.type || unit?.kind || '?' }}</span></div>
  17 + <div><label>retries</label><span>{{ unit?.retries ?? 0 }}/{{ unit?.retry_budget ?? '?' }}</span></div>
  18 + <div><label>contract</label><span class="hash">{{ unit?.contract_hash || '?' }}</span></div>
  19 + <div><label>depends_on</label><span>{{ (unit?.depends_on || []).join(', ') || '∅' }}</span></div>
  20 + </div>
  21 + </section>
  22 +
  23 + <section>
  24 + <h3>Recent runs ({{ runs.length }})</h3>
  25 + <ul class="runs">
  26 + <li v-for="r in runs" :key="r.run_id" :class="['run', r.result?.ok ? 'ok' : 'fail']">
  27 + <div class="row">
  28 + <span class="rid">{{ r.run_id }}</span>
  29 + <span class="dot" :class="{ok: r.result?.ok, fail: r.result?.ok === false, crashed: r.result?.crashed }" />
  30 + <span class="sum">
  31 + {{ r.result ? `${r.result.summary?.passed || 0}/${r.result.summary?.total || 0}` : 'no result' }}
  32 + </span>
  33 + </div>
  34 + <div v-for="(f, i) in (r.result?.failures || [])" :key="i" class="failure">
  35 + <code>{{ f.test || f.case_id }}</code>: {{ f.hint || f.stack?.slice(0, 100) || 'no hint' }}
  36 + </div>
  37 + </li>
  38 + <li v-if="!runs.length" class="empty">no runs yet</li>
  39 + </ul>
  40 + </section>
  41 +
  42 + <section v-if="coderLog">
  43 + <h3>coder.log (tail)</h3>
  44 + <pre class="coder-log">{{ coderLog }}</pre>
  45 + </section>
  46 + </div>
  47 + </aside>
  48 + </div>
  49 +</template>
  50 +
  51 +<script setup>
  52 +import { ref, onMounted } from 'vue';
  53 +const props = defineProps({
  54 + pid: { type: String, required: true },
  55 + unitId: { type: String, required: true },
  56 +});
  57 +defineEmits(['close']);
  58 +
  59 +const loading = ref(true);
  60 +const err = ref('');
  61 +const unit = ref(null);
  62 +const runs = ref([]);
  63 +const coderLog = ref('');
  64 +
  65 +onMounted(async () => {
  66 + try {
  67 + const r = await fetch(`/api/devloop/projects/${props.pid}/units/${props.unitId}`);
  68 + if (!r.ok) { err.value = `HTTP ${r.status}`; return; }
  69 + const j = await r.json();
  70 + unit.value = j.unit;
  71 + runs.value = j.runs || [];
  72 + coderLog.value = j.coder_log_tail || '';
  73 + } catch (e) {
  74 + err.value = e.message;
  75 + } finally {
  76 + loading.value = false;
  77 + }
  78 +});
  79 +</script>
  80 +
  81 +<style scoped>
  82 +.drawer-backdrop {
  83 + position:fixed; inset:0; background:rgba(0,0,0,.35); z-index:900;
  84 + display:flex; justify-content:flex-end;
  85 +}
  86 +.drawer {
  87 + background:#fff; width:520px; max-width:95vw; height:100vh; overflow-y:auto;
  88 + padding:20px 24px; box-shadow:-8px 0 32px rgba(0,0,0,.15);
  89 +}
  90 +header { display:flex; justify-content:space-between; align-items:center; margin-bottom:12px; }
  91 +h2 { margin:0; font-family: ui-monospace, monospace; }
  92 +.x { background:none; border:none; font-size:24px; cursor:pointer; color:#888; }
  93 +section { margin:18px 0; }
  94 +h3 { font-size:13px; color:#666; margin:0 0 8px; text-transform:uppercase; }
  95 +.loading, .err { padding:20px; text-align:center; color:#888; }
  96 +.err { color:#c00; }
  97 +.meta { display:grid; grid-template-columns: 110px 1fr; gap:6px 12px; font-size:13px; }
  98 +.meta label { color:#888; }
  99 +.meta .hash { font-family: ui-monospace, monospace; font-size:11px; color:#555; }
  100 +.badge { display:inline-block; padding:2px 8px; border-radius:10px; font-size:11px; text-transform:uppercase; background:#eee; }
  101 +.badge.passed { background:#d4edda; color:#155724; }
  102 +.badge.failed { background:#f8d7da; color:#721c24; }
  103 +.badge.coding { background:#fff3cd; color:#856404; }
  104 +.badge.testing { background:#cce5ff; color:#004085; }
  105 +.badge.pending { background:#eee; color:#666; }
  106 +.badge.blocked { background:#d6d8db; color:#383d41; }
  107 +.runs { list-style:none; padding:0; margin:0; }
  108 +.run { border:1px solid #eee; border-radius:4px; padding:8px 10px; margin-bottom:8px; }
  109 +.run.ok { background:#fafefa; }
  110 +.run.fail { background:#fef9f9; }
  111 +.run .row { display:flex; align-items:center; gap:10px; }
  112 +.rid { font-family: ui-monospace, monospace; font-size:12px; color:#555; }
  113 +.dot { width:8px; height:8px; border-radius:50%; background:#ccc; }
  114 +.dot.ok { background:#0a6; }
  115 +.dot.fail { background:#c00; }
  116 +.dot.crashed { background:#822; }
  117 +.sum { color:#666; font-size:12px; }
  118 +.failure { margin-top:4px; padding:4px 8px; background:#fff8f8; border-left:2px solid #c00; font-size:11px; color:#444; }
  119 +.failure code { background:#eee; padding:1px 4px; border-radius:2px; font-size:11px; }
  120 +.coder-log { background:#0f1115; color:#e6e6e6; padding:10px; border-radius:4px; font-size:11px; max-height:240px; overflow-y:auto; }
  121 +.empty { color:#888; padding:10px; }
  122 +</style>
... ...
web/src/components/devloop/UnitsDAG.vue 0 → 100644
  1 +<template>
  2 + <div class="dag-wrap">
  3 + <svg v-if="units.length" :viewBox="`0 0 ${W} ${H}`" :width="W" :height="H">
  4 + <defs>
  5 + <marker id="arr" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
  6 + <path d="M0,0 L10,5 L0,10 z" fill="#999" />
  7 + </marker>
  8 + </defs>
  9 + <line
  10 + v-for="(e, i) in edges"
  11 + :key="'e'+i"
  12 + :x1="e.x1" :y1="e.y1" :x2="e.x2" :y2="e.y2"
  13 + stroke="#999" stroke-width="1.5" marker-end="url(#arr)"
  14 + />
  15 + <g
  16 + v-for="(n, i) in nodes"
  17 + :key="'n'+n.id"
  18 + :transform="`translate(${n.x},${n.y})`"
  19 + class="node-g"
  20 + @click="$emit('select', n.id)"
  21 + >
  22 + <rect :width="NW" :height="NH" rx="6" :class="['node', n.status]" />
  23 + <text :x="NW/2" :y="20" text-anchor="middle" class="id-label">{{ n.id }}</text>
  24 + <text :x="NW/2" :y="38" text-anchor="middle" class="status-label">{{ n.status }}</text>
  25 + </g>
  26 + </svg>
  27 + <div v-else class="empty">no units yet</div>
  28 + <p class="hint">click a unit node to inspect recent runs</p>
  29 + </div>
  30 +</template>
  31 +
  32 +<script setup>
  33 +import { computed } from 'vue';
  34 +const props = defineProps({ units: { type: Array, default: () => [] } });
  35 +defineEmits(['select']);
  36 +
  37 +const NW = 130, NH = 52, GAP_X = 40, GAP_Y = 80, PAD = 20;
  38 +
  39 +const waves = computed(() => {
  40 + const remaining = new Set(props.units.map(u => u.id));
  41 + const deps = new Map(props.units.map(u => [u.id, (u.depends_on || []).slice()]));
  42 + const out = [];
  43 + let safety = 100;
  44 + while (remaining.size > 0 && safety-- > 0) {
  45 + const wave = [];
  46 + for (const id of remaining) {
  47 + if (deps.get(id).every(d => !remaining.has(d))) wave.push(id);
  48 + }
  49 + if (!wave.length) break;
  50 + for (const id of wave) remaining.delete(id);
  51 + out.push(wave);
  52 + }
  53 + return out;
  54 +});
  55 +
  56 +const nodes = computed(() => {
  57 + const out = [];
  58 + waves.value.forEach((wave, wi) => {
  59 + wave.forEach((id, ni) => {
  60 + const u = props.units.find(x => x.id === id);
  61 + out.push({
  62 + id, status: u?.status || 'unknown',
  63 + x: PAD + wi * (NW + GAP_X),
  64 + y: PAD + ni * (NH + 20),
  65 + wave: wi,
  66 + });
  67 + });
  68 + });
  69 + return out;
  70 +});
  71 +
  72 +const edges = computed(() => {
  73 + const out = [];
  74 + for (const u of props.units) {
  75 + for (const dep of (u.depends_on || [])) {
  76 + const a = nodes.value.find(n => n.id === dep);
  77 + const b = nodes.value.find(n => n.id === u.id);
  78 + if (a && b) out.push({ x1: a.x + NW, y1: a.y + NH/2, x2: b.x, y2: b.y + NH/2 });
  79 + }
  80 + }
  81 + return out;
  82 +});
  83 +
  84 +const W = computed(() => Math.max(400, PAD * 2 + waves.value.length * (NW + GAP_X) - GAP_X));
  85 +const H = computed(() => {
  86 + const maxRow = Math.max(...waves.value.map(w => w.length), 1);
  87 + return PAD * 2 + maxRow * (NH + 20) - 20;
  88 +});
  89 +</script>
  90 +
  91 +<style scoped>
  92 +.dag-wrap svg { display:block; max-width:100%; background:#fafafb; border:1px dashed #eee; border-radius:6px; }
  93 +.node-g { cursor: pointer; }
  94 +.node { fill:#fff; stroke:#888; stroke-width:1.5; }
  95 +.node:hover { stroke:#222; }
  96 +.node.pending { fill:#f5f5f5; stroke:#888; }
  97 +.node.coding { fill:#fff3cd; stroke:#856404; }
  98 +.node.testing { fill:#cce5ff; stroke:#004085; }
  99 +.node.passed { fill:#d4edda; stroke:#155724; }
  100 +.node.failed { fill:#f8d7da; stroke:#721c24; }
  101 +.node.blocked { fill:#d6d8db; stroke:#383d41; }
  102 +.node.unknown { fill:#fff; stroke:#aaa; }
  103 +.id-label { font-size:13px; font-weight:600; font-family: ui-monospace, monospace; pointer-events:none; }
  104 +.status-label { font-size:10px; fill:#666; text-transform:uppercase; pointer-events:none; }
  105 +.empty { padding:24px; color:#888; text-align:center; }
  106 +.hint { font-size:11px; color:#aaa; margin-top:6px; }
  107 +</style>
... ...
web/src/components/devloop/ops/BuglistView.vue 0 → 100644
  1 +<template>
  2 + <div class="bug-view">
  3 + <div class="filters">
  4 + <label>Status:
  5 + <select v-model="filterStatus" @change="reload">
  6 + <option value="">all</option>
  7 + <option>open</option><option>fixing</option>
  8 + <option>fixed</option><option>verified</option>
  9 + <option>wontfix</option><option>duplicate</option>
  10 + </select>
  11 + </label>
  12 + <span class="total">Total: {{ bugs.length }}</span>
  13 + <button class="btn" @click="reload">Refresh</button>
  14 + </div>
  15 +
  16 + <table v-if="bugs.length">
  17 + <thead>
  18 + <tr>
  19 + <th>ID</th>
  20 + <th>Status</th>
  21 + <th>Unit</th>
  22 + <th>Severity</th>
  23 + <th>Category</th>
  24 + <th>Title</th>
  25 + <th>Updated</th>
  26 + </tr>
  27 + </thead>
  28 + <tbody>
  29 + <tr v-for="b in bugs" :key="b.id" :class="b.status">
  30 + <td class="id">{{ b.id }}</td>
  31 + <td><span :class="['badge', b.status]">{{ b.status }}</span></td>
  32 + <td>{{ b.unit_id || '-' }}</td>
  33 + <td>{{ b.severity || '-' }}</td>
  34 + <td>{{ b.category || 'unknown' }}</td>
  35 + <td class="title">{{ b.title }}</td>
  36 + <td class="ts">{{ formatTs(b.last_updated_at || b.discovered_at) }}</td>
  37 + </tr>
  38 + </tbody>
  39 + </table>
  40 + <p v-else class="empty">no bugs (or none match filter)</p>
  41 + </div>
  42 +</template>
  43 +
  44 +<script setup>
  45 +import { ref, onMounted } from 'vue';
  46 +const props = defineProps({ pid: { type: String, required: true } });
  47 +
  48 +const bugs = ref([]);
  49 +const filterStatus = ref('');
  50 +
  51 +async function reload() {
  52 + const qs = filterStatus.value ? `?status=${filterStatus.value}` : '';
  53 + const r = await fetch(`/api/devloop/projects/${props.pid}/bugs${qs}`);
  54 + if (!r.ok) { bugs.value = []; return; }
  55 + const j = await r.json();
  56 + bugs.value = j.bugs || [];
  57 +}
  58 +
  59 +function formatTs(s) {
  60 + if (!s) return '-';
  61 + try { return new Date(s).toLocaleString(); } catch { return s; }
  62 +}
  63 +
  64 +onMounted(reload);
  65 +</script>
  66 +
  67 +<style scoped>
  68 +.bug-view {}
  69 +.filters { display:flex; align-items:center; gap:14px; margin-bottom:12px; font-size:13px; }
  70 +.filters select { padding:4px 8px; }
  71 +.filters .total { color:#666; }
  72 +.btn { background:#222; color:#fff; border:none; border-radius:4px; padding:4px 12px; cursor:pointer; font-size:12px; }
  73 +table { width:100%; border-collapse:collapse; font-size:13px; }
  74 +th, td { padding:8px 10px; text-align:left; border-bottom:1px solid #eee; }
  75 +th { background:#fafafb; color:#666; font-weight:500; font-size:12px; }
  76 +tr:hover { background:#fafafa; }
  77 +.id { font-family: ui-monospace, monospace; color:#555; font-size:11px; }
  78 +.title { color:#222; }
  79 +.ts { color:#888; font-size:11px; }
  80 +.badge {
  81 + display:inline-block; padding:2px 8px; border-radius:10px; font-size:11px; text-transform:uppercase;
  82 +}
  83 +.badge.open { background:#fef0c8; color:#8d6708; }
  84 +.badge.fixing { background:#cce5ff; color:#004085; }
  85 +.badge.fixed { background:#d4edda; color:#155724; }
  86 +.badge.verified { background:#a3e0b8; color:#0a4a1d; }
  87 +.badge.wontfix { background:#ddd; color:#444; }
  88 +.badge.duplicate { background:#eee; color:#666; }
  89 +.empty { padding:30px; text-align:center; color:#888; }
  90 +</style>
... ...
web/src/components/devloop/ops/ChangelogView.vue 0 → 100644
  1 +<template>
  2 + <div class="changelog-view">
  3 + <div class="bar">
  4 + <span>Total: {{ entries.length }} changes</span>
  5 + <span class="grow" />
  6 + <button class="btn" @click="reload">Refresh</button>
  7 + </div>
  8 +
  9 + <table v-if="entries.length">
  10 + <thead>
  11 + <tr>
  12 + <th>When</th>
  13 + <th>Kind</th>
  14 + <th>Rev</th>
  15 + <th>By</th>
  16 + <th>Trigger</th>
  17 + <th>Summary</th>
  18 + </tr>
  19 + </thead>
  20 + <tbody>
  21 + <tr v-for="(e, i) in entries" :key="i">
  22 + <td class="ts">{{ formatTs(e.ts) }}</td>
  23 + <td><span :class="['k', e.kind]">{{ e.kind }}</span></td>
  24 + <td>{{ e.rev }}</td>
  25 + <td>{{ e.by || '-' }}</td>
  26 + <td class="trigger">[{{ e.trigger || '-' }}]</td>
  27 + <td class="summary">{{ e.summary }}</td>
  28 + </tr>
  29 + </tbody>
  30 + </table>
  31 + <p v-else class="empty">no changelog entries</p>
  32 + </div>
  33 +</template>
  34 +
  35 +<script setup>
  36 +import { ref, onMounted } from 'vue';
  37 +const props = defineProps({ pid: { type: String, required: true } });
  38 +const entries = ref([]);
  39 +
  40 +async function reload() {
  41 + const r = await fetch(`/api/devloop/projects/${props.pid}/changelog`);
  42 + if (!r.ok) return;
  43 + const j = await r.json();
  44 + entries.value = j.entries || [];
  45 +}
  46 +
  47 +function formatTs(s) {
  48 + if (!s) return '-';
  49 + try { return new Date(s).toLocaleString(); } catch { return s; }
  50 +}
  51 +
  52 +onMounted(reload);
  53 +</script>
  54 +
  55 +<style scoped>
  56 +.changelog-view {}
  57 +.bar { display:flex; align-items:center; gap:14px; margin-bottom:12px; font-size:13px; }
  58 +.bar .grow { flex:1; }
  59 +.btn { background:#222; color:#fff; border:none; border-radius:4px; padding:4px 12px; cursor:pointer; font-size:12px; }
  60 +table { width:100%; border-collapse:collapse; font-size:13px; }
  61 +th, td { padding:8px 10px; text-align:left; border-bottom:1px solid #eee; }
  62 +th { background:#fafafb; color:#666; font-weight:500; font-size:12px; }
  63 +.ts { color:#888; font-size:11px; }
  64 +.k {
  65 + display:inline-block; padding:1px 8px; border-radius:10px;
  66 + font-family: ui-monospace, monospace; font-size:11px;
  67 +}
  68 +.k.prd_rev { background:#deecff; color:#1d4d99; }
  69 +.k.sdd_rev { background:#ffeede; color:#a64a00; }
  70 +.trigger { color:#888; font-size:11px; }
  71 +.summary { color:#444; }
  72 +.empty { padding:30px; text-align:center; color:#888; }
  73 +</style>
... ...
web/src/components/devloop/ops/LoopLogView.vue 0 → 100644
  1 +<template>
  2 + <div class="loops-view">
  3 + <div class="bar">
  4 + <span>Total: {{ events.length }} events</span>
  5 + <span class="grow" />
  6 + <label>Filter:
  7 + <input v-model="filterText" placeholder="kind / summary substring" />
  8 + </label>
  9 + <button class="btn" @click="reload">Refresh</button>
  10 + </div>
  11 +
  12 + <section v-for="(grp, key) in groups" :key="key" class="group">
  13 + <h3>{{ key }} <span class="count">({{ grp.length }})</span></h3>
  14 + <ul>
  15 + <li v-for="e in grp" :key="e.ts + e.kind">
  16 + <span class="ts">{{ formatTs(e.ts) }}</span>
  17 + <span :class="['kind', e.kind.replace(/\./g, '-')]">{{ e.kind }}</span>
  18 + <span class="src">[{{ e.source || 'system' }}]</span>
  19 + <span class="summary">{{ e.summary }}</span>
  20 + </li>
  21 + </ul>
  22 + </section>
  23 +
  24 + <p v-if="!events.length" class="empty">no timeline events</p>
  25 + </div>
  26 +</template>
  27 +
  28 +<script setup>
  29 +import { ref, computed, onMounted } from 'vue';
  30 +const props = defineProps({ pid: { type: String, required: true } });
  31 +const events = ref([]);
  32 +const filterText = ref('');
  33 +
  34 +async function reload() {
  35 + const r = await fetch(`/api/devloop/projects/${props.pid}/timeline?limit=500`);
  36 + if (!r.ok) return;
  37 + const j = await r.json();
  38 + events.value = j.events || [];
  39 +}
  40 +
  41 +const filtered = computed(() => {
  42 + const q = filterText.value.toLowerCase().trim();
  43 + if (!q) return events.value;
  44 + return events.value.filter(e =>
  45 + (e.kind || '').toLowerCase().includes(q) ||
  46 + (e.summary || '').toLowerCase().includes(q));
  47 +});
  48 +
  49 +const groups = computed(() => {
  50 + const out = { 'L1 (unit)': [], 'L2 (integration / triage)': [], 'L3 (human / status)': [], 'System / Docs / Git': [] };
  51 + for (const e of filtered.value) {
  52 + const k = e.kind || '';
  53 + if (/^unit\.|^L1/.test(k)) out['L1 (unit)'].push(e);
  54 + else if (/integration|triage|sdd\.|units\.reset|l2_/.test(k)) out['L2 (integration / triage)'].push(e);
  55 + else if (/^project\.status|^project\.shipped|^project\.abandoned|human_intervention|prd\.updated/.test(k)) out['L3 (human / status)'].push(e);
  56 + else out['System / Docs / Git'].push(e);
  57 + }
  58 + return out;
  59 +});
  60 +
  61 +function formatTs(ts) {
  62 + if (!ts) return '-';
  63 + try { return new Date(ts).toLocaleTimeString(); } catch { return String(ts); }
  64 +}
  65 +
  66 +onMounted(reload);
  67 +</script>
  68 +
  69 +<style scoped>
  70 +.loops-view {}
  71 +.bar { display:flex; align-items:center; gap:14px; margin-bottom:12px; font-size:13px; }
  72 +.bar .grow { flex:1; }
  73 +.bar input { padding:4px 8px; border:1px solid #ccc; border-radius:4px; width:200px; }
  74 +.btn { background:#222; color:#fff; border:none; border-radius:4px; padding:4px 12px; cursor:pointer; font-size:12px; }
  75 +.group { margin-bottom:18px; }
  76 +.group h3 { font-size:12px; color:#888; margin:0 0 6px; text-transform:uppercase; letter-spacing:0.5px; }
  77 +.group h3 .count { color:#bbb; font-weight:400; }
  78 +ul { list-style:none; padding:0; margin:0; }
  79 +li {
  80 + display:flex; gap:8px; padding:5px 8px; font-size:12px;
  81 + border-bottom:1px solid #f5f5f7;
  82 +}
  83 +li:hover { background:#fafafa; }
  84 +.ts { color:#888; flex:0 0 100px; font-family: ui-monospace, monospace; }
  85 +.kind {
  86 + flex:0 0 auto; font-family: ui-monospace, monospace; font-size:11px;
  87 + padding:1px 6px; border-radius:4px; background:#eef; color:#446;
  88 +}
  89 +.kind.unit-state { background:#cce5ff; color:#004085; }
  90 +.kind.integration-result { background:#ffeede; color:#a64a00; }
  91 +.kind.triage-result { background:#fde6f5; color:#8b1d72; }
  92 +.kind.sdd-updated, .kind.prd-updated { background:#ffd; color:#7a6b00; }
  93 +.kind.project-shipped { background:#d4edda; color:#155724; }
  94 +.kind.project-status { background:#eee; color:#444; }
  95 +.src { color:#999; font-size:11px; flex:0 0 auto; }
  96 +.summary { color:#444; flex:1; overflow:hidden; text-overflow:ellipsis; }
  97 +.empty { padding:30px; text-align:center; color:#888; }
  98 +</style>
... ...
web/src/components/devloop/ops/ProgressView.vue 0 → 100644
  1 +<template>
  2 + <div class="progress-view">
  3 + <div v-if="!state" class="empty">loading…</div>
  4 + <div v-else class="cards">
  5 + <div class="card big">
  6 + <label>Project Status</label>
  7 + <div class="value">
  8 + <span :class="['badge', currentStatus]">{{ currentStatus }}</span>
  9 + </div>
  10 + <div class="meta">created {{ formatTs(state.project?.created_at) }}</div>
  11 + </div>
  12 +
  13 + <div class="card">
  14 + <label>PRD rev</label>
  15 + <div class="value mono">{{ state.prd_rev ?? '-' }}</div>
  16 + </div>
  17 +
  18 + <div class="card">
  19 + <label>SDD rev</label>
  20 + <div class="value mono">{{ state.sdd_rev ?? '-' }}</div>
  21 + </div>
  22 +
  23 + <div class="card">
  24 + <label>Units progress</label>
  25 + <div class="value">{{ unitsPassed }}/{{ units.length }} passed</div>
  26 + <div class="bar"><div :style="{ width: pctPassed + '%' }" class="fill" /></div>
  27 + </div>
  28 +
  29 + <div class="card">
  30 + <label>L2 rounds</label>
  31 + <div class="value">{{ l2Rounds }} / {{ l2Budget }}</div>
  32 + <div class="bar"><div :style="{ width: pctL2 + '%' }" class="fill l2" /></div>
  33 + </div>
  34 +
  35 + <div class="card">
  36 + <label>Bugs (open)</label>
  37 + <div class="value">{{ bugsOpen }} <small>/ {{ bugsTotal }} total</small></div>
  38 + </div>
  39 + </div>
  40 +
  41 + <h3>Per-unit breakdown</h3>
  42 + <table v-if="units.length">
  43 + <thead>
  44 + <tr><th>Unit</th><th>Status</th><th>Retries</th><th>Contract hash</th></tr>
  45 + </thead>
  46 + <tbody>
  47 + <tr v-for="u in units" :key="u.id">
  48 + <td class="mono">{{ u.id }}</td>
  49 + <td><span :class="['badge', u.status]">{{ u.status }}</span></td>
  50 + <td>{{ u.retries ?? 0 }} / {{ u.retry_budget ?? '-' }}</td>
  51 + <td class="mono hash">{{ u.contract_hash || '-' }}</td>
  52 + </tr>
  53 + </tbody>
  54 + </table>
  55 + </div>
  56 +</template>
  57 +
  58 +<script setup>
  59 +import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
  60 +const props = defineProps({ pid: { type: String, required: true } });
  61 +
  62 +const state = ref(null);
  63 +const bugs = ref([]);
  64 +let timer = null;
  65 +
  66 +async function load() {
  67 + try {
  68 + const [sR, bR] = await Promise.all([
  69 + fetch(`/api/devloop/projects/${props.pid}/state`),
  70 + fetch(`/api/devloop/projects/${props.pid}/bugs`),
  71 + ]);
  72 + if (sR.ok) state.value = await sR.json();
  73 + if (bR.ok) bugs.value = (await bR.json()).bugs || [];
  74 + } catch { /* ignore */ }
  75 +}
  76 +
  77 +const units = computed(() => state.value?.units || []);
  78 +const unitsPassed = computed(() => units.value.filter(u => u.status === 'passed').length);
  79 +const pctPassed = computed(() => units.value.length ? Math.round(unitsPassed.value / units.value.length * 100) : 0);
  80 +const currentStatus = computed(() => state.value?.status || state.value?.project?.status || 'unknown');
  81 +const l2Rounds = computed(() => {
  82 + // pull from units.json via the existing /state endpoint structure
  83 + // state.units_rev exists but rounds field needs separate fetch; for now read project.json if surfaced
  84 + return state.value?.project?.l2_rounds ?? 0;
  85 +});
  86 +const l2Budget = computed(() => 3);
  87 +const pctL2 = computed(() => Math.min(100, (l2Rounds.value / l2Budget.value) * 100));
  88 +const bugsOpen = computed(() => bugs.value.filter(b => b.status === 'open' || b.status === 'fixing').length);
  89 +const bugsTotal = computed(() => bugs.value.length);
  90 +
  91 +function formatTs(s) { if (!s) return '-'; try { return new Date(s).toLocaleString(); } catch { return s; } }
  92 +
  93 +onMounted(() => {
  94 + load();
  95 + timer = setInterval(load, 3000);
  96 +});
  97 +onBeforeUnmount(() => { if (timer) clearInterval(timer); });
  98 +</script>
  99 +
  100 +<style scoped>
  101 +.progress-view {}
  102 +.cards { display:grid; grid-template-columns: repeat(3, 1fr); gap:14px; }
  103 +.card { background:#fafafb; border:1px solid #e7e7eb; border-radius:8px; padding:14px 16px; }
  104 +.card.big { grid-column: 1 / -1; }
  105 +.card label { font-size:11px; text-transform:uppercase; color:#888; }
  106 +.card .value { font-size:22px; font-weight:600; margin-top:4px; }
  107 +.card .value small { font-size:13px; color:#888; font-weight:400; }
  108 +.card .meta { font-size:11px; color:#aaa; }
  109 +.mono { font-family: ui-monospace, monospace; }
  110 +.bar { width:100%; height:6px; background:#eee; border-radius:3px; margin-top:6px; overflow:hidden; }
  111 +.fill { height:6px; background:#0a6; border-radius:3px; transition: width .3s; }
  112 +.fill.l2 { background:#a64a00; }
  113 +.empty { padding:30px; text-align:center; color:#888; }
  114 +h3 { font-size:13px; color:#666; margin:24px 0 8px; text-transform:uppercase; }
  115 +table { width:100%; border-collapse:collapse; font-size:13px; }
  116 +th, td { padding:8px 10px; text-align:left; border-bottom:1px solid #eee; }
  117 +th { background:#fafafb; color:#666; font-weight:500; font-size:12px; }
  118 +.hash { font-size:11px; color:#666; }
  119 +.badge { display:inline-block; padding:2px 10px; border-radius:12px; font-size:11px; text-transform:uppercase; font-weight:600; }
  120 +.badge.draft { background:#eee; color:#666; }
  121 +.badge.running { background:#cce5ff; color:#004085; }
  122 +.badge.shipped { background:#d4edda; color:#155724; }
  123 +.badge.shipped-final { background:#0a6; color:#fff; }
  124 +.badge.abandoned { background:#222; color:#fff; }
  125 +.badge.l2_blocked { background:#fff3cd; color:#856404; }
  126 +.badge.l2_exhausted { background:#f8d7da; color:#721c24; }
  127 +.badge.pending { background:#eee; color:#666; }
  128 +.badge.coding { background:#fff3cd; color:#856404; }
  129 +.badge.testing { background:#cce5ff; color:#004085; }
  130 +.badge.passed { background:#d4edda; color:#155724; }
  131 +.badge.failed { background:#f8d7da; color:#721c24; }
  132 +.badge.blocked { background:#d6d8db; color:#383d41; }
  133 +.badge.unknown { background:#fafafa; color:#888; }
  134 +</style>
... ...
web/src/router/index.js
... ... @@ -69,6 +69,19 @@ const routes = [
69 69 name: 'QuotaMonitor',
70 70 component: () => import('@/views/QuotaMonitor.vue'),
71 71 },
  72 + // P5 · DevLoop Dashboard + Ops
  73 + {
  74 + path: '/devloop/:pid',
  75 + name: 'DevLoopDashboard',
  76 + component: () => import('@/views/DevLoopDashboard.vue'),
  77 + props: true,
  78 + },
  79 + {
  80 + path: '/devloop/:pid/ops/:tab(bugs|loops|changelog|progress)',
  81 + name: 'DevLoopOps',
  82 + component: () => import('@/views/DevLoopOps.vue'),
  83 + props: true,
  84 + },
72 85 ]
73 86  
74 87 const router = createRouter({
... ...
web/src/views/DevLoopDashboard.vue 0 → 100644
  1 +<template>
  2 + <div class="devloop-dashboard">
  3 + <DashboardHeader
  4 + :pid="pid"
  5 + :state="state"
  6 + @ops="goOps"
  7 + @feedback="openFeedback"
  8 + @signoff="onSignOff"
  9 + @abandon="onAbandon"
  10 + />
  11 +
  12 + <div v-if="error" class="error-banner">{{ error }}</div>
  13 +
  14 + <div v-if="state" class="grid">
  15 + <section class="loop-status">
  16 + <LoopStatusBar :state="state" />
  17 + </section>
  18 +
  19 + <section class="dag">
  20 + <h2>Units DAG</h2>
  21 + <UnitsDAG
  22 + :units="state.units || []"
  23 + @select="onUnitClick"
  24 + />
  25 + </section>
  26 +
  27 + <section class="live">
  28 + <h2>Live agent stream</h2>
  29 + <LiveAgentStream :pid="pid" />
  30 + </section>
  31 +
  32 + <section class="diff">
  33 + <h2>PRD changes</h2>
  34 + <PrdDiffPanel :pid="pid" :state="state" />
  35 + </section>
  36 + </div>
  37 +
  38 + <UnitDrawer
  39 + v-if="drawerUnit"
  40 + :pid="pid"
  41 + :unit-id="drawerUnit"
  42 + @close="drawerUnit = null"
  43 + />
  44 +
  45 + <FeedbackModal
  46 + v-if="showFeedback"
  47 + :pid="pid"
  48 + @close="showFeedback = false"
  49 + @submitted="onFeedbackSubmitted"
  50 + />
  51 + </div>
  52 +</template>
  53 +
  54 +<script setup>
  55 +import { ref, onMounted, onBeforeUnmount } from 'vue';
  56 +import { useRouter } from 'vue-router';
  57 +import DashboardHeader from '@/components/devloop/DashboardHeader.vue';
  58 +import LoopStatusBar from '@/components/devloop/LoopStatusBar.vue';
  59 +import UnitsDAG from '@/components/devloop/UnitsDAG.vue';
  60 +import LiveAgentStream from '@/components/devloop/LiveAgentStream.vue';
  61 +import PrdDiffPanel from '@/components/devloop/PrdDiffPanel.vue';
  62 +import FeedbackModal from '@/components/devloop/FeedbackModal.vue';
  63 +import UnitDrawer from '@/components/devloop/UnitDrawer.vue';
  64 +
  65 +const props = defineProps({ pid: { type: String, required: true } });
  66 +const router = useRouter();
  67 +
  68 +const state = ref(null);
  69 +const error = ref('');
  70 +const showFeedback = ref(false);
  71 +const drawerUnit = ref(null);
  72 +let pollTimer = null;
  73 +
  74 +async function fetchState() {
  75 + try {
  76 + const r = await fetch(`/api/devloop/projects/${props.pid}/state`);
  77 + if (!r.ok) {
  78 + error.value = r.status === 404 ? `项目不存在: ${props.pid}` : `读 state 失败: ${r.status}`;
  79 + state.value = null;
  80 + return;
  81 + }
  82 + error.value = '';
  83 + state.value = await r.json();
  84 + } catch (e) {
  85 + error.value = e.message;
  86 + }
  87 +}
  88 +
  89 +function goOps(tab = 'bugs') {
  90 + router.push(`/devloop/${props.pid}/ops/${tab}`);
  91 +}
  92 +
  93 +function openFeedback() { showFeedback.value = true; }
  94 +
  95 +function onUnitClick(unitId) { drawerUnit.value = unitId; }
  96 +
  97 +async function onSignOff() {
  98 + if (!confirm('确认 Sign Off? 此后将无法再提交反馈。')) return;
  99 + const r = await fetch(`/api/devloop/projects/${props.pid}/sign-off`, {
  100 + method: 'POST', headers: { 'Content-Type': 'application/json' },
  101 + body: JSON.stringify({ by: 'human' }),
  102 + });
  103 + const j = await r.json();
  104 + if (!r.ok) alert('Sign off failed: ' + (j.error || r.status));
  105 + await fetchState();
  106 +}
  107 +
  108 +async function onAbandon() {
  109 + const reason = prompt('Abandon 原因 (可空):', 'no longer needed');
  110 + if (reason === null) return;
  111 + const r = await fetch(`/api/devloop/projects/${props.pid}/abandon`, {
  112 + method: 'POST', headers: { 'Content-Type': 'application/json' },
  113 + body: JSON.stringify({ by: 'human', reason }),
  114 + });
  115 + const j = await r.json();
  116 + if (!r.ok) alert('Abandon failed: ' + (j.error || r.status));
  117 + await fetchState();
  118 +}
  119 +
  120 +function onFeedbackSubmitted() { showFeedback.value = false; fetchState(); }
  121 +
  122 +onMounted(async () => {
  123 + await fetchState();
  124 + pollTimer = setInterval(fetchState, 2500);
  125 +});
  126 +
  127 +onBeforeUnmount(() => {
  128 + if (pollTimer) clearInterval(pollTimer);
  129 +});
  130 +</script>
  131 +
  132 +<style scoped>
  133 +.devloop-dashboard { max-width: 1280px; margin: 0 auto; padding: 16px 24px; }
  134 +.error-banner { background:#fee; border:1px solid #fcc; color:#c00; padding:8px 12px; border-radius:6px; margin:8px 0; }
  135 +.grid { display:grid; grid-template-columns: 1fr 1fr; gap:16px; margin-top:16px; }
  136 +.grid section { background:#fff; border:1px solid #e7e7eb; border-radius:8px; padding:16px; min-height: 200px; }
  137 +.grid section.live, .grid section.diff { min-height: 280px; }
  138 +.grid h2 { font-size:14px; font-weight:600; margin:0 0 8px 0; color:#555; }
  139 +.loop-status, .dag { grid-column: 1 / -1; }
  140 +@media (max-width: 900px) { .grid { grid-template-columns: 1fr; } }
  141 +</style>
... ...
web/src/views/DevLoopOps.vue 0 → 100644
  1 +<template>
  2 + <div class="devloop-ops">
  3 + <header class="hdr">
  4 + <router-link :to="`/devloop/${pid}`" class="back">← Dashboard</router-link>
  5 + <h1>Ops · {{ pid }}</h1>
  6 + </header>
  7 +
  8 + <nav class="tabs">
  9 + <router-link
  10 + v-for="t in tabs"
  11 + :key="t.id"
  12 + :to="`/devloop/${pid}/ops/${t.id}`"
  13 + :class="['tab', { active: t.id === tab }]"
  14 + >{{ t.label }}</router-link>
  15 + </nav>
  16 +
  17 + <component :is="activeView" :pid="pid" />
  18 + </div>
  19 +</template>
  20 +
  21 +<script setup>
  22 +import { computed } from 'vue';
  23 +import BuglistView from '@/components/devloop/ops/BuglistView.vue';
  24 +import LoopLogView from '@/components/devloop/ops/LoopLogView.vue';
  25 +import ChangelogView from '@/components/devloop/ops/ChangelogView.vue';
  26 +import ProgressView from '@/components/devloop/ops/ProgressView.vue';
  27 +
  28 +const props = defineProps({
  29 + pid: { type: String, required: true },
  30 + tab: { type: String, required: true },
  31 +});
  32 +
  33 +const tabs = [
  34 + { id: 'bugs', label: 'Bugs' },
  35 + { id: 'loops', label: 'Loops (Timeline)' },
  36 + { id: 'changelog', label: 'Changelog' },
  37 + { id: 'progress', label: 'Progress' },
  38 +];
  39 +
  40 +const viewMap = {
  41 + bugs: BuglistView,
  42 + loops: LoopLogView,
  43 + changelog: ChangelogView,
  44 + progress: ProgressView,
  45 +};
  46 +const activeView = computed(() => viewMap[props.tab] || BuglistView);
  47 +</script>
  48 +
  49 +<style scoped>
  50 +.devloop-ops { max-width: 1280px; margin: 0 auto; padding: 16px 24px; }
  51 +.hdr { display:flex; align-items:center; gap:14px; padding-bottom:8px; border-bottom:1px solid #eee; }
  52 +.back { color:#888; text-decoration:none; font-size:13px; }
  53 +.back:hover { color:#222; }
  54 +h1 { font-size:18px; margin:0; font-family: ui-monospace, monospace; }
  55 +.tabs { display:flex; gap:0; margin:16px 0; border-bottom:1px solid #e3e3e8; }
  56 +.tab {
  57 + padding:8px 16px; text-decoration:none; color:#666; border-bottom:2px solid transparent;
  58 + font-size:13px; font-weight:500;
  59 +}
  60 +.tab:hover { color:#222; }
  61 +.tab.active { color:#0a6; border-bottom-color:#0a6; }
  62 +</style>
... ...