@pat-lewczuk/cezar 0.1.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/config.d.ts +55 -0
- package/dist/config.js +44 -0
- package/dist/config.js.map +1 -0
- package/dist/core/agent-runner.d.ts +13 -0
- package/dist/core/claude-cli-runner.js +23 -2
- package/dist/core/claude-cli-runner.js.map +1 -1
- package/dist/git-worktree.d.ts +49 -0
- package/dist/git-worktree.js +129 -0
- package/dist/git-worktree.js.map +1 -0
- package/dist/handoff.d.ts +42 -0
- package/dist/handoff.js +120 -0
- package/dist/handoff.js.map +1 -0
- package/dist/index.js +27 -6
- package/dist/index.js.map +1 -1
- package/dist/planner.d.ts +17 -0
- package/dist/planner.js +244 -0
- package/dist/planner.js.map +1 -0
- package/dist/runs/store.d.ts +40 -13
- package/dist/runs/store.js +33 -3
- package/dist/runs/store.js.map +1 -1
- package/dist/server/github.d.ts +28 -0
- package/dist/server/github.js +133 -0
- package/dist/server/github.js.map +1 -0
- package/dist/server/launch-key.d.ts +7 -0
- package/dist/server/launch-key.js +33 -0
- package/dist/server/launch-key.js.map +1 -0
- package/dist/server/pr.d.ts +22 -0
- package/dist/server/pr.js +92 -0
- package/dist/server/pr.js.map +1 -0
- package/dist/server/server.js +370 -15
- package/dist/server/server.js.map +1 -1
- package/dist/skills-remote.d.ts +35 -0
- package/dist/skills-remote.js +266 -0
- package/dist/skills-remote.js.map +1 -0
- package/dist/skills.d.ts +18 -6
- package/dist/skills.js +8 -4
- package/dist/skills.js.map +1 -1
- package/dist/todos.d.ts +60 -0
- package/dist/todos.js +166 -0
- package/dist/todos.js.map +1 -0
- package/dist/workflows/load.js +5 -9
- package/dist/workflows/load.js.map +1 -1
- package/dist/workflows/run.d.ts +62 -3
- package/dist/workflows/run.js +332 -43
- package/dist/workflows/run.js.map +1 -1
- package/dist/workflows/types.d.ts +27 -20
- package/dist/workflows/types.js +21 -0
- package/dist/workflows/types.js.map +1 -1
- package/package.json +1 -1
- package/scripts/mock-claude.mjs +118 -0
- package/web/app.js +1851 -149
- package/web/index.html +73 -22
- package/web/style.css +1271 -223
package/web/app.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
/* cez cockpit — vanilla JS, no build step. Talks to the local server via
|
|
4
|
-
fetch + Server-Sent Events.
|
|
3
|
+
/* cez cockpit v2 — vanilla JS, no build step. Talks to the local server via
|
|
4
|
+
fetch + Server-Sent Events. Layout: a persistent sidebar (new task, nav,
|
|
5
|
+
run list, env footer) + one main view per tab. */
|
|
5
6
|
|
|
6
7
|
const $ = (sel, el = document) => el.querySelector(sel);
|
|
7
8
|
|
|
@@ -12,8 +13,37 @@ const state = {
|
|
|
12
13
|
lastSeq: 0, // dedup across SSE reconnect replays
|
|
13
14
|
autoScroll: true,
|
|
14
15
|
workflows: [],
|
|
16
|
+
taskSource: 'workflow', // 'workflow' (a chain) | 'skill' — what taskRef names
|
|
17
|
+
taskRef: null, // selected chain/skill name (the "≡ fix-and-verify" pill)
|
|
18
|
+
taskModel: '', // model override ('' = auto; the "⊞ auto" pill)
|
|
19
|
+
modelsAvailable: false, // claude CLI detected → model menu lists Claude models
|
|
20
|
+
srcMenuTab: 'workflow', // Workflows | Skills tab inside the source pill menu
|
|
21
|
+
srcMenuQuery: '', // search box inside the source pill menu
|
|
15
22
|
pendingImages: [], // [{mediaType, data, preview}] queued for the next message
|
|
23
|
+
taskImages: [], // screenshots pasted into the new-task form
|
|
16
24
|
listView: 'active', // 'active' | 'archived'
|
|
25
|
+
todos: [], // global inbox entries (spec 007)
|
|
26
|
+
plan: null, // {task, steps, rationale, fallback} — proposed chain (spec 008)
|
|
27
|
+
planDragIdx: null, // index of the plan step being dragged
|
|
28
|
+
variants: 1, // ×1/×2/×3 switch — parallel variants (spec 010)
|
|
29
|
+
expandedGroups: new Set(), // groupIds expanded in the run list
|
|
30
|
+
selectedGroupId: null, // group shown in the compare view (instead of a run)
|
|
31
|
+
launchKey: null, // bookmarklet auto-start secret (spec 011), lazy-fetched
|
|
32
|
+
bmAuto: true, // "One-click launch (auto-submit)" checkbox
|
|
33
|
+
bmFilter: '', // per-skill bookmarklet filter text
|
|
34
|
+
theme: document.documentElement.dataset.theme || 'dark',
|
|
35
|
+
// GitHub tab
|
|
36
|
+
gh: null, // /api/github payload
|
|
37
|
+
ghView: 'issues', // 'issues' | 'prs'
|
|
38
|
+
ghSel: null, // selected item url
|
|
39
|
+
ghWorkflow: null, // workflow chip
|
|
40
|
+
ghSkills: new Set(), // skill names toggled on
|
|
41
|
+
ghQueued: new Map(), // item url -> run id (client-side "queued" marker)
|
|
42
|
+
lastGhRun: null,
|
|
43
|
+
// Skills tab
|
|
44
|
+
skillsList: null, // /api/skills payload (shared with the GitHub tab chips)
|
|
45
|
+
skillSel: null, // selected skill name, or '__bm' for the bookmarklet panel
|
|
46
|
+
skillQuery: '',
|
|
17
47
|
};
|
|
18
48
|
|
|
19
49
|
const STATUS_ORDER = { waiting: 0, review: 1, running: 2, queued: 3 };
|
|
@@ -37,6 +67,16 @@ function timeAgo(iso) {
|
|
|
37
67
|
return `${Math.floor(s / 86400)}d ago`;
|
|
38
68
|
}
|
|
39
69
|
|
|
70
|
+
/* Compact form for the run list — "2m", "1h", "3d". */
|
|
71
|
+
function shortAgo(iso) {
|
|
72
|
+
if (!iso) return '';
|
|
73
|
+
const s = Math.max(0, (Date.now() - new Date(iso).getTime()) / 1000);
|
|
74
|
+
if (s < 60) return `${Math.floor(s)}s`;
|
|
75
|
+
if (s < 3600) return `${Math.floor(s / 60)}m`;
|
|
76
|
+
if (s < 86400) return `${Math.floor(s / 3600)}h`;
|
|
77
|
+
return `${Math.floor(s / 86400)}d`;
|
|
78
|
+
}
|
|
79
|
+
|
|
40
80
|
function fmtTokens(n) {
|
|
41
81
|
if (!n) return '0 tok';
|
|
42
82
|
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M tok`;
|
|
@@ -49,14 +89,49 @@ function fmtCost(usd) {
|
|
|
49
89
|
return `$${usd >= 10 ? usd.toFixed(0) : usd.toFixed(2)}`;
|
|
50
90
|
}
|
|
51
91
|
|
|
52
|
-
|
|
92
|
+
/* Queue position among queued runs (FIFO = createdAt order) — spec 006. */
|
|
93
|
+
function queuePosition(run) {
|
|
94
|
+
const queued = [...state.runs.values()]
|
|
95
|
+
.filter((r) => !r.archived && r.status === 'queued')
|
|
96
|
+
.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
|
|
97
|
+
const idx = queued.findIndex((r) => r.id === run.id);
|
|
98
|
+
return idx >= 0 ? idx + 1 : null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const STATUS_LABEL = {
|
|
102
|
+
waiting: 'needs you',
|
|
103
|
+
review: 'review',
|
|
104
|
+
running: 'running',
|
|
105
|
+
queued: 'queued',
|
|
106
|
+
done: 'done',
|
|
107
|
+
failed: 'failed',
|
|
108
|
+
cancelled: 'cancelled',
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
function statusPill(run) {
|
|
112
|
+
const status = run.status;
|
|
113
|
+
const pulse = status === 'waiting' || status === 'running' || status === 'review';
|
|
114
|
+
let label = STATUS_LABEL[status] ?? status;
|
|
115
|
+
if (status === 'queued') {
|
|
116
|
+
const pos = queuePosition(run);
|
|
117
|
+
if (pos) label += ` #${pos}`;
|
|
118
|
+
}
|
|
119
|
+
return `<span class="pill ${esc(status)}">${pulse ? '<span class="pulse-dot"></span>' : ''}${esc(label)}</span>`;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function prLink(run) {
|
|
53
123
|
if (!run.pullRequestUrl) return '';
|
|
54
124
|
const num = run.pullRequestUrl.split('/').pop();
|
|
55
|
-
return `<a
|
|
125
|
+
return `<a href="${esc(run.pullRequestUrl)}" target="_blank" rel="noopener">PR #${esc(num)}</a>`;
|
|
56
126
|
}
|
|
57
127
|
|
|
58
|
-
|
|
59
|
-
|
|
128
|
+
/* ---- parallel variants (spec 010) ---- */
|
|
129
|
+
|
|
130
|
+
const TERMINAL_STATUSES = ['done', 'failed', 'review', 'cancelled'];
|
|
131
|
+
|
|
132
|
+
/* Group title = any variant's title without its " (A)" suffix. */
|
|
133
|
+
function groupTitle(run) {
|
|
134
|
+
return run.title.replace(/ \([A-C]\)$/, '');
|
|
60
135
|
}
|
|
61
136
|
|
|
62
137
|
async function getJson(url) {
|
|
@@ -65,39 +140,282 @@ async function getJson(url) {
|
|
|
65
140
|
return res.json();
|
|
66
141
|
}
|
|
67
142
|
|
|
143
|
+
/* Minimal unified-diff renderer (spec 009) — shared by the "± Diff" panel and
|
|
144
|
+
the review gate. Text parsing only: `diff --git` starts a collapsible
|
|
145
|
+
per-file <details>, +/− lines get colored, @@ hunks and file meta dim.
|
|
146
|
+
Non-diff text (degradation notes) renders as a plain <pre>. ZERO libs. */
|
|
147
|
+
function renderDiff(text) {
|
|
148
|
+
const raw = String(text ?? '').trimEnd();
|
|
149
|
+
if (!raw.trim()) return '<div class="dim">(no changes)</div>';
|
|
150
|
+
if (!raw.includes('diff --git ')) return `<pre>${esc(raw)}</pre>`;
|
|
151
|
+
const out = [];
|
|
152
|
+
let file = null; // { name, lines }
|
|
153
|
+
const flush = () => {
|
|
154
|
+
if (!file) return;
|
|
155
|
+
out.push(
|
|
156
|
+
`<details class="diff-file" open><summary>${esc(file.name)}</summary><pre>${file.lines.join('\n')}</pre></details>`,
|
|
157
|
+
);
|
|
158
|
+
file = null;
|
|
159
|
+
};
|
|
160
|
+
for (const line of raw.split('\n')) {
|
|
161
|
+
if (line.startsWith('diff --git ')) {
|
|
162
|
+
flush();
|
|
163
|
+
const m = / b\/(.+)$/.exec(line);
|
|
164
|
+
file = { name: m ? m[1] : line.slice(11), lines: [] };
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
if (!file) continue; // preamble before the first file header
|
|
168
|
+
let cls = '';
|
|
169
|
+
if (line.startsWith('+++') || line.startsWith('---')) cls = 'diff-meta';
|
|
170
|
+
else if (line.startsWith('@@')) cls = 'diff-hunk';
|
|
171
|
+
else if (line.startsWith('+')) cls = 'diff-add';
|
|
172
|
+
else if (line.startsWith('-')) cls = 'diff-del';
|
|
173
|
+
else if (/^(index |new file|deleted file|old mode|new mode|similarity|rename |copy |Binary files)/.test(line)) cls = 'diff-meta';
|
|
174
|
+
file.lines.push(cls ? `<span class="${cls}">${esc(line)}</span>` : esc(line));
|
|
175
|
+
}
|
|
176
|
+
flush();
|
|
177
|
+
return out.join('');
|
|
178
|
+
}
|
|
179
|
+
|
|
68
180
|
// ---- boot ------------------------------------------------------------------
|
|
69
181
|
|
|
70
182
|
async function init() {
|
|
71
183
|
bindUi();
|
|
72
|
-
|
|
184
|
+
applyTheme(state.theme);
|
|
185
|
+
const [health, workflowsRes, runs, todos, skills] = await Promise.all([
|
|
73
186
|
getJson('/api/health'),
|
|
74
187
|
getJson('/api/workflows'),
|
|
75
188
|
getJson('/api/runs'),
|
|
189
|
+
getJson('/api/todos').catch(() => []),
|
|
190
|
+
getJson('/api/skills').catch(() => []), // the form's skill mode + GitHub chips
|
|
76
191
|
]);
|
|
77
|
-
|
|
78
|
-
state.
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
.join('');
|
|
192
|
+
state.todos = todos;
|
|
193
|
+
state.skillsList = skills;
|
|
194
|
+
renderInboxBadge();
|
|
195
|
+
renderChrome(health);
|
|
196
|
+
setWorkflowOptions(workflowsRes.workflows);
|
|
83
197
|
for (const run of runs) state.runs.set(run.id, run);
|
|
84
198
|
renderRunList();
|
|
85
199
|
connectGlobal();
|
|
86
|
-
const
|
|
87
|
-
if (
|
|
200
|
+
const deepLinked = await handleDeepLink();
|
|
201
|
+
if (!deepLinked) {
|
|
202
|
+
const latest = sortedRuns()[0];
|
|
203
|
+
if (latest) selectRun(latest.id);
|
|
204
|
+
}
|
|
88
205
|
}
|
|
89
206
|
|
|
90
|
-
function
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
207
|
+
function applyTheme(theme) {
|
|
208
|
+
state.theme = theme;
|
|
209
|
+
document.documentElement.dataset.theme = theme;
|
|
210
|
+
try {
|
|
211
|
+
localStorage.setItem('cez-theme', theme);
|
|
212
|
+
} catch {
|
|
213
|
+
// private mode — the toggle still works for this page
|
|
214
|
+
}
|
|
215
|
+
const btn = $('#theme-toggle');
|
|
216
|
+
if (btn) btn.textContent = theme === 'dark' ? 'LIGHT ☼' : 'DARK ☾';
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// ---- bookmarklet deep-link (spec 011) ----------------------------------------
|
|
220
|
+
|
|
221
|
+
/* `/new?skill=<name>&ref=<github-url>&auto=1&key=<launchKey>` — opened by a
|
|
222
|
+
bookmarklet clicked on a GitHub PR/issue. `auto=1` + the correct launch-key
|
|
223
|
+
starts the run immediately; anything else (no auto, bad key, a drive-by
|
|
224
|
+
page guessing the URL) only prefills the form — the user presses Run.
|
|
225
|
+
Returns true when a run was started (and selected). */
|
|
226
|
+
async function handleDeepLink() {
|
|
227
|
+
const params = new URLSearchParams(location.search);
|
|
228
|
+
if (location.pathname !== '/new' && !params.has('skill') && !params.has('ref')) return false;
|
|
229
|
+
const skill = (params.get('skill') ?? '').trim();
|
|
230
|
+
const ref = (params.get('ref') ?? params.get('task') ?? '').trim();
|
|
231
|
+
const auto = params.get('auto') === '1';
|
|
232
|
+
const key = params.get('key') ?? '';
|
|
233
|
+
history.replaceState({}, '', '/'); // never re-trigger on reload
|
|
234
|
+
if (!ref) return false;
|
|
235
|
+
|
|
236
|
+
if (auto) {
|
|
237
|
+
let launchKey = '';
|
|
238
|
+
try {
|
|
239
|
+
launchKey = (await getJson('/api/launch-key')).key;
|
|
240
|
+
} catch {
|
|
241
|
+
// fall through to the blocked path
|
|
242
|
+
}
|
|
243
|
+
if (launchKey && key === launchKey) {
|
|
244
|
+
// Per-skill: one inline step (the spec-008 API); no skill: quick-task.
|
|
245
|
+
const body = skill
|
|
246
|
+
? { steps: [{ id: 'task', name: skill, skill, prompt: ref }], task: ref }
|
|
247
|
+
: { workflow: 'quick-task', task: ref };
|
|
248
|
+
try {
|
|
249
|
+
const res = await fetch('/api/runs', {
|
|
250
|
+
method: 'POST',
|
|
251
|
+
headers: { 'content-type': 'application/json' },
|
|
252
|
+
body: JSON.stringify(body),
|
|
253
|
+
});
|
|
254
|
+
const data = await res.json();
|
|
255
|
+
if (!res.ok) throw new Error(data.error ?? `HTTP ${res.status}`);
|
|
256
|
+
handleStarted(data);
|
|
257
|
+
alertBar(skill ? `Started "${skill}" on ${oneLine(ref)}` : `Started task on ${oneLine(ref)}`);
|
|
258
|
+
return true;
|
|
259
|
+
} catch (err) {
|
|
260
|
+
alertBar(`Auto-start failed: ${err.message} — review the form and press Run`);
|
|
261
|
+
}
|
|
262
|
+
} else {
|
|
263
|
+
alertBar('auto-launch blocked (bad key) — press Run');
|
|
264
|
+
}
|
|
95
265
|
}
|
|
266
|
+
|
|
267
|
+
// Prefill path: the form carries everything; quick-task (or the planner)
|
|
268
|
+
// resolves a named skill from the task text — zero extra UI.
|
|
269
|
+
const form = $('#new-task');
|
|
270
|
+
form.task.value = skill ? `Use the "${skill}" skill on: ${ref}` : ref;
|
|
271
|
+
if (state.workflows.some((w) => w.name === 'quick-task')) {
|
|
272
|
+
state.taskSource = 'workflow';
|
|
273
|
+
state.taskRef = 'quick-task';
|
|
274
|
+
renderTaskPills();
|
|
275
|
+
}
|
|
276
|
+
if (!auto) alertBar('Form prefilled from GitHub — review & press Run');
|
|
277
|
+
$('#run-btn').focus();
|
|
278
|
+
return false;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function setWorkflowOptions(workflows) {
|
|
282
|
+
state.workflows = workflows;
|
|
283
|
+
if (
|
|
284
|
+
!state.taskRef ||
|
|
285
|
+
(state.taskSource === 'workflow' && !state.workflows.some((w) => w.name === state.taskRef))
|
|
286
|
+
) {
|
|
287
|
+
state.taskSource = 'workflow';
|
|
288
|
+
state.taskRef = state.workflows[0]?.name ?? 'quick-task';
|
|
289
|
+
}
|
|
290
|
+
renderTaskPills();
|
|
291
|
+
if (!state.ghWorkflow || !state.workflows.some((w) => w.name === state.ghWorkflow)) {
|
|
292
|
+
state.ghWorkflow = state.workflows[0]?.name ?? 'quick-task';
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/* ---- the form's pill dropdowns — "≡ fix-and-verify ⌄" and "⊞ auto ⌄" ---- */
|
|
297
|
+
|
|
298
|
+
const PILL_ICONS = {
|
|
299
|
+
chain: 'M4 6h16M4 12h16M4 18h10', // ≡ list
|
|
300
|
+
skill: 'M4 19.5A2.5 2.5 0 016.5 17H20M6.5 2H20v20H6.5A2.5 2.5 0 014 19.5v-15A2.5 2.5 0 016.5 2z', // book
|
|
301
|
+
model: 'M9 3v3M15 3v3M9 18v3M15 18v3M3 9h3M3 15h3M18 9h3M18 15h3M6 6h12v12H6z', // chip
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
function pillHtml(iconPath, label, menuHtml) {
|
|
305
|
+
return `
|
|
306
|
+
<button type="button" class="pill-btn">
|
|
307
|
+
<svg class="pic" viewBox="0 0 24 24"><path d="${iconPath}"/></svg>
|
|
308
|
+
<span class="pl">${esc(label)}</span>
|
|
309
|
+
<svg class="chev" viewBox="0 0 24 24"><path d="M6 9l6 6 6-6"/></svg>
|
|
310
|
+
</button>
|
|
311
|
+
<div class="pill-menu" hidden>${menuHtml}</div>`;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function menuItemHtml(data, title, desc, selected) {
|
|
315
|
+
return `
|
|
316
|
+
<div class="menu-item ${selected ? 'on' : ''}" ${data}>
|
|
317
|
+
<div class="mi-title"><span class="chk">${selected ? '✓' : ''}</span>${esc(title)}</div>
|
|
318
|
+
${desc ? `<div class="mi-desc">${esc(desc)}</div>` : ''}
|
|
319
|
+
</div>`;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/* One pill covers chains AND skills: a search box + a Workflows|Skills tab
|
|
323
|
+
inside the menu ("zero new concepts": you pick what runs, the kind is
|
|
324
|
+
implicit in where you found it). */
|
|
325
|
+
function srcMenuItemsHtml() {
|
|
326
|
+
const q = state.srcMenuQuery.trim().toLowerCase();
|
|
327
|
+
const items =
|
|
328
|
+
state.srcMenuTab === 'skill'
|
|
329
|
+
? (state.skillsList ?? []).map((s) => ({ name: s.name, desc: s.description, source: 'skill' }))
|
|
330
|
+
: state.workflows.map((w) => ({ name: w.name, desc: w.description, source: 'workflow' }));
|
|
331
|
+
const filtered = items.filter(
|
|
332
|
+
(i) => !q || i.name.toLowerCase().includes(q) || (i.desc ?? '').toLowerCase().includes(q),
|
|
333
|
+
);
|
|
334
|
+
if (!filtered.length) {
|
|
335
|
+
return `<div class="menu-empty">${
|
|
336
|
+
state.srcMenuTab === 'skill' && !(state.skillsList ?? []).length
|
|
337
|
+
? 'No skills yet — drop Markdown files into .ai/skills/'
|
|
338
|
+
: '(nothing matches)'
|
|
339
|
+
}</div>`;
|
|
340
|
+
}
|
|
341
|
+
return filtered
|
|
342
|
+
.map((i) =>
|
|
343
|
+
menuItemHtml(
|
|
344
|
+
`data-mi="src" data-source="${i.source}" data-value="${esc(i.name)}"`,
|
|
345
|
+
i.name,
|
|
346
|
+
i.desc,
|
|
347
|
+
state.taskSource === i.source && state.taskRef === i.name,
|
|
348
|
+
),
|
|
349
|
+
)
|
|
350
|
+
.join('');
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function renderTaskPills() {
|
|
354
|
+
const srcMenu = `
|
|
355
|
+
<div class="menu-search">
|
|
356
|
+
<svg viewBox="0 0 24 24"><circle cx="11" cy="11" r="7"/><path d="M21 21l-5-5"/></svg>
|
|
357
|
+
<input id="src-search" type="text" placeholder="Search…" autocomplete="off">
|
|
358
|
+
</div>
|
|
359
|
+
<div class="menu-tabs">
|
|
360
|
+
<button type="button" data-mtab="workflow" class="${state.srcMenuTab === 'workflow' ? 'active' : ''}">Workflows</button>
|
|
361
|
+
<button type="button" data-mtab="skill" class="${state.srcMenuTab === 'skill' ? 'active' : ''}">Skills</button>
|
|
362
|
+
</div>
|
|
363
|
+
<div id="src-menu-items">${srcMenuItemsHtml()}</div>`;
|
|
364
|
+
$('#src-pill').innerHTML = pillHtml(
|
|
365
|
+
state.taskSource === 'skill' ? PILL_ICONS.skill : PILL_ICONS.chain,
|
|
366
|
+
state.taskRef ?? 'quick-task',
|
|
367
|
+
srcMenu,
|
|
368
|
+
);
|
|
369
|
+
|
|
370
|
+
const models = state.modelsAvailable ? CLAUDE_MODELS : CLAUDE_MODELS.slice(0, 1);
|
|
371
|
+
const selectedModel = models.find((m) => m.id === state.taskModel) ?? models[0];
|
|
372
|
+
const modelMenu = models
|
|
373
|
+
.map((m) => menuItemHtml(`data-mi="model" data-value="${esc(m.id)}"`, m.label, m.desc, m.id === (selectedModel?.id ?? '')))
|
|
374
|
+
.join('');
|
|
375
|
+
$('#model-pill').innerHTML = pillHtml(PILL_ICONS.model, selectedModel?.label ?? 'auto', modelMenu);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function closePillMenus() {
|
|
379
|
+
for (const el of document.querySelectorAll('.pill-select.open')) {
|
|
380
|
+
el.classList.remove('open');
|
|
381
|
+
const menu = el.querySelector('.pill-menu');
|
|
382
|
+
if (menu) menu.hidden = true;
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function renderChrome(health) {
|
|
387
|
+
const repoChip = $('#repo-chip');
|
|
388
|
+
repoChip.hidden = false;
|
|
389
|
+
repoChip.textContent = health.repo
|
|
390
|
+
? `${health.repo.root.split('/').pop()} / ${health.repo.branch}`
|
|
391
|
+
: 'no git — tasks run in place';
|
|
392
|
+
repoChip.title = health.repo ? `${health.repo.root} · ${health.repo.branch}` : 'not a git repo — tasks run in place, one at a time';
|
|
96
393
|
$('#env-chips').innerHTML = health.checks
|
|
97
|
-
.map(
|
|
394
|
+
.map(
|
|
395
|
+
(c) =>
|
|
396
|
+
`<span class="env-chip ${c.available ? 'ok' : 'bad'}" title="${esc(c.hint ?? c.version ?? '')}"><span class="led"></span>${esc(c.name)}</span>`,
|
|
397
|
+
)
|
|
98
398
|
.join('');
|
|
399
|
+
|
|
400
|
+
// Model pill fed by what's actually connected: claude CLI present → the
|
|
401
|
+
// Claude catalog; otherwise only "auto".
|
|
402
|
+
state.modelsAvailable = health.checks.some((c) => c.name === 'claude' && c.available);
|
|
403
|
+
renderTaskPills();
|
|
99
404
|
}
|
|
100
405
|
|
|
406
|
+
/* What `claude --model` accepts: "auto" (no flag — each step decides), the
|
|
407
|
+
stable tier aliases (they track the newest release), then current pinned
|
|
408
|
+
versions for reproducible runs. */
|
|
409
|
+
const CLAUDE_MODELS = [
|
|
410
|
+
{ id: '', label: 'auto', desc: 'Pick the best model per step' },
|
|
411
|
+
{ id: 'opus', label: 'opus', desc: 'Deep reasoning for hard tasks' },
|
|
412
|
+
{ id: 'sonnet', label: 'sonnet', desc: 'Fast and cheap' },
|
|
413
|
+
{ id: 'haiku', label: 'haiku', desc: 'Fastest — simple, scoped tasks' },
|
|
414
|
+
{ id: 'claude-opus-4-8', label: 'Opus 4.8', desc: 'Pinned version' },
|
|
415
|
+
{ id: 'claude-sonnet-5', label: 'Sonnet 5', desc: 'Pinned version' },
|
|
416
|
+
{ id: 'claude-haiku-4-5', label: 'Haiku 4.5', desc: 'Pinned version' },
|
|
417
|
+
];
|
|
418
|
+
|
|
101
419
|
function connectGlobal() {
|
|
102
420
|
const es = new EventSource('/api/events');
|
|
103
421
|
es.addEventListener('run', (e) => {
|
|
@@ -115,11 +433,20 @@ function connectGlobal() {
|
|
|
115
433
|
$('#detail').innerHTML = '<div class="empty">Select a run — or start one.</div>';
|
|
116
434
|
}
|
|
117
435
|
});
|
|
436
|
+
es.addEventListener('todos', (e) => {
|
|
437
|
+
state.todos = JSON.parse(e.data);
|
|
438
|
+
renderInboxBadge();
|
|
439
|
+
if (!$('#view-inbox').hidden) renderInbox();
|
|
440
|
+
});
|
|
118
441
|
}
|
|
119
442
|
|
|
120
443
|
// ---- UI events -------------------------------------------------------------
|
|
121
444
|
|
|
122
445
|
function bindUi() {
|
|
446
|
+
$('#theme-toggle').addEventListener('click', () => {
|
|
447
|
+
applyTheme(state.theme === 'dark' ? 'light' : 'dark');
|
|
448
|
+
});
|
|
449
|
+
|
|
123
450
|
$('#tabs').addEventListener('click', (e) => {
|
|
124
451
|
const btn = e.target.closest('button[data-view]');
|
|
125
452
|
if (!btn) return;
|
|
@@ -129,20 +456,208 @@ function bindUi() {
|
|
|
129
456
|
view.hidden = false;
|
|
130
457
|
if (btn.dataset.view === 'repo') loadRepo();
|
|
131
458
|
if (btn.dataset.view === 'skills') loadSkills();
|
|
459
|
+
if (btn.dataset.view === 'inbox') renderInbox();
|
|
460
|
+
if (btn.dataset.view === 'github') loadGithub();
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
$('#view-inbox').addEventListener('click', async (e) => {
|
|
464
|
+
const goto = e.target.closest('[data-goto-run]');
|
|
465
|
+
if (goto) {
|
|
466
|
+
e.preventDefault();
|
|
467
|
+
showRunsView();
|
|
468
|
+
selectRun(goto.dataset.gotoRun);
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
const btn = e.target.closest('button[data-todo-action]');
|
|
472
|
+
if (!btn) return;
|
|
473
|
+
const id = btn.closest('.todo-card')?.dataset.id;
|
|
474
|
+
if (!id) return;
|
|
475
|
+
if (btn.dataset.todoAction === 'remove') {
|
|
476
|
+
await fetch(`/api/todos/${id}`, { method: 'DELETE' });
|
|
477
|
+
state.todos = state.todos.filter((t) => t.id !== id);
|
|
478
|
+
renderInboxBadge();
|
|
479
|
+
renderInbox();
|
|
480
|
+
} else if (btn.dataset.todoAction === 'start') {
|
|
481
|
+
btn.disabled = true;
|
|
482
|
+
try {
|
|
483
|
+
const res = await fetch(`/api/todos/${id}/start`, { method: 'POST' });
|
|
484
|
+
const data = await res.json().catch(() => ({}));
|
|
485
|
+
if (!res.ok) throw new Error(data.error ?? `HTTP ${res.status}`);
|
|
486
|
+
const todo = state.todos.find((t) => t.id === id);
|
|
487
|
+
if (todo) todo.startedTaskId = data.run.id;
|
|
488
|
+
renderInboxBadge();
|
|
489
|
+
renderInbox();
|
|
490
|
+
state.runs.set(data.run.id, data.run);
|
|
491
|
+
showRunsView();
|
|
492
|
+
renderRunList();
|
|
493
|
+
selectRun(data.run.id);
|
|
494
|
+
} catch (err) {
|
|
495
|
+
alertBar(err.message);
|
|
496
|
+
btn.disabled = false;
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
bindGithubView();
|
|
502
|
+
bindSkillsView();
|
|
503
|
+
|
|
504
|
+
const taskForm = $('#new-task');
|
|
505
|
+
|
|
506
|
+
// Pill dropdowns: toggle on the pill, select on a menu item, close on any
|
|
507
|
+
// outside click (one document listener; menus are re-rendered per change).
|
|
508
|
+
document.addEventListener('click', (e) => {
|
|
509
|
+
const pill = e.target.closest('.pill-select');
|
|
510
|
+
if (!pill) {
|
|
511
|
+
closePillMenus();
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
514
|
+
const item = e.target.closest('.menu-item[data-mi]');
|
|
515
|
+
if (item) {
|
|
516
|
+
if (item.dataset.mi === 'src') {
|
|
517
|
+
state.taskSource = item.dataset.source;
|
|
518
|
+
state.taskRef = item.dataset.value;
|
|
519
|
+
} else if (item.dataset.mi === 'model') {
|
|
520
|
+
state.taskModel = item.dataset.value;
|
|
521
|
+
}
|
|
522
|
+
closePillMenus();
|
|
523
|
+
renderTaskPills();
|
|
524
|
+
return;
|
|
525
|
+
}
|
|
526
|
+
// Workflows | Skills switch inside the source menu.
|
|
527
|
+
const mtab = e.target.closest('button[data-mtab]');
|
|
528
|
+
if (mtab) {
|
|
529
|
+
state.srcMenuTab = mtab.dataset.mtab;
|
|
530
|
+
for (const b of mtab.parentElement.children) b.classList.toggle('active', b === mtab);
|
|
531
|
+
const box = $('#src-menu-items');
|
|
532
|
+
if (box) box.innerHTML = srcMenuItemsHtml();
|
|
533
|
+
$('#src-search')?.focus();
|
|
534
|
+
return;
|
|
535
|
+
}
|
|
536
|
+
if (e.target.closest('.menu-search')) return; // typing, not toggling
|
|
537
|
+
if (e.target.closest('.pill-btn')) {
|
|
538
|
+
const menu = pill.querySelector('.pill-menu');
|
|
539
|
+
const wasOpen = pill.classList.contains('open');
|
|
540
|
+
closePillMenus();
|
|
541
|
+
if (!wasOpen && menu) {
|
|
542
|
+
pill.classList.add('open');
|
|
543
|
+
menu.hidden = false;
|
|
544
|
+
// Fresh view on open: current kind's tab, empty query, focused search.
|
|
545
|
+
if (pill.id === 'src-pill') {
|
|
546
|
+
state.srcMenuTab = state.taskSource;
|
|
547
|
+
state.srcMenuQuery = '';
|
|
548
|
+
const search = $('#src-search');
|
|
549
|
+
if (search) search.value = '';
|
|
550
|
+
for (const b of menu.querySelectorAll('button[data-mtab]')) {
|
|
551
|
+
b.classList.toggle('active', b.dataset.mtab === state.srcMenuTab);
|
|
552
|
+
}
|
|
553
|
+
const box = $('#src-menu-items');
|
|
554
|
+
if (box) box.innerHTML = srcMenuItemsHtml();
|
|
555
|
+
search?.focus();
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
}
|
|
132
559
|
});
|
|
133
560
|
|
|
134
|
-
|
|
561
|
+
// Live filter — re-render only the item list so the input keeps focus.
|
|
562
|
+
document.addEventListener('input', (e) => {
|
|
563
|
+
if (e.target.id !== 'src-search') return;
|
|
564
|
+
state.srcMenuQuery = e.target.value;
|
|
565
|
+
const box = $('#src-menu-items');
|
|
566
|
+
if (box) box.innerHTML = srcMenuItemsHtml();
|
|
567
|
+
});
|
|
568
|
+
// Enter in the search picks the first match (and never submits the form).
|
|
569
|
+
document.addEventListener('keydown', (e) => {
|
|
570
|
+
if (e.target.id !== 'src-search' || e.key !== 'Enter') return;
|
|
571
|
+
e.preventDefault();
|
|
572
|
+
$('#src-menu-items .menu-item')?.click();
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
// 📎 — attach images to the task (same pipeline as ⌘V paste).
|
|
576
|
+
const taskFile = $('#task-file');
|
|
577
|
+
$('#task-attach').addEventListener('click', () => taskFile.click());
|
|
578
|
+
taskFile.addEventListener('change', () => {
|
|
579
|
+
for (const file of taskFile.files ?? []) {
|
|
580
|
+
void readImageFile(file).then((img) => {
|
|
581
|
+
if (!img || state.taskImages.length >= 4) return;
|
|
582
|
+
state.taskImages.push(img);
|
|
583
|
+
renderTaskThumbs();
|
|
584
|
+
});
|
|
585
|
+
}
|
|
586
|
+
taskFile.value = '';
|
|
587
|
+
});
|
|
588
|
+
|
|
589
|
+
// ⌘↵ / Ctrl+↵ submits from inside the textarea.
|
|
590
|
+
taskForm.task.addEventListener('keydown', (e) => {
|
|
591
|
+
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
|
|
592
|
+
e.preventDefault();
|
|
593
|
+
taskForm.requestSubmit();
|
|
594
|
+
}
|
|
595
|
+
});
|
|
596
|
+
|
|
597
|
+
// The task box grows on focus and with content, springs back when left
|
|
598
|
+
// empty. Explicit px heights so the CSS height transition animates.
|
|
599
|
+
const taskBox = taskForm.task;
|
|
600
|
+
const autosizeTask = () => {
|
|
601
|
+
const engaged = document.activeElement === taskBox || taskBox.value.trim();
|
|
602
|
+
const floor = engaged ? 92 : 40;
|
|
603
|
+
const prev = taskBox.style.height || '40px';
|
|
604
|
+
taskBox.style.height = 'auto';
|
|
605
|
+
const target = Math.max(floor, Math.min(taskBox.scrollHeight, 220));
|
|
606
|
+
taskBox.style.height = prev; // restore the start point…
|
|
607
|
+
void taskBox.offsetHeight; // …force a reflow…
|
|
608
|
+
taskBox.style.height = `${target}px`; // …then animate to the target
|
|
609
|
+
};
|
|
610
|
+
taskBox.addEventListener('focus', autosizeTask);
|
|
611
|
+
taskBox.addEventListener('input', autosizeTask);
|
|
612
|
+
taskBox.addEventListener('blur', autosizeTask);
|
|
613
|
+
|
|
614
|
+
// Screenshots pasted into the task box travel with POST /api/runs and reach
|
|
615
|
+
// the first agent step's opening message.
|
|
616
|
+
taskBox.addEventListener('paste', (e) => {
|
|
617
|
+
const items = [...(e.clipboardData?.items ?? [])].filter((i) => i.type.startsWith('image/'));
|
|
618
|
+
if (!items.length) return;
|
|
619
|
+
e.preventDefault();
|
|
620
|
+
for (const item of items) {
|
|
621
|
+
const file = item.getAsFile();
|
|
622
|
+
if (file) {
|
|
623
|
+
void readImageFile(file).then((img) => {
|
|
624
|
+
if (!img || state.taskImages.length >= 4) return;
|
|
625
|
+
state.taskImages.push(img);
|
|
626
|
+
renderTaskThumbs();
|
|
627
|
+
});
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
});
|
|
631
|
+
$('#task-thumbs').addEventListener('click', (e) => {
|
|
632
|
+
const thumb = e.target.closest('[data-idx]');
|
|
633
|
+
if (!thumb) return;
|
|
634
|
+
state.taskImages.splice(Number(thumb.dataset.idx), 1);
|
|
635
|
+
renderTaskThumbs();
|
|
636
|
+
});
|
|
637
|
+
|
|
638
|
+
taskForm.addEventListener('submit', async (e) => {
|
|
135
639
|
e.preventDefault();
|
|
136
640
|
const form = e.target;
|
|
137
641
|
const errorBox = $('#form-error');
|
|
138
642
|
errorBox.hidden = true;
|
|
139
643
|
const body = {
|
|
140
644
|
task: form.task.value.trim(),
|
|
141
|
-
|
|
142
|
-
|
|
645
|
+
model: state.taskModel || undefined,
|
|
646
|
+
variants: state.variants > 1 ? state.variants : undefined,
|
|
647
|
+
images: state.taskImages.length
|
|
648
|
+
? state.taskImages.map((i) => ({ mediaType: i.mediaType, data: i.data }))
|
|
649
|
+
: undefined,
|
|
143
650
|
};
|
|
144
651
|
if (!body.task) return;
|
|
145
|
-
|
|
652
|
+
if (state.taskSource === 'skill' && state.taskRef) {
|
|
653
|
+
// A skill runs as a one-step inline chain — same shape the inbox and
|
|
654
|
+
// the bookmarklet auto-start use (spec 008's API).
|
|
655
|
+
body.steps = [{ id: 'task', name: state.taskRef, skill: state.taskRef, prompt: '{{task}}' }];
|
|
656
|
+
} else {
|
|
657
|
+
body.workflow = state.taskRef ?? 'quick-task';
|
|
658
|
+
}
|
|
659
|
+
discardPlan(); // a plain Run supersedes any pending plan (spec 008)
|
|
660
|
+
$('#run-btn').disabled = true;
|
|
146
661
|
try {
|
|
147
662
|
const res = await fetch('/api/runs', {
|
|
148
663
|
method: 'POST',
|
|
@@ -152,21 +667,43 @@ function bindUi() {
|
|
|
152
667
|
const data = await res.json();
|
|
153
668
|
if (!res.ok) throw new Error(data.error ?? `HTTP ${res.status}`);
|
|
154
669
|
form.task.value = '';
|
|
155
|
-
state.
|
|
156
|
-
|
|
157
|
-
|
|
670
|
+
state.taskImages = [];
|
|
671
|
+
renderTaskThumbs();
|
|
672
|
+
form.task.dispatchEvent(new Event('blur')); // shrink the box back
|
|
673
|
+
handleStarted(data);
|
|
158
674
|
} catch (err) {
|
|
159
675
|
errorBox.textContent = err.message;
|
|
160
676
|
errorBox.hidden = false;
|
|
161
677
|
} finally {
|
|
162
|
-
|
|
678
|
+
$('#run-btn').disabled = false;
|
|
163
679
|
}
|
|
164
680
|
});
|
|
165
681
|
|
|
682
|
+
bindPlanPanel();
|
|
683
|
+
|
|
684
|
+
// Parallel variants (spec 010) have no form control right now — the ×1/×2/×3
|
|
685
|
+
// switch was retired from the UI. The backend path stays: state.variants is
|
|
686
|
+
// still sent with POST /api/runs whenever something sets it above 1.
|
|
687
|
+
|
|
166
688
|
$('#run-list').addEventListener('click', (e) => {
|
|
167
|
-
if (e.target.closest('a')) return; //
|
|
689
|
+
if (e.target.closest('a')) return; // links navigate, not select
|
|
690
|
+
const compare = e.target.closest('button[data-compare]');
|
|
691
|
+
if (compare) {
|
|
692
|
+
void selectGroup(compare.dataset.compare);
|
|
693
|
+
return;
|
|
694
|
+
}
|
|
168
695
|
const item = e.target.closest('.run-item');
|
|
169
|
-
if (item)
|
|
696
|
+
if (!item) return;
|
|
697
|
+
if (item.dataset.id) {
|
|
698
|
+
showRunsView();
|
|
699
|
+
selectRun(item.dataset.id);
|
|
700
|
+
} else if (item.dataset.group) {
|
|
701
|
+
// Group tile: toggle the variant list under it (in-memory UI state).
|
|
702
|
+
const gid = item.dataset.group;
|
|
703
|
+
if (state.expandedGroups.has(gid)) state.expandedGroups.delete(gid);
|
|
704
|
+
else state.expandedGroups.add(gid);
|
|
705
|
+
renderRunList();
|
|
706
|
+
}
|
|
170
707
|
});
|
|
171
708
|
|
|
172
709
|
$('#list-tabs').addEventListener('click', async (e) => {
|
|
@@ -181,6 +718,19 @@ function bindUi() {
|
|
|
181
718
|
});
|
|
182
719
|
|
|
183
720
|
$('#detail').addEventListener('click', async (e) => {
|
|
721
|
+
// Agent screenshots zoom into a lightbox.
|
|
722
|
+
const shot = e.target.closest('[data-lightbox]');
|
|
723
|
+
if (shot) {
|
|
724
|
+
openLightbox(shot.dataset.lightbox, shot.dataset.name);
|
|
725
|
+
return;
|
|
726
|
+
}
|
|
727
|
+
// Compare view (spec 010): "Pick this one" lives in #detail without a
|
|
728
|
+
// selected run — handle it before the per-run actions.
|
|
729
|
+
const pick = e.target.closest('button[data-pick]');
|
|
730
|
+
if (pick) {
|
|
731
|
+
void pickVariant(pick);
|
|
732
|
+
return;
|
|
733
|
+
}
|
|
184
734
|
const btn = e.target.closest('button[data-action]');
|
|
185
735
|
if (!btn) return;
|
|
186
736
|
const id = state.selectedId;
|
|
@@ -224,6 +774,82 @@ function bindUi() {
|
|
|
224
774
|
alertBar(data.error ?? 'cannot open terminal');
|
|
225
775
|
}
|
|
226
776
|
}
|
|
777
|
+
} else if (btn.dataset.action === 'diff') {
|
|
778
|
+
const panel = $('#diff-panel');
|
|
779
|
+
if (!panel) return;
|
|
780
|
+
if (!panel.hidden) {
|
|
781
|
+
panel.hidden = true;
|
|
782
|
+
return;
|
|
783
|
+
}
|
|
784
|
+
panel.hidden = false;
|
|
785
|
+
$('#diff-body').innerHTML = '<div class="dim">Loading…</div>';
|
|
786
|
+
try {
|
|
787
|
+
const res = await fetch(`/api/runs/${id}/diff`);
|
|
788
|
+
const text = await res.text();
|
|
789
|
+
$('#diff-body').innerHTML = text.trim() ? renderDiff(text) : '<div class="dim">(no changes yet)</div>';
|
|
790
|
+
} catch (err) {
|
|
791
|
+
$('#diff-body').textContent = `✗ ${err.message}`;
|
|
792
|
+
}
|
|
793
|
+
} else if (btn.dataset.action === 'send-back') {
|
|
794
|
+
// Review gate (spec 009): the notes go back into the same session via
|
|
795
|
+
// "Continue" — the run leaves `review`, works, and gates again.
|
|
796
|
+
const notes = $('#review-notes')?.value.trim();
|
|
797
|
+
if (!notes) {
|
|
798
|
+
alertBar('Write what to change first.');
|
|
799
|
+
$('#review-notes')?.focus();
|
|
800
|
+
return;
|
|
801
|
+
}
|
|
802
|
+
btn.disabled = true;
|
|
803
|
+
const res = await fetch(`/api/runs/${id}/continue`, {
|
|
804
|
+
method: 'POST',
|
|
805
|
+
headers: { 'content-type': 'application/json' },
|
|
806
|
+
body: JSON.stringify({ text: `Review feedback:\n${notes}` }),
|
|
807
|
+
});
|
|
808
|
+
if (!res.ok) {
|
|
809
|
+
const data = await res.json().catch(() => ({}));
|
|
810
|
+
alertBar(data.error ?? 'cannot send back');
|
|
811
|
+
btn.disabled = false;
|
|
812
|
+
}
|
|
813
|
+
// on success the SSE run update flips the status and hides the panel
|
|
814
|
+
} else if (btn.dataset.action === 'draft-pr') {
|
|
815
|
+
btn.disabled = true;
|
|
816
|
+
const res = await fetch(`/api/runs/${id}/pr`, { method: 'POST' });
|
|
817
|
+
const data = await res.json().catch(() => ({}));
|
|
818
|
+
if (res.ok) {
|
|
819
|
+
alertBar(`Draft PR created — ${data.url}`);
|
|
820
|
+
} else {
|
|
821
|
+
alertBar(data.error ?? 'PR creation failed');
|
|
822
|
+
const manual = $('#review-manual');
|
|
823
|
+
if (manual && data.manual) {
|
|
824
|
+
manual.hidden = false;
|
|
825
|
+
manual.textContent = `manual path: ${data.manual}`;
|
|
826
|
+
}
|
|
827
|
+
btn.disabled = false;
|
|
828
|
+
}
|
|
829
|
+
} else if (btn.dataset.action === 'notes') {
|
|
830
|
+
const panel = $('#notes-panel');
|
|
831
|
+
if (!panel) return;
|
|
832
|
+
if (!panel.hidden) {
|
|
833
|
+
panel.hidden = true;
|
|
834
|
+
return;
|
|
835
|
+
}
|
|
836
|
+
panel.hidden = false;
|
|
837
|
+
$('#notes-pre').textContent = 'Loading…';
|
|
838
|
+
try {
|
|
839
|
+
const res = await fetch(`/api/runs/${id}/handoff`);
|
|
840
|
+
const text = await res.text();
|
|
841
|
+
$('#notes-pre').textContent = text.trim() || '(no notes yet — the handoff file is seeded when the task starts)';
|
|
842
|
+
} catch (err) {
|
|
843
|
+
$('#notes-pre').textContent = `✗ ${err.message}`;
|
|
844
|
+
}
|
|
845
|
+
} else if (btn.dataset.action === 'remove-worktree') {
|
|
846
|
+
const res = await fetch(`/api/runs/${id}/remove-worktree`, { method: 'POST' });
|
|
847
|
+
if (!res.ok) {
|
|
848
|
+
const data = await res.json().catch(() => ({}));
|
|
849
|
+
alertBar(data.error ?? 'cannot remove worktree');
|
|
850
|
+
} else {
|
|
851
|
+
alertBar('Worktree removed.');
|
|
852
|
+
}
|
|
227
853
|
} else if (btn.dataset.action === 'jump-bottom') {
|
|
228
854
|
state.autoScroll = true;
|
|
229
855
|
const log = $('#log');
|
|
@@ -240,6 +866,193 @@ function bindUi() {
|
|
|
240
866
|
});
|
|
241
867
|
}
|
|
242
868
|
|
|
869
|
+
// ---- chain-from-prompt (spec 008) --------------------------------------------
|
|
870
|
+
|
|
871
|
+
function bindPlanPanel() {
|
|
872
|
+
$('#plan-btn').addEventListener('click', () => void planTask());
|
|
873
|
+
|
|
874
|
+
const panel = $('#plan-panel');
|
|
875
|
+
panel.addEventListener('click', (e) => {
|
|
876
|
+
if (!state.plan) return;
|
|
877
|
+
const rm = e.target.closest('[data-plan-remove]');
|
|
878
|
+
if (rm) {
|
|
879
|
+
state.plan.steps.splice(Number(rm.dataset.planRemove), 1);
|
|
880
|
+
renderPlan();
|
|
881
|
+
return;
|
|
882
|
+
}
|
|
883
|
+
const btn = e.target.closest('button[data-plan-action]');
|
|
884
|
+
if (!btn) return;
|
|
885
|
+
if (btn.dataset.planAction === 'discard') discardPlan();
|
|
886
|
+
else if (btn.dataset.planAction === 'start') void startPlannedRun(btn);
|
|
887
|
+
else if (btn.dataset.planAction === 'save') void savePlanAsChain(btn);
|
|
888
|
+
});
|
|
889
|
+
|
|
890
|
+
// Reorder by drag (HTML5 draggable — no libraries).
|
|
891
|
+
panel.addEventListener('dragstart', (e) => {
|
|
892
|
+
const step = e.target.closest('.plan-step');
|
|
893
|
+
if (!step) return;
|
|
894
|
+
state.planDragIdx = Number(step.dataset.idx);
|
|
895
|
+
e.dataTransfer.effectAllowed = 'move';
|
|
896
|
+
});
|
|
897
|
+
panel.addEventListener('dragover', (e) => {
|
|
898
|
+
const step = e.target.closest('.plan-step');
|
|
899
|
+
if (!step || state.planDragIdx === null) return;
|
|
900
|
+
e.preventDefault();
|
|
901
|
+
e.dataTransfer.dropEffect = 'move';
|
|
902
|
+
step.classList.add('drag-over');
|
|
903
|
+
});
|
|
904
|
+
panel.addEventListener('dragleave', (e) => {
|
|
905
|
+
e.target.closest('.plan-step')?.classList.remove('drag-over');
|
|
906
|
+
});
|
|
907
|
+
panel.addEventListener('drop', (e) => {
|
|
908
|
+
const step = e.target.closest('.plan-step');
|
|
909
|
+
if (!step || state.planDragIdx === null || !state.plan) return;
|
|
910
|
+
e.preventDefault();
|
|
911
|
+
const from = state.planDragIdx;
|
|
912
|
+
const to = Number(step.dataset.idx);
|
|
913
|
+
state.planDragIdx = null;
|
|
914
|
+
if (from === to) return;
|
|
915
|
+
const [moved] = state.plan.steps.splice(from, 1);
|
|
916
|
+
state.plan.steps.splice(to, 0, moved);
|
|
917
|
+
renderPlan();
|
|
918
|
+
});
|
|
919
|
+
panel.addEventListener('dragend', () => {
|
|
920
|
+
state.planDragIdx = null;
|
|
921
|
+
for (const el of panel.querySelectorAll('.drag-over')) el.classList.remove('drag-over');
|
|
922
|
+
});
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
async function planTask() {
|
|
926
|
+
const form = $('#new-task');
|
|
927
|
+
const task = form.task.value.trim();
|
|
928
|
+
const errorBox = $('#form-error');
|
|
929
|
+
errorBox.hidden = true;
|
|
930
|
+
if (!task) {
|
|
931
|
+
form.task.focus();
|
|
932
|
+
return;
|
|
933
|
+
}
|
|
934
|
+
const btn = $('#plan-btn');
|
|
935
|
+
const label = btn.textContent;
|
|
936
|
+
btn.disabled = true;
|
|
937
|
+
btn.textContent = '⏳ Planning…';
|
|
938
|
+
try {
|
|
939
|
+
const res = await fetch('/api/plan', {
|
|
940
|
+
method: 'POST',
|
|
941
|
+
headers: { 'content-type': 'application/json' },
|
|
942
|
+
body: JSON.stringify({ task }),
|
|
943
|
+
});
|
|
944
|
+
const data = await res.json();
|
|
945
|
+
if (!res.ok) throw new Error(data.error ?? `HTTP ${res.status}`);
|
|
946
|
+
state.plan = { task, steps: data.steps, rationale: data.rationale, fallback: data.fallback };
|
|
947
|
+
renderPlan();
|
|
948
|
+
} catch (err) {
|
|
949
|
+
errorBox.textContent = err.message;
|
|
950
|
+
errorBox.hidden = false;
|
|
951
|
+
} finally {
|
|
952
|
+
btn.disabled = false;
|
|
953
|
+
btn.textContent = label;
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
function oneLine(s, max = 70) {
|
|
958
|
+
const first = String(s ?? '').split('\n')[0] ?? '';
|
|
959
|
+
return first.length > max ? `${first.slice(0, max - 1)}…` : first;
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
function renderPlan() {
|
|
963
|
+
const panel = $('#plan-panel');
|
|
964
|
+
if (!state.plan) {
|
|
965
|
+
panel.hidden = true;
|
|
966
|
+
panel.innerHTML = '';
|
|
967
|
+
return;
|
|
968
|
+
}
|
|
969
|
+
const { steps, rationale, fallback } = state.plan;
|
|
970
|
+
panel.hidden = false;
|
|
971
|
+
panel.innerHTML = `
|
|
972
|
+
${fallback ? '<div class="plan-note">planner unavailable — single-step plan</div>' : ''}
|
|
973
|
+
${rationale ? `<div class="plan-rationale dim" title="${esc(rationale)}">${esc(rationale)}</div>` : ''}
|
|
974
|
+
<div class="plan-steps">
|
|
975
|
+
${steps
|
|
976
|
+
.map(
|
|
977
|
+
(s, i) => `
|
|
978
|
+
<div class="plan-step" draggable="true" data-idx="${i}">
|
|
979
|
+
<span class="grip" title="Drag to reorder">≡</span>
|
|
980
|
+
<span class="num">${i + 1}.</span>
|
|
981
|
+
<span class="name">${esc(s.name ?? s.id)}</span>
|
|
982
|
+
${s.skill ? `<span class="plan-badge skill" title="skill">${esc(s.skill)}</span>` : ''}
|
|
983
|
+
${s.command ? '<span class="plan-badge check">check</span>' : ''}
|
|
984
|
+
<span class="hint dim" title="${esc(s.command ?? s.prompt ?? '')}">${esc(oneLine(s.command ?? s.prompt ?? ''))}</span>
|
|
985
|
+
<button type="button" class="plan-remove" data-plan-remove="${i}" title="Remove step">✕</button>
|
|
986
|
+
</div>`,
|
|
987
|
+
)
|
|
988
|
+
.join('') || '<div class="dim">(no steps left — discard and plan again)</div>'}
|
|
989
|
+
</div>
|
|
990
|
+
<div class="plan-actions">
|
|
991
|
+
<button type="button" class="btn-dark" data-plan-action="start" ${steps.length ? '' : 'disabled'}>▶ Start</button>
|
|
992
|
+
<button type="button" class="btn-ghost" data-plan-action="save" ${steps.length ? '' : 'disabled'}>Save as chain</button>
|
|
993
|
+
<button type="button" class="btn-ghost" data-plan-action="discard">Discard</button>
|
|
994
|
+
</div>`;
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
function discardPlan() {
|
|
998
|
+
state.plan = null;
|
|
999
|
+
state.planDragIdx = null;
|
|
1000
|
+
renderPlan();
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
async function startPlannedRun(btn) {
|
|
1004
|
+
const form = $('#new-task');
|
|
1005
|
+
const task = form.task.value.trim() || state.plan.task;
|
|
1006
|
+
btn.disabled = true;
|
|
1007
|
+
try {
|
|
1008
|
+
const res = await fetch('/api/runs', {
|
|
1009
|
+
method: 'POST',
|
|
1010
|
+
headers: { 'content-type': 'application/json' },
|
|
1011
|
+
body: JSON.stringify({
|
|
1012
|
+
task,
|
|
1013
|
+
steps: state.plan.steps,
|
|
1014
|
+
model: state.taskModel || undefined,
|
|
1015
|
+
variants: state.variants > 1 ? state.variants : undefined,
|
|
1016
|
+
images: state.taskImages.length
|
|
1017
|
+
? state.taskImages.map((i) => ({ mediaType: i.mediaType, data: i.data }))
|
|
1018
|
+
: undefined,
|
|
1019
|
+
}),
|
|
1020
|
+
});
|
|
1021
|
+
const data = await res.json();
|
|
1022
|
+
if (!res.ok) throw new Error(data.error ?? `HTTP ${res.status}`);
|
|
1023
|
+
form.task.value = '';
|
|
1024
|
+
state.taskImages = [];
|
|
1025
|
+
renderTaskThumbs();
|
|
1026
|
+
discardPlan();
|
|
1027
|
+
handleStarted(data);
|
|
1028
|
+
} catch (err) {
|
|
1029
|
+
alertBar(err.message);
|
|
1030
|
+
btn.disabled = false;
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
async function savePlanAsChain(btn) {
|
|
1035
|
+
const name = prompt('Chain name:');
|
|
1036
|
+
if (!name || !name.trim()) return;
|
|
1037
|
+
btn.disabled = true;
|
|
1038
|
+
try {
|
|
1039
|
+
const res = await fetch('/api/workflows', {
|
|
1040
|
+
method: 'POST',
|
|
1041
|
+
headers: { 'content-type': 'application/json' },
|
|
1042
|
+
body: JSON.stringify({ name: name.trim(), steps: state.plan.steps }),
|
|
1043
|
+
});
|
|
1044
|
+
const data = await res.json();
|
|
1045
|
+
if (!res.ok) throw new Error(data.error ?? `HTTP ${res.status}`);
|
|
1046
|
+
alertBar(`Saved — ${String(data.path).split('/').pop()}`);
|
|
1047
|
+
const workflowsRes = await getJson('/api/workflows');
|
|
1048
|
+
setWorkflowOptions(workflowsRes.workflows);
|
|
1049
|
+
} catch (err) {
|
|
1050
|
+
alertBar(err.message);
|
|
1051
|
+
} finally {
|
|
1052
|
+
btn.disabled = false;
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
|
|
243
1056
|
// ---- live-session message bar ------------------------------------------------
|
|
244
1057
|
|
|
245
1058
|
function bindMessageBar(runId) {
|
|
@@ -291,11 +1104,12 @@ function bindMessageBar(runId) {
|
|
|
291
1104
|
});
|
|
292
1105
|
}
|
|
293
1106
|
|
|
294
|
-
|
|
295
|
-
|
|
1107
|
+
/* File → {mediaType, data, preview}, or null when oversized. Shared by the
|
|
1108
|
+
live-session bar and the new-task form. */
|
|
1109
|
+
async function readImageFile(file) {
|
|
296
1110
|
if (file.size > 5 * 1024 * 1024) {
|
|
297
1111
|
alertBar('Image too large (max 5 MB)');
|
|
298
|
-
return;
|
|
1112
|
+
return null;
|
|
299
1113
|
}
|
|
300
1114
|
const buf = await file.arrayBuffer();
|
|
301
1115
|
let binary = '';
|
|
@@ -304,10 +1118,26 @@ async function addImage(file) {
|
|
|
304
1118
|
binary += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
|
|
305
1119
|
}
|
|
306
1120
|
const data = btoa(binary);
|
|
307
|
-
|
|
1121
|
+
return { mediaType: file.type, data, preview: `data:${file.type};base64,${data}` };
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
async function addImage(file) {
|
|
1125
|
+
if (state.pendingImages.length >= 4) return;
|
|
1126
|
+
const img = await readImageFile(file);
|
|
1127
|
+
if (!img) return;
|
|
1128
|
+
state.pendingImages.push(img);
|
|
308
1129
|
renderThumbs();
|
|
309
1130
|
}
|
|
310
1131
|
|
|
1132
|
+
function renderTaskThumbs() {
|
|
1133
|
+
const box = $('#task-thumbs');
|
|
1134
|
+
if (!box) return;
|
|
1135
|
+
box.hidden = state.taskImages.length === 0;
|
|
1136
|
+
box.innerHTML = state.taskImages
|
|
1137
|
+
.map((img, idx) => `<span class="thumb" data-idx="${idx}" title="Click to remove"><img src="${img.preview}"></span>`)
|
|
1138
|
+
.join('');
|
|
1139
|
+
}
|
|
1140
|
+
|
|
311
1141
|
function renderThumbs() {
|
|
312
1142
|
const box = $('#msg-thumbs');
|
|
313
1143
|
if (!box) return;
|
|
@@ -340,6 +1170,23 @@ async function sendMessage(runId, text, images) {
|
|
|
340
1170
|
}
|
|
341
1171
|
}
|
|
342
1172
|
|
|
1173
|
+
function openLightbox(url, name) {
|
|
1174
|
+
closeLightbox();
|
|
1175
|
+
const el = document.createElement('div');
|
|
1176
|
+
el.id = 'lightbox';
|
|
1177
|
+
el.innerHTML = `
|
|
1178
|
+
<div class="lb-inner">
|
|
1179
|
+
<img src="${esc(url)}" alt="${esc(name ?? '')}">
|
|
1180
|
+
<div class="lb-name">${esc(name ?? '')} — click anywhere to close</div>
|
|
1181
|
+
</div>`;
|
|
1182
|
+
el.addEventListener('click', closeLightbox);
|
|
1183
|
+
document.body.appendChild(el);
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
function closeLightbox() {
|
|
1187
|
+
$('#lightbox')?.remove();
|
|
1188
|
+
}
|
|
1189
|
+
|
|
343
1190
|
function alertBar(message) {
|
|
344
1191
|
const existing = $('#toast');
|
|
345
1192
|
if (existing) existing.remove();
|
|
@@ -352,6 +1199,16 @@ function alertBar(message) {
|
|
|
352
1199
|
|
|
353
1200
|
// ---- run list --------------------------------------------------------------
|
|
354
1201
|
|
|
1202
|
+
/* POST /api/runs answers with one run (×1) or {runs: [...]} (variants). */
|
|
1203
|
+
function handleStarted(data) {
|
|
1204
|
+
const runs = Array.isArray(data.runs) ? data.runs : [data];
|
|
1205
|
+
for (const run of runs) state.runs.set(run.id, run);
|
|
1206
|
+
const first = runs[0];
|
|
1207
|
+
if (first?.groupId) state.expandedGroups.add(first.groupId);
|
|
1208
|
+
renderRunList();
|
|
1209
|
+
if (first) selectRun(first.id);
|
|
1210
|
+
}
|
|
1211
|
+
|
|
355
1212
|
function sortedRuns() {
|
|
356
1213
|
// Needs-you-first: waiting/review, then running/queued, then by recency.
|
|
357
1214
|
return [...state.runs.values()]
|
|
@@ -364,6 +1221,14 @@ function sortedRuns() {
|
|
|
364
1221
|
});
|
|
365
1222
|
}
|
|
366
1223
|
|
|
1224
|
+
/* Which sidebar group a run (or a variant group's best member) belongs to. */
|
|
1225
|
+
function bucketOf(status) {
|
|
1226
|
+
if (state.listView === 'archived') return 'Archived';
|
|
1227
|
+
if (status === 'waiting' || status === 'review') return 'Needs you';
|
|
1228
|
+
if (status === 'running' || status === 'queued') return 'Working';
|
|
1229
|
+
return 'Recent';
|
|
1230
|
+
}
|
|
1231
|
+
|
|
367
1232
|
function renderRunList() {
|
|
368
1233
|
const all = [...state.runs.values()];
|
|
369
1234
|
const activeCount = all.filter((r) => !r.archived).length;
|
|
@@ -371,26 +1236,81 @@ function renderRunList() {
|
|
|
371
1236
|
const finishedCount = all.filter(
|
|
372
1237
|
(r) => !r.archived && ['done', 'failed', 'cancelled'].includes(r.status),
|
|
373
1238
|
).length;
|
|
374
|
-
const waitingCount = all.filter(
|
|
1239
|
+
const waitingCount = all.filter(
|
|
1240
|
+
(r) => !r.archived && (r.status === 'waiting' || r.status === 'review'),
|
|
1241
|
+
).length;
|
|
375
1242
|
|
|
376
1243
|
$('#list-tabs').innerHTML = `
|
|
377
1244
|
<button data-list="active" class="${state.listView === 'active' ? 'active' : ''}">
|
|
378
|
-
Active
|
|
1245
|
+
Active${activeCount ? ` ${activeCount}` : ''}${waitingCount ? ' <span class="dot"></span>' : ''}
|
|
379
1246
|
</button>
|
|
380
1247
|
<button data-list="archived" class="${state.listView === 'archived' ? 'active' : ''}">
|
|
381
|
-
Archived
|
|
1248
|
+
Archived${archivedCount ? ` ${archivedCount}` : ''}
|
|
382
1249
|
</button>
|
|
383
1250
|
${state.listView === 'active' && finishedCount ? `<button data-list="archive-finished" title="Archive all finished runs">📥 ${finishedCount}</button>` : ''}`;
|
|
384
1251
|
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
)
|
|
393
|
-
|
|
1252
|
+
// Variant groups (spec 010): runs sharing a groupId collapse into one
|
|
1253
|
+
// group tile at the position of their best-ranked member; click expands.
|
|
1254
|
+
const runs = sortedRuns();
|
|
1255
|
+
const seenGroups = new Set();
|
|
1256
|
+
const buckets = new Map(); // label -> html[]
|
|
1257
|
+
const push = (label, html) => {
|
|
1258
|
+
if (!buckets.has(label)) buckets.set(label, []);
|
|
1259
|
+
buckets.get(label).push(html);
|
|
1260
|
+
};
|
|
1261
|
+
for (const r of runs) {
|
|
1262
|
+
if (r.groupId) {
|
|
1263
|
+
if (seenGroups.has(r.groupId)) continue;
|
|
1264
|
+
seenGroups.add(r.groupId);
|
|
1265
|
+
const members = runs
|
|
1266
|
+
.filter((m) => m.groupId === r.groupId)
|
|
1267
|
+
.sort((a, b) => (a.variant ?? '').localeCompare(b.variant ?? ''));
|
|
1268
|
+
if (members.length > 1) {
|
|
1269
|
+
push(bucketOf(r.status), groupTileHtml(r.groupId, members));
|
|
1270
|
+
continue;
|
|
1271
|
+
}
|
|
1272
|
+
// A lone survivor (the picked winner) renders like any other run.
|
|
1273
|
+
}
|
|
1274
|
+
push(bucketOf(r.status), runItemHtml(r));
|
|
1275
|
+
}
|
|
1276
|
+
const order = ['Needs you', 'Working', 'Recent', 'Archived'];
|
|
1277
|
+
$('#run-list').innerHTML =
|
|
1278
|
+
order
|
|
1279
|
+
.filter((label) => buckets.has(label))
|
|
1280
|
+
.map((label) => `<div class="group-label">${label}</div>${buckets.get(label).join('')}`)
|
|
1281
|
+
.join('') ||
|
|
1282
|
+
`<div class="dim" style="padding:14px 12px;font-size:12px">${
|
|
1283
|
+
state.listView === 'archived' ? 'Nothing archived yet.' : 'No runs yet — describe a task above.'
|
|
1284
|
+
}</div>`;
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
function runItemHtml(r, variantRow = false) {
|
|
1288
|
+
const timeText =
|
|
1289
|
+
r.status === 'queued' ? `#${queuePosition(r) ?? '·'}` : shortAgo(r.finishedAt ?? r.createdAt);
|
|
1290
|
+
return `
|
|
1291
|
+
<div class="run-item ${variantRow ? 'variant-row' : ''} ${r.id === state.selectedId ? 'selected' : ''}" data-id="${r.id}" title="${esc(r.task)}">
|
|
1292
|
+
<span class="dot ${esc(r.status)}"></span>
|
|
1293
|
+
${r.variant ? `<span class="time">${esc(r.variant)}</span>` : ''}
|
|
1294
|
+
<span class="title">${esc(variantRow ? groupTitle(r) : r.title)}</span>
|
|
1295
|
+
${r.pullRequestUrl ? `<a class="time" href="${esc(r.pullRequestUrl)}" target="_blank" rel="noopener" title="open the PR">PR↗</a>` : ''}
|
|
1296
|
+
<span class="time">${esc(timeText)}</span>
|
|
1297
|
+
</div>`;
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
function groupTileHtml(groupId, members) {
|
|
1301
|
+
const first = members[0];
|
|
1302
|
+
const expanded = state.expandedGroups.has(groupId);
|
|
1303
|
+
const allTerminal = members.every((m) => TERMINAL_STATUSES.includes(m.status));
|
|
1304
|
+
return `
|
|
1305
|
+
<div class="run-item group-item ${state.selectedGroupId === groupId ? 'selected' : ''}" data-group="${esc(groupId)}" title="${esc(first.task)}">
|
|
1306
|
+
<span class="time">${expanded ? '▾' : '▸'}</span>
|
|
1307
|
+
<span class="title">${esc(groupTitle(first))}</span>
|
|
1308
|
+
<span class="dots">${members
|
|
1309
|
+
.map((m) => `<span class="dot ${esc(m.status)}" title="${esc(`${m.variant ?? '?'} · ${m.status}`)}"></span>`)
|
|
1310
|
+
.join('')}</span>
|
|
1311
|
+
${allTerminal ? `<button type="button" class="compare-btn" data-compare="${esc(groupId)}" title="Compare the variants' diffs side by side">⚖</button>` : `<span class="time">${members.length}×</span>`}
|
|
1312
|
+
</div>
|
|
1313
|
+
${expanded ? members.map((m) => runItemHtml(m, true)).join('') : ''}`;
|
|
394
1314
|
}
|
|
395
1315
|
|
|
396
1316
|
// ---- run detail ------------------------------------------------------------
|
|
@@ -399,6 +1319,7 @@ function selectRun(id) {
|
|
|
399
1319
|
const run = state.runs.get(id);
|
|
400
1320
|
if (!run) return;
|
|
401
1321
|
state.selectedId = id;
|
|
1322
|
+
state.selectedGroupId = null;
|
|
402
1323
|
state.lastSeq = 0;
|
|
403
1324
|
state.autoScroll = true;
|
|
404
1325
|
if (state.runEs) {
|
|
@@ -422,21 +1343,43 @@ function selectRun(id) {
|
|
|
422
1343
|
function renderDetailShell(run) {
|
|
423
1344
|
state.pendingImages = [];
|
|
424
1345
|
$('#detail').innerHTML = `
|
|
425
|
-
<div class="detail-
|
|
426
|
-
|
|
1346
|
+
<div class="detail-head">
|
|
1347
|
+
<div class="meta-line" id="d-meta"></div>
|
|
1348
|
+
<div class="title-row">
|
|
1349
|
+
<h1 id="d-title"></h1>
|
|
1350
|
+
<span id="d-badge"></span>
|
|
1351
|
+
</div>
|
|
1352
|
+
<div class="head-bar">
|
|
1353
|
+
<span class="steps-line" id="d-steps"></span>
|
|
1354
|
+
<div class="spacer"></div>
|
|
1355
|
+
<span id="d-actions" style="display:flex;align-items:center;gap:4px;flex-wrap:wrap"></span>
|
|
1356
|
+
</div>
|
|
1357
|
+
<div id="d-error" class="run-error" hidden></div>
|
|
1358
|
+
<div id="d-resume" class="resume-hint" hidden></div>
|
|
1359
|
+
</div>
|
|
1360
|
+
<div id="review-panel" class="detail-panel" hidden></div>
|
|
1361
|
+
<div id="diff-panel" class="detail-panel" hidden><div class="inner"><div class="panel-label">What this task changed</div><div id="diff-body"></div></div></div>
|
|
1362
|
+
<div id="notes-panel" class="detail-panel" hidden><div class="inner"><div class="panel-label">Handoff notes</div><pre id="notes-pre"></pre></div></div>
|
|
427
1363
|
<div class="log-wrap">
|
|
428
|
-
<div id="log"></div>
|
|
1364
|
+
<div id="log"><div class="log-inner" id="log-inner"></div></div>
|
|
429
1365
|
<button id="jump-bottom" data-action="jump-bottom" hidden>↓ jump to bottom</button>
|
|
430
1366
|
</div>
|
|
431
|
-
<
|
|
432
|
-
<div id="
|
|
433
|
-
<
|
|
434
|
-
<
|
|
435
|
-
<
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
1367
|
+
<div class="composer-wrap">
|
|
1368
|
+
<div id="waiting-note" hidden><span class="pulse-dot"></span>The agent is paused, waiting for your reply</div>
|
|
1369
|
+
<form id="msg-form">
|
|
1370
|
+
<div id="msg-thumbs" hidden></div>
|
|
1371
|
+
<div class="msg-row">
|
|
1372
|
+
<textarea id="msg-text" rows="1" placeholder="Message the agent…"></textarea>
|
|
1373
|
+
<button type="button" id="msg-attach" title="Attach an image (or paste a screenshot)">
|
|
1374
|
+
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M20 11l-8 8a5 5 0 01-7-7l8-8a3.4 3.4 0 015 5l-8 8a1.7 1.7 0 01-2.4-2.4l7-7"/></svg>
|
|
1375
|
+
</button>
|
|
1376
|
+
<button type="submit" id="msg-send" title="Send (Enter)">
|
|
1377
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><path d="M12 19V5M6 11l6-6 6 6"/></svg>
|
|
1378
|
+
</button>
|
|
1379
|
+
</div>
|
|
1380
|
+
<input type="file" id="msg-file" accept="image/*" multiple hidden>
|
|
1381
|
+
</form>
|
|
1382
|
+
</div>`;
|
|
440
1383
|
bindMessageBar(run.id);
|
|
441
1384
|
updateDetail(run);
|
|
442
1385
|
$('#log').addEventListener('scroll', () => {
|
|
@@ -447,24 +1390,76 @@ function renderDetailShell(run) {
|
|
|
447
1390
|
});
|
|
448
1391
|
}
|
|
449
1392
|
|
|
1393
|
+
const STEP_MARK = { done: '✓', running: '●', waiting: '●', review: '●', failed: '✗' };
|
|
1394
|
+
|
|
450
1395
|
function updateDetail(run) {
|
|
451
1396
|
state.runs.set(run.id, run);
|
|
452
1397
|
const active = run.status === 'running' || run.status === 'queued' || run.status === 'waiting';
|
|
453
1398
|
const lastSession = [...run.steps].reverse().find((s) => s.sessionId)?.sessionId;
|
|
454
|
-
|
|
455
|
-
${
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
1399
|
+
const resumeCmd = run.worktreePath
|
|
1400
|
+
? `cd ${run.worktreePath} && claude --resume ${lastSession}`
|
|
1401
|
+
: `claude --resume ${lastSession}`;
|
|
1402
|
+
|
|
1403
|
+
const metaParts = [
|
|
1404
|
+
esc(run.workflow),
|
|
1405
|
+
esc(fmtTokens(run.tokensUsed)),
|
|
1406
|
+
run.costUsd ? esc(fmtCost(run.costUsd)) : null,
|
|
1407
|
+
`started ${esc(timeAgo(run.startedAt ?? run.createdAt))}`,
|
|
1408
|
+
run.finishedAt ? `finished ${esc(timeAgo(run.finishedAt))}` : null,
|
|
1409
|
+
run.model ? `model ${esc(run.model)}` : null,
|
|
1410
|
+
run.branch ? esc(run.branch) : null,
|
|
1411
|
+
prLink(run) || null,
|
|
1412
|
+
].filter(Boolean);
|
|
1413
|
+
$('#d-meta').innerHTML = metaParts.map((p) => `<span>${p}</span>`).join('<span>·</span>');
|
|
1414
|
+
|
|
1415
|
+
$('#d-title').textContent = run.title;
|
|
1416
|
+
$('#d-title').title = run.task;
|
|
1417
|
+
$('#d-badge').innerHTML = statusPill(run);
|
|
1418
|
+
|
|
1419
|
+
$('#d-steps').innerHTML = run.steps
|
|
1420
|
+
.map((s) => {
|
|
1421
|
+
const mark = STEP_MARK[s.status] ?? '○';
|
|
1422
|
+
const label = `${mark} ${esc(s.name)}${s.iterations > 1 ? ` ×${s.iterations}` : ''}`;
|
|
1423
|
+
const tip = `${s.kind} · ${s.status} · ${fmtTokens(s.tokensUsed)}${s.error ? `\n${s.error}` : ''}${s.sessionId ? `\nsession: ${s.sessionId}` : ''}`;
|
|
1424
|
+
return `<span title="${esc(tip)}">${label}</span>`;
|
|
1425
|
+
})
|
|
1426
|
+
.join('<span style="opacity:.5"> → </span>');
|
|
1427
|
+
|
|
1428
|
+
$('#d-actions').innerHTML = `
|
|
1429
|
+
${run.status === 'waiting' || run.status === 'review' ? `<button class="btn-dark" data-action="finish" title="${run.status === 'review' ? 'Accept the changes without a PR' : 'Close the session'}">✓ Finish</button>` : ''}
|
|
1430
|
+
${!active && lastSession ? '<button class="btn-text" data-action="continue">▶ Continue</button><button class="btn-text" data-action="open-cli" title="Take over the session in a real terminal">Terminal</button>' : ''}
|
|
1431
|
+
${run.worktreePath ? '<button class="btn-text" data-action="diff" title="What this task changed (worktree vs base)">± Diff</button>' : ''}
|
|
1432
|
+
<button class="btn-text" data-action="notes" title="Handoff notes — what the agent did and what's left">Notes</button>
|
|
1433
|
+
${run.archived && run.worktreePath ? '<button class="btn-text" data-action="remove-worktree" title="Remove the task worktree and its branch">Remove worktree</button>' : ''}
|
|
1434
|
+
${!active ? `<button class="btn-text" data-action="archive" title="${run.archived ? 'Unarchive' : 'Archive'}">${run.archived ? 'Unarchive' : 'Archive'}</button>` : ''}
|
|
460
1435
|
${active
|
|
461
|
-
? '<button data-action="cancel"
|
|
462
|
-
: '<button data-action="delete"
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
1436
|
+
? '<button class="btn-text danger" data-action="cancel">■ Cancel</button>'
|
|
1437
|
+
: '<button class="btn-text danger" data-action="delete">Delete</button>'}`;
|
|
1438
|
+
|
|
1439
|
+
const errBox = $('#d-error');
|
|
1440
|
+
errBox.hidden = !run.error;
|
|
1441
|
+
if (run.error) errBox.textContent = `✗ ${run.error}`;
|
|
1442
|
+
const resumeBox = $('#d-resume');
|
|
1443
|
+
const showResume = !active && lastSession;
|
|
1444
|
+
resumeBox.hidden = !showResume;
|
|
1445
|
+
if (showResume) resumeBox.textContent = `take over interactively: ${resumeCmd}`;
|
|
1446
|
+
|
|
1447
|
+
// Review gate (spec 009): the panel lives while the run rests at `review`;
|
|
1448
|
+
// it (re)loads the diff on each entry into review and clears on exit, so a
|
|
1449
|
+
// send-back round always comes back with a fresh diff.
|
|
1450
|
+
const reviewPanel = $('#review-panel');
|
|
1451
|
+
if (reviewPanel) {
|
|
1452
|
+
const inReview = run.status === 'review';
|
|
1453
|
+
reviewPanel.hidden = !inReview;
|
|
1454
|
+
if (inReview && !reviewPanel.dataset.loaded) {
|
|
1455
|
+
reviewPanel.dataset.loaded = '1';
|
|
1456
|
+
void renderReviewPanel(run.id);
|
|
1457
|
+
} else if (!inReview && reviewPanel.dataset.loaded) {
|
|
1458
|
+
delete reviewPanel.dataset.loaded;
|
|
1459
|
+
reviewPanel.innerHTML = '';
|
|
1460
|
+
}
|
|
1461
|
+
}
|
|
1462
|
+
|
|
468
1463
|
const msgForm = $('#msg-form');
|
|
469
1464
|
if (msgForm) {
|
|
470
1465
|
const sessionOpen = run.status === 'running' || run.status === 'waiting';
|
|
@@ -474,56 +1469,212 @@ function updateDetail(run) {
|
|
|
474
1469
|
$('#msg-attach').disabled = !sessionOpen;
|
|
475
1470
|
$('#msg-text').placeholder = sessionOpen
|
|
476
1471
|
? run.status === 'waiting'
|
|
477
|
-
? '
|
|
478
|
-
: 'Message the agent… (Enter to send, ⌘V
|
|
479
|
-
: 'Session closed.';
|
|
1472
|
+
? 'Reply to the agent… (Enter to send, ⌘V pastes a screenshot)'
|
|
1473
|
+
: 'Message the agent… (Enter to send, ⌘V pastes a screenshot)'
|
|
1474
|
+
: 'Session closed — Continue to reopen.';
|
|
1475
|
+
$('#waiting-note').hidden = run.status !== 'waiting';
|
|
1476
|
+
}
|
|
1477
|
+
}
|
|
1478
|
+
|
|
1479
|
+
/* Review gate panel (spec 009): the full diff on top, then a notes field with
|
|
1480
|
+
exactly two buttons — "Send back" (feedback into the same session) and
|
|
1481
|
+
"Draft PR". The third exit, "✓ Finish" (accept without a PR), sits in the
|
|
1482
|
+
detail header. */
|
|
1483
|
+
async function renderReviewPanel(runId) {
|
|
1484
|
+
const panel = $('#review-panel');
|
|
1485
|
+
if (!panel) return;
|
|
1486
|
+
panel.innerHTML = `
|
|
1487
|
+
<div class="inner">
|
|
1488
|
+
<div class="panel-label">Changes ready for review</div>
|
|
1489
|
+
<div id="review-diff"><div class="dim">Loading diff…</div></div>
|
|
1490
|
+
<textarea id="review-notes" rows="2" placeholder="Notes for the agent — what should change?"></textarea>
|
|
1491
|
+
<div class="review-buttons">
|
|
1492
|
+
<button type="button" class="btn-ghost" data-action="send-back" title="Send the notes back into the agent's session">↩ Send back</button>
|
|
1493
|
+
<button type="button" class="btn-dark" data-action="draft-pr" title="Push the branch and open a draft PR">Draft PR</button>
|
|
1494
|
+
</div>
|
|
1495
|
+
<div id="review-manual" class="dim mono" hidden></div>
|
|
1496
|
+
</div>`;
|
|
1497
|
+
try {
|
|
1498
|
+
const res = await fetch(`/api/runs/${runId}/diff`);
|
|
1499
|
+
const text = await res.text();
|
|
1500
|
+
const box = $('#review-diff');
|
|
1501
|
+
if (box) box.innerHTML = renderDiff(text);
|
|
1502
|
+
} catch (err) {
|
|
1503
|
+
const box = $('#review-diff');
|
|
1504
|
+
if (box) box.textContent = `✗ ${err.message}`;
|
|
1505
|
+
}
|
|
1506
|
+
}
|
|
1507
|
+
|
|
1508
|
+
// ---- variant compare view (spec 010) ----------------------------------------
|
|
1509
|
+
|
|
1510
|
+
/* "⚖ Compare": columns per variant — status, cost, `git diff --stat`, the
|
|
1511
|
+
first Progress-log lines — each with a "Pick this one" button; full diffs
|
|
1512
|
+
collapse under the columns (renderDiff from spec 009, reused). */
|
|
1513
|
+
async function selectGroup(groupId) {
|
|
1514
|
+
state.selectedGroupId = groupId;
|
|
1515
|
+
state.selectedId = null;
|
|
1516
|
+
if (state.runEs) {
|
|
1517
|
+
state.runEs.close();
|
|
1518
|
+
state.runEs = null;
|
|
1519
|
+
}
|
|
1520
|
+
showRunsView();
|
|
1521
|
+
renderRunList();
|
|
1522
|
+
const detail = $('#detail');
|
|
1523
|
+
detail.innerHTML = '<div class="empty">Loading variants…</div>';
|
|
1524
|
+
let data;
|
|
1525
|
+
try {
|
|
1526
|
+
data = await getJson(`/api/groups/${groupId}`);
|
|
1527
|
+
} catch (err) {
|
|
1528
|
+
detail.innerHTML = `<div class="empty">✗ ${esc(err.message)}</div>`;
|
|
1529
|
+
return;
|
|
1530
|
+
}
|
|
1531
|
+
if (state.selectedGroupId !== groupId) return; // user moved on meanwhile
|
|
1532
|
+
renderCompareView(data.runs);
|
|
1533
|
+
}
|
|
1534
|
+
|
|
1535
|
+
function renderCompareView(variants) {
|
|
1536
|
+
const title = variants.length ? groupTitle(variants[0]) : '';
|
|
1537
|
+
$('#detail').innerHTML = `
|
|
1538
|
+
<div class="compare-view"><div class="inner">
|
|
1539
|
+
<div class="compare-head">
|
|
1540
|
+
<h1 title="${esc(title)}">⚖ ${esc(title)}</h1>
|
|
1541
|
+
<span class="dim">${variants.length} variants — pick the diff you want to keep; the others are archived and their worktrees removed</span>
|
|
1542
|
+
</div>
|
|
1543
|
+
<div class="compare-cols">
|
|
1544
|
+
${variants
|
|
1545
|
+
.map(
|
|
1546
|
+
(v) => `
|
|
1547
|
+
<div class="compare-col">
|
|
1548
|
+
<div class="col-head"><span class="variant-letter">${esc(v.variant)}</span> ${statusPill(v)}</div>
|
|
1549
|
+
<div class="col-meta">${fmtTokens(v.tokensUsed)}${v.costUsd ? ` · ${fmtCost(v.costUsd)}` : ''}</div>
|
|
1550
|
+
<pre class="col-stat">${esc(v.diffStat || '(no changes)')}</pre>
|
|
1551
|
+
<pre class="col-handoff">${esc(v.handoffExcerpt || '(no progress notes)')}</pre>
|
|
1552
|
+
<button type="button" class="btn-dark" data-pick="${esc(v.id)}">✔ Pick this one</button>
|
|
1553
|
+
</div>`,
|
|
1554
|
+
)
|
|
1555
|
+
.join('')}
|
|
1556
|
+
</div>
|
|
1557
|
+
<div class="compare-diffs">
|
|
1558
|
+
${variants
|
|
1559
|
+
.map(
|
|
1560
|
+
(v) => `
|
|
1561
|
+
<details class="compare-diff">
|
|
1562
|
+
<summary>Variant ${esc(v.variant)} — full diff</summary>
|
|
1563
|
+
<div class="diff-body" data-group-diff="${esc(v.id)}"><div class="dim">Loading…</div></div>
|
|
1564
|
+
</details>`,
|
|
1565
|
+
)
|
|
1566
|
+
.join('')}
|
|
1567
|
+
</div>
|
|
1568
|
+
</div></div>`;
|
|
1569
|
+
// Full diffs (max 3) via the existing per-run endpoint.
|
|
1570
|
+
for (const v of variants) {
|
|
1571
|
+
void fetch(`/api/runs/${v.id}/diff`)
|
|
1572
|
+
.then((r) => r.text())
|
|
1573
|
+
.then((text) => {
|
|
1574
|
+
const box = document.querySelector(`[data-group-diff="${v.id}"]`);
|
|
1575
|
+
if (box) box.innerHTML = renderDiff(text);
|
|
1576
|
+
})
|
|
1577
|
+
.catch(() => undefined);
|
|
480
1578
|
}
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
1579
|
+
}
|
|
1580
|
+
|
|
1581
|
+
/* "Pick this one" → the winner rests at `review` (the spec-009 gate takes it
|
|
1582
|
+
from there); the losers are archived and their worktrees removed. */
|
|
1583
|
+
async function pickVariant(btn) {
|
|
1584
|
+
const groupId = state.selectedGroupId;
|
|
1585
|
+
if (!groupId) return;
|
|
1586
|
+
btn.disabled = true;
|
|
1587
|
+
try {
|
|
1588
|
+
const res = await fetch(`/api/groups/${groupId}/pick`, {
|
|
1589
|
+
method: 'POST',
|
|
1590
|
+
headers: { 'content-type': 'application/json' },
|
|
1591
|
+
body: JSON.stringify({ runId: btn.dataset.pick }),
|
|
1592
|
+
});
|
|
1593
|
+
const data = await res.json();
|
|
1594
|
+
if (!res.ok) throw new Error(data.error ?? `HTTP ${res.status}`);
|
|
1595
|
+
if (data.winner) state.runs.set(data.winner.id, data.winner);
|
|
1596
|
+
state.expandedGroups.delete(groupId);
|
|
1597
|
+
renderRunList();
|
|
1598
|
+
if (data.winner) selectRun(data.winner.id); // lands on the review gate
|
|
1599
|
+
} catch (err) {
|
|
1600
|
+
alertBar(err.message);
|
|
1601
|
+
btn.disabled = false;
|
|
1602
|
+
}
|
|
1603
|
+
}
|
|
1604
|
+
|
|
1605
|
+
// ---- transcript ------------------------------------------------------------
|
|
1606
|
+
|
|
1607
|
+
/* The most telling single argument of a tool call — a command, a path, a
|
|
1608
|
+
pattern — shown inside the tool chip. */
|
|
1609
|
+
function toolArg(input) {
|
|
1610
|
+
if (input == null) return null;
|
|
1611
|
+
if (typeof input === 'string') return input.trim() || null;
|
|
1612
|
+
if (typeof input !== 'object') return String(input);
|
|
1613
|
+
for (const key of ['command', 'file_path', 'path', 'pattern', 'url', 'query', 'description', 'prompt']) {
|
|
1614
|
+
const v = input[key];
|
|
1615
|
+
if (typeof v === 'string' && v.trim()) return v.trim();
|
|
1616
|
+
}
|
|
1617
|
+
const s = JSON.stringify(input);
|
|
1618
|
+
return s === '{}' ? null : s;
|
|
492
1619
|
}
|
|
493
1620
|
|
|
494
1621
|
function appendLog(evt) {
|
|
1622
|
+
const inner = $('#log-inner');
|
|
495
1623
|
const log = $('#log');
|
|
496
|
-
if (!log) return;
|
|
1624
|
+
if (!inner || !log) return;
|
|
497
1625
|
const el = document.createElement('div');
|
|
498
|
-
el.className = `ev ${evt.type}${evt.isError ? ' error' : ''}`;
|
|
499
1626
|
|
|
500
1627
|
switch (evt.type) {
|
|
501
1628
|
case 'text':
|
|
1629
|
+
el.className = 'ev text';
|
|
502
1630
|
el.textContent = evt.text ?? '';
|
|
503
1631
|
break;
|
|
504
1632
|
case 'tool-call': {
|
|
505
|
-
|
|
506
|
-
const
|
|
507
|
-
el.innerHTML =
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
1633
|
+
el.className = 'ev tool';
|
|
1634
|
+
const arg = toolArg(evt.input);
|
|
1635
|
+
el.innerHTML = `<span class="ok">✓</span><span>${esc(evt.tool)}</span>${
|
|
1636
|
+
arg ? `<span class="arg">${esc(oneLine(arg, 64))}</span>` : ''
|
|
1637
|
+
}`;
|
|
1638
|
+
if (arg && arg.length > 64) el.title = arg.slice(0, 1000);
|
|
511
1639
|
break;
|
|
512
1640
|
}
|
|
513
1641
|
case 'tool-result': {
|
|
514
1642
|
const result = String(evt.result ?? '');
|
|
515
|
-
const first = result.split('\n')[0] ?? '';
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
1643
|
+
const first = (result.split('\n')[0] ?? '').slice(0, 140);
|
|
1644
|
+
const hasMore = result.length > first.length;
|
|
1645
|
+
el.className = `ev result${evt.isError ? ' error' : ''}`;
|
|
1646
|
+
el.innerHTML = hasMore
|
|
1647
|
+
? `<details><summary><span class="lead-in">↳</span><span class="head">${esc(first)}${result.length > 140 ? '…' : ''}</span><span class="show">show</span></summary><pre>${esc(result.slice(0, 10_000))}</pre></details>`
|
|
1648
|
+
: `<span class="ev tool" style="margin:0"><span class="lead-in" style="color:var(--text3);font-size:11px">↳</span><span>${esc(first)}</span></span>`;
|
|
520
1649
|
break;
|
|
521
1650
|
}
|
|
522
|
-
case 'check-output':
|
|
523
|
-
el.
|
|
1651
|
+
case 'check-output': {
|
|
1652
|
+
el.className = 'ev check';
|
|
1653
|
+
const ok = evt.exitCode === 0;
|
|
1654
|
+
el.innerHTML = `
|
|
1655
|
+
<div class="check-head">
|
|
1656
|
+
<span class="lbl">Command</span>
|
|
1657
|
+
<code>${esc(evt.stepId ?? 'check')}</code>
|
|
1658
|
+
<span class="check-pill ${ok ? 'pass' : 'fail'}">${ok ? 'passed' : `failed (exit ${esc(String(evt.exitCode))})`}</span>
|
|
1659
|
+
</div>
|
|
1660
|
+
<pre>${esc(String(evt.text ?? ''))}</pre>`;
|
|
1661
|
+
break;
|
|
1662
|
+
}
|
|
1663
|
+
case 'image':
|
|
1664
|
+
el.className = 'ev image-ev';
|
|
1665
|
+
el.innerHTML = `
|
|
1666
|
+
<div class="img-card" data-lightbox="${esc(evt.url)}" data-name="${esc(evt.name)}" title="Click to zoom">
|
|
1667
|
+
<img src="${esc(evt.url)}" alt="${esc(evt.name)}" loading="lazy">
|
|
1668
|
+
<div class="img-foot">
|
|
1669
|
+
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="var(--text3)" stroke-width="1.8" stroke-linejoin="round"><path d="M4 5h16v14H4z"/><path d="M4 15l5-5 4 4 3-3 4 4"/><circle cx="9.5" cy="9.5" r="1.1"/></svg>
|
|
1670
|
+
<span class="nm">${esc(evt.name)}</span>
|
|
1671
|
+
<span class="zm">click to zoom</span>
|
|
1672
|
+
</div>
|
|
1673
|
+
</div>`;
|
|
524
1674
|
break;
|
|
525
1675
|
case 'step-start':
|
|
526
|
-
el.
|
|
1676
|
+
el.className = 'ev step';
|
|
1677
|
+
el.innerHTML = `<span class="step-label">${esc(evt.name ?? evt.stepId ?? 'step')}${evt.iteration > 1 ? ` · attempt ${esc(String(evt.iteration))}` : ''}</span><div class="rule"></div>`;
|
|
527
1678
|
break;
|
|
528
1679
|
case 'step-end':
|
|
529
1680
|
el.className = 'ev note';
|
|
@@ -531,14 +1682,17 @@ function appendLog(evt) {
|
|
|
531
1682
|
break;
|
|
532
1683
|
case 'note':
|
|
533
1684
|
case 'lifecycle':
|
|
1685
|
+
el.className = 'ev note';
|
|
534
1686
|
el.textContent = `· ${evt.message ?? ''}`;
|
|
535
1687
|
break;
|
|
536
1688
|
case 'user-message': {
|
|
1689
|
+
el.className = 'ev user';
|
|
537
1690
|
const imgs = evt.imageCount > 0 ? ` [${evt.imageCount} image${evt.imageCount > 1 ? 's' : ''}]` : '';
|
|
538
|
-
el.
|
|
1691
|
+
el.innerHTML = `<div class="bubble">${esc(`${evt.text ?? ''}${imgs}`)}</div>`;
|
|
539
1692
|
break;
|
|
540
1693
|
}
|
|
541
1694
|
case 'error':
|
|
1695
|
+
el.className = 'ev err';
|
|
542
1696
|
el.textContent = `✗ ${evt.message ?? ''}`;
|
|
543
1697
|
break;
|
|
544
1698
|
case 'token-usage':
|
|
@@ -548,82 +1702,630 @@ function appendLog(evt) {
|
|
|
548
1702
|
case 'done':
|
|
549
1703
|
return;
|
|
550
1704
|
default:
|
|
1705
|
+
el.className = 'ev note';
|
|
551
1706
|
el.textContent = JSON.stringify(evt);
|
|
552
1707
|
}
|
|
553
1708
|
|
|
554
|
-
|
|
1709
|
+
inner.appendChild(el);
|
|
555
1710
|
if (state.autoScroll) log.scrollTop = log.scrollHeight;
|
|
556
1711
|
}
|
|
557
1712
|
|
|
1713
|
+
// ---- inbox view (spec 007) -----------------------------------------------------
|
|
1714
|
+
|
|
1715
|
+
/* Entries already turned into a task (startedTaskId) are hidden — they stay
|
|
1716
|
+
in todos.json as an audit trail. */
|
|
1717
|
+
function visibleTodos() {
|
|
1718
|
+
return state.todos.filter((t) => !t.startedTaskId);
|
|
1719
|
+
}
|
|
1720
|
+
|
|
1721
|
+
function renderInboxBadge() {
|
|
1722
|
+
const badgeEl = $('#inbox-badge');
|
|
1723
|
+
if (!badgeEl) return;
|
|
1724
|
+
const count = visibleTodos().length;
|
|
1725
|
+
badgeEl.textContent = count;
|
|
1726
|
+
badgeEl.hidden = count === 0;
|
|
1727
|
+
}
|
|
1728
|
+
|
|
1729
|
+
function showRunsView() {
|
|
1730
|
+
const btn = $('#tabs button[data-view="runs"]');
|
|
1731
|
+
if (btn && !btn.classList.contains('active')) btn.click();
|
|
1732
|
+
}
|
|
1733
|
+
|
|
1734
|
+
function renderInbox() {
|
|
1735
|
+
const view = $('#view-inbox');
|
|
1736
|
+
const todos = visibleTodos();
|
|
1737
|
+
view.innerHTML = `
|
|
1738
|
+
<div class="page">
|
|
1739
|
+
<h1>Inbox</h1>
|
|
1740
|
+
<p class="lead">Follow-ups agents suggested when they finished a task.</p>
|
|
1741
|
+
${
|
|
1742
|
+
todos.length
|
|
1743
|
+
? todos
|
|
1744
|
+
.map((t) => {
|
|
1745
|
+
const source = t.taskId
|
|
1746
|
+
? state.runs.has(t.taskId)
|
|
1747
|
+
? `<a href="#" data-goto-run="${esc(t.taskId)}">source task</a>`
|
|
1748
|
+
: '<span class="gone">source task deleted</span>'
|
|
1749
|
+
: '';
|
|
1750
|
+
const pr = t.prUrl
|
|
1751
|
+
? `<a href="${esc(t.prUrl)}" target="_blank" rel="noopener">PR</a>`
|
|
1752
|
+
: '';
|
|
1753
|
+
return `
|
|
1754
|
+
<div class="todo-card" data-id="${esc(t.id)}">
|
|
1755
|
+
<div class="todo-main">
|
|
1756
|
+
<div class="summary">${esc(t.summary)}</div>
|
|
1757
|
+
<div class="meta">
|
|
1758
|
+
${t.ts ? `<span>${esc(timeAgo(t.ts))}</span>` : ''}
|
|
1759
|
+
${t.action ? `<span>${esc(t.action)}</span>` : ''}
|
|
1760
|
+
${source} ${pr}
|
|
1761
|
+
${t.suggestedSkill ? `<span>skill: ${esc(t.suggestedSkill)}</span>` : ''}
|
|
1762
|
+
</div>
|
|
1763
|
+
</div>
|
|
1764
|
+
<button class="btn-dark" data-todo-action="start" title="Start a task from this follow-up">▶ Run</button>
|
|
1765
|
+
<button class="btn-text" data-todo-action="remove" title="Check off (remove)">Dismiss</button>
|
|
1766
|
+
</div>`;
|
|
1767
|
+
})
|
|
1768
|
+
.join('')
|
|
1769
|
+
: '<div class="dim" style="padding:16px 0">Inbox empty — agents drop follow-up suggestions here when they finish a task.</div>'
|
|
1770
|
+
}
|
|
1771
|
+
</div>`;
|
|
1772
|
+
}
|
|
1773
|
+
|
|
1774
|
+
// ---- GitHub view -------------------------------------------------------------
|
|
1775
|
+
|
|
1776
|
+
const GH_ISSUE_ICON = 'M12 3a9 9 0 100 18 9 9 0 000-18zM12 10.5a1.5 1.5 0 100 3 1.5 1.5 0 000-3z';
|
|
1777
|
+
const GH_PR_ICON =
|
|
1778
|
+
'M7 8.5v7M7 3.5a2.5 2.5 0 100 5 2.5 2.5 0 000-5zM7 15.5a2.5 2.5 0 100 5 2.5 2.5 0 000-5zM17 15.5a2.5 2.5 0 100 5 2.5 2.5 0 000-5zM17 15v-4a3 3 0 00-3-3h-2.5';
|
|
1779
|
+
|
|
1780
|
+
function bindGithubView() {
|
|
1781
|
+
const view = $('#view-github');
|
|
1782
|
+
view.addEventListener('click', async (e) => {
|
|
1783
|
+
const refresh = e.target.closest('[data-gh-refresh]');
|
|
1784
|
+
if (refresh) {
|
|
1785
|
+
await loadGithub(true);
|
|
1786
|
+
return;
|
|
1787
|
+
}
|
|
1788
|
+
const tab = e.target.closest('button[data-gh-view]');
|
|
1789
|
+
if (tab) {
|
|
1790
|
+
state.ghView = tab.dataset.ghView;
|
|
1791
|
+
state.ghSel = null;
|
|
1792
|
+
renderGithub();
|
|
1793
|
+
return;
|
|
1794
|
+
}
|
|
1795
|
+
const row = e.target.closest('.gh-row');
|
|
1796
|
+
if (row) {
|
|
1797
|
+
state.ghSel = row.dataset.url;
|
|
1798
|
+
renderGithub();
|
|
1799
|
+
return;
|
|
1800
|
+
}
|
|
1801
|
+
const wf = e.target.closest('button[data-gh-workflow]');
|
|
1802
|
+
if (wf) {
|
|
1803
|
+
state.ghWorkflow = wf.dataset.ghWorkflow;
|
|
1804
|
+
renderGithub();
|
|
1805
|
+
return;
|
|
1806
|
+
}
|
|
1807
|
+
const sk = e.target.closest('button[data-gh-skill]');
|
|
1808
|
+
if (sk) {
|
|
1809
|
+
const name = sk.dataset.ghSkill;
|
|
1810
|
+
if (state.ghSkills.has(name)) state.ghSkills.delete(name);
|
|
1811
|
+
else state.ghSkills.add(name);
|
|
1812
|
+
renderGithub();
|
|
1813
|
+
return;
|
|
1814
|
+
}
|
|
1815
|
+
const viewRun = e.target.closest('button[data-gh-view-run]');
|
|
1816
|
+
if (viewRun) {
|
|
1817
|
+
showRunsView();
|
|
1818
|
+
selectRun(viewRun.dataset.ghViewRun);
|
|
1819
|
+
return;
|
|
1820
|
+
}
|
|
1821
|
+
const runBtn = e.target.closest('button[data-gh-run]');
|
|
1822
|
+
if (runBtn) {
|
|
1823
|
+
await runOnGithub(runBtn);
|
|
1824
|
+
return;
|
|
1825
|
+
}
|
|
1826
|
+
});
|
|
1827
|
+
}
|
|
1828
|
+
|
|
1829
|
+
async function loadGithub(refresh = false) {
|
|
1830
|
+
const view = $('#view-github');
|
|
1831
|
+
if (!state.gh || refresh) {
|
|
1832
|
+
if (!state.gh) view.innerHTML = '<div class="gh-unavailable">Loading GitHub…</div>';
|
|
1833
|
+
try {
|
|
1834
|
+
// Skill chips ride along — same catalog as the Skills tab.
|
|
1835
|
+
const [gh, skills] = await Promise.all([
|
|
1836
|
+
getJson(`/api/github${refresh ? '?refresh=1' : ''}`),
|
|
1837
|
+
state.skillsList ? Promise.resolve(state.skillsList) : getJson('/api/skills').catch(() => []),
|
|
1838
|
+
]);
|
|
1839
|
+
state.gh = gh;
|
|
1840
|
+
state.skillsList = skills;
|
|
1841
|
+
} catch (err) {
|
|
1842
|
+
view.innerHTML = `<div class="gh-unavailable">✗ ${esc(err.message)}</div>`;
|
|
1843
|
+
return;
|
|
1844
|
+
}
|
|
1845
|
+
}
|
|
1846
|
+
renderGithub();
|
|
1847
|
+
}
|
|
1848
|
+
|
|
1849
|
+
function ghItems() {
|
|
1850
|
+
if (!state.gh) return [];
|
|
1851
|
+
return state.ghView === 'issues' ? state.gh.issues : state.gh.prs;
|
|
1852
|
+
}
|
|
1853
|
+
|
|
1854
|
+
function renderGithub() {
|
|
1855
|
+
const view = $('#view-github');
|
|
1856
|
+
const gh = state.gh;
|
|
1857
|
+
if (!gh) return;
|
|
1858
|
+
if (!gh.available) {
|
|
1859
|
+
view.innerHTML = `
|
|
1860
|
+
<div class="gh-unavailable">
|
|
1861
|
+
<div style="font-family:var(--serif);font-size:21px;color:var(--text);margin-bottom:10px">GitHub</div>
|
|
1862
|
+
GitHub is unavailable here — ${esc(gh.reason ?? 'unknown reason')}.<br><br>
|
|
1863
|
+
The tab needs the <span class="mono">gh</span> CLI, logged in (<span class="mono">gh auth login</span>),
|
|
1864
|
+
and a repo with a GitHub remote. Everything else in cezar works without it.
|
|
1865
|
+
<div style="margin-top:14px"><button class="btn-ghost" data-gh-refresh>⟳ Try again</button></div>
|
|
1866
|
+
</div>`;
|
|
1867
|
+
return;
|
|
1868
|
+
}
|
|
1869
|
+
|
|
1870
|
+
const items = ghItems();
|
|
1871
|
+
let sel = items.find((i) => i.url === state.ghSel) ?? items[0] ?? null;
|
|
1872
|
+
if (sel) state.ghSel = sel.url;
|
|
1873
|
+
|
|
1874
|
+
const rows = items.length
|
|
1875
|
+
? items
|
|
1876
|
+
.map((i) => {
|
|
1877
|
+
const queuedRun = state.ghQueued.get(i.url);
|
|
1878
|
+
return `
|
|
1879
|
+
<div class="gh-row ${sel && i.url === sel.url ? 'selected' : ''}" data-url="${esc(i.url)}">
|
|
1880
|
+
<div class="row1">
|
|
1881
|
+
<svg viewBox="0 0 24 24" style="stroke:${i.kind === 'issue' ? 'var(--green)' : 'var(--accent)'}"><path d="${i.kind === 'issue' ? GH_ISSUE_ICON : GH_PR_ICON}"/></svg>
|
|
1882
|
+
<span class="t">${esc(i.title)}</span>
|
|
1883
|
+
</div>
|
|
1884
|
+
<div class="row2">
|
|
1885
|
+
<span>#${i.number}</span><span>${esc(i.author)}</span><span>${esc(shortAgo(i.createdAt))}</span>
|
|
1886
|
+
${queuedRun ? '<span class="queued-flag">↗ run queued</span>' : ''}
|
|
1887
|
+
</div>
|
|
1888
|
+
</div>`;
|
|
1889
|
+
})
|
|
1890
|
+
.join('')
|
|
1891
|
+
: `<div class="dim" style="padding:12px">No open ${state.ghView === 'issues' ? 'issues' : 'pull requests'}.</div>`;
|
|
1892
|
+
|
|
1893
|
+
const detail = sel ? ghDetailHtml(sel) : '<div class="empty">Nothing selected.</div>';
|
|
1894
|
+
|
|
1895
|
+
view.innerHTML = `
|
|
1896
|
+
<div class="split">
|
|
1897
|
+
<div class="split-list">
|
|
1898
|
+
<div class="split-head">
|
|
1899
|
+
<div class="head-row">
|
|
1900
|
+
<h1>GitHub</h1>
|
|
1901
|
+
<span class="mono dim" style="font-size:10.5px">${esc(gh.repo ?? '')}</span>
|
|
1902
|
+
<button class="head-note" data-gh-refresh style="border:none;background:transparent;cursor:pointer" title="Refresh from GitHub">
|
|
1903
|
+
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M20 11a8 8 0 10-2.3 5.7M20 11V5m0 6h-6"/></svg>
|
|
1904
|
+
synced ${esc(gh.syncedAt ? shortAgo(gh.syncedAt) : '?')} ago
|
|
1905
|
+
</button>
|
|
1906
|
+
</div>
|
|
1907
|
+
<div class="sub-tabs">
|
|
1908
|
+
<button data-gh-view="issues" class="${state.ghView === 'issues' ? 'active' : ''}">Issues · ${gh.issues.length}</button>
|
|
1909
|
+
<button data-gh-view="prs" class="${state.ghView === 'prs' ? 'active' : ''}">Pull requests · ${gh.prs.length}</button>
|
|
1910
|
+
</div>
|
|
1911
|
+
</div>
|
|
1912
|
+
<div class="split-rows">${rows}</div>
|
|
1913
|
+
</div>
|
|
1914
|
+
<div class="split-detail">${detail}</div>
|
|
1915
|
+
</div>`;
|
|
1916
|
+
}
|
|
1917
|
+
|
|
1918
|
+
function ghDetailHtml(item) {
|
|
1919
|
+
const kindWord = item.kind === 'pr' ? 'pull request' : 'issue';
|
|
1920
|
+
const diffStat =
|
|
1921
|
+
item.kind === 'pr' && (item.additions || item.deletions) ? ` · +${item.additions} −${item.deletions}` : '';
|
|
1922
|
+
const checks = item.checks
|
|
1923
|
+
? `<span class="gh-checks ${esc(item.checks)}">${
|
|
1924
|
+
item.checks === 'passing' ? '✓ checks passing' : item.checks === 'failing' ? '✗ checks failing' : '○ checks pending'
|
|
1925
|
+
}</span>`
|
|
1926
|
+
: '';
|
|
1927
|
+
const skills = (state.skillsList ?? []).map((s) => s.name);
|
|
1928
|
+
const queuedRun = state.ghQueued.get(item.url);
|
|
1929
|
+
return `
|
|
1930
|
+
<div class="inner">
|
|
1931
|
+
<div class="mono dim" style="font-size:10.5px">#${item.number} · ${kindWord} · opened by ${esc(item.author)} · ${esc(shortAgo(item.createdAt))} ago${item.comments ? ` · ${item.comments} comments` : ''}${diffStat} · <a href="${esc(item.url)}" target="_blank" rel="noopener">open on GitHub ↗</a></div>
|
|
1932
|
+
<h1 style="margin-top:8px">${esc(item.title)}</h1>
|
|
1933
|
+
<div style="display:flex;align-items:center;gap:7px;margin-top:12px;flex-wrap:wrap">
|
|
1934
|
+
${item.labels.map((l) => `<span class="gh-label">${esc(l)}</span>`).join('')}
|
|
1935
|
+
${checks}
|
|
1936
|
+
</div>
|
|
1937
|
+
<div class="gh-body">${esc(item.body || '(no description)')}</div>
|
|
1938
|
+
<div class="gh-hand">
|
|
1939
|
+
<div class="hand-label">
|
|
1940
|
+
<svg viewBox="0 0 24 24"><path d="M13 2L4.5 13.5h5.5L9 22l8.5-11.5H12L13 2z"/></svg>
|
|
1941
|
+
Hand this to the agent
|
|
1942
|
+
</div>
|
|
1943
|
+
<div class="hand-row">
|
|
1944
|
+
<span class="k" style="padding-top:0;align-self:center">workflow</span>
|
|
1945
|
+
<div class="chips">
|
|
1946
|
+
${state.workflows
|
|
1947
|
+
.map(
|
|
1948
|
+
(w) =>
|
|
1949
|
+
`<button class="chip-toggle ${state.ghWorkflow === w.name ? 'on' : ''}" data-gh-workflow="${esc(w.name)}" title="${esc(w.description ?? '')}">${esc(w.name)}</button>`,
|
|
1950
|
+
)
|
|
1951
|
+
.join('')}
|
|
1952
|
+
</div>
|
|
1953
|
+
</div>
|
|
1954
|
+
${
|
|
1955
|
+
skills.length
|
|
1956
|
+
? `<div class="hand-row">
|
|
1957
|
+
<span class="k">skills</span>
|
|
1958
|
+
<div class="chips">
|
|
1959
|
+
${skills
|
|
1960
|
+
.map(
|
|
1961
|
+
(name) =>
|
|
1962
|
+
`<button class="chip-toggle ${state.ghSkills.has(name) ? 'on' : ''}" data-gh-skill="${esc(name)}">${esc(name)}</button>`,
|
|
1963
|
+
)
|
|
1964
|
+
.join('')}
|
|
1965
|
+
</div>
|
|
1966
|
+
</div>`
|
|
1967
|
+
: ''
|
|
1968
|
+
}
|
|
1969
|
+
<div class="go-row">
|
|
1970
|
+
<button class="btn-dark" data-gh-run="${esc(item.url)}">▶ Run agent on this ${item.kind === 'pr' ? 'PR' : 'issue'}</button>
|
|
1971
|
+
${
|
|
1972
|
+
queuedRun
|
|
1973
|
+
? `<span class="queued-ok">✓ queued</span><button class="btn-text" data-gh-view-run="${esc(queuedRun)}">View in Runs →</button>`
|
|
1974
|
+
: ''
|
|
1975
|
+
}
|
|
1976
|
+
</div>
|
|
1977
|
+
</div>
|
|
1978
|
+
</div>`;
|
|
1979
|
+
}
|
|
1980
|
+
|
|
1981
|
+
async function runOnGithub(btn) {
|
|
1982
|
+
const item = ghItems().find((i) => i.url === btn.dataset.ghRun);
|
|
1983
|
+
if (!item) return;
|
|
1984
|
+
const skills = [...state.ghSkills].filter((name) => (state.skillsList ?? []).some((s) => s.name === name));
|
|
1985
|
+
let task = `${item.kind === 'pr' ? 'Address GitHub pull request' : 'Fix GitHub issue'} #${item.number}: ${item.title}\n\n${item.url}`;
|
|
1986
|
+
if (item.body?.trim()) task += `\n\n---\n\n${item.body.trim()}`;
|
|
1987
|
+
if (skills.length) task += `\n\nUse these skills where relevant: ${skills.join(', ')}.`;
|
|
1988
|
+
btn.disabled = true;
|
|
1989
|
+
try {
|
|
1990
|
+
const res = await fetch('/api/runs', {
|
|
1991
|
+
method: 'POST',
|
|
1992
|
+
headers: { 'content-type': 'application/json' },
|
|
1993
|
+
body: JSON.stringify({ workflow: state.ghWorkflow, task }),
|
|
1994
|
+
});
|
|
1995
|
+
const data = await res.json();
|
|
1996
|
+
if (!res.ok) throw new Error(data.error ?? `HTTP ${res.status}`);
|
|
1997
|
+
state.runs.set(data.id, data);
|
|
1998
|
+
state.ghQueued.set(item.url, data.id);
|
|
1999
|
+
state.lastGhRun = data.id;
|
|
2000
|
+
renderRunList();
|
|
2001
|
+
renderGithub();
|
|
2002
|
+
} catch (err) {
|
|
2003
|
+
alertBar(err.message);
|
|
2004
|
+
btn.disabled = false;
|
|
2005
|
+
}
|
|
2006
|
+
}
|
|
2007
|
+
|
|
558
2008
|
// ---- repo view ---------------------------------------------------------------
|
|
559
2009
|
|
|
2010
|
+
function statusClass(status) {
|
|
2011
|
+
if (/A|\?/.test(status)) return 'added';
|
|
2012
|
+
if (/D/.test(status)) return 'deleted';
|
|
2013
|
+
return '';
|
|
2014
|
+
}
|
|
2015
|
+
|
|
560
2016
|
async function loadRepo() {
|
|
561
2017
|
const view = $('#view-repo');
|
|
562
|
-
view.innerHTML = '<div class="dim">Loading…</div>';
|
|
2018
|
+
view.innerHTML = '<div class="page dim">Loading…</div>';
|
|
563
2019
|
try {
|
|
564
2020
|
const [repo, diff] = await Promise.all([
|
|
565
2021
|
getJson('/api/repo'),
|
|
566
2022
|
fetch('/api/repo/diff').then((r) => r.text()),
|
|
567
2023
|
]);
|
|
568
2024
|
if (!repo.info) {
|
|
569
|
-
view.innerHTML = '<div class="
|
|
2025
|
+
view.innerHTML = '<div class="page"><h1>Repository</h1><p class="lead">Not a git repository — tasks run in place, one at a time.</p></div>';
|
|
570
2026
|
return;
|
|
571
2027
|
}
|
|
572
2028
|
view.innerHTML = `
|
|
573
|
-
<div class="
|
|
574
|
-
<
|
|
575
|
-
<
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
2029
|
+
<div class="page">
|
|
2030
|
+
<h1>Repository</h1>
|
|
2031
|
+
<p class="lead mono" style="font-size:12px">${esc(repo.info.root)} · ${esc(repo.info.branch)}${repo.info.remote ? ` · ${esc(repo.info.remote)}` : ''}</p>
|
|
2032
|
+
|
|
2033
|
+
<div class="section-label">Working tree · ${repo.status.length ? `${repo.status.length} changed` : 'clean'}</div>
|
|
2034
|
+
${
|
|
2035
|
+
repo.status.length
|
|
2036
|
+
? repo.status
|
|
2037
|
+
.map(
|
|
2038
|
+
(s) =>
|
|
2039
|
+
`<div class="repo-file"><span class="st ${statusClass(s.status)}">${esc(s.status)}</span><span class="p">${esc(s.path)}</span></div>`,
|
|
2040
|
+
)
|
|
2041
|
+
.join('')
|
|
2042
|
+
: '<div class="dim" style="font-size:12.5px">Nothing modified.</div>'
|
|
2043
|
+
}
|
|
2044
|
+
${
|
|
2045
|
+
diff.trim()
|
|
2046
|
+
? `<details class="repo-diff"><summary>Diff vs HEAD</summary><div class="diff-wrap">${renderDiff(diff)}</div></details>`
|
|
2047
|
+
: ''
|
|
2048
|
+
}
|
|
2049
|
+
|
|
2050
|
+
<div class="section-label">Recent commits</div>
|
|
2051
|
+
${repo.log
|
|
2052
|
+
.map(
|
|
2053
|
+
(l) =>
|
|
2054
|
+
`<div class="commit-row"><span class="hash">${esc(l.hash)}</span><span class="subj">${esc(l.subject)}</span><span class="when" title="${esc(l.author)}">${esc(l.when)}</span></div>`,
|
|
2055
|
+
)
|
|
2056
|
+
.join('')}
|
|
590
2057
|
</div>`;
|
|
591
2058
|
} catch (err) {
|
|
592
|
-
view.innerHTML = `<div class="
|
|
2059
|
+
view.innerHTML = `<div class="page">✗ ${esc(err.message)}</div>`;
|
|
593
2060
|
}
|
|
594
2061
|
}
|
|
595
2062
|
|
|
596
2063
|
// ---- skills view ---------------------------------------------------------------
|
|
597
2064
|
|
|
598
|
-
|
|
2065
|
+
const SKILL_ICON = 'M12 3l2 5.5L19.5 10 14 12l-2 5.5L10 12l-5.5-2L10 8.5 12 3z';
|
|
2066
|
+
|
|
2067
|
+
function bindSkillsView() {
|
|
599
2068
|
const view = $('#view-skills');
|
|
600
|
-
view.
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
if (!skills.length) {
|
|
604
|
-
view.innerHTML = `
|
|
605
|
-
<div class="panel">
|
|
606
|
-
<h3>Skills</h3>
|
|
607
|
-
<div>No skills yet. Drop Markdown files into <span class="mono">.ai/skills/</span> or <span class="mono">.ai/cezar/skills/</span> —
|
|
608
|
-
optional frontmatter: <span class="mono">name</span>, <span class="mono">description</span>.
|
|
609
|
-
Reference one from a workflow step via <span class="mono">skill: <name></span>.</div>
|
|
610
|
-
</div>`;
|
|
2069
|
+
view.addEventListener('click', (e) => {
|
|
2070
|
+
if (e.target.closest('#skills-refresh')) {
|
|
2071
|
+
void loadSkills(true);
|
|
611
2072
|
return;
|
|
612
2073
|
}
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
2074
|
+
const row = e.target.closest('.skill-row');
|
|
2075
|
+
if (row) {
|
|
2076
|
+
state.skillSel = row.dataset.skill;
|
|
2077
|
+
renderSkills();
|
|
2078
|
+
return;
|
|
2079
|
+
}
|
|
2080
|
+
});
|
|
2081
|
+
// Filter without re-rendering the input (it would lose focus).
|
|
2082
|
+
view.addEventListener('input', (e) => {
|
|
2083
|
+
if (e.target.id !== 'skill-filter') return;
|
|
2084
|
+
state.skillQuery = e.target.value;
|
|
2085
|
+
const rows = $('#skill-rows');
|
|
2086
|
+
if (rows) rows.innerHTML = skillRowsHtml();
|
|
2087
|
+
});
|
|
2088
|
+
}
|
|
2089
|
+
|
|
2090
|
+
async function loadSkills(refresh = false) {
|
|
2091
|
+
const view = $('#view-skills');
|
|
2092
|
+
if (!state.skillsList || refresh) {
|
|
2093
|
+
if (!state.skillsList) view.innerHTML = '<div class="gh-unavailable">Loading…</div>';
|
|
2094
|
+
try {
|
|
2095
|
+
state.skillsList = refresh
|
|
2096
|
+
? await fetch('/api/skills/refresh', { method: 'POST' }).then((r) => {
|
|
2097
|
+
if (!r.ok) throw new Error(`refresh → ${r.status}`);
|
|
2098
|
+
return r.json();
|
|
2099
|
+
})
|
|
2100
|
+
: await getJson('/api/skills');
|
|
2101
|
+
if (refresh) alertBar('Team skills refreshed.');
|
|
2102
|
+
renderTaskPills(); // the form's source pill lists the same catalog
|
|
2103
|
+
} catch (err) {
|
|
2104
|
+
view.innerHTML = `<div class="gh-unavailable">✗ ${esc(err.message)}</div>`;
|
|
2105
|
+
return;
|
|
2106
|
+
}
|
|
2107
|
+
}
|
|
2108
|
+
renderSkills();
|
|
2109
|
+
}
|
|
2110
|
+
|
|
2111
|
+
function filteredSkills() {
|
|
2112
|
+
const q = state.skillQuery.trim().toLowerCase();
|
|
2113
|
+
return (state.skillsList ?? []).filter(
|
|
2114
|
+
(s) => !q || s.name.toLowerCase().includes(q) || (s.description ?? '').toLowerCase().includes(q),
|
|
2115
|
+
);
|
|
2116
|
+
}
|
|
2117
|
+
|
|
2118
|
+
function skillRowsHtml() {
|
|
2119
|
+
const skills = filteredSkills();
|
|
2120
|
+
const rows = skills
|
|
2121
|
+
.map(
|
|
2122
|
+
(s) => `
|
|
2123
|
+
<div class="skill-row ${state.skillSel === s.name ? 'selected' : ''}" data-skill="${esc(s.name)}">
|
|
2124
|
+
<div class="row1">
|
|
2125
|
+
<svg viewBox="0 0 24 24"><path d="${SKILL_ICON}"/></svg>
|
|
2126
|
+
<span class="name">${esc(s.name)}</span>
|
|
2127
|
+
<span class="skill-tag ${esc(s.source)}">${esc(s.source)}</span>
|
|
2128
|
+
</div>
|
|
2129
|
+
${s.description ? `<div class="desc">${esc(s.description)}</div>` : ''}
|
|
2130
|
+
</div>`,
|
|
2131
|
+
)
|
|
2132
|
+
.join('');
|
|
2133
|
+
const empty = state.skillsList?.length
|
|
2134
|
+
? '<div class="dim" style="padding:12px">(no skills match)</div>'
|
|
2135
|
+
: `<div class="dim" style="padding:12px;font-size:12px;line-height:1.6">No skills yet. Drop Markdown files into <span class="mono">.ai/skills/</span> or <span class="mono">.ai/cezar/skills/</span> — optional frontmatter: <span class="mono">name</span>, <span class="mono">description</span>. Team skills from your skills repo appear here too — try ⟳ Refresh.</div>`;
|
|
2136
|
+
return rows || empty;
|
|
2137
|
+
}
|
|
2138
|
+
|
|
2139
|
+
/* Always-visible entry below the (scrollable) skill list — spec 011 must not
|
|
2140
|
+
drown under a long team catalog. */
|
|
2141
|
+
function bookmarkletRowHtml() {
|
|
2142
|
+
return `
|
|
2143
|
+
<div class="skill-row pinned ${state.skillSel === '__bm' ? 'selected' : ''}" data-skill="__bm">
|
|
2144
|
+
<div class="row1">
|
|
2145
|
+
<svg viewBox="0 0 24 24" style="stroke:var(--accent)"><path d="M13 2L4.5 13.5h5.5L9 22l8.5-11.5H12L13 2z"/></svg>
|
|
2146
|
+
<span class="name">Run from GitHub</span>
|
|
2147
|
+
<span class="skill-tag">bookmarklets</span>
|
|
2148
|
+
</div>
|
|
2149
|
+
<div class="desc">One-click skill launch from any GitHub PR or issue.</div>
|
|
2150
|
+
</div>`;
|
|
2151
|
+
}
|
|
2152
|
+
|
|
2153
|
+
/* Workflows referencing this skill — "fix-and-verify › Verify". */
|
|
2154
|
+
function skillUsedBy(name) {
|
|
2155
|
+
const out = [];
|
|
2156
|
+
for (const w of state.workflows) {
|
|
2157
|
+
for (const s of w.steps ?? []) {
|
|
2158
|
+
if (s.skill === name) out.push(`${w.name} › ${s.name ?? s.id}`);
|
|
2159
|
+
}
|
|
2160
|
+
}
|
|
2161
|
+
return out;
|
|
2162
|
+
}
|
|
2163
|
+
|
|
2164
|
+
function renderSkills() {
|
|
2165
|
+
const view = $('#view-skills');
|
|
2166
|
+
const skills = state.skillsList ?? [];
|
|
2167
|
+
if (state.skillSel !== '__bm' && !skills.some((s) => s.name === state.skillSel)) {
|
|
2168
|
+
state.skillSel = skills[0]?.name ?? '__bm';
|
|
2169
|
+
}
|
|
2170
|
+
|
|
2171
|
+
view.innerHTML = `
|
|
2172
|
+
<div class="split">
|
|
2173
|
+
<div class="split-list">
|
|
2174
|
+
<div class="split-head">
|
|
2175
|
+
<div class="head-row">
|
|
2176
|
+
<h1>Skills</h1>
|
|
2177
|
+
<button id="skills-refresh" class="btn-ghost" style="margin-left:auto;height:27px;font-size:12px" title="git fetch the team skills repos">⟳ Refresh</button>
|
|
2178
|
+
</div>
|
|
2179
|
+
</div>
|
|
2180
|
+
<input id="skill-filter" class="filter-input" placeholder="Filter skills…" value="${esc(state.skillQuery)}">
|
|
2181
|
+
<div class="split-rows" id="skill-rows">${skillRowsHtml()}</div>
|
|
2182
|
+
<div class="list-foot">${bookmarkletRowHtml()}</div>
|
|
2183
|
+
</div>
|
|
2184
|
+
<div class="split-detail" id="skill-detail">${state.skillSel === '__bm' ? bookmarkletShellHtml() : skillDetailHtml()}</div>
|
|
2185
|
+
</div>`;
|
|
2186
|
+
|
|
2187
|
+
if (state.skillSel === '__bm') void bindBookmarklets(skills);
|
|
2188
|
+
}
|
|
2189
|
+
|
|
2190
|
+
function skillDetailHtml() {
|
|
2191
|
+
const skill = (state.skillsList ?? []).find((s) => s.name === state.skillSel);
|
|
2192
|
+
if (!skill) return '<div class="empty">No skill selected.</div>';
|
|
2193
|
+
const usedBy = skillUsedBy(skill.name);
|
|
2194
|
+
return `
|
|
2195
|
+
<div class="inner skill-detail">
|
|
2196
|
+
<div class="title-row">
|
|
2197
|
+
<h1>${esc(skill.name)}</h1>
|
|
2198
|
+
<span class="skill-tag ${esc(skill.source)}">${esc(skill.source)}</span>
|
|
2199
|
+
</div>
|
|
2200
|
+
<div class="path-line">${esc(skill.path)}${skill.team ? ` · from ${esc(skill.team.repo)}` : ''}</div>
|
|
2201
|
+
${skill.description ? `<div class="desc">${esc(skill.description)}</div>` : ''}
|
|
2202
|
+
<div class="section-label">Used by</div>
|
|
2203
|
+
${
|
|
2204
|
+
usedBy.length
|
|
2205
|
+
? usedBy
|
|
2206
|
+
.map(
|
|
2207
|
+
(u) =>
|
|
2208
|
+
`<div class="used-row"><svg viewBox="0 0 24 24"><path d="M4 12h12M12 6l6 6-6 6"/></svg>${esc(u)}</div>`,
|
|
2209
|
+
)
|
|
2210
|
+
.join('')
|
|
2211
|
+
: '<div class="dim" style="font-size:12.5px;padding:6px 0">Not referenced by any workflow yet — quick-task picks it up when the task mentions it.</div>'
|
|
2212
|
+
}
|
|
2213
|
+
<div class="content-head">
|
|
2214
|
+
<span class="section-label">Content</span>
|
|
2215
|
+
<span class="spacer"></span>
|
|
2216
|
+
</div>
|
|
2217
|
+
<pre class="content">${esc(skill.body)}</pre>
|
|
2218
|
+
</div>`;
|
|
2219
|
+
}
|
|
2220
|
+
|
|
2221
|
+
// ---- bookmarklet generator (spec 011) ------------------------------------------
|
|
2222
|
+
|
|
2223
|
+
/* The javascript: URL dragged to the bookmarks bar. On a GitHub PR/issue it
|
|
2224
|
+
scans localhost:4321–4330 for cockpits (GET /api/health, 800 ms timeout,
|
|
2225
|
+
CORS-enabled server-side), picks the one whose repo.remote matches the
|
|
2226
|
+
page's owner/repo (fallback: first alive) and opens /new there. The whole
|
|
2227
|
+
program is one URI-encoded expression — no external state. */
|
|
2228
|
+
function bookmarkletUrl(skillName, auto, key) {
|
|
2229
|
+
const enc = (s) => encodeURIComponent(s).replaceAll("'", '%27'); // ' survives encodeURIComponent — would break the JS string
|
|
2230
|
+
const query = `${skillName ? `skill=${enc(skillName)}&` : ''}auto=${auto ? '1' : '0'}&key=${enc(key)}&ref=`;
|
|
2231
|
+
const code =
|
|
2232
|
+
`(async()=>{const m=location.href.match(/^https:\\/\\/github\\.com\\/([^\\/]+)\\/([^\\/]+)\\/(pull|issues)\\/\\d+/);` +
|
|
2233
|
+
`if(!m){alert('Open a GitHub PR or issue first');return;}` +
|
|
2234
|
+
`const q='${query}'+encodeURIComponent(location.href);` +
|
|
2235
|
+
`const f=async p=>{try{const c=new AbortController();setTimeout(()=>c.abort(),800);` +
|
|
2236
|
+
`const r=await fetch('http://localhost:'+p+'/api/health',{signal:c.signal});const h=await r.json();` +
|
|
2237
|
+
`return{p,remote:(h.repo&&h.repo.remote)||''}}catch(e){return null}};` +
|
|
2238
|
+
`const rs=(await Promise.all([4321,4322,4323,4324,4325,4326,4327,4328,4329,4330].map(f))).filter(Boolean);` +
|
|
2239
|
+
`if(!rs.length){alert('cezar cockpit is not running - start it with: npx cezar');return;}` +
|
|
2240
|
+
`const t=rs.find(r=>r.remote.includes(m[1]+'/'+m[2]))||rs[0];` +
|
|
2241
|
+
`open('http://localhost:'+t.p+'/new?'+q,'_blank');})();`;
|
|
2242
|
+
return `javascript:${encodeURIComponent(code)}`;
|
|
2243
|
+
}
|
|
2244
|
+
|
|
2245
|
+
function bookmarkletShellHtml() {
|
|
2246
|
+
return `
|
|
2247
|
+
<div class="inner" id="bm-panel">
|
|
2248
|
+
<h1>Run from GitHub</h1>
|
|
2249
|
+
<div class="bm-help">Drag a button below to your browser's bookmarks bar.
|
|
2250
|
+
On any GitHub PR or issue, click it — it finds your running cockpit on
|
|
2251
|
+
localhost (ports 4321–4330) and picks the one serving that repo.
|
|
2252
|
+
The cockpit must be running: <span class="mono">npx cezar</span>.</div>
|
|
2253
|
+
<label class="bm-auto"><input type="checkbox" id="bm-auto">
|
|
2254
|
+
One-click launch (auto-submit) <span class="dim">— re-drag the buttons after changing this</span></label>
|
|
2255
|
+
<div id="bm-generic"></div>
|
|
2256
|
+
<input id="bm-filter" class="bm-filter" placeholder="Filter skills…">
|
|
2257
|
+
<div id="bm-list"></div>
|
|
2258
|
+
</div>`;
|
|
2259
|
+
}
|
|
2260
|
+
|
|
2261
|
+
function bmRowHtml(label, url, hint) {
|
|
2262
|
+
return `
|
|
2263
|
+
<div class="bm-row">
|
|
2264
|
+
<a class="bm" draggable="true" href="${esc(url)}" title="Drag me to your bookmarks bar">⚡ ${esc(label)}</a>
|
|
2265
|
+
<button type="button" class="bm-copy" data-copy="${esc(url)}" title="Copy the bookmarklet URL">Copy</button>
|
|
2266
|
+
${hint ? `<span class="dim bm-hint">${esc(hint)}</span>` : ''}
|
|
2267
|
+
</div>`;
|
|
2268
|
+
}
|
|
2269
|
+
|
|
2270
|
+
function renderBmLinks(skills) {
|
|
2271
|
+
const key = state.launchKey ?? '';
|
|
2272
|
+
// Generic launcher: no skill, auto forced off — it only prefills the form.
|
|
2273
|
+
$('#bm-generic').innerHTML = bmRowHtml(
|
|
2274
|
+
'cezar: this PR/issue',
|
|
2275
|
+
bookmarkletUrl('', false, key),
|
|
2276
|
+
'prefills the form — nothing starts by itself',
|
|
2277
|
+
);
|
|
2278
|
+
const filter = state.bmFilter.trim().toLowerCase();
|
|
2279
|
+
const filtered = skills.filter((s) => s.name.toLowerCase().includes(filter));
|
|
2280
|
+
$('#bm-list').innerHTML = filtered.length
|
|
2281
|
+
? filtered
|
|
2282
|
+
.map((s) => bmRowHtml(`/${s.name}`, bookmarkletUrl(s.name, state.bmAuto, key), s.source))
|
|
2283
|
+
.join('')
|
|
2284
|
+
: `<div class="dim">${skills.length ? '(no skills match)' : '(no skills yet — the generic launcher above still works)'}</div>`;
|
|
2285
|
+
}
|
|
2286
|
+
|
|
2287
|
+
async function bindBookmarklets(skills) {
|
|
2288
|
+
if (state.launchKey === null) {
|
|
2289
|
+
try {
|
|
2290
|
+
state.launchKey = (await getJson('/api/launch-key')).key;
|
|
2291
|
+
} catch {
|
|
2292
|
+
state.launchKey = ''; // degraded: bookmarklets still work, auto-start won't
|
|
2293
|
+
}
|
|
626
2294
|
}
|
|
2295
|
+
const panel = $('#bm-panel');
|
|
2296
|
+
if (!panel) return; // skills view re-rendered meanwhile
|
|
2297
|
+
const autoBox = $('#bm-auto');
|
|
2298
|
+
autoBox.checked = state.bmAuto;
|
|
2299
|
+
autoBox.addEventListener('change', () => {
|
|
2300
|
+
state.bmAuto = autoBox.checked;
|
|
2301
|
+
renderBmLinks(skills);
|
|
2302
|
+
});
|
|
2303
|
+
const filterBox = $('#bm-filter');
|
|
2304
|
+
filterBox.value = state.bmFilter;
|
|
2305
|
+
filterBox.addEventListener('input', () => {
|
|
2306
|
+
state.bmFilter = filterBox.value;
|
|
2307
|
+
renderBmLinks(skills);
|
|
2308
|
+
});
|
|
2309
|
+
panel.addEventListener('click', async (e) => {
|
|
2310
|
+
const link = e.target.closest('a.bm');
|
|
2311
|
+
if (link) {
|
|
2312
|
+
// The cockpit page never executes the javascript: URL itself — the
|
|
2313
|
+
// links are only a drag source (spec 011 §5).
|
|
2314
|
+
e.preventDefault();
|
|
2315
|
+
alertBar('Drag me to your bookmarks bar');
|
|
2316
|
+
return;
|
|
2317
|
+
}
|
|
2318
|
+
const copy = e.target.closest('button[data-copy]');
|
|
2319
|
+
if (copy) {
|
|
2320
|
+
try {
|
|
2321
|
+
await navigator.clipboard.writeText(copy.dataset.copy);
|
|
2322
|
+
alertBar('Bookmarklet URL copied.');
|
|
2323
|
+
} catch {
|
|
2324
|
+
alertBar('Copy failed — drag the button instead.');
|
|
2325
|
+
}
|
|
2326
|
+
}
|
|
2327
|
+
});
|
|
2328
|
+
renderBmLinks(skills);
|
|
627
2329
|
}
|
|
628
2330
|
|
|
629
2331
|
init().catch((err) => {
|