@pat-lewczuk/cezar 0.1.4 → 0.1.5
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/README.md +124 -37
- package/dist/config.d.ts +46 -0
- package/dist/config.js +31 -0
- package/dist/config.js.map +1 -1
- package/dist/core/agent-runner.d.ts +16 -0
- package/dist/core/agent-runner.js +9 -0
- package/dist/core/agent-runner.js.map +1 -1
- package/dist/core/claude-cli-runner.d.ts +6 -0
- package/dist/core/claude-cli-runner.js +27 -43
- package/dist/core/claude-cli-runner.js.map +1 -1
- package/dist/core/codex-app-server-runner.js +42 -5
- package/dist/core/codex-app-server-runner.js.map +1 -1
- package/dist/core/opencode-server-runner.js +42 -5
- package/dist/core/opencode-server-runner.js.map +1 -1
- package/dist/git-worktree.d.ts +21 -0
- package/dist/git-worktree.js +48 -4
- package/dist/git-worktree.js.map +1 -1
- package/dist/handoff.d.ts +1 -1
- package/dist/handoff.js +2 -0
- package/dist/handoff.js.map +1 -1
- package/dist/index.js +34 -4
- package/dist/index.js.map +1 -1
- package/dist/runs/store.d.ts +82 -1
- package/dist/runs/store.js +57 -5
- package/dist/runs/store.js.map +1 -1
- package/dist/server/git.js +12 -1
- package/dist/server/git.js.map +1 -1
- package/dist/server/github.d.ts +8 -29
- package/dist/server/github.js +7 -139
- package/dist/server/github.js.map +1 -1
- package/dist/server/pr.d.ts +5 -20
- package/dist/server/pr.js +4 -95
- package/dist/server/pr.js.map +1 -1
- package/dist/server/server.d.ts +30 -1
- package/dist/server/server.js +448 -19
- package/dist/server/server.js.map +1 -1
- package/dist/workflows/run.d.ts +90 -0
- package/dist/workflows/run.js +412 -29
- package/dist/workflows/run.js.map +1 -1
- package/dist/workflows/types.d.ts +16 -16
- package/package.json +44 -6
- package/scripts/check-pack.mjs +46 -0
- package/scripts/dev.mjs +74 -0
- package/scripts/mock-claude.mjs +54 -2
- package/scripts/test-process-usage.mjs +47 -0
- package/web/dist/assets/{toaster-BS2E1bhz.js → centered-state-zgEVNWsj.js} +1 -1
- package/web/dist/assets/compare-variants-ChHCcSQX.js +1 -0
- package/web/dist/assets/dialog-DDRjj-uK.js +1 -0
- package/web/dist/assets/{diff-view-CctnJHWe.js → diff-view-CWRO0GMe.js} +1 -1
- package/web/dist/assets/dist-id2CeyV-.js +1 -0
- package/web/dist/assets/git-toolbar-C4jo602L.js +1 -0
- package/web/dist/assets/github-BOIzQFwE.js +1 -0
- package/web/dist/assets/index-Cv1pQs9U.js +11 -0
- package/web/dist/assets/repo-git-BuyJiqna.js +1 -0
- package/web/dist/assets/{run-diff-C7mfxj7f.js → run-diff-BocG6LVd.js} +1 -1
- package/web/dist/assets/run-header-CDKw22ek.js +1 -0
- package/web/dist/assets/skill-detail-CcFFKmGO.js +1 -0
- package/web/dist/assets/skills-Bc_4U3yq.js +1 -0
- package/web/dist/assets/tab-link-C77EP3i6.js +1 -0
- package/web/dist/assets/task-changes-DsDgN8-x.js +1 -0
- package/web/dist/assets/task-commits-Dve6Fozc.js +1 -0
- package/web/dist/assets/{task-files-FOAAz2FB.js → task-files-BDMc_z0B.js} +1 -1
- package/web/dist/assets/{task-thread-Bncxe8Os.js → task-thread-CmRuPj9H.js} +2 -2
- package/web/dist/assets/use-desktop-CyEWsxSU.js +3 -0
- package/web/dist/assets/{workflows-Dg2wQg6i.js → workflows-J3z3kYic.js} +1 -1
- package/web/dist/index.html +5 -5
- package/web/app/node_modules/.vite/vitest/ca84d1343b96baa8137c943ed1860e522cacb238/results.json +0 -1
- package/web/app/src/components/app-shell-container.tsx +0 -76
- package/web/app.js +0 -3428
- package/web/dist/assets/compare-variants-D6fPL9cS.js +0 -1
- package/web/dist/assets/dialog-BEgEWFDS.js +0 -1
- package/web/dist/assets/dist-CW7M_Jhs.js +0 -1
- package/web/dist/assets/git-toolbar-D8OPPqBP.js +0 -1
- package/web/dist/assets/github-BYIHwIJB.js +0 -1
- package/web/dist/assets/index-9qG98Orw.js +0 -11
- package/web/dist/assets/repo-git-XTqlVOl2.js +0 -1
- package/web/dist/assets/run-header-CfVeY3uM.js +0 -1
- package/web/dist/assets/skill-detail-C9cniyId.js +0 -1
- package/web/dist/assets/skills-DXgY1uXJ.js +0 -1
- package/web/dist/assets/tab-link-phWEqSBy.js +0 -1
- package/web/dist/assets/task-changes-DYwnPnTN.js +0 -1
- package/web/dist/assets/task-commits-DZzxEzbn.js +0 -1
- package/web/dist/assets/use-desktop-eHm2iTXX.js +0 -3
- package/web/index.html +0 -108
- package/web/style.css +0 -1767
package/web/app.js
DELETED
|
@@ -1,3428 +0,0 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
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. */
|
|
6
|
-
|
|
7
|
-
const $ = (sel, el = document) => el.querySelector(sel);
|
|
8
|
-
|
|
9
|
-
const state = {
|
|
10
|
-
runs: new Map(), // id -> RunRecord
|
|
11
|
-
selectedId: null,
|
|
12
|
-
runEs: null, // per-run EventSource
|
|
13
|
-
lastSeq: 0, // dedup across SSE reconnect replays
|
|
14
|
-
autoScroll: true,
|
|
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, // agent CLI detected → model menu lists that runner's models
|
|
20
|
-
taskRunner: 'claude', // selected agent backend (the "⚙ claude" pill)
|
|
21
|
-
runnersAvailable: ['claude'], // runner ids detected on the host (from /api/health)
|
|
22
|
-
srcMenuTab: 'workflow', // Workflows | Skills tab inside the source pill menu
|
|
23
|
-
srcMenuQuery: '', // search box inside the source pill menu
|
|
24
|
-
pendingImages: [], // [{mediaType, data, preview}] queued for the next message
|
|
25
|
-
taskImages: [], // screenshots pasted into the new-task form
|
|
26
|
-
listView: 'active', // 'active' | 'archived'
|
|
27
|
-
todos: [], // global inbox entries (spec 007)
|
|
28
|
-
plan: null, // {task, steps, rationale, fallback} — proposed chain (spec 008)
|
|
29
|
-
planDragIdx: null, // index of the plan step being dragged
|
|
30
|
-
variants: 1, // ×1/×2/×3 switch — parallel variants (spec 010)
|
|
31
|
-
expandedGroups: new Set(), // groupIds expanded in the run list
|
|
32
|
-
selectedGroupId: null, // group shown in the compare view (instead of a run)
|
|
33
|
-
launchKey: null, // bookmarklet auto-start secret (spec 011), lazy-fetched
|
|
34
|
-
bmAuto: true, // "One-click launch (auto-submit)" checkbox
|
|
35
|
-
bmFilter: '', // per-skill bookmarklet filter text
|
|
36
|
-
theme: document.documentElement.dataset.theme || 'dark',
|
|
37
|
-
// GitHub tab
|
|
38
|
-
gh: null, // /api/github payload
|
|
39
|
-
ghFull: false, // true once the "everything open" fetch landed
|
|
40
|
-
ghFullLoading: false,
|
|
41
|
-
ghView: 'issues', // 'issues' | 'prs'
|
|
42
|
-
ghSel: null, // selected item url
|
|
43
|
-
ghWorkflow: null, // workflow chip (null = none — skills/quick-task run)
|
|
44
|
-
ghSkills: new Set(), // skill names toggled on
|
|
45
|
-
ghSkillQuery: '', // filter over the skill chips (long catalogs)
|
|
46
|
-
ghQueued: new Map(), // item url -> run id (client-side "queued" marker)
|
|
47
|
-
lastGhRun: null,
|
|
48
|
-
// Skills tab
|
|
49
|
-
skillsList: null, // /api/skills payload (shared with the GitHub tab chips)
|
|
50
|
-
skillSel: null, // selected skill name, or '__bm' for the bookmarklet panel
|
|
51
|
-
skillQuery: '',
|
|
52
|
-
// Workflows tab — the builder canvas (spec 012)
|
|
53
|
-
wb: null, // {name, description, steps, query, importOpen, importText, importError, copied}
|
|
54
|
-
wbDrag: null, // {from:'palette', skill} | {from:'step', index}
|
|
55
|
-
};
|
|
56
|
-
|
|
57
|
-
const STATUS_ORDER = { waiting: 0, review: 1, running: 2, queued: 3 };
|
|
58
|
-
|
|
59
|
-
// ---- helpers ---------------------------------------------------------------
|
|
60
|
-
|
|
61
|
-
function esc(s) {
|
|
62
|
-
return String(s ?? '')
|
|
63
|
-
.replaceAll('&', '&')
|
|
64
|
-
.replaceAll('<', '<')
|
|
65
|
-
.replaceAll('>', '>')
|
|
66
|
-
.replaceAll('"', '"');
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
function timeAgo(iso) {
|
|
70
|
-
if (!iso) return '';
|
|
71
|
-
const s = Math.max(0, (Date.now() - new Date(iso).getTime()) / 1000);
|
|
72
|
-
if (s < 60) return `${Math.floor(s)}s ago`;
|
|
73
|
-
if (s < 3600) return `${Math.floor(s / 60)}m ago`;
|
|
74
|
-
if (s < 86400) return `${Math.floor(s / 3600)}h ago`;
|
|
75
|
-
return `${Math.floor(s / 86400)}d ago`;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
/* Compact form for the run list — "2m", "1h", "3d". */
|
|
79
|
-
function shortAgo(iso) {
|
|
80
|
-
if (!iso) return '';
|
|
81
|
-
const s = Math.max(0, (Date.now() - new Date(iso).getTime()) / 1000);
|
|
82
|
-
if (s < 60) return `${Math.floor(s)}s`;
|
|
83
|
-
if (s < 3600) return `${Math.floor(s / 60)}m`;
|
|
84
|
-
if (s < 86400) return `${Math.floor(s / 3600)}h`;
|
|
85
|
-
return `${Math.floor(s / 86400)}d`;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
function fmtTokens(n) {
|
|
89
|
-
if (!n) return '0 tok';
|
|
90
|
-
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M tok`;
|
|
91
|
-
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k tok`;
|
|
92
|
-
return `${n} tok`;
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
function fmtCost(usd) {
|
|
96
|
-
if (!usd) return '';
|
|
97
|
-
return `$${usd >= 10 ? usd.toFixed(0) : usd.toFixed(2)}`;
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
/* Queue position among queued runs (FIFO = createdAt order) — spec 006. */
|
|
101
|
-
function queuePosition(run) {
|
|
102
|
-
const queued = [...state.runs.values()]
|
|
103
|
-
.filter((r) => !r.archived && r.status === 'queued')
|
|
104
|
-
.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
|
|
105
|
-
const idx = queued.findIndex((r) => r.id === run.id);
|
|
106
|
-
return idx >= 0 ? idx + 1 : null;
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
const STATUS_LABEL = {
|
|
110
|
-
waiting: 'needs you',
|
|
111
|
-
review: 'needs review',
|
|
112
|
-
running: 'running',
|
|
113
|
-
queued: 'queued',
|
|
114
|
-
done: 'done',
|
|
115
|
-
failed: 'failed',
|
|
116
|
-
cancelled: 'cancelled',
|
|
117
|
-
};
|
|
118
|
-
|
|
119
|
-
function statusPill(run) {
|
|
120
|
-
const status = run.status;
|
|
121
|
-
const pulse = status === 'waiting' || status === 'running' || status === 'review';
|
|
122
|
-
let label = STATUS_LABEL[status] ?? status;
|
|
123
|
-
if (status === 'queued') {
|
|
124
|
-
const pos = queuePosition(run);
|
|
125
|
-
if (pos) label += ` #${pos}`;
|
|
126
|
-
}
|
|
127
|
-
return `<span class="pill ${esc(status)}">${pulse ? '<span class="pulse-dot"></span>' : ''}${esc(label)}</span>`;
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
function prLink(run) {
|
|
131
|
-
if (!run.pullRequestUrl) return '';
|
|
132
|
-
const num = run.pullRequestUrl.split('/').pop();
|
|
133
|
-
return `<a href="${esc(run.pullRequestUrl)}" target="_blank" rel="noopener">PR #${esc(num)}</a>`;
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
/* ---- parallel variants (spec 010) ---- */
|
|
137
|
-
|
|
138
|
-
const TERMINAL_STATUSES = ['done', 'failed', 'review', 'cancelled'];
|
|
139
|
-
|
|
140
|
-
/* Group title = any variant's title without its " (A)" suffix. */
|
|
141
|
-
function groupTitle(run) {
|
|
142
|
-
return run.title.replace(/ \([A-C]\)$/, '');
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
async function getJson(url) {
|
|
146
|
-
const res = await fetch(url);
|
|
147
|
-
if (!res.ok) throw new Error(`${url} → ${res.status}`);
|
|
148
|
-
return res.json();
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
/* Minimal unified-diff renderer (spec 009) — shared by the "± Diff" panel and
|
|
152
|
-
the review gate. Text parsing only: `diff --git` starts a collapsible
|
|
153
|
-
per-file <details>, +/− lines get colored, @@ hunks and file meta dim.
|
|
154
|
-
Non-diff text (degradation notes) renders as a plain <pre>. ZERO libs. */
|
|
155
|
-
function renderDiff(text) {
|
|
156
|
-
const raw = String(text ?? '').trimEnd();
|
|
157
|
-
if (!raw.trim()) return '<div class="dim">(no changes)</div>';
|
|
158
|
-
if (!raw.includes('diff --git ')) return `<pre>${esc(raw)}</pre>`;
|
|
159
|
-
const out = [];
|
|
160
|
-
let file = null; // { name, lines }
|
|
161
|
-
const flush = () => {
|
|
162
|
-
if (!file) return;
|
|
163
|
-
out.push(
|
|
164
|
-
`<details class="diff-file" open><summary>${esc(file.name)}</summary><pre>${file.lines.join('\n')}</pre></details>`,
|
|
165
|
-
);
|
|
166
|
-
file = null;
|
|
167
|
-
};
|
|
168
|
-
for (const line of raw.split('\n')) {
|
|
169
|
-
if (line.startsWith('diff --git ')) {
|
|
170
|
-
flush();
|
|
171
|
-
const m = / b\/(.+)$/.exec(line);
|
|
172
|
-
file = { name: m ? m[1] : line.slice(11), lines: [] };
|
|
173
|
-
continue;
|
|
174
|
-
}
|
|
175
|
-
if (!file) continue; // preamble before the first file header
|
|
176
|
-
let cls = '';
|
|
177
|
-
if (line.startsWith('+++') || line.startsWith('---')) cls = 'diff-meta';
|
|
178
|
-
else if (line.startsWith('@@')) cls = 'diff-hunk';
|
|
179
|
-
else if (line.startsWith('+')) cls = 'diff-add';
|
|
180
|
-
else if (line.startsWith('-')) cls = 'diff-del';
|
|
181
|
-
else if (/^(index |new file|deleted file|old mode|new mode|similarity|rename |copy |Binary files)/.test(line)) cls = 'diff-meta';
|
|
182
|
-
file.lines.push(cls ? `<span class="${cls}">${esc(line)}</span>` : esc(line));
|
|
183
|
-
}
|
|
184
|
-
flush();
|
|
185
|
-
return out.join('');
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
/* Tiny markdown renderer for the handoff notes — headings, bold, inline code,
|
|
189
|
-
fenced code, lists, links, hr. Escape-first, line-based, ZERO libs. Not a
|
|
190
|
-
full parser; handoff files are simple and anything unrecognized falls
|
|
191
|
-
through as a plain paragraph. */
|
|
192
|
-
function renderMarkdown(src) {
|
|
193
|
-
const lines = String(src ?? '').replace(/\r\n?/g, '\n').split('\n');
|
|
194
|
-
const out = [];
|
|
195
|
-
let list = null; // 'ul' | 'ol'
|
|
196
|
-
let code = null; // collected fenced-code lines
|
|
197
|
-
let quote = false; // inside a > blockquote
|
|
198
|
-
const closeList = () => {
|
|
199
|
-
if (list) {
|
|
200
|
-
out.push(`</${list}>`);
|
|
201
|
-
list = null;
|
|
202
|
-
}
|
|
203
|
-
};
|
|
204
|
-
const closeQuote = () => {
|
|
205
|
-
if (quote) {
|
|
206
|
-
out.push('</blockquote>');
|
|
207
|
-
quote = false;
|
|
208
|
-
}
|
|
209
|
-
};
|
|
210
|
-
const inline = (s) => {
|
|
211
|
-
let h = esc(s);
|
|
212
|
-
h = h.replace(/`([^`]+)`/g, '<code>$1</code>');
|
|
213
|
-
h = h.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
|
|
214
|
-
h = h.replace(/\[([^\]]+)\]\((https?:[^)\s]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>');
|
|
215
|
-
h = h.replace(/(^|\s)(https?:\/\/[^\s<]+)/g, '$1<a href="$2" target="_blank" rel="noopener">$2</a>');
|
|
216
|
-
return h;
|
|
217
|
-
};
|
|
218
|
-
for (const line of lines) {
|
|
219
|
-
if (code) {
|
|
220
|
-
if (/^```/.test(line)) {
|
|
221
|
-
out.push(`<pre><code>${esc(code.join('\n'))}</code></pre>`);
|
|
222
|
-
code = null;
|
|
223
|
-
} else {
|
|
224
|
-
code.push(line);
|
|
225
|
-
}
|
|
226
|
-
continue;
|
|
227
|
-
}
|
|
228
|
-
if (/^```/.test(line)) {
|
|
229
|
-
closeList();
|
|
230
|
-
closeQuote();
|
|
231
|
-
code = [];
|
|
232
|
-
continue;
|
|
233
|
-
}
|
|
234
|
-
const bq = /^>\s?(.*)$/.exec(line);
|
|
235
|
-
if (bq) {
|
|
236
|
-
if (!quote) {
|
|
237
|
-
closeList();
|
|
238
|
-
out.push('<blockquote>');
|
|
239
|
-
quote = true;
|
|
240
|
-
}
|
|
241
|
-
if (bq[1].trim()) out.push(`<p>${inline(bq[1])}</p>`);
|
|
242
|
-
continue;
|
|
243
|
-
}
|
|
244
|
-
closeQuote();
|
|
245
|
-
const heading = /^(#{1,4})\s+(.*)$/.exec(line);
|
|
246
|
-
if (heading) {
|
|
247
|
-
closeList();
|
|
248
|
-
const level = heading[1].length;
|
|
249
|
-
out.push(`<h${level}>${inline(heading[2])}</h${level}>`);
|
|
250
|
-
continue;
|
|
251
|
-
}
|
|
252
|
-
if (/^\s*(---+|\*\*\*+)\s*$/.test(line)) {
|
|
253
|
-
closeList();
|
|
254
|
-
out.push('<hr>');
|
|
255
|
-
continue;
|
|
256
|
-
}
|
|
257
|
-
const ul = /^\s*[-*]\s+(.*)$/.exec(line);
|
|
258
|
-
const ol = /^\s*\d+[.)]\s+(.*)$/.exec(line);
|
|
259
|
-
if (ul || ol) {
|
|
260
|
-
const kind = ul ? 'ul' : 'ol';
|
|
261
|
-
if (list !== kind) {
|
|
262
|
-
closeList();
|
|
263
|
-
out.push(`<${kind}>`);
|
|
264
|
-
list = kind;
|
|
265
|
-
}
|
|
266
|
-
const content = (ul ?? ol)[1];
|
|
267
|
-
const task = /^\[( |x|X)\]\s+(.*)$/.exec(content);
|
|
268
|
-
out.push(
|
|
269
|
-
task
|
|
270
|
-
? `<li class="task"><span class="cb">${task[1].trim() ? '☑' : '☐'}</span>${inline(task[2])}</li>`
|
|
271
|
-
: `<li>${inline(content)}</li>`,
|
|
272
|
-
);
|
|
273
|
-
continue;
|
|
274
|
-
}
|
|
275
|
-
closeList();
|
|
276
|
-
if (line.trim()) out.push(`<p>${inline(line)}</p>`);
|
|
277
|
-
}
|
|
278
|
-
if (code) out.push(`<pre><code>${esc(code.join('\n'))}</code></pre>`); // unclosed fence
|
|
279
|
-
closeList();
|
|
280
|
-
closeQuote();
|
|
281
|
-
return out.join('\n');
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
// ---- boot ------------------------------------------------------------------
|
|
285
|
-
|
|
286
|
-
async function init() {
|
|
287
|
-
bindUi();
|
|
288
|
-
applyTheme(state.theme);
|
|
289
|
-
const [health, workflowsRes, runs, todos, skills, uiState] = await Promise.all([
|
|
290
|
-
getJson('/api/health'),
|
|
291
|
-
getJson('/api/workflows'),
|
|
292
|
-
getJson('/api/runs'),
|
|
293
|
-
getJson('/api/todos').catch(() => []),
|
|
294
|
-
getJson('/api/skills').catch(() => []), // the form's skill mode + GitHub chips
|
|
295
|
-
getJson('/api/ui-state').catch(() => ({})),
|
|
296
|
-
]);
|
|
297
|
-
state.todos = todos;
|
|
298
|
-
state.skillsList = skills;
|
|
299
|
-
renderInboxBadge();
|
|
300
|
-
renderChrome(health);
|
|
301
|
-
// Preselect what you actually run: the last-used source (persisted in
|
|
302
|
-
// .ai/cezar/ui-state.json), else the first skill, else the first workflow.
|
|
303
|
-
const last = uiState.lastTask;
|
|
304
|
-
const lastValid =
|
|
305
|
-
last &&
|
|
306
|
-
(last.source === 'skill'
|
|
307
|
-
? skills.some((s) => s.name === last.ref)
|
|
308
|
-
: workflowsRes.workflows.some((w) => w.name === last.ref));
|
|
309
|
-
const pick = lastValid ? last : defaultTaskSource(workflowsRes.workflows);
|
|
310
|
-
state.taskSource = pick.source;
|
|
311
|
-
state.taskRef = pick.ref;
|
|
312
|
-
setWorkflowOptions(workflowsRes.workflows);
|
|
313
|
-
for (const run of runs) state.runs.set(run.id, run);
|
|
314
|
-
renderRunList();
|
|
315
|
-
connectGlobal();
|
|
316
|
-
const deepLinked = await handleDeepLink();
|
|
317
|
-
if (!deepLinked) {
|
|
318
|
-
const latest = sortedRuns()[0];
|
|
319
|
-
if (latest) selectRun(latest.id);
|
|
320
|
-
}
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
function applyTheme(theme) {
|
|
324
|
-
state.theme = theme;
|
|
325
|
-
document.documentElement.dataset.theme = theme;
|
|
326
|
-
try {
|
|
327
|
-
localStorage.setItem('cez-theme', theme);
|
|
328
|
-
} catch {
|
|
329
|
-
// private mode — the toggle still works for this page
|
|
330
|
-
}
|
|
331
|
-
const btn = $('#theme-toggle');
|
|
332
|
-
if (btn) btn.textContent = theme === 'dark' ? 'LIGHT ☼' : 'DARK ☾';
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
// ---- bookmarklet deep-link (spec 011) ----------------------------------------
|
|
336
|
-
|
|
337
|
-
/* `/new?skill=<name>&ref=<github-url>&auto=1&key=<launchKey>` — opened by a
|
|
338
|
-
bookmarklet clicked on a GitHub PR/issue. `auto=1` + the correct launch-key
|
|
339
|
-
starts the run immediately; anything else (no auto, bad key, a drive-by
|
|
340
|
-
page guessing the URL) only prefills the form — the user presses Run.
|
|
341
|
-
Returns true when a run was started (and selected). */
|
|
342
|
-
async function handleDeepLink() {
|
|
343
|
-
const params = new URLSearchParams(location.search);
|
|
344
|
-
if (location.pathname !== '/new' && !params.has('skill') && !params.has('ref')) return false;
|
|
345
|
-
const skill = (params.get('skill') ?? '').trim();
|
|
346
|
-
const ref = (params.get('ref') ?? params.get('task') ?? '').trim();
|
|
347
|
-
const auto = params.get('auto') === '1';
|
|
348
|
-
const key = params.get('key') ?? '';
|
|
349
|
-
history.replaceState({}, '', '/'); // never re-trigger on reload
|
|
350
|
-
if (!ref) return false;
|
|
351
|
-
|
|
352
|
-
if (auto) {
|
|
353
|
-
let launchKey = '';
|
|
354
|
-
try {
|
|
355
|
-
launchKey = (await getJson('/api/launch-key')).key;
|
|
356
|
-
} catch {
|
|
357
|
-
// fall through to the blocked path
|
|
358
|
-
}
|
|
359
|
-
if (launchKey && key === launchKey) {
|
|
360
|
-
// Per-skill: one inline step (the spec-008 API); no skill: quick-task.
|
|
361
|
-
const body = skill
|
|
362
|
-
? { steps: [{ id: 'task', name: skill, skill, prompt: ref }], task: ref }
|
|
363
|
-
: { workflow: 'quick-task', task: ref };
|
|
364
|
-
try {
|
|
365
|
-
const res = await fetch('/api/runs', {
|
|
366
|
-
method: 'POST',
|
|
367
|
-
headers: { 'content-type': 'application/json' },
|
|
368
|
-
body: JSON.stringify(body),
|
|
369
|
-
});
|
|
370
|
-
const data = await res.json();
|
|
371
|
-
if (!res.ok) throw new Error(data.error ?? `HTTP ${res.status}`);
|
|
372
|
-
handleStarted(data);
|
|
373
|
-
alertBar(skill ? `Started "${skill}" on ${oneLine(ref)}` : `Started task on ${oneLine(ref)}`);
|
|
374
|
-
return true;
|
|
375
|
-
} catch (err) {
|
|
376
|
-
alertBar(`Auto-start failed: ${err.message} — review the form and press Run`);
|
|
377
|
-
}
|
|
378
|
-
} else {
|
|
379
|
-
alertBar('auto-launch blocked (bad key) — press Run');
|
|
380
|
-
}
|
|
381
|
-
}
|
|
382
|
-
|
|
383
|
-
// Prefill path: the form carries everything; quick-task (or the planner)
|
|
384
|
-
// resolves a named skill from the task text — zero extra UI.
|
|
385
|
-
const form = $('#new-task');
|
|
386
|
-
form.task.value = skill ? `Use the "${skill}" skill on: ${ref}` : ref;
|
|
387
|
-
if (state.workflows.some((w) => w.name === 'quick-task')) {
|
|
388
|
-
state.taskSource = 'workflow';
|
|
389
|
-
state.taskRef = 'quick-task';
|
|
390
|
-
renderTaskPills();
|
|
391
|
-
}
|
|
392
|
-
if (!auto) alertBar('Form prefilled from GitHub — review & press Run');
|
|
393
|
-
$('#run-btn').focus();
|
|
394
|
-
return false;
|
|
395
|
-
}
|
|
396
|
-
|
|
397
|
-
/* Skills first (feedback 2026-07-11): the natural default is a skill, not a
|
|
398
|
-
chain — workflows stay one tab away in the picker. */
|
|
399
|
-
function defaultTaskSource(workflows = state.workflows) {
|
|
400
|
-
const firstSkill = (state.skillsList ?? [])[0];
|
|
401
|
-
if (firstSkill) return { source: 'skill', ref: firstSkill.name };
|
|
402
|
-
return { source: 'workflow', ref: workflows[0]?.name ?? 'quick-task' };
|
|
403
|
-
}
|
|
404
|
-
|
|
405
|
-
function setWorkflowOptions(workflows) {
|
|
406
|
-
state.workflows = workflows;
|
|
407
|
-
const refValid =
|
|
408
|
-
state.taskRef &&
|
|
409
|
-
(state.taskSource === 'skill'
|
|
410
|
-
? (state.skillsList ?? []).some((s) => s.name === state.taskRef)
|
|
411
|
-
: state.workflows.some((w) => w.name === state.taskRef));
|
|
412
|
-
if (!refValid) {
|
|
413
|
-
const pick = defaultTaskSource(workflows);
|
|
414
|
-
state.taskSource = pick.source;
|
|
415
|
-
state.taskRef = pick.ref;
|
|
416
|
-
}
|
|
417
|
-
renderTaskPills();
|
|
418
|
-
// GitHub's workflow chip is optional (null = run skills or quick-task) —
|
|
419
|
-
// only clear it when it names a workflow that no longer exists.
|
|
420
|
-
if (state.ghWorkflow && !state.workflows.some((w) => w.name === state.ghWorkflow)) {
|
|
421
|
-
state.ghWorkflow = null;
|
|
422
|
-
}
|
|
423
|
-
}
|
|
424
|
-
|
|
425
|
-
/* Remember what was just run so the next session preselects it. Fire-and-
|
|
426
|
-
forget — a failed write only costs the convenience. */
|
|
427
|
-
function saveLastTaskSource() {
|
|
428
|
-
if (!state.taskRef) return;
|
|
429
|
-
void fetch('/api/ui-state', {
|
|
430
|
-
method: 'PUT',
|
|
431
|
-
headers: { 'content-type': 'application/json' },
|
|
432
|
-
body: JSON.stringify({ lastTask: { source: state.taskSource, ref: state.taskRef } }),
|
|
433
|
-
}).catch(() => {});
|
|
434
|
-
}
|
|
435
|
-
|
|
436
|
-
/* ---- the form's pill dropdowns — "≡ fix-and-verify ⌄" and "⊞ auto ⌄" ---- */
|
|
437
|
-
|
|
438
|
-
const PILL_ICONS = {
|
|
439
|
-
chain: 'M4 6h16M4 12h16M4 18h10', // ≡ list
|
|
440
|
-
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
|
|
441
|
-
model: 'M9 3v3M15 3v3M9 18v3M15 18v3M3 9h3M3 15h3M18 9h3M18 15h3M6 6h12v12H6z', // chip
|
|
442
|
-
runner: 'M12 2l9 5v10l-9 5-9-5V7l9-5zM12 2v20M3 7l9 5 9-5', // 3D box (backend/engine)
|
|
443
|
-
};
|
|
444
|
-
|
|
445
|
-
/* Small inline icons for the run-detail action bar (design: every action has
|
|
446
|
-
a glyph, not typography like "±" / "▶"). Stroke icons by default; `fill`
|
|
447
|
-
for solid play/stop. */
|
|
448
|
-
function biIcon(d, fill = false) {
|
|
449
|
-
return `<svg class="bi${fill ? ' bi-fill' : ''}" viewBox="0 0 24 24"><path d="${d}"/></svg>`;
|
|
450
|
-
}
|
|
451
|
-
const BI = {
|
|
452
|
-
check: biIcon('M5 12l5 5 9-11'),
|
|
453
|
-
play: biIcon('M6 4l14 8-14 8V4z', true),
|
|
454
|
-
stop: biIcon('M7 7h10v10H7z', true),
|
|
455
|
-
terminal: biIcon('M4 17l6-5-6-5M12 19h8'),
|
|
456
|
-
diff: biIcon('M9 5v6M6 8h6M6 16h6M16 5v14'),
|
|
457
|
-
notes: biIcon('M6 3h9l4 4v14H6V3zM15 3v4h4M9 12h7M9 16h5'),
|
|
458
|
-
folder: biIcon('M3 8a2 2 0 012-2h4l2 2h8a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2V8zM9 14h6'),
|
|
459
|
-
archive: biIcon('M4 5h16v4H4zM6 9v10h12V9M10 13h4'),
|
|
460
|
-
trash: biIcon('M4 7h16M10 7V5h4v2M6 7l1 13h10l1-13M10 11v6M14 11v6'),
|
|
461
|
-
};
|
|
462
|
-
|
|
463
|
-
function pillHtml(iconPath, label, menuHtml) {
|
|
464
|
-
return `
|
|
465
|
-
<button type="button" class="pill-btn">
|
|
466
|
-
<svg class="pic" viewBox="0 0 24 24"><path d="${iconPath}"/></svg>
|
|
467
|
-
<span class="pl">${esc(label)}</span>
|
|
468
|
-
<svg class="chev" viewBox="0 0 24 24"><path d="M6 9l6 6 6-6"/></svg>
|
|
469
|
-
</button>
|
|
470
|
-
<div class="pill-menu" hidden>${menuHtml}</div>`;
|
|
471
|
-
}
|
|
472
|
-
|
|
473
|
-
function menuItemHtml(data, title, desc, selected, iconPath) {
|
|
474
|
-
return `
|
|
475
|
-
<div class="menu-item ${selected ? 'on' : ''}" ${data}>
|
|
476
|
-
<div class="mi-title"><span class="chk">${selected ? '✓' : ''}</span>${
|
|
477
|
-
iconPath ? `<svg class="mi-ic" viewBox="0 0 24 24"><path d="${iconPath}"/></svg>` : ''
|
|
478
|
-
}${esc(title)}</div>
|
|
479
|
-
${desc ? `<div class="mi-desc">${esc(desc)}</div>` : ''}
|
|
480
|
-
</div>`;
|
|
481
|
-
}
|
|
482
|
-
|
|
483
|
-
/* One pill covers chains AND skills: a search box + a Workflows|Skills tab
|
|
484
|
-
inside the menu ("zero new concepts": you pick what runs, the kind is
|
|
485
|
-
implicit in where you found it). */
|
|
486
|
-
function srcMenuItemsHtml() {
|
|
487
|
-
const q = state.srcMenuQuery.trim().toLowerCase();
|
|
488
|
-
const items =
|
|
489
|
-
state.srcMenuTab === 'skill'
|
|
490
|
-
? (state.skillsList ?? []).map((s) => ({ name: s.name, desc: s.description, source: 'skill' }))
|
|
491
|
-
: state.workflows.map((w) => ({ name: w.name, desc: w.description, source: 'workflow' }));
|
|
492
|
-
const filtered = items.filter(
|
|
493
|
-
(i) => !q || i.name.toLowerCase().includes(q) || (i.desc ?? '').toLowerCase().includes(q),
|
|
494
|
-
);
|
|
495
|
-
if (!filtered.length) {
|
|
496
|
-
return `<div class="menu-empty">${
|
|
497
|
-
state.srcMenuTab === 'skill' && !(state.skillsList ?? []).length
|
|
498
|
-
? 'No skills yet — drop Markdown files into .ai/skills/'
|
|
499
|
-
: '(nothing matches)'
|
|
500
|
-
}</div>`;
|
|
501
|
-
}
|
|
502
|
-
return filtered
|
|
503
|
-
.map((i) =>
|
|
504
|
-
menuItemHtml(
|
|
505
|
-
`data-mi="src" data-source="${i.source}" data-value="${esc(i.name)}"`,
|
|
506
|
-
i.name,
|
|
507
|
-
i.desc,
|
|
508
|
-
state.taskSource === i.source && state.taskRef === i.name,
|
|
509
|
-
i.source === 'skill' ? SKILL_ICON : PILL_ICONS.chain,
|
|
510
|
-
),
|
|
511
|
-
)
|
|
512
|
-
.join('');
|
|
513
|
-
}
|
|
514
|
-
|
|
515
|
-
function renderTaskPills() {
|
|
516
|
-
const srcMenu = `
|
|
517
|
-
<div class="menu-search">
|
|
518
|
-
<svg viewBox="0 0 24 24"><circle cx="11" cy="11" r="7"/><path d="M21 21l-5-5"/></svg>
|
|
519
|
-
<input id="src-search" type="text" placeholder="Search…" autocomplete="off">
|
|
520
|
-
</div>
|
|
521
|
-
<div class="menu-tabs">
|
|
522
|
-
<button type="button" data-mtab="workflow" class="${state.srcMenuTab === 'workflow' ? 'active' : ''}">Workflows</button>
|
|
523
|
-
<button type="button" data-mtab="skill" class="${state.srcMenuTab === 'skill' ? 'active' : ''}">Skills</button>
|
|
524
|
-
</div>
|
|
525
|
-
<div id="src-menu-items">${srcMenuItemsHtml()}</div>`;
|
|
526
|
-
$('#src-pill').innerHTML = pillHtml(
|
|
527
|
-
state.taskSource === 'skill' ? PILL_ICONS.skill : PILL_ICONS.chain,
|
|
528
|
-
state.taskRef ?? 'quick-task',
|
|
529
|
-
srcMenu,
|
|
530
|
-
);
|
|
531
|
-
|
|
532
|
-
// Runner pill — only shown when more than one backend is installed, so a
|
|
533
|
-
// single-runner (claude-only) host stays as simple as before.
|
|
534
|
-
const runnerPill = $('#runner-pill');
|
|
535
|
-
if (runnerPill) {
|
|
536
|
-
const runners = RUNNERS.filter((r) => state.runnersAvailable.includes(r.id));
|
|
537
|
-
if (runners.length > 1) {
|
|
538
|
-
runnerPill.hidden = false;
|
|
539
|
-
const selected = runners.find((r) => r.id === state.taskRunner) ?? runners[0];
|
|
540
|
-
const runnerMenu = runners
|
|
541
|
-
.map((r) =>
|
|
542
|
-
menuItemHtml(
|
|
543
|
-
`data-mi="runner" data-value="${esc(r.id)}"`,
|
|
544
|
-
r.label,
|
|
545
|
-
r.desc,
|
|
546
|
-
r.id === selected.id,
|
|
547
|
-
PILL_ICONS.runner,
|
|
548
|
-
),
|
|
549
|
-
)
|
|
550
|
-
.join('');
|
|
551
|
-
runnerPill.innerHTML = pillHtml(PILL_ICONS.runner, selected.label, runnerMenu);
|
|
552
|
-
} else {
|
|
553
|
-
runnerPill.hidden = true;
|
|
554
|
-
}
|
|
555
|
-
}
|
|
556
|
-
|
|
557
|
-
const all = modelsForRunner(state.taskRunner);
|
|
558
|
-
const models = state.modelsAvailable ? all : all.slice(0, 1);
|
|
559
|
-
const selectedModel = models.find((m) => m.id === state.taskModel) ?? models[0];
|
|
560
|
-
const modelMenu = models
|
|
561
|
-
.map((m) =>
|
|
562
|
-
menuItemHtml(
|
|
563
|
-
`data-mi="model" data-value="${esc(m.id)}"`,
|
|
564
|
-
m.label,
|
|
565
|
-
m.desc,
|
|
566
|
-
m.id === (selectedModel?.id ?? ''),
|
|
567
|
-
PILL_ICONS.model,
|
|
568
|
-
),
|
|
569
|
-
)
|
|
570
|
-
.join('');
|
|
571
|
-
$('#model-pill').innerHTML = pillHtml(PILL_ICONS.model, selectedModel?.label ?? 'auto', modelMenu);
|
|
572
|
-
}
|
|
573
|
-
|
|
574
|
-
function closePillMenus() {
|
|
575
|
-
for (const el of document.querySelectorAll('.pill-select.open')) {
|
|
576
|
-
el.classList.remove('open');
|
|
577
|
-
const menu = el.querySelector('.pill-menu');
|
|
578
|
-
if (menu) menu.hidden = true;
|
|
579
|
-
}
|
|
580
|
-
}
|
|
581
|
-
|
|
582
|
-
function renderChrome(health) {
|
|
583
|
-
const repoChip = $('#repo-chip');
|
|
584
|
-
repoChip.hidden = false;
|
|
585
|
-
repoChip.textContent = health.repo
|
|
586
|
-
? `${health.repo.root.split('/').pop()} / ${health.repo.branch}`
|
|
587
|
-
: 'no git — tasks run in place';
|
|
588
|
-
repoChip.title = health.repo ? `${health.repo.root} · ${health.repo.branch}` : 'not a git repo — tasks run in place, one at a time';
|
|
589
|
-
$('#env-chips').innerHTML = health.checks
|
|
590
|
-
.map(
|
|
591
|
-
(c) =>
|
|
592
|
-
`<span class="env-chip ${c.available ? 'ok' : 'bad'}" title="${esc(c.hint ?? c.version ?? '')}"><span class="led"></span>${esc(c.name)}</span>`,
|
|
593
|
-
)
|
|
594
|
-
.join('');
|
|
595
|
-
|
|
596
|
-
// Which agent backends are installed → which runners the pill offers.
|
|
597
|
-
const available = RUNNERS.map((r) => r.id).filter((id) =>
|
|
598
|
-
health.checks.some((c) => c.name === id && c.available),
|
|
599
|
-
);
|
|
600
|
-
state.runnersAvailable = available.length ? available : ['claude'];
|
|
601
|
-
// Preselect the configured default when it's installed, else the first
|
|
602
|
-
// available runner.
|
|
603
|
-
const preferred = health.defaultRunner;
|
|
604
|
-
if (preferred && state.runnersAvailable.includes(preferred)) {
|
|
605
|
-
state.taskRunner = preferred;
|
|
606
|
-
} else if (!state.runnersAvailable.includes(state.taskRunner)) {
|
|
607
|
-
state.taskRunner = state.runnersAvailable[0];
|
|
608
|
-
}
|
|
609
|
-
// Model pill is populated only when the selected runner's CLI is present;
|
|
610
|
-
// otherwise it collapses to "auto".
|
|
611
|
-
state.modelsAvailable = state.runnersAvailable.includes(state.taskRunner);
|
|
612
|
-
renderTaskPills();
|
|
613
|
-
}
|
|
614
|
-
|
|
615
|
-
/* The selectable agent backends. Only those actually installed on the host
|
|
616
|
-
(detected via /api/health checks) are offered in the pill. */
|
|
617
|
-
const RUNNERS = [
|
|
618
|
-
{ id: 'claude', label: 'claude', desc: 'Claude Code CLI' },
|
|
619
|
-
{ id: 'codex', label: 'codex', desc: 'OpenAI Codex (app-server)' },
|
|
620
|
-
{ id: 'opencode', label: 'opencode', desc: 'OpenCode (serve)' },
|
|
621
|
-
];
|
|
622
|
-
|
|
623
|
-
/* Model presets per runner. `id: ''` is always "auto" — no model flag, the
|
|
624
|
-
runner decides. Claude takes tier aliases + pinned versions; Codex takes
|
|
625
|
-
gpt-*-codex ids; OpenCode takes `provider/model` ids. */
|
|
626
|
-
const MODELS_BY_RUNNER = {
|
|
627
|
-
claude: [
|
|
628
|
-
{ id: '', label: 'auto', desc: 'Pick the best model per step' },
|
|
629
|
-
{ id: 'opus', label: 'opus', desc: 'Deep reasoning for hard tasks' },
|
|
630
|
-
{ id: 'sonnet', label: 'sonnet', desc: 'Fast and cheap' },
|
|
631
|
-
{ id: 'haiku', label: 'haiku', desc: 'Fastest — simple, scoped tasks' },
|
|
632
|
-
{ id: 'claude-opus-4-8', label: 'Opus 4.8', desc: 'Pinned version' },
|
|
633
|
-
{ id: 'claude-sonnet-5', label: 'Sonnet 5', desc: 'Pinned version' },
|
|
634
|
-
{ id: 'claude-haiku-4-5', label: 'Haiku 4.5', desc: 'Pinned version' },
|
|
635
|
-
],
|
|
636
|
-
codex: [
|
|
637
|
-
{ id: '', label: 'auto', desc: 'Use your Codex default model' },
|
|
638
|
-
{ id: 'gpt-5.1-codex', label: 'gpt-5.1-codex', desc: 'Codex-tuned' },
|
|
639
|
-
{ id: 'gpt-5.1-codex-mini', label: 'gpt-5.1-codex-mini', desc: 'Faster, cheaper' },
|
|
640
|
-
{ id: 'gpt-5-codex', label: 'gpt-5-codex', desc: 'Previous generation' },
|
|
641
|
-
],
|
|
642
|
-
opencode: [
|
|
643
|
-
{ id: '', label: 'auto', desc: 'Use your OpenCode default model' },
|
|
644
|
-
{ id: 'anthropic/claude-sonnet-4-5', label: 'claude-sonnet-4.5', desc: 'via Anthropic' },
|
|
645
|
-
{ id: 'openai/gpt-5.1', label: 'gpt-5.1', desc: 'via OpenAI' },
|
|
646
|
-
{ id: 'openai/gpt-5.1-codex', label: 'gpt-5.1-codex', desc: 'via OpenAI' },
|
|
647
|
-
],
|
|
648
|
-
};
|
|
649
|
-
|
|
650
|
-
function modelsForRunner(runner) {
|
|
651
|
-
return MODELS_BY_RUNNER[runner] ?? MODELS_BY_RUNNER.claude;
|
|
652
|
-
}
|
|
653
|
-
|
|
654
|
-
/* The global stream replays nothing after a drop (laptop sleep, server
|
|
655
|
-
restart) — a missed `run` event would leave a stale status in the sidebar
|
|
656
|
-
forever (seen live: a run resting at `review` still listed under Working).
|
|
657
|
-
Every (re)connect and every return to a visible tab re-syncs the full run
|
|
658
|
-
list instead. */
|
|
659
|
-
let lastResyncAt = 0;
|
|
660
|
-
async function resyncRuns() {
|
|
661
|
-
if (Date.now() - lastResyncAt < 2_000) return; // boot + onopen double-fire guard
|
|
662
|
-
lastResyncAt = Date.now();
|
|
663
|
-
try {
|
|
664
|
-
const runs = await getJson('/api/runs');
|
|
665
|
-
const fresh = new Set(runs.map((r) => r.id));
|
|
666
|
-
for (const id of [...state.runs.keys()]) if (!fresh.has(id)) state.runs.delete(id);
|
|
667
|
-
for (const run of runs) state.runs.set(run.id, run);
|
|
668
|
-
renderRunList();
|
|
669
|
-
const sel = state.selectedId ? state.runs.get(state.selectedId) : null;
|
|
670
|
-
if (sel) {
|
|
671
|
-
updateDetail(sel);
|
|
672
|
-
} else if (state.selectedId) {
|
|
673
|
-
state.selectedId = null;
|
|
674
|
-
$('#detail').innerHTML = '<div class="empty">Select a run — or start one.</div>';
|
|
675
|
-
}
|
|
676
|
-
} catch {
|
|
677
|
-
// offline — the next reconnect/visibility flip retries
|
|
678
|
-
}
|
|
679
|
-
}
|
|
680
|
-
|
|
681
|
-
function connectGlobal() {
|
|
682
|
-
const es = new EventSource('/api/events');
|
|
683
|
-
es.onopen = () => void resyncRuns(); // fires on the initial connect AND every auto-reconnect
|
|
684
|
-
document.addEventListener('visibilitychange', () => {
|
|
685
|
-
// SSE can die silently across a sleep — a tab coming back re-syncs too.
|
|
686
|
-
if (!document.hidden) void resyncRuns();
|
|
687
|
-
});
|
|
688
|
-
es.addEventListener('run', (e) => {
|
|
689
|
-
const run = JSON.parse(e.data);
|
|
690
|
-
state.runs.set(run.id, run);
|
|
691
|
-
renderRunList();
|
|
692
|
-
if (run.id === state.selectedId) updateDetail(run);
|
|
693
|
-
});
|
|
694
|
-
es.addEventListener('run-deleted', (e) => {
|
|
695
|
-
const { id } = JSON.parse(e.data);
|
|
696
|
-
state.runs.delete(id);
|
|
697
|
-
renderRunList();
|
|
698
|
-
if (id === state.selectedId) {
|
|
699
|
-
state.selectedId = null;
|
|
700
|
-
$('#detail').innerHTML = '<div class="empty">Select a run — or start one.</div>';
|
|
701
|
-
}
|
|
702
|
-
});
|
|
703
|
-
es.addEventListener('todos', (e) => {
|
|
704
|
-
state.todos = JSON.parse(e.data);
|
|
705
|
-
renderInboxBadge();
|
|
706
|
-
if (!$('#view-inbox').hidden) renderInbox();
|
|
707
|
-
});
|
|
708
|
-
}
|
|
709
|
-
|
|
710
|
-
// ---- UI events -------------------------------------------------------------
|
|
711
|
-
|
|
712
|
-
function bindUi() {
|
|
713
|
-
$('#theme-toggle').addEventListener('click', () => {
|
|
714
|
-
applyTheme(state.theme === 'dark' ? 'light' : 'dark');
|
|
715
|
-
});
|
|
716
|
-
|
|
717
|
-
$('#tabs').addEventListener('click', (e) => {
|
|
718
|
-
const btn = e.target.closest('button[data-view]');
|
|
719
|
-
if (!btn) return;
|
|
720
|
-
for (const b of $('#tabs').children) b.classList.toggle('active', b === btn);
|
|
721
|
-
for (const view of document.querySelectorAll('.view')) view.hidden = true;
|
|
722
|
-
const view = $(`#view-${btn.dataset.view}`);
|
|
723
|
-
view.hidden = false;
|
|
724
|
-
if (btn.dataset.view === 'repo') loadRepo();
|
|
725
|
-
if (btn.dataset.view === 'skills') loadSkills();
|
|
726
|
-
if (btn.dataset.view === 'inbox') renderInbox();
|
|
727
|
-
if (btn.dataset.view === 'github') loadGithub();
|
|
728
|
-
if (btn.dataset.view === 'workflows') openWorkflowsView();
|
|
729
|
-
});
|
|
730
|
-
|
|
731
|
-
$('#view-inbox').addEventListener('click', async (e) => {
|
|
732
|
-
const goto = e.target.closest('[data-goto-run]');
|
|
733
|
-
if (goto) {
|
|
734
|
-
e.preventDefault();
|
|
735
|
-
showRunsView();
|
|
736
|
-
selectRun(goto.dataset.gotoRun);
|
|
737
|
-
return;
|
|
738
|
-
}
|
|
739
|
-
const btn = e.target.closest('button[data-todo-action]');
|
|
740
|
-
if (!btn) return;
|
|
741
|
-
const id = btn.closest('.todo-card')?.dataset.id;
|
|
742
|
-
if (!id) return;
|
|
743
|
-
if (btn.dataset.todoAction === 'remove') {
|
|
744
|
-
await fetch(`/api/todos/${id}`, { method: 'DELETE' });
|
|
745
|
-
state.todos = state.todos.filter((t) => t.id !== id);
|
|
746
|
-
renderInboxBadge();
|
|
747
|
-
renderInbox();
|
|
748
|
-
} else if (btn.dataset.todoAction === 'start') {
|
|
749
|
-
btn.disabled = true;
|
|
750
|
-
try {
|
|
751
|
-
const res = await fetch(`/api/todos/${id}/start`, { method: 'POST' });
|
|
752
|
-
const data = await res.json().catch(() => ({}));
|
|
753
|
-
if (!res.ok) throw new Error(data.error ?? `HTTP ${res.status}`);
|
|
754
|
-
const todo = state.todos.find((t) => t.id === id);
|
|
755
|
-
if (todo) todo.startedTaskId = data.run.id;
|
|
756
|
-
renderInboxBadge();
|
|
757
|
-
renderInbox();
|
|
758
|
-
state.runs.set(data.run.id, data.run);
|
|
759
|
-
showRunsView();
|
|
760
|
-
renderRunList();
|
|
761
|
-
selectRun(data.run.id);
|
|
762
|
-
} catch (err) {
|
|
763
|
-
alertBar(err.message);
|
|
764
|
-
btn.disabled = false;
|
|
765
|
-
}
|
|
766
|
-
}
|
|
767
|
-
});
|
|
768
|
-
|
|
769
|
-
bindGithubView();
|
|
770
|
-
bindSkillsView();
|
|
771
|
-
bindWorkflowsView();
|
|
772
|
-
bindRepoView();
|
|
773
|
-
|
|
774
|
-
const taskForm = $('#new-task');
|
|
775
|
-
|
|
776
|
-
// Pill dropdowns: toggle on the pill, select on a menu item, close on any
|
|
777
|
-
// outside click (one document listener; menus are re-rendered per change).
|
|
778
|
-
document.addEventListener('click', (e) => {
|
|
779
|
-
const pill = e.target.closest('.pill-select');
|
|
780
|
-
if (!pill) {
|
|
781
|
-
closePillMenus();
|
|
782
|
-
return;
|
|
783
|
-
}
|
|
784
|
-
const item = e.target.closest('.menu-item[data-mi]');
|
|
785
|
-
if (item) {
|
|
786
|
-
if (item.dataset.mi === 'src') {
|
|
787
|
-
state.taskSource = item.dataset.source;
|
|
788
|
-
state.taskRef = item.dataset.value;
|
|
789
|
-
} else if (item.dataset.mi === 'model') {
|
|
790
|
-
state.taskModel = item.dataset.value;
|
|
791
|
-
} else if (item.dataset.mi === 'runner') {
|
|
792
|
-
// Switching backend invalidates the model list — reset to auto.
|
|
793
|
-
state.taskRunner = item.dataset.value;
|
|
794
|
-
state.taskModel = '';
|
|
795
|
-
}
|
|
796
|
-
closePillMenus();
|
|
797
|
-
renderTaskPills();
|
|
798
|
-
return;
|
|
799
|
-
}
|
|
800
|
-
// Workflows | Skills switch inside the source menu.
|
|
801
|
-
const mtab = e.target.closest('button[data-mtab]');
|
|
802
|
-
if (mtab) {
|
|
803
|
-
state.srcMenuTab = mtab.dataset.mtab;
|
|
804
|
-
for (const b of mtab.parentElement.children) b.classList.toggle('active', b === mtab);
|
|
805
|
-
const box = $('#src-menu-items');
|
|
806
|
-
if (box) box.innerHTML = srcMenuItemsHtml();
|
|
807
|
-
$('#src-search')?.focus();
|
|
808
|
-
return;
|
|
809
|
-
}
|
|
810
|
-
if (e.target.closest('.menu-search')) return; // typing, not toggling
|
|
811
|
-
if (e.target.closest('.pill-btn')) {
|
|
812
|
-
const menu = pill.querySelector('.pill-menu');
|
|
813
|
-
const wasOpen = pill.classList.contains('open');
|
|
814
|
-
closePillMenus();
|
|
815
|
-
if (!wasOpen && menu) {
|
|
816
|
-
pill.classList.add('open');
|
|
817
|
-
menu.hidden = false;
|
|
818
|
-
// Fresh view on open: Skills tab by default (feedback 2026-07-11 —
|
|
819
|
-
// skills are what people pick 9 times out of 10), empty query,
|
|
820
|
-
// focused search.
|
|
821
|
-
if (pill.id === 'src-pill') {
|
|
822
|
-
state.srcMenuTab = 'skill';
|
|
823
|
-
state.srcMenuQuery = '';
|
|
824
|
-
const search = $('#src-search');
|
|
825
|
-
if (search) search.value = '';
|
|
826
|
-
for (const b of menu.querySelectorAll('button[data-mtab]')) {
|
|
827
|
-
b.classList.toggle('active', b.dataset.mtab === state.srcMenuTab);
|
|
828
|
-
}
|
|
829
|
-
const box = $('#src-menu-items');
|
|
830
|
-
if (box) box.innerHTML = srcMenuItemsHtml();
|
|
831
|
-
search?.focus();
|
|
832
|
-
}
|
|
833
|
-
}
|
|
834
|
-
}
|
|
835
|
-
});
|
|
836
|
-
|
|
837
|
-
// Live filter — re-render only the item list so the input keeps focus.
|
|
838
|
-
document.addEventListener('input', (e) => {
|
|
839
|
-
if (e.target.id !== 'src-search') return;
|
|
840
|
-
state.srcMenuQuery = e.target.value;
|
|
841
|
-
const box = $('#src-menu-items');
|
|
842
|
-
if (box) box.innerHTML = srcMenuItemsHtml();
|
|
843
|
-
});
|
|
844
|
-
// Enter in the search picks the first match (and never submits the form).
|
|
845
|
-
document.addEventListener('keydown', (e) => {
|
|
846
|
-
if (e.target.id !== 'src-search' || e.key !== 'Enter') return;
|
|
847
|
-
e.preventDefault();
|
|
848
|
-
$('#src-menu-items .menu-item')?.click();
|
|
849
|
-
});
|
|
850
|
-
|
|
851
|
-
// 📎 — attach images to the task (same pipeline as ⌘V paste).
|
|
852
|
-
const taskFile = $('#task-file');
|
|
853
|
-
$('#task-attach').addEventListener('click', () => taskFile.click());
|
|
854
|
-
taskFile.addEventListener('change', () => {
|
|
855
|
-
for (const file of taskFile.files ?? []) {
|
|
856
|
-
void readImageFile(file).then((img) => {
|
|
857
|
-
if (!img || state.taskImages.length >= 4) return;
|
|
858
|
-
state.taskImages.push(img);
|
|
859
|
-
renderTaskThumbs();
|
|
860
|
-
});
|
|
861
|
-
}
|
|
862
|
-
taskFile.value = '';
|
|
863
|
-
});
|
|
864
|
-
|
|
865
|
-
// ⌘↵ / Ctrl+↵ submits from inside the textarea.
|
|
866
|
-
taskForm.task.addEventListener('keydown', (e) => {
|
|
867
|
-
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
|
|
868
|
-
e.preventDefault();
|
|
869
|
-
taskForm.requestSubmit();
|
|
870
|
-
}
|
|
871
|
-
});
|
|
872
|
-
|
|
873
|
-
// The task box grows on focus and with content, springs back when left
|
|
874
|
-
// empty. Explicit px heights so the CSS height transition animates.
|
|
875
|
-
const taskBox = taskForm.task;
|
|
876
|
-
const autosizeTask = () => {
|
|
877
|
-
const engaged = document.activeElement === taskBox || taskBox.value.trim();
|
|
878
|
-
const floor = engaged ? 92 : 40;
|
|
879
|
-
const prev = taskBox.style.height || '40px';
|
|
880
|
-
taskBox.style.height = 'auto';
|
|
881
|
-
const target = Math.max(floor, Math.min(taskBox.scrollHeight, 220));
|
|
882
|
-
taskBox.style.height = prev; // restore the start point…
|
|
883
|
-
void taskBox.offsetHeight; // …force a reflow…
|
|
884
|
-
taskBox.style.height = `${target}px`; // …then animate to the target
|
|
885
|
-
};
|
|
886
|
-
taskBox.addEventListener('focus', autosizeTask);
|
|
887
|
-
taskBox.addEventListener('input', autosizeTask);
|
|
888
|
-
taskBox.addEventListener('blur', autosizeTask);
|
|
889
|
-
|
|
890
|
-
// Drop target for GitHub rows (and any dragged text): prefill the task box
|
|
891
|
-
// with the dragged prompt, then pick a skill/workflow and press Run.
|
|
892
|
-
taskBox.addEventListener('dragover', (e) => {
|
|
893
|
-
if (e.dataTransfer?.types.includes('text/plain')) {
|
|
894
|
-
e.preventDefault();
|
|
895
|
-
e.dataTransfer.dropEffect = 'copy';
|
|
896
|
-
}
|
|
897
|
-
});
|
|
898
|
-
taskBox.addEventListener('drop', (e) => {
|
|
899
|
-
const text = e.dataTransfer?.getData('text/plain');
|
|
900
|
-
if (!text) return;
|
|
901
|
-
e.preventDefault();
|
|
902
|
-
taskBox.value = text;
|
|
903
|
-
taskBox.focus();
|
|
904
|
-
taskBox.dispatchEvent(new Event('input')); // autosize to the content
|
|
905
|
-
});
|
|
906
|
-
|
|
907
|
-
// Screenshots pasted into the task box travel with POST /api/runs and reach
|
|
908
|
-
// the first agent step's opening message.
|
|
909
|
-
taskBox.addEventListener('paste', (e) => {
|
|
910
|
-
const items = [...(e.clipboardData?.items ?? [])].filter((i) => i.type.startsWith('image/'));
|
|
911
|
-
if (!items.length) return;
|
|
912
|
-
e.preventDefault();
|
|
913
|
-
for (const item of items) {
|
|
914
|
-
const file = item.getAsFile();
|
|
915
|
-
if (file) {
|
|
916
|
-
void readImageFile(file).then((img) => {
|
|
917
|
-
if (!img || state.taskImages.length >= 4) return;
|
|
918
|
-
state.taskImages.push(img);
|
|
919
|
-
renderTaskThumbs();
|
|
920
|
-
});
|
|
921
|
-
}
|
|
922
|
-
}
|
|
923
|
-
});
|
|
924
|
-
$('#task-thumbs').addEventListener('click', (e) => {
|
|
925
|
-
const thumb = e.target.closest('[data-idx]');
|
|
926
|
-
if (!thumb) return;
|
|
927
|
-
state.taskImages.splice(Number(thumb.dataset.idx), 1);
|
|
928
|
-
renderTaskThumbs();
|
|
929
|
-
});
|
|
930
|
-
|
|
931
|
-
taskForm.addEventListener('submit', async (e) => {
|
|
932
|
-
e.preventDefault();
|
|
933
|
-
const form = e.target;
|
|
934
|
-
const errorBox = $('#form-error');
|
|
935
|
-
errorBox.hidden = true;
|
|
936
|
-
const body = {
|
|
937
|
-
task: form.task.value.trim(),
|
|
938
|
-
model: state.taskModel || undefined,
|
|
939
|
-
runner: state.runnersAvailable.length > 1 ? state.taskRunner : undefined,
|
|
940
|
-
variants: state.variants > 1 ? state.variants : undefined,
|
|
941
|
-
images: state.taskImages.length
|
|
942
|
-
? state.taskImages.map((i) => ({ mediaType: i.mediaType, data: i.data }))
|
|
943
|
-
: undefined,
|
|
944
|
-
};
|
|
945
|
-
if (!body.task) return;
|
|
946
|
-
if (state.taskSource === 'skill' && state.taskRef) {
|
|
947
|
-
// A skill runs as a one-step inline chain — same shape the inbox and
|
|
948
|
-
// the bookmarklet auto-start use (spec 008's API).
|
|
949
|
-
body.steps = [{ id: 'task', name: state.taskRef, skill: state.taskRef, prompt: '{{task}}' }];
|
|
950
|
-
} else {
|
|
951
|
-
body.workflow = state.taskRef ?? 'quick-task';
|
|
952
|
-
}
|
|
953
|
-
discardPlan(); // a plain Run supersedes any pending plan (spec 008)
|
|
954
|
-
$('#run-btn').disabled = true;
|
|
955
|
-
try {
|
|
956
|
-
const res = await fetch('/api/runs', {
|
|
957
|
-
method: 'POST',
|
|
958
|
-
headers: { 'content-type': 'application/json' },
|
|
959
|
-
body: JSON.stringify(body),
|
|
960
|
-
});
|
|
961
|
-
const data = await res.json();
|
|
962
|
-
if (!res.ok) throw new Error(data.error ?? `HTTP ${res.status}`);
|
|
963
|
-
form.task.value = '';
|
|
964
|
-
state.taskImages = [];
|
|
965
|
-
renderTaskThumbs();
|
|
966
|
-
form.task.dispatchEvent(new Event('blur')); // shrink the box back
|
|
967
|
-
saveLastTaskSource();
|
|
968
|
-
handleStarted(data);
|
|
969
|
-
} catch (err) {
|
|
970
|
-
errorBox.textContent = err.message;
|
|
971
|
-
errorBox.hidden = false;
|
|
972
|
-
} finally {
|
|
973
|
-
$('#run-btn').disabled = false;
|
|
974
|
-
}
|
|
975
|
-
});
|
|
976
|
-
|
|
977
|
-
bindPlanPanel();
|
|
978
|
-
|
|
979
|
-
// Parallel variants (spec 010) have no form control right now — the ×1/×2/×3
|
|
980
|
-
// switch was retired from the UI. The backend path stays: state.variants is
|
|
981
|
-
// still sent with POST /api/runs whenever something sets it above 1.
|
|
982
|
-
|
|
983
|
-
$('#run-list').addEventListener('click', (e) => {
|
|
984
|
-
if (e.target.closest('a')) return; // links navigate, not select
|
|
985
|
-
const compare = e.target.closest('button[data-compare]');
|
|
986
|
-
if (compare) {
|
|
987
|
-
void selectGroup(compare.dataset.compare);
|
|
988
|
-
return;
|
|
989
|
-
}
|
|
990
|
-
const item = e.target.closest('.run-item');
|
|
991
|
-
if (!item) return;
|
|
992
|
-
if (item.dataset.id) {
|
|
993
|
-
showRunsView();
|
|
994
|
-
selectRun(item.dataset.id);
|
|
995
|
-
} else if (item.dataset.group) {
|
|
996
|
-
// Group tile: toggle the variant list under it (in-memory UI state).
|
|
997
|
-
const gid = item.dataset.group;
|
|
998
|
-
if (state.expandedGroups.has(gid)) state.expandedGroups.delete(gid);
|
|
999
|
-
else state.expandedGroups.add(gid);
|
|
1000
|
-
renderRunList();
|
|
1001
|
-
}
|
|
1002
|
-
});
|
|
1003
|
-
|
|
1004
|
-
$('#list-tabs').addEventListener('click', async (e) => {
|
|
1005
|
-
const btn = e.target.closest('button[data-list]');
|
|
1006
|
-
if (!btn) return;
|
|
1007
|
-
if (btn.dataset.list === 'archive-finished') {
|
|
1008
|
-
await fetch('/api/runs/archive-finished', { method: 'POST' });
|
|
1009
|
-
return; // SSE run updates re-render the list
|
|
1010
|
-
}
|
|
1011
|
-
state.listView = btn.dataset.list;
|
|
1012
|
-
renderRunList();
|
|
1013
|
-
});
|
|
1014
|
-
|
|
1015
|
-
$('#detail').addEventListener('click', async (e) => {
|
|
1016
|
-
// Agent screenshots zoom into a lightbox.
|
|
1017
|
-
const shot = e.target.closest('[data-lightbox]');
|
|
1018
|
-
if (shot) {
|
|
1019
|
-
openLightbox(shot.dataset.lightbox, shot.dataset.name);
|
|
1020
|
-
return;
|
|
1021
|
-
}
|
|
1022
|
-
// Compare view (spec 010): "Pick this one" lives in #detail without a
|
|
1023
|
-
// selected run — handle it before the per-run actions.
|
|
1024
|
-
const pick = e.target.closest('button[data-pick]');
|
|
1025
|
-
if (pick) {
|
|
1026
|
-
void pickVariant(pick);
|
|
1027
|
-
return;
|
|
1028
|
-
}
|
|
1029
|
-
const btn = e.target.closest('button[data-action]');
|
|
1030
|
-
if (!btn) return;
|
|
1031
|
-
const id = state.selectedId;
|
|
1032
|
-
if (!id) return;
|
|
1033
|
-
if (btn.dataset.action === 'cancel') {
|
|
1034
|
-
await fetch(`/api/runs/${id}/cancel`, { method: 'POST' });
|
|
1035
|
-
} else if (btn.dataset.action === 'delete') {
|
|
1036
|
-
if (!confirm('Delete this run and its log?')) return;
|
|
1037
|
-
await fetch(`/api/runs/${id}`, { method: 'DELETE' });
|
|
1038
|
-
} else if (btn.dataset.action === 'finish') {
|
|
1039
|
-
await fetch(`/api/runs/${id}/finish`, { method: 'POST' });
|
|
1040
|
-
} else if (btn.dataset.action === 'archive') {
|
|
1041
|
-
const run = state.runs.get(id);
|
|
1042
|
-
await fetch(`/api/runs/${id}/archive`, {
|
|
1043
|
-
method: 'POST',
|
|
1044
|
-
headers: { 'content-type': 'application/json' },
|
|
1045
|
-
body: JSON.stringify({ archived: !run?.archived }),
|
|
1046
|
-
});
|
|
1047
|
-
} else if (btn.dataset.action === 'continue') {
|
|
1048
|
-
const res = await fetch(`/api/runs/${id}/continue`, {
|
|
1049
|
-
method: 'POST',
|
|
1050
|
-
headers: { 'content-type': 'application/json' },
|
|
1051
|
-
body: JSON.stringify({}),
|
|
1052
|
-
});
|
|
1053
|
-
if (!res.ok) {
|
|
1054
|
-
const data = await res.json().catch(() => ({}));
|
|
1055
|
-
alertBar(data.error ?? 'cannot continue');
|
|
1056
|
-
}
|
|
1057
|
-
} else if (btn.dataset.action === 'open-cli') {
|
|
1058
|
-
const res = await fetch(`/api/runs/${id}/open-in-cli`, { method: 'POST' });
|
|
1059
|
-
const data = await res.json().catch(() => ({}));
|
|
1060
|
-
if (!res.ok) {
|
|
1061
|
-
if (data.command) {
|
|
1062
|
-
try {
|
|
1063
|
-
await navigator.clipboard.writeText(data.command);
|
|
1064
|
-
alertBar('No terminal found — command copied to clipboard.');
|
|
1065
|
-
} catch {
|
|
1066
|
-
alertBar(`Run manually: ${data.command}`);
|
|
1067
|
-
}
|
|
1068
|
-
} else {
|
|
1069
|
-
alertBar(data.error ?? 'cannot open terminal');
|
|
1070
|
-
}
|
|
1071
|
-
}
|
|
1072
|
-
} else if (btn.dataset.action === 'diff') {
|
|
1073
|
-
const panel = $('#diff-panel');
|
|
1074
|
-
if (!panel) return;
|
|
1075
|
-
if (!panel.hidden) {
|
|
1076
|
-
panel.hidden = true;
|
|
1077
|
-
return;
|
|
1078
|
-
}
|
|
1079
|
-
panel.hidden = false;
|
|
1080
|
-
$('#diff-body').innerHTML = '<div class="dim">Loading…</div>';
|
|
1081
|
-
try {
|
|
1082
|
-
const res = await fetch(`/api/runs/${id}/diff`);
|
|
1083
|
-
const text = await res.text();
|
|
1084
|
-
$('#diff-body').innerHTML = text.trim() ? renderDiff(text) : '<div class="dim">(no changes yet)</div>';
|
|
1085
|
-
} catch (err) {
|
|
1086
|
-
$('#diff-body').textContent = `✗ ${err.message}`;
|
|
1087
|
-
}
|
|
1088
|
-
} else if (btn.dataset.action === 'send-back') {
|
|
1089
|
-
// Review gate (spec 009): the notes go back into the same session via
|
|
1090
|
-
// "Continue" — the run leaves `review`, works, and gates again.
|
|
1091
|
-
const notes = $('#review-notes')?.value.trim();
|
|
1092
|
-
if (!notes) {
|
|
1093
|
-
alertBar('Write what to change first.');
|
|
1094
|
-
$('#review-notes')?.focus();
|
|
1095
|
-
return;
|
|
1096
|
-
}
|
|
1097
|
-
btn.disabled = true;
|
|
1098
|
-
const res = await fetch(`/api/runs/${id}/continue`, {
|
|
1099
|
-
method: 'POST',
|
|
1100
|
-
headers: { 'content-type': 'application/json' },
|
|
1101
|
-
body: JSON.stringify({ text: `Review feedback:\n${notes}` }),
|
|
1102
|
-
});
|
|
1103
|
-
if (!res.ok) {
|
|
1104
|
-
const data = await res.json().catch(() => ({}));
|
|
1105
|
-
alertBar(data.error ?? 'cannot send back');
|
|
1106
|
-
btn.disabled = false;
|
|
1107
|
-
}
|
|
1108
|
-
// on success the SSE run update flips the status and hides the panel
|
|
1109
|
-
} else if (btn.dataset.action === 'draft-pr') {
|
|
1110
|
-
btn.disabled = true;
|
|
1111
|
-
const res = await fetch(`/api/runs/${id}/pr`, { method: 'POST' });
|
|
1112
|
-
const data = await res.json().catch(() => ({}));
|
|
1113
|
-
if (res.ok) {
|
|
1114
|
-
alertBar(`Draft PR created — ${data.url}`);
|
|
1115
|
-
} else {
|
|
1116
|
-
alertBar(data.error ?? 'PR creation failed');
|
|
1117
|
-
const manual = $('#review-manual');
|
|
1118
|
-
if (manual && data.manual) {
|
|
1119
|
-
manual.hidden = false;
|
|
1120
|
-
manual.textContent = `manual path: ${data.manual}`;
|
|
1121
|
-
}
|
|
1122
|
-
btn.disabled = false;
|
|
1123
|
-
}
|
|
1124
|
-
} else if (btn.dataset.action === 'notes') {
|
|
1125
|
-
const panel = $('#notes-panel');
|
|
1126
|
-
if (!panel) return;
|
|
1127
|
-
if (!panel.hidden) {
|
|
1128
|
-
panel.hidden = true;
|
|
1129
|
-
return;
|
|
1130
|
-
}
|
|
1131
|
-
panel.hidden = false;
|
|
1132
|
-
$('#notes-md').textContent = 'Loading…';
|
|
1133
|
-
try {
|
|
1134
|
-
const res = await fetch(`/api/runs/${id}/handoff`);
|
|
1135
|
-
const text = await res.text();
|
|
1136
|
-
$('#notes-md').innerHTML = text.trim()
|
|
1137
|
-
? renderMarkdown(text)
|
|
1138
|
-
: '<p class="dim">(no notes yet — the handoff file is seeded when the task starts)</p>';
|
|
1139
|
-
} catch (err) {
|
|
1140
|
-
$('#notes-md').textContent = `✗ ${err.message}`;
|
|
1141
|
-
}
|
|
1142
|
-
} else if (btn.dataset.action === 'remove-worktree') {
|
|
1143
|
-
const res = await fetch(`/api/runs/${id}/remove-worktree`, { method: 'POST' });
|
|
1144
|
-
if (!res.ok) {
|
|
1145
|
-
const data = await res.json().catch(() => ({}));
|
|
1146
|
-
alertBar(data.error ?? 'cannot remove worktree');
|
|
1147
|
-
} else {
|
|
1148
|
-
alertBar('Worktree removed.');
|
|
1149
|
-
}
|
|
1150
|
-
} else if (btn.dataset.action === 'jump-bottom') {
|
|
1151
|
-
state.autoScroll = true;
|
|
1152
|
-
const log = $('#log');
|
|
1153
|
-
if (log) log.scrollTop = log.scrollHeight;
|
|
1154
|
-
btn.hidden = true;
|
|
1155
|
-
}
|
|
1156
|
-
});
|
|
1157
|
-
|
|
1158
|
-
// Quick replies for agent questions (janitor-style): Alt+A approve, Alt+C continue.
|
|
1159
|
-
document.addEventListener('keydown', (e) => {
|
|
1160
|
-
if (!e.altKey || !state.selectedId) return;
|
|
1161
|
-
if (e.code === 'KeyA') void sendMessage(state.selectedId, 'Yes, approved.', []);
|
|
1162
|
-
if (e.code === 'KeyC') void sendMessage(state.selectedId, 'Continue.', []);
|
|
1163
|
-
});
|
|
1164
|
-
}
|
|
1165
|
-
|
|
1166
|
-
// ---- chain-from-prompt (spec 008) --------------------------------------------
|
|
1167
|
-
|
|
1168
|
-
function bindPlanPanel() {
|
|
1169
|
-
$('#plan-btn').addEventListener('click', () => void planTask());
|
|
1170
|
-
|
|
1171
|
-
const panel = $('#plan-panel');
|
|
1172
|
-
panel.addEventListener('click', (e) => {
|
|
1173
|
-
if (!state.plan) return;
|
|
1174
|
-
const rm = e.target.closest('[data-plan-remove]');
|
|
1175
|
-
if (rm) {
|
|
1176
|
-
state.plan.steps.splice(Number(rm.dataset.planRemove), 1);
|
|
1177
|
-
renderPlan();
|
|
1178
|
-
return;
|
|
1179
|
-
}
|
|
1180
|
-
const btn = e.target.closest('button[data-plan-action]');
|
|
1181
|
-
if (!btn) return;
|
|
1182
|
-
if (btn.dataset.planAction === 'discard') discardPlan();
|
|
1183
|
-
else if (btn.dataset.planAction === 'start') void startPlannedRun(btn);
|
|
1184
|
-
else if (btn.dataset.planAction === 'save') void savePlanAsChain(btn);
|
|
1185
|
-
});
|
|
1186
|
-
|
|
1187
|
-
// Reorder by drag (HTML5 draggable — no libraries).
|
|
1188
|
-
panel.addEventListener('dragstart', (e) => {
|
|
1189
|
-
const step = e.target.closest('.plan-step');
|
|
1190
|
-
if (!step) return;
|
|
1191
|
-
state.planDragIdx = Number(step.dataset.idx);
|
|
1192
|
-
e.dataTransfer.effectAllowed = 'move';
|
|
1193
|
-
});
|
|
1194
|
-
panel.addEventListener('dragover', (e) => {
|
|
1195
|
-
const step = e.target.closest('.plan-step');
|
|
1196
|
-
if (!step || state.planDragIdx === null) return;
|
|
1197
|
-
e.preventDefault();
|
|
1198
|
-
e.dataTransfer.dropEffect = 'move';
|
|
1199
|
-
step.classList.add('drag-over');
|
|
1200
|
-
});
|
|
1201
|
-
panel.addEventListener('dragleave', (e) => {
|
|
1202
|
-
e.target.closest('.plan-step')?.classList.remove('drag-over');
|
|
1203
|
-
});
|
|
1204
|
-
panel.addEventListener('drop', (e) => {
|
|
1205
|
-
const step = e.target.closest('.plan-step');
|
|
1206
|
-
if (!step || state.planDragIdx === null || !state.plan) return;
|
|
1207
|
-
e.preventDefault();
|
|
1208
|
-
const from = state.planDragIdx;
|
|
1209
|
-
const to = Number(step.dataset.idx);
|
|
1210
|
-
state.planDragIdx = null;
|
|
1211
|
-
if (from === to) return;
|
|
1212
|
-
const [moved] = state.plan.steps.splice(from, 1);
|
|
1213
|
-
state.plan.steps.splice(to, 0, moved);
|
|
1214
|
-
renderPlan();
|
|
1215
|
-
});
|
|
1216
|
-
panel.addEventListener('dragend', () => {
|
|
1217
|
-
state.planDragIdx = null;
|
|
1218
|
-
for (const el of panel.querySelectorAll('.drag-over')) el.classList.remove('drag-over');
|
|
1219
|
-
});
|
|
1220
|
-
}
|
|
1221
|
-
|
|
1222
|
-
async function planTask() {
|
|
1223
|
-
const form = $('#new-task');
|
|
1224
|
-
const task = form.task.value.trim();
|
|
1225
|
-
const errorBox = $('#form-error');
|
|
1226
|
-
errorBox.hidden = true;
|
|
1227
|
-
if (!task) {
|
|
1228
|
-
form.task.focus();
|
|
1229
|
-
return;
|
|
1230
|
-
}
|
|
1231
|
-
const btn = $('#plan-btn');
|
|
1232
|
-
const original = btn.innerHTML; // keep the star icon — no emoji swap
|
|
1233
|
-
btn.disabled = true;
|
|
1234
|
-
btn.classList.add('busy');
|
|
1235
|
-
btn.innerHTML =
|
|
1236
|
-
'<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"><path d="M12 3l2 5.5L19.5 10 14 12l-2 5.5L10 12l-5.5-2L10 8.5 12 3z"/></svg>Planning…';
|
|
1237
|
-
try {
|
|
1238
|
-
const res = await fetch('/api/plan', {
|
|
1239
|
-
method: 'POST',
|
|
1240
|
-
headers: { 'content-type': 'application/json' },
|
|
1241
|
-
body: JSON.stringify({ task }),
|
|
1242
|
-
});
|
|
1243
|
-
const data = await res.json();
|
|
1244
|
-
if (!res.ok) throw new Error(data.error ?? `HTTP ${res.status}`);
|
|
1245
|
-
state.plan = { task, steps: data.steps, rationale: data.rationale, fallback: data.fallback };
|
|
1246
|
-
renderPlan();
|
|
1247
|
-
} catch (err) {
|
|
1248
|
-
errorBox.textContent = err.message;
|
|
1249
|
-
errorBox.hidden = false;
|
|
1250
|
-
} finally {
|
|
1251
|
-
btn.disabled = false;
|
|
1252
|
-
btn.classList.remove('busy');
|
|
1253
|
-
btn.innerHTML = original;
|
|
1254
|
-
}
|
|
1255
|
-
}
|
|
1256
|
-
|
|
1257
|
-
function oneLine(s, max = 70) {
|
|
1258
|
-
const first = String(s ?? '').split('\n')[0] ?? '';
|
|
1259
|
-
return first.length > max ? `${first.slice(0, max - 1)}…` : first;
|
|
1260
|
-
}
|
|
1261
|
-
|
|
1262
|
-
/* The proposed chain renders as an overlay over the main window (feedback
|
|
1263
|
-
2026-07-11: the sidebar was too cramped — step names and prompts were
|
|
1264
|
-
truncated to uselessness). Same bindings as before: #plan-panel, .plan-step,
|
|
1265
|
-
data-plan-* — only the placement and roominess changed. */
|
|
1266
|
-
function renderPlan() {
|
|
1267
|
-
const overlay = $('#plan-overlay');
|
|
1268
|
-
const panel = $('#plan-panel');
|
|
1269
|
-
if (!state.plan) {
|
|
1270
|
-
overlay.hidden = true;
|
|
1271
|
-
panel.innerHTML = '';
|
|
1272
|
-
return;
|
|
1273
|
-
}
|
|
1274
|
-
const { task, steps, rationale, fallback } = state.plan;
|
|
1275
|
-
overlay.hidden = false;
|
|
1276
|
-
panel.innerHTML = `
|
|
1277
|
-
<div class="plan-head">
|
|
1278
|
-
<div style="min-width:0">
|
|
1279
|
-
<div class="panel-label" style="margin-bottom:5px">Proposed chain</div>
|
|
1280
|
-
<div class="plan-task" title="${esc(task)}">${esc(oneLine(task, 120))}</div>
|
|
1281
|
-
</div>
|
|
1282
|
-
<button type="button" class="plan-close" data-plan-action="discard" title="Discard the plan">×</button>
|
|
1283
|
-
</div>
|
|
1284
|
-
${fallback ? '<div class="plan-note">planner unavailable — single-step plan</div>' : ''}
|
|
1285
|
-
${rationale ? `<div class="plan-rationale">${esc(rationale)}</div>` : ''}
|
|
1286
|
-
<div class="plan-steps">
|
|
1287
|
-
${steps
|
|
1288
|
-
.map(
|
|
1289
|
-
(s, i) => `
|
|
1290
|
-
<div class="plan-step" draggable="true" data-idx="${i}">
|
|
1291
|
-
<span class="grip" title="Drag to reorder">≡</span>
|
|
1292
|
-
<span class="num">${String(i + 1).padStart(2, '0')}</span>
|
|
1293
|
-
<div class="plan-step-main">
|
|
1294
|
-
<div class="row1">
|
|
1295
|
-
<span class="name">${esc(s.name ?? s.id)}</span>
|
|
1296
|
-
${s.skill ? `<span class="plan-badge skill" title="skill">${esc(s.skill)}</span>` : ''}
|
|
1297
|
-
${s.command ? '<span class="plan-badge check">check</span>' : ''}
|
|
1298
|
-
</div>
|
|
1299
|
-
<div class="hint">${esc(s.command ?? s.prompt ?? '')}</div>
|
|
1300
|
-
</div>
|
|
1301
|
-
<button type="button" class="plan-remove" data-plan-remove="${i}" title="Remove step">✕</button>
|
|
1302
|
-
</div>`,
|
|
1303
|
-
)
|
|
1304
|
-
.join('') || '<div class="dim">(no steps left — discard and plan again)</div>'}
|
|
1305
|
-
</div>
|
|
1306
|
-
<div class="plan-actions">
|
|
1307
|
-
<button type="button" class="btn-dark" data-plan-action="start" ${steps.length ? '' : 'disabled'}>▶ Start</button>
|
|
1308
|
-
<button type="button" class="btn-ghost" data-plan-action="save" ${steps.length ? '' : 'disabled'}>Save as chain</button>
|
|
1309
|
-
<button type="button" class="btn-ghost" data-plan-action="discard">Discard</button>
|
|
1310
|
-
</div>`;
|
|
1311
|
-
}
|
|
1312
|
-
|
|
1313
|
-
function discardPlan() {
|
|
1314
|
-
state.plan = null;
|
|
1315
|
-
state.planDragIdx = null;
|
|
1316
|
-
renderPlan();
|
|
1317
|
-
}
|
|
1318
|
-
|
|
1319
|
-
async function startPlannedRun(btn) {
|
|
1320
|
-
const form = $('#new-task');
|
|
1321
|
-
const task = form.task.value.trim() || state.plan.task;
|
|
1322
|
-
btn.disabled = true;
|
|
1323
|
-
try {
|
|
1324
|
-
const res = await fetch('/api/runs', {
|
|
1325
|
-
method: 'POST',
|
|
1326
|
-
headers: { 'content-type': 'application/json' },
|
|
1327
|
-
body: JSON.stringify({
|
|
1328
|
-
task,
|
|
1329
|
-
steps: state.plan.steps,
|
|
1330
|
-
model: state.taskModel || undefined,
|
|
1331
|
-
variants: state.variants > 1 ? state.variants : undefined,
|
|
1332
|
-
images: state.taskImages.length
|
|
1333
|
-
? state.taskImages.map((i) => ({ mediaType: i.mediaType, data: i.data }))
|
|
1334
|
-
: undefined,
|
|
1335
|
-
}),
|
|
1336
|
-
});
|
|
1337
|
-
const data = await res.json();
|
|
1338
|
-
if (!res.ok) throw new Error(data.error ?? `HTTP ${res.status}`);
|
|
1339
|
-
form.task.value = '';
|
|
1340
|
-
state.taskImages = [];
|
|
1341
|
-
renderTaskThumbs();
|
|
1342
|
-
discardPlan();
|
|
1343
|
-
handleStarted(data);
|
|
1344
|
-
} catch (err) {
|
|
1345
|
-
alertBar(err.message);
|
|
1346
|
-
btn.disabled = false;
|
|
1347
|
-
}
|
|
1348
|
-
}
|
|
1349
|
-
|
|
1350
|
-
async function savePlanAsChain(btn) {
|
|
1351
|
-
const name = prompt('Chain name:');
|
|
1352
|
-
if (!name || !name.trim()) return;
|
|
1353
|
-
btn.disabled = true;
|
|
1354
|
-
try {
|
|
1355
|
-
const res = await fetch('/api/workflows', {
|
|
1356
|
-
method: 'POST',
|
|
1357
|
-
headers: { 'content-type': 'application/json' },
|
|
1358
|
-
body: JSON.stringify({ name: name.trim(), steps: state.plan.steps }),
|
|
1359
|
-
});
|
|
1360
|
-
const data = await res.json();
|
|
1361
|
-
if (!res.ok) throw new Error(data.error ?? `HTTP ${res.status}`);
|
|
1362
|
-
alertBar(`Saved — ${String(data.path).split('/').pop()}`);
|
|
1363
|
-
const workflowsRes = await getJson('/api/workflows');
|
|
1364
|
-
setWorkflowOptions(workflowsRes.workflows);
|
|
1365
|
-
} catch (err) {
|
|
1366
|
-
alertBar(err.message);
|
|
1367
|
-
} finally {
|
|
1368
|
-
btn.disabled = false;
|
|
1369
|
-
}
|
|
1370
|
-
}
|
|
1371
|
-
|
|
1372
|
-
// ---- live-session message bar ------------------------------------------------
|
|
1373
|
-
|
|
1374
|
-
function bindMessageBar(runId) {
|
|
1375
|
-
const form = $('#msg-form');
|
|
1376
|
-
const textarea = $('#msg-text');
|
|
1377
|
-
const fileInput = $('#msg-file');
|
|
1378
|
-
|
|
1379
|
-
form.addEventListener('submit', async (e) => {
|
|
1380
|
-
e.preventDefault();
|
|
1381
|
-
const text = textarea.value.trim();
|
|
1382
|
-
const images = state.pendingImages.map((i) => ({ mediaType: i.mediaType, data: i.data }));
|
|
1383
|
-
if (!text && images.length === 0) return;
|
|
1384
|
-
const ok = await sendMessage(runId, text, images);
|
|
1385
|
-
if (ok) {
|
|
1386
|
-
textarea.value = '';
|
|
1387
|
-
state.pendingImages = [];
|
|
1388
|
-
renderThumbs();
|
|
1389
|
-
}
|
|
1390
|
-
});
|
|
1391
|
-
|
|
1392
|
-
textarea.addEventListener('keydown', (e) => {
|
|
1393
|
-
if (e.key === 'Enter' && !e.shiftKey) {
|
|
1394
|
-
e.preventDefault();
|
|
1395
|
-
form.requestSubmit();
|
|
1396
|
-
}
|
|
1397
|
-
});
|
|
1398
|
-
|
|
1399
|
-
textarea.addEventListener('paste', (e) => {
|
|
1400
|
-
const items = [...(e.clipboardData?.items ?? [])].filter((i) => i.type.startsWith('image/'));
|
|
1401
|
-
if (!items.length) return;
|
|
1402
|
-
e.preventDefault();
|
|
1403
|
-
for (const item of items) {
|
|
1404
|
-
const file = item.getAsFile();
|
|
1405
|
-
if (file) void addImage(file);
|
|
1406
|
-
}
|
|
1407
|
-
});
|
|
1408
|
-
|
|
1409
|
-
$('#msg-attach').addEventListener('click', () => fileInput.click());
|
|
1410
|
-
fileInput.addEventListener('change', () => {
|
|
1411
|
-
for (const file of fileInput.files ?? []) void addImage(file);
|
|
1412
|
-
fileInput.value = '';
|
|
1413
|
-
});
|
|
1414
|
-
|
|
1415
|
-
$('#msg-thumbs').addEventListener('click', (e) => {
|
|
1416
|
-
const thumb = e.target.closest('[data-idx]');
|
|
1417
|
-
if (!thumb) return;
|
|
1418
|
-
state.pendingImages.splice(Number(thumb.dataset.idx), 1);
|
|
1419
|
-
renderThumbs();
|
|
1420
|
-
});
|
|
1421
|
-
}
|
|
1422
|
-
|
|
1423
|
-
/* File → {mediaType, data, preview}, or null when oversized. Shared by the
|
|
1424
|
-
live-session bar and the new-task form. */
|
|
1425
|
-
async function readImageFile(file) {
|
|
1426
|
-
if (file.size > 5 * 1024 * 1024) {
|
|
1427
|
-
alertBar('Image too large (max 5 MB)');
|
|
1428
|
-
return null;
|
|
1429
|
-
}
|
|
1430
|
-
const buf = await file.arrayBuffer();
|
|
1431
|
-
let binary = '';
|
|
1432
|
-
const bytes = new Uint8Array(buf);
|
|
1433
|
-
for (let i = 0; i < bytes.length; i += 0x8000) {
|
|
1434
|
-
binary += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
|
|
1435
|
-
}
|
|
1436
|
-
const data = btoa(binary);
|
|
1437
|
-
return { mediaType: file.type, data, preview: `data:${file.type};base64,${data}` };
|
|
1438
|
-
}
|
|
1439
|
-
|
|
1440
|
-
async function addImage(file) {
|
|
1441
|
-
if (state.pendingImages.length >= 4) return;
|
|
1442
|
-
const img = await readImageFile(file);
|
|
1443
|
-
if (!img) return;
|
|
1444
|
-
state.pendingImages.push(img);
|
|
1445
|
-
renderThumbs();
|
|
1446
|
-
}
|
|
1447
|
-
|
|
1448
|
-
function renderTaskThumbs() {
|
|
1449
|
-
const box = $('#task-thumbs');
|
|
1450
|
-
if (!box) return;
|
|
1451
|
-
box.hidden = state.taskImages.length === 0;
|
|
1452
|
-
box.innerHTML = state.taskImages
|
|
1453
|
-
.map((img, idx) => `<span class="thumb" data-idx="${idx}" title="Click to remove"><img src="${img.preview}"></span>`)
|
|
1454
|
-
.join('');
|
|
1455
|
-
}
|
|
1456
|
-
|
|
1457
|
-
function renderThumbs() {
|
|
1458
|
-
const box = $('#msg-thumbs');
|
|
1459
|
-
if (!box) return;
|
|
1460
|
-
box.hidden = state.pendingImages.length === 0;
|
|
1461
|
-
box.innerHTML = state.pendingImages
|
|
1462
|
-
.map((img, idx) => `<span class="thumb" data-idx="${idx}" title="Click to remove"><img src="${img.preview}"></span>`)
|
|
1463
|
-
.join('');
|
|
1464
|
-
}
|
|
1465
|
-
|
|
1466
|
-
async function sendMessage(runId, text, images) {
|
|
1467
|
-
try {
|
|
1468
|
-
const res = await fetch(`/api/runs/${runId}/messages`, {
|
|
1469
|
-
method: 'POST',
|
|
1470
|
-
headers: { 'content-type': 'application/json' },
|
|
1471
|
-
body: JSON.stringify({ text, images }),
|
|
1472
|
-
});
|
|
1473
|
-
if (res.status === 409) {
|
|
1474
|
-
alertBar('Session closed — the agent is no longer listening.');
|
|
1475
|
-
return false;
|
|
1476
|
-
}
|
|
1477
|
-
if (!res.ok) {
|
|
1478
|
-
const data = await res.json().catch(() => ({}));
|
|
1479
|
-
alertBar(data.error ?? `HTTP ${res.status}`);
|
|
1480
|
-
return false;
|
|
1481
|
-
}
|
|
1482
|
-
return true;
|
|
1483
|
-
} catch (err) {
|
|
1484
|
-
alertBar(err.message);
|
|
1485
|
-
return false;
|
|
1486
|
-
}
|
|
1487
|
-
}
|
|
1488
|
-
|
|
1489
|
-
function openLightbox(url, name) {
|
|
1490
|
-
closeLightbox();
|
|
1491
|
-
const el = document.createElement('div');
|
|
1492
|
-
el.id = 'lightbox';
|
|
1493
|
-
el.innerHTML = `
|
|
1494
|
-
<div class="lb-inner">
|
|
1495
|
-
<img src="${esc(url)}" alt="${esc(name ?? '')}">
|
|
1496
|
-
<div class="lb-name">${esc(name ?? '')} — click anywhere to close</div>
|
|
1497
|
-
</div>`;
|
|
1498
|
-
el.addEventListener('click', closeLightbox);
|
|
1499
|
-
document.body.appendChild(el);
|
|
1500
|
-
}
|
|
1501
|
-
|
|
1502
|
-
function closeLightbox() {
|
|
1503
|
-
$('#lightbox')?.remove();
|
|
1504
|
-
}
|
|
1505
|
-
|
|
1506
|
-
function alertBar(message) {
|
|
1507
|
-
const existing = $('#toast');
|
|
1508
|
-
if (existing) existing.remove();
|
|
1509
|
-
const el = document.createElement('div');
|
|
1510
|
-
el.id = 'toast';
|
|
1511
|
-
el.textContent = message;
|
|
1512
|
-
document.body.appendChild(el);
|
|
1513
|
-
setTimeout(() => el.remove(), 4000);
|
|
1514
|
-
}
|
|
1515
|
-
|
|
1516
|
-
// ---- run list --------------------------------------------------------------
|
|
1517
|
-
|
|
1518
|
-
/* POST /api/runs answers with one run (×1) or {runs: [...]} (variants). */
|
|
1519
|
-
function handleStarted(data) {
|
|
1520
|
-
const runs = Array.isArray(data.runs) ? data.runs : [data];
|
|
1521
|
-
for (const run of runs) state.runs.set(run.id, run);
|
|
1522
|
-
const first = runs[0];
|
|
1523
|
-
if (first?.groupId) state.expandedGroups.add(first.groupId);
|
|
1524
|
-
renderRunList();
|
|
1525
|
-
if (first) selectRun(first.id);
|
|
1526
|
-
}
|
|
1527
|
-
|
|
1528
|
-
function sortedRuns() {
|
|
1529
|
-
// Needs-you-first: waiting/review, then running/queued, then by recency.
|
|
1530
|
-
return [...state.runs.values()]
|
|
1531
|
-
.filter((r) => (state.listView === 'archived' ? r.archived : !r.archived))
|
|
1532
|
-
.sort((a, b) => {
|
|
1533
|
-
const pa = STATUS_ORDER[a.status] ?? 9;
|
|
1534
|
-
const pb = STATUS_ORDER[b.status] ?? 9;
|
|
1535
|
-
if (pa !== pb) return pa - pb;
|
|
1536
|
-
return b.createdAt.localeCompare(a.createdAt);
|
|
1537
|
-
});
|
|
1538
|
-
}
|
|
1539
|
-
|
|
1540
|
-
/* Which sidebar group a run (or a variant group's best member) belongs to. */
|
|
1541
|
-
function bucketOf(status) {
|
|
1542
|
-
if (state.listView === 'archived') return 'Archived';
|
|
1543
|
-
if (status === 'waiting' || status === 'review') return 'Needs you';
|
|
1544
|
-
if (status === 'running' || status === 'queued') return 'Working';
|
|
1545
|
-
return 'Recent';
|
|
1546
|
-
}
|
|
1547
|
-
|
|
1548
|
-
function renderRunList() {
|
|
1549
|
-
const all = [...state.runs.values()];
|
|
1550
|
-
const activeCount = all.filter((r) => !r.archived).length;
|
|
1551
|
-
const archivedCount = all.length - activeCount;
|
|
1552
|
-
const finishedCount = all.filter(
|
|
1553
|
-
(r) => !r.archived && ['done', 'failed', 'cancelled'].includes(r.status),
|
|
1554
|
-
).length;
|
|
1555
|
-
const waitingCount = all.filter(
|
|
1556
|
-
(r) => !r.archived && (r.status === 'waiting' || r.status === 'review'),
|
|
1557
|
-
).length;
|
|
1558
|
-
|
|
1559
|
-
$('#list-tabs').innerHTML = `
|
|
1560
|
-
<button data-list="active" class="${state.listView === 'active' ? 'active' : ''}">
|
|
1561
|
-
Active${activeCount ? ` ${activeCount}` : ''}${waitingCount ? ' <span class="dot"></span>' : ''}
|
|
1562
|
-
</button>
|
|
1563
|
-
<button data-list="archived" class="${state.listView === 'archived' ? 'active' : ''}">
|
|
1564
|
-
Archived${archivedCount ? ` ${archivedCount}` : ''}
|
|
1565
|
-
</button>
|
|
1566
|
-
${state.listView === 'active' && finishedCount ? `<button data-list="archive-finished" title="Archive all finished runs"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1.5px;margin-right:4px"><path d="M4 5h16v4H4zM6 9v10h12V9M9 13h6"/></svg>${finishedCount}</button>` : ''}`;
|
|
1567
|
-
|
|
1568
|
-
// Variant groups (spec 010): runs sharing a groupId collapse into one
|
|
1569
|
-
// group tile at the position of their best-ranked member; click expands.
|
|
1570
|
-
const runs = sortedRuns();
|
|
1571
|
-
const seenGroups = new Set();
|
|
1572
|
-
const buckets = new Map(); // label -> html[]
|
|
1573
|
-
const push = (label, html) => {
|
|
1574
|
-
if (!buckets.has(label)) buckets.set(label, []);
|
|
1575
|
-
buckets.get(label).push(html);
|
|
1576
|
-
};
|
|
1577
|
-
for (const r of runs) {
|
|
1578
|
-
if (r.groupId) {
|
|
1579
|
-
if (seenGroups.has(r.groupId)) continue;
|
|
1580
|
-
seenGroups.add(r.groupId);
|
|
1581
|
-
const members = runs
|
|
1582
|
-
.filter((m) => m.groupId === r.groupId)
|
|
1583
|
-
.sort((a, b) => (a.variant ?? '').localeCompare(b.variant ?? ''));
|
|
1584
|
-
if (members.length > 1) {
|
|
1585
|
-
push(bucketOf(r.status), groupTileHtml(r.groupId, members));
|
|
1586
|
-
continue;
|
|
1587
|
-
}
|
|
1588
|
-
// A lone survivor (the picked winner) renders like any other run.
|
|
1589
|
-
}
|
|
1590
|
-
push(bucketOf(r.status), runItemHtml(r));
|
|
1591
|
-
}
|
|
1592
|
-
const order = ['Needs you', 'Working', 'Recent', 'Archived'];
|
|
1593
|
-
$('#run-list').innerHTML =
|
|
1594
|
-
order
|
|
1595
|
-
.filter((label) => buckets.has(label))
|
|
1596
|
-
.map((label) => `<div class="group-label">${label}</div>${buckets.get(label).join('')}`)
|
|
1597
|
-
.join('') ||
|
|
1598
|
-
`<div class="dim" style="padding:14px 12px;font-size:12px">${
|
|
1599
|
-
state.listView === 'archived' ? 'Nothing archived yet.' : 'No runs yet — describe a task above.'
|
|
1600
|
-
}</div>`;
|
|
1601
|
-
}
|
|
1602
|
-
|
|
1603
|
-
function runItemHtml(r, variantRow = false) {
|
|
1604
|
-
const timeText =
|
|
1605
|
-
r.status === 'queued' ? `#${queuePosition(r) ?? '·'}` : shortAgo(r.finishedAt ?? r.createdAt);
|
|
1606
|
-
return `
|
|
1607
|
-
<div class="run-item ${variantRow ? 'variant-row' : ''} ${r.id === state.selectedId ? 'selected' : ''}" data-id="${r.id}" title="${esc(r.task)}">
|
|
1608
|
-
<span class="dot ${esc(r.status)}"></span>
|
|
1609
|
-
${r.variant ? `<span class="time">${esc(r.variant)}</span>` : ''}
|
|
1610
|
-
<span class="title">${esc(variantRow ? groupTitle(r) : r.title)}</span>
|
|
1611
|
-
${r.pullRequestUrl ? `<a class="time" href="${esc(r.pullRequestUrl)}" target="_blank" rel="noopener" title="open the PR">PR↗</a>` : ''}
|
|
1612
|
-
<span class="time">${esc(timeText)}</span>
|
|
1613
|
-
</div>`;
|
|
1614
|
-
}
|
|
1615
|
-
|
|
1616
|
-
function groupTileHtml(groupId, members) {
|
|
1617
|
-
const first = members[0];
|
|
1618
|
-
const expanded = state.expandedGroups.has(groupId);
|
|
1619
|
-
const allTerminal = members.every((m) => TERMINAL_STATUSES.includes(m.status));
|
|
1620
|
-
return `
|
|
1621
|
-
<div class="run-item group-item ${state.selectedGroupId === groupId ? 'selected' : ''}" data-group="${esc(groupId)}" title="${esc(first.task)}">
|
|
1622
|
-
<span class="time">${expanded ? '▾' : '▸'}</span>
|
|
1623
|
-
<span class="title">${esc(groupTitle(first))}</span>
|
|
1624
|
-
<span class="dots">${members
|
|
1625
|
-
.map((m) => `<span class="dot ${esc(m.status)}" title="${esc(`${m.variant ?? '?'} · ${m.status}`)}"></span>`)
|
|
1626
|
-
.join('')}</span>
|
|
1627
|
-
${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>`}
|
|
1628
|
-
</div>
|
|
1629
|
-
${expanded ? members.map((m) => runItemHtml(m, true)).join('') : ''}`;
|
|
1630
|
-
}
|
|
1631
|
-
|
|
1632
|
-
// ---- run detail ------------------------------------------------------------
|
|
1633
|
-
|
|
1634
|
-
function selectRun(id) {
|
|
1635
|
-
const run = state.runs.get(id);
|
|
1636
|
-
if (!run) return;
|
|
1637
|
-
state.selectedId = id;
|
|
1638
|
-
state.selectedGroupId = null;
|
|
1639
|
-
state.lastSeq = 0;
|
|
1640
|
-
state.autoScroll = true;
|
|
1641
|
-
if (state.runEs) {
|
|
1642
|
-
state.runEs.close();
|
|
1643
|
-
state.runEs = null;
|
|
1644
|
-
}
|
|
1645
|
-
renderRunList();
|
|
1646
|
-
renderDetailShell(run);
|
|
1647
|
-
|
|
1648
|
-
const es = new EventSource(`/api/runs/${id}/events`);
|
|
1649
|
-
state.runEs = es;
|
|
1650
|
-
es.addEventListener('run-event', (e) => {
|
|
1651
|
-
const evt = JSON.parse(e.data);
|
|
1652
|
-
if (evt.seq <= state.lastSeq) return; // reconnect replay dedup
|
|
1653
|
-
state.lastSeq = evt.seq;
|
|
1654
|
-
appendLog(evt);
|
|
1655
|
-
});
|
|
1656
|
-
es.addEventListener('run', (e) => updateDetail(JSON.parse(e.data)));
|
|
1657
|
-
}
|
|
1658
|
-
|
|
1659
|
-
function renderDetailShell(run) {
|
|
1660
|
-
state.pendingImages = [];
|
|
1661
|
-
$('#detail').innerHTML = `
|
|
1662
|
-
<div class="detail-head">
|
|
1663
|
-
<div class="meta-line" id="d-meta"></div>
|
|
1664
|
-
<div class="title-row">
|
|
1665
|
-
<h1 id="d-title"></h1>
|
|
1666
|
-
<span id="d-badge"></span>
|
|
1667
|
-
</div>
|
|
1668
|
-
<div class="head-bar">
|
|
1669
|
-
<span class="steps-line" id="d-steps"></span>
|
|
1670
|
-
<div class="spacer"></div>
|
|
1671
|
-
<span id="d-actions" style="display:flex;align-items:center;gap:4px;flex-wrap:wrap"></span>
|
|
1672
|
-
</div>
|
|
1673
|
-
<div id="d-error" class="run-error" hidden></div>
|
|
1674
|
-
<div id="d-resume" class="resume-hint" hidden></div>
|
|
1675
|
-
</div>
|
|
1676
|
-
<div id="review-panel" class="detail-panel" hidden></div>
|
|
1677
|
-
<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>
|
|
1678
|
-
<div id="notes-panel" class="detail-panel" hidden><div class="inner"><div class="panel-label">Handoff notes</div><div id="notes-md" class="md"></div></div></div>
|
|
1679
|
-
<div class="log-wrap">
|
|
1680
|
-
<div id="log"><div class="log-inner" id="log-inner"></div></div>
|
|
1681
|
-
<button id="jump-bottom" data-action="jump-bottom" hidden>↓ jump to bottom</button>
|
|
1682
|
-
</div>
|
|
1683
|
-
<div class="composer-wrap">
|
|
1684
|
-
<div id="waiting-note" hidden><span class="pulse-dot"></span>The agent is paused, waiting for your reply</div>
|
|
1685
|
-
<form id="msg-form">
|
|
1686
|
-
<div id="msg-thumbs" hidden></div>
|
|
1687
|
-
<div class="msg-row">
|
|
1688
|
-
<textarea id="msg-text" rows="1" placeholder="Message the agent…"></textarea>
|
|
1689
|
-
<button type="button" id="msg-attach" title="Attach an image (or paste a screenshot)">
|
|
1690
|
-
<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>
|
|
1691
|
-
</button>
|
|
1692
|
-
<button type="submit" id="msg-send" title="Send (Enter)">
|
|
1693
|
-
<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>
|
|
1694
|
-
</button>
|
|
1695
|
-
</div>
|
|
1696
|
-
<input type="file" id="msg-file" accept="image/*" multiple hidden>
|
|
1697
|
-
</form>
|
|
1698
|
-
</div>`;
|
|
1699
|
-
bindMessageBar(run.id);
|
|
1700
|
-
updateDetail(run);
|
|
1701
|
-
$('#log').addEventListener('scroll', () => {
|
|
1702
|
-
const log = $('#log');
|
|
1703
|
-
const nearBottom = log.scrollHeight - log.scrollTop - log.clientHeight < 80;
|
|
1704
|
-
state.autoScroll = nearBottom;
|
|
1705
|
-
$('#jump-bottom').hidden = nearBottom;
|
|
1706
|
-
});
|
|
1707
|
-
}
|
|
1708
|
-
|
|
1709
|
-
const STEP_MARK = { done: '✓', running: '●', waiting: '●', review: '●', failed: '✗' };
|
|
1710
|
-
|
|
1711
|
-
function updateDetail(run) {
|
|
1712
|
-
state.runs.set(run.id, run);
|
|
1713
|
-
const active = run.status === 'running' || run.status === 'queued' || run.status === 'waiting';
|
|
1714
|
-
const lastSession = [...run.steps].reverse().find((s) => s.sessionId)?.sessionId;
|
|
1715
|
-
const resumeCmd = run.worktreePath
|
|
1716
|
-
? `cd ${run.worktreePath} && claude --resume ${lastSession}`
|
|
1717
|
-
: `claude --resume ${lastSession}`;
|
|
1718
|
-
|
|
1719
|
-
const metaParts = [
|
|
1720
|
-
esc(run.workflow),
|
|
1721
|
-
esc(fmtTokens(run.tokensUsed)),
|
|
1722
|
-
run.costUsd ? esc(fmtCost(run.costUsd)) : null,
|
|
1723
|
-
`started ${esc(timeAgo(run.startedAt ?? run.createdAt))}`,
|
|
1724
|
-
run.finishedAt ? `finished ${esc(timeAgo(run.finishedAt))}` : null,
|
|
1725
|
-
run.runner && run.runner !== 'claude' ? `runner ${esc(run.runner)}` : null,
|
|
1726
|
-
run.model ? `model ${esc(run.model)}` : null,
|
|
1727
|
-
run.branch ? esc(run.branch) : null,
|
|
1728
|
-
prLink(run) || null,
|
|
1729
|
-
].filter(Boolean);
|
|
1730
|
-
$('#d-meta').innerHTML = metaParts.map((p) => `<span>${p}</span>`).join('<span>·</span>');
|
|
1731
|
-
|
|
1732
|
-
$('#d-title').textContent = run.title;
|
|
1733
|
-
$('#d-title').title = run.task;
|
|
1734
|
-
$('#d-badge').innerHTML = statusPill(run);
|
|
1735
|
-
|
|
1736
|
-
$('#d-steps').innerHTML = run.steps
|
|
1737
|
-
.map((s) => {
|
|
1738
|
-
const mark = STEP_MARK[s.status] ?? '○';
|
|
1739
|
-
const label = `${mark} ${esc(s.name)}${s.iterations > 1 ? ` ×${s.iterations}` : ''}`;
|
|
1740
|
-
const tip = `${s.kind} · ${s.status} · ${fmtTokens(s.tokensUsed)}${s.error ? `\n${s.error}` : ''}${s.sessionId ? `\nsession: ${s.sessionId}` : ''}`;
|
|
1741
|
-
return `<span title="${esc(tip)}">${label}</span>`;
|
|
1742
|
-
})
|
|
1743
|
-
.join('<span style="opacity:.5"> → </span>');
|
|
1744
|
-
|
|
1745
|
-
$('#d-actions').innerHTML = `
|
|
1746
|
-
${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'}">${BI.check}Finish</button>` : ''}
|
|
1747
|
-
${!active && lastSession ? `<button class="btn-text" data-action="continue">${BI.play}Continue</button><button class="btn-text" data-action="open-cli" title="Take over the session in a real terminal">${BI.terminal}Terminal</button>` : ''}
|
|
1748
|
-
${run.worktreePath ? `<button class="btn-text" data-action="diff" title="What this task changed (worktree vs base)">${BI.diff}Diff</button>` : ''}
|
|
1749
|
-
<button class="btn-text" data-action="notes" title="Handoff notes — what the agent did and what's left">${BI.notes}Notes</button>
|
|
1750
|
-
${run.archived && run.worktreePath ? `<button class="btn-text" data-action="remove-worktree" title="Remove the task worktree and its branch">${BI.folder}Remove worktree</button>` : ''}
|
|
1751
|
-
${!active ? `<button class="btn-text" data-action="archive" title="${run.archived ? 'Unarchive' : 'Archive'}">${BI.archive}${run.archived ? 'Unarchive' : 'Archive'}</button>` : ''}
|
|
1752
|
-
${active
|
|
1753
|
-
? `<button class="btn-text danger" data-action="cancel">${BI.stop}Cancel</button>`
|
|
1754
|
-
: `<button class="btn-text danger" data-action="delete">${BI.trash}Delete</button>`}`;
|
|
1755
|
-
|
|
1756
|
-
const errBox = $('#d-error');
|
|
1757
|
-
errBox.hidden = !run.error;
|
|
1758
|
-
if (run.error) errBox.textContent = `✗ ${run.error}`;
|
|
1759
|
-
const resumeBox = $('#d-resume');
|
|
1760
|
-
const showResume = !active && lastSession;
|
|
1761
|
-
resumeBox.hidden = !showResume;
|
|
1762
|
-
if (showResume) resumeBox.textContent = `take over interactively: ${resumeCmd}`;
|
|
1763
|
-
|
|
1764
|
-
// Review gate (spec 009): the panel lives while the run rests at `review`;
|
|
1765
|
-
// it (re)loads the diff on each entry into review and clears on exit, so a
|
|
1766
|
-
// send-back round always comes back with a fresh diff.
|
|
1767
|
-
const reviewPanel = $('#review-panel');
|
|
1768
|
-
if (reviewPanel) {
|
|
1769
|
-
const inReview = run.status === 'review';
|
|
1770
|
-
reviewPanel.hidden = !inReview;
|
|
1771
|
-
if (inReview && !reviewPanel.dataset.loaded) {
|
|
1772
|
-
reviewPanel.dataset.loaded = '1';
|
|
1773
|
-
void renderReviewPanel(run.id);
|
|
1774
|
-
} else if (!inReview && reviewPanel.dataset.loaded) {
|
|
1775
|
-
delete reviewPanel.dataset.loaded;
|
|
1776
|
-
reviewPanel.innerHTML = '';
|
|
1777
|
-
}
|
|
1778
|
-
}
|
|
1779
|
-
|
|
1780
|
-
const msgForm = $('#msg-form');
|
|
1781
|
-
if (msgForm) {
|
|
1782
|
-
const sessionOpen = run.status === 'running' || run.status === 'waiting';
|
|
1783
|
-
msgForm.classList.toggle('disabled', !sessionOpen);
|
|
1784
|
-
$('#msg-text').disabled = !sessionOpen;
|
|
1785
|
-
$('#msg-send').disabled = !sessionOpen;
|
|
1786
|
-
$('#msg-attach').disabled = !sessionOpen;
|
|
1787
|
-
$('#msg-text').placeholder = sessionOpen
|
|
1788
|
-
? run.status === 'waiting'
|
|
1789
|
-
? 'Reply to the agent… (Enter to send, ⌘V pastes a screenshot)'
|
|
1790
|
-
: 'Message the agent… (Enter to send, ⌘V pastes a screenshot)'
|
|
1791
|
-
: 'Session closed — Continue to reopen.';
|
|
1792
|
-
$('#waiting-note').hidden = run.status !== 'waiting';
|
|
1793
|
-
}
|
|
1794
|
-
}
|
|
1795
|
-
|
|
1796
|
-
/* Review gate panel (spec 009): the full diff on top, then a notes field with
|
|
1797
|
-
exactly two buttons — "Send back" (feedback into the same session) and
|
|
1798
|
-
"Draft PR". The third exit, "✓ Finish" (accept without a PR), sits in the
|
|
1799
|
-
detail header. */
|
|
1800
|
-
async function renderReviewPanel(runId) {
|
|
1801
|
-
const panel = $('#review-panel');
|
|
1802
|
-
if (!panel) return;
|
|
1803
|
-
// A PR the agent already opened itself (skill-driven `gh pr create`, spotted
|
|
1804
|
-
// in the transcript) replaces the Draft PR button — a second click would
|
|
1805
|
-
// open a duplicate PR.
|
|
1806
|
-
const prUrl = state.runs.get(runId)?.pullRequestUrl;
|
|
1807
|
-
panel.innerHTML = `
|
|
1808
|
-
<div class="inner">
|
|
1809
|
-
<div class="panel-label">Changes ready for review</div>
|
|
1810
|
-
<div id="review-diff"><div class="dim">Loading diff…</div></div>
|
|
1811
|
-
<textarea id="review-notes" rows="2" placeholder="Notes for the agent — what should change?"></textarea>
|
|
1812
|
-
<div class="review-buttons">
|
|
1813
|
-
<button type="button" class="btn-ghost" data-action="send-back" title="Send the notes back into the agent's session">↩ Send back</button>
|
|
1814
|
-
${
|
|
1815
|
-
prUrl
|
|
1816
|
-
? `<a class="btn-dark" style="text-decoration:none" href="${esc(prUrl)}" target="_blank" rel="noopener" title="The agent already opened this PR">PR ↗ open on GitHub</a>`
|
|
1817
|
-
: '<button type="button" class="btn-dark" data-action="draft-pr" title="Push the branch and open a draft PR">Draft PR</button>'
|
|
1818
|
-
}
|
|
1819
|
-
</div>
|
|
1820
|
-
<div id="review-manual" class="dim mono" hidden></div>
|
|
1821
|
-
</div>`;
|
|
1822
|
-
try {
|
|
1823
|
-
const res = await fetch(`/api/runs/${runId}/diff`);
|
|
1824
|
-
const text = await res.text();
|
|
1825
|
-
const box = $('#review-diff');
|
|
1826
|
-
if (box) box.innerHTML = renderDiff(text);
|
|
1827
|
-
} catch (err) {
|
|
1828
|
-
const box = $('#review-diff');
|
|
1829
|
-
if (box) box.textContent = `✗ ${err.message}`;
|
|
1830
|
-
}
|
|
1831
|
-
}
|
|
1832
|
-
|
|
1833
|
-
// ---- variant compare view (spec 010) ----------------------------------------
|
|
1834
|
-
|
|
1835
|
-
/* "⚖ Compare": columns per variant — status, cost, `git diff --stat`, the
|
|
1836
|
-
first Progress-log lines — each with a "Pick this one" button; full diffs
|
|
1837
|
-
collapse under the columns (renderDiff from spec 009, reused). */
|
|
1838
|
-
async function selectGroup(groupId) {
|
|
1839
|
-
state.selectedGroupId = groupId;
|
|
1840
|
-
state.selectedId = null;
|
|
1841
|
-
if (state.runEs) {
|
|
1842
|
-
state.runEs.close();
|
|
1843
|
-
state.runEs = null;
|
|
1844
|
-
}
|
|
1845
|
-
showRunsView();
|
|
1846
|
-
renderRunList();
|
|
1847
|
-
const detail = $('#detail');
|
|
1848
|
-
detail.innerHTML = '<div class="empty">Loading variants…</div>';
|
|
1849
|
-
let data;
|
|
1850
|
-
try {
|
|
1851
|
-
data = await getJson(`/api/groups/${groupId}`);
|
|
1852
|
-
} catch (err) {
|
|
1853
|
-
detail.innerHTML = `<div class="empty">✗ ${esc(err.message)}</div>`;
|
|
1854
|
-
return;
|
|
1855
|
-
}
|
|
1856
|
-
if (state.selectedGroupId !== groupId) return; // user moved on meanwhile
|
|
1857
|
-
renderCompareView(data.runs);
|
|
1858
|
-
}
|
|
1859
|
-
|
|
1860
|
-
function renderCompareView(variants) {
|
|
1861
|
-
const title = variants.length ? groupTitle(variants[0]) : '';
|
|
1862
|
-
$('#detail').innerHTML = `
|
|
1863
|
-
<div class="compare-view"><div class="inner">
|
|
1864
|
-
<div class="compare-head">
|
|
1865
|
-
<h1 title="${esc(title)}">⚖ ${esc(title)}</h1>
|
|
1866
|
-
<span class="dim">${variants.length} variants — pick the diff you want to keep; the others are archived and their worktrees removed</span>
|
|
1867
|
-
</div>
|
|
1868
|
-
<div class="compare-cols">
|
|
1869
|
-
${variants
|
|
1870
|
-
.map(
|
|
1871
|
-
(v) => `
|
|
1872
|
-
<div class="compare-col">
|
|
1873
|
-
<div class="col-head"><span class="variant-letter">${esc(v.variant)}</span> ${statusPill(v)}</div>
|
|
1874
|
-
<div class="col-meta">${fmtTokens(v.tokensUsed)}${v.costUsd ? ` · ${fmtCost(v.costUsd)}` : ''}</div>
|
|
1875
|
-
<pre class="col-stat">${esc(v.diffStat || '(no changes)')}</pre>
|
|
1876
|
-
<pre class="col-handoff">${esc(v.handoffExcerpt || '(no progress notes)')}</pre>
|
|
1877
|
-
<button type="button" class="btn-dark" data-pick="${esc(v.id)}">✔ Pick this one</button>
|
|
1878
|
-
</div>`,
|
|
1879
|
-
)
|
|
1880
|
-
.join('')}
|
|
1881
|
-
</div>
|
|
1882
|
-
<div class="compare-diffs">
|
|
1883
|
-
${variants
|
|
1884
|
-
.map(
|
|
1885
|
-
(v) => `
|
|
1886
|
-
<details class="compare-diff">
|
|
1887
|
-
<summary>Variant ${esc(v.variant)} — full diff</summary>
|
|
1888
|
-
<div class="diff-body" data-group-diff="${esc(v.id)}"><div class="dim">Loading…</div></div>
|
|
1889
|
-
</details>`,
|
|
1890
|
-
)
|
|
1891
|
-
.join('')}
|
|
1892
|
-
</div>
|
|
1893
|
-
</div></div>`;
|
|
1894
|
-
// Full diffs (max 3) via the existing per-run endpoint.
|
|
1895
|
-
for (const v of variants) {
|
|
1896
|
-
void fetch(`/api/runs/${v.id}/diff`)
|
|
1897
|
-
.then((r) => r.text())
|
|
1898
|
-
.then((text) => {
|
|
1899
|
-
const box = document.querySelector(`[data-group-diff="${v.id}"]`);
|
|
1900
|
-
if (box) box.innerHTML = renderDiff(text);
|
|
1901
|
-
})
|
|
1902
|
-
.catch(() => undefined);
|
|
1903
|
-
}
|
|
1904
|
-
}
|
|
1905
|
-
|
|
1906
|
-
/* "Pick this one" → the winner rests at `review` (the spec-009 gate takes it
|
|
1907
|
-
from there); the losers are archived and their worktrees removed. */
|
|
1908
|
-
async function pickVariant(btn) {
|
|
1909
|
-
const groupId = state.selectedGroupId;
|
|
1910
|
-
if (!groupId) return;
|
|
1911
|
-
btn.disabled = true;
|
|
1912
|
-
try {
|
|
1913
|
-
const res = await fetch(`/api/groups/${groupId}/pick`, {
|
|
1914
|
-
method: 'POST',
|
|
1915
|
-
headers: { 'content-type': 'application/json' },
|
|
1916
|
-
body: JSON.stringify({ runId: btn.dataset.pick }),
|
|
1917
|
-
});
|
|
1918
|
-
const data = await res.json();
|
|
1919
|
-
if (!res.ok) throw new Error(data.error ?? `HTTP ${res.status}`);
|
|
1920
|
-
if (data.winner) state.runs.set(data.winner.id, data.winner);
|
|
1921
|
-
state.expandedGroups.delete(groupId);
|
|
1922
|
-
renderRunList();
|
|
1923
|
-
if (data.winner) selectRun(data.winner.id); // lands on the review gate
|
|
1924
|
-
} catch (err) {
|
|
1925
|
-
alertBar(err.message);
|
|
1926
|
-
btn.disabled = false;
|
|
1927
|
-
}
|
|
1928
|
-
}
|
|
1929
|
-
|
|
1930
|
-
// ---- transcript ------------------------------------------------------------
|
|
1931
|
-
|
|
1932
|
-
/* The most telling single argument of a tool call — a command, a path, a
|
|
1933
|
-
pattern — shown inside the tool chip. */
|
|
1934
|
-
/* Design language for tool chips: a human verb, not the raw tool name —
|
|
1935
|
-
"✓ Ran `npm test`", "✓ Accepted edits to `src/x.ts`". Unknown tools keep
|
|
1936
|
-
their name as the verb. */
|
|
1937
|
-
const TOOL_VERB = {
|
|
1938
|
-
Bash: 'Ran',
|
|
1939
|
-
Read: 'Read',
|
|
1940
|
-
Edit: 'Accepted edits to',
|
|
1941
|
-
MultiEdit: 'Accepted edits to',
|
|
1942
|
-
NotebookEdit: 'Accepted edits to',
|
|
1943
|
-
Write: 'Created',
|
|
1944
|
-
Grep: 'Searched',
|
|
1945
|
-
Glob: 'Searched',
|
|
1946
|
-
LS: 'Listed',
|
|
1947
|
-
WebFetch: 'Fetched',
|
|
1948
|
-
WebSearch: 'Searched the web for',
|
|
1949
|
-
TodoWrite: 'Updated the todo list',
|
|
1950
|
-
Task: 'Delegated',
|
|
1951
|
-
};
|
|
1952
|
-
|
|
1953
|
-
function toolVerb(tool) {
|
|
1954
|
-
return TOOL_VERB[tool] ?? tool;
|
|
1955
|
-
}
|
|
1956
|
-
|
|
1957
|
-
function toolArg(input) {
|
|
1958
|
-
if (input == null) return null;
|
|
1959
|
-
if (typeof input === 'string') return input.trim() || null;
|
|
1960
|
-
if (typeof input !== 'object') return String(input);
|
|
1961
|
-
for (const key of ['command', 'file_path', 'path', 'pattern', 'url', 'query', 'name', 'description', 'prompt']) {
|
|
1962
|
-
const v = input[key];
|
|
1963
|
-
if (typeof v === 'string' && v.trim()) return v.trim();
|
|
1964
|
-
}
|
|
1965
|
-
// No recognizable primary argument — the design never shows raw JSON blobs.
|
|
1966
|
-
return null;
|
|
1967
|
-
}
|
|
1968
|
-
|
|
1969
|
-
/* Claude-Code-style collapsing for tool streaks: consecutive tool calls /
|
|
1970
|
-
results stack in one group — the last few stay visible, older ones fold
|
|
1971
|
-
under a "\u25b8 N earlier tool calls" toggle. Any other event breaks the
|
|
1972
|
-
streak. Selecting another run clears the log, which disconnects the group —
|
|
1973
|
-
the isConnected check below then starts a fresh one. */
|
|
1974
|
-
const STREAK_TAIL = 3;
|
|
1975
|
-
let toolStreak = null; // { wrap, toggle, hiddenBox, tailBox }
|
|
1976
|
-
|
|
1977
|
-
function streakLabel(hiddenBox) {
|
|
1978
|
-
const n = hiddenBox.children.length;
|
|
1979
|
-
return `${hiddenBox.hidden ? '\u25b8' : '\u25be'} ${n} earlier tool call${n === 1 ? '' : 's'}`;
|
|
1980
|
-
}
|
|
1981
|
-
|
|
1982
|
-
function newToolStreak(inner) {
|
|
1983
|
-
const wrap = document.createElement('div');
|
|
1984
|
-
wrap.className = 'ev tool-streak';
|
|
1985
|
-
const toggle = document.createElement('button');
|
|
1986
|
-
toggle.type = 'button';
|
|
1987
|
-
toggle.className = 'streak-toggle';
|
|
1988
|
-
toggle.hidden = true;
|
|
1989
|
-
const hiddenBox = document.createElement('div');
|
|
1990
|
-
hiddenBox.className = 'streak-box';
|
|
1991
|
-
hiddenBox.hidden = true;
|
|
1992
|
-
const tailBox = document.createElement('div');
|
|
1993
|
-
tailBox.className = 'streak-box';
|
|
1994
|
-
toggle.addEventListener('click', () => {
|
|
1995
|
-
hiddenBox.hidden = !hiddenBox.hidden;
|
|
1996
|
-
toggle.textContent = streakLabel(hiddenBox);
|
|
1997
|
-
});
|
|
1998
|
-
wrap.append(toggle, hiddenBox, tailBox);
|
|
1999
|
-
inner.appendChild(wrap);
|
|
2000
|
-
return { wrap, toggle, hiddenBox, tailBox };
|
|
2001
|
-
}
|
|
2002
|
-
|
|
2003
|
-
function appendToStreak(inner, el) {
|
|
2004
|
-
if (!toolStreak || !toolStreak.wrap.isConnected) toolStreak = newToolStreak(inner);
|
|
2005
|
-
toolStreak.tailBox.appendChild(el);
|
|
2006
|
-
// Overflow rolls the oldest visible chip into the folded box (order kept).
|
|
2007
|
-
while (toolStreak.tailBox.children.length > STREAK_TAIL) {
|
|
2008
|
-
toolStreak.hiddenBox.appendChild(toolStreak.tailBox.firstElementChild);
|
|
2009
|
-
}
|
|
2010
|
-
if (toolStreak.hiddenBox.children.length > 0) {
|
|
2011
|
-
toolStreak.toggle.hidden = false;
|
|
2012
|
-
toolStreak.toggle.textContent = streakLabel(toolStreak.hiddenBox);
|
|
2013
|
-
}
|
|
2014
|
-
}
|
|
2015
|
-
|
|
2016
|
-
function appendLog(evt) {
|
|
2017
|
-
const inner = $('#log-inner');
|
|
2018
|
-
const log = $('#log');
|
|
2019
|
-
if (!inner || !log) return;
|
|
2020
|
-
const el = document.createElement('div');
|
|
2021
|
-
|
|
2022
|
-
switch (evt.type) {
|
|
2023
|
-
case 'text':
|
|
2024
|
-
// Agents speak markdown — render it (bold, code, lists) instead of
|
|
2025
|
-
// showing raw ** and ##.
|
|
2026
|
-
el.className = 'ev text md';
|
|
2027
|
-
el.innerHTML = renderMarkdown(evt.text ?? '');
|
|
2028
|
-
break;
|
|
2029
|
-
case 'tool-call': {
|
|
2030
|
-
el.className = 'ev tool';
|
|
2031
|
-
const arg = toolArg(evt.input);
|
|
2032
|
-
el.innerHTML = `<span class="ok">✓</span><span>${esc(toolVerb(evt.tool))}</span>${
|
|
2033
|
-
arg ? `<span class="arg">${esc(oneLine(arg, 64))}</span>` : ''
|
|
2034
|
-
}`;
|
|
2035
|
-
el.title = arg && arg.length > 64 ? arg.slice(0, 1000) : evt.tool;
|
|
2036
|
-
break;
|
|
2037
|
-
}
|
|
2038
|
-
case 'tool-result': {
|
|
2039
|
-
const result = String(evt.result ?? '');
|
|
2040
|
-
const first = (result.split('\n')[0] ?? '').slice(0, 140);
|
|
2041
|
-
const hasMore = result.length > first.length;
|
|
2042
|
-
el.className = `ev result${evt.isError ? ' error' : ''}`;
|
|
2043
|
-
el.innerHTML = hasMore
|
|
2044
|
-
? `<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>`
|
|
2045
|
-
: `<span class="ev tool" style="margin:0"><span class="lead-in" style="color:var(--text3);font-size:11px">↳</span><span>${esc(first)}</span></span>`;
|
|
2046
|
-
break;
|
|
2047
|
-
}
|
|
2048
|
-
case 'check-output': {
|
|
2049
|
-
el.className = 'ev check';
|
|
2050
|
-
const ok = evt.exitCode === 0;
|
|
2051
|
-
el.innerHTML = `
|
|
2052
|
-
<div class="check-head">
|
|
2053
|
-
<span class="lbl">Command</span>
|
|
2054
|
-
<code>${esc(evt.command ?? evt.stepId ?? 'check')}</code>
|
|
2055
|
-
<span class="check-pill ${ok ? 'pass' : 'fail'}">${ok ? 'passed' : `failed (exit ${esc(String(evt.exitCode))})`}</span>
|
|
2056
|
-
</div>
|
|
2057
|
-
<pre>${esc(String(evt.text ?? ''))}</pre>`;
|
|
2058
|
-
break;
|
|
2059
|
-
}
|
|
2060
|
-
case 'image':
|
|
2061
|
-
el.className = 'ev image-ev';
|
|
2062
|
-
el.innerHTML = `
|
|
2063
|
-
<div class="img-card" data-lightbox="${esc(evt.url)}" data-name="${esc(evt.name)}" title="Click to zoom">
|
|
2064
|
-
<img src="${esc(evt.url)}" alt="${esc(evt.name)}" loading="lazy">
|
|
2065
|
-
<div class="img-foot">
|
|
2066
|
-
<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>
|
|
2067
|
-
<span class="nm">${esc(evt.name)}</span>
|
|
2068
|
-
<span class="zm">click to zoom</span>
|
|
2069
|
-
</div>
|
|
2070
|
-
</div>`;
|
|
2071
|
-
break;
|
|
2072
|
-
case 'step-start':
|
|
2073
|
-
el.className = 'ev step';
|
|
2074
|
-
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>`;
|
|
2075
|
-
break;
|
|
2076
|
-
case 'step-end':
|
|
2077
|
-
// Successful step ends are already told by the steps rail and the next
|
|
2078
|
-
// step divider — the design keeps the transcript free of this noise.
|
|
2079
|
-
if (evt.status !== 'failed') return;
|
|
2080
|
-
el.className = 'ev note';
|
|
2081
|
-
el.textContent = `step ${evt.stepId}: failed${evt.error ? ` — ${evt.error}` : ''}`;
|
|
2082
|
-
break;
|
|
2083
|
-
case 'note':
|
|
2084
|
-
case 'lifecycle':
|
|
2085
|
-
el.className = 'ev note';
|
|
2086
|
-
el.textContent = `· ${evt.message ?? ''}`;
|
|
2087
|
-
break;
|
|
2088
|
-
case 'user-message': {
|
|
2089
|
-
el.className = 'ev user';
|
|
2090
|
-
const imgs = evt.imageCount > 0 ? ` [${evt.imageCount} image${evt.imageCount > 1 ? 's' : ''}]` : '';
|
|
2091
|
-
el.innerHTML = `<div class="bubble">${esc(`${evt.text ?? ''}${imgs}`)}</div>`;
|
|
2092
|
-
break;
|
|
2093
|
-
}
|
|
2094
|
-
case 'error':
|
|
2095
|
-
el.className = 'ev err';
|
|
2096
|
-
el.textContent = `✗ ${evt.message ?? ''}`;
|
|
2097
|
-
break;
|
|
2098
|
-
case 'token-usage':
|
|
2099
|
-
case 'cost':
|
|
2100
|
-
case 'turn-end':
|
|
2101
|
-
return; // reflected in the header/steps already
|
|
2102
|
-
case 'done':
|
|
2103
|
-
return;
|
|
2104
|
-
default:
|
|
2105
|
-
el.className = 'ev note';
|
|
2106
|
-
el.textContent = JSON.stringify(evt);
|
|
2107
|
-
}
|
|
2108
|
-
|
|
2109
|
-
if (evt.type === 'tool-call' || evt.type === 'tool-result') {
|
|
2110
|
-
appendToStreak(inner, el);
|
|
2111
|
-
} else {
|
|
2112
|
-
toolStreak = null; // anything else breaks the streak
|
|
2113
|
-
inner.appendChild(el);
|
|
2114
|
-
}
|
|
2115
|
-
if (state.autoScroll) log.scrollTop = log.scrollHeight;
|
|
2116
|
-
}
|
|
2117
|
-
|
|
2118
|
-
// ---- inbox view (spec 007) -----------------------------------------------------
|
|
2119
|
-
|
|
2120
|
-
/* Entries already turned into a task (startedTaskId) are hidden — they stay
|
|
2121
|
-
in todos.json as an audit trail. */
|
|
2122
|
-
function visibleTodos() {
|
|
2123
|
-
return state.todos.filter((t) => !t.startedTaskId);
|
|
2124
|
-
}
|
|
2125
|
-
|
|
2126
|
-
function renderInboxBadge() {
|
|
2127
|
-
const badgeEl = $('#inbox-badge');
|
|
2128
|
-
if (!badgeEl) return;
|
|
2129
|
-
const count = visibleTodos().length;
|
|
2130
|
-
badgeEl.textContent = count;
|
|
2131
|
-
badgeEl.hidden = count === 0;
|
|
2132
|
-
}
|
|
2133
|
-
|
|
2134
|
-
function showRunsView() {
|
|
2135
|
-
const btn = $('#tabs button[data-view="runs"]');
|
|
2136
|
-
if (btn && !btn.classList.contains('active')) btn.click();
|
|
2137
|
-
}
|
|
2138
|
-
|
|
2139
|
-
function renderInbox() {
|
|
2140
|
-
const view = $('#view-inbox');
|
|
2141
|
-
const todos = visibleTodos();
|
|
2142
|
-
view.innerHTML = `
|
|
2143
|
-
<div class="page">
|
|
2144
|
-
<h1>Inbox</h1>
|
|
2145
|
-
<p class="lead">Follow-ups agents suggested when they finished a task.</p>
|
|
2146
|
-
${
|
|
2147
|
-
todos.length
|
|
2148
|
-
? todos
|
|
2149
|
-
.map((t) => {
|
|
2150
|
-
const source = t.taskId
|
|
2151
|
-
? state.runs.has(t.taskId)
|
|
2152
|
-
? `<a href="#" data-goto-run="${esc(t.taskId)}">source task</a>`
|
|
2153
|
-
: '<span class="gone">source task deleted</span>'
|
|
2154
|
-
: '';
|
|
2155
|
-
const pr = t.prUrl
|
|
2156
|
-
? `<a href="${esc(t.prUrl)}" target="_blank" rel="noopener">PR</a>`
|
|
2157
|
-
: '';
|
|
2158
|
-
return `
|
|
2159
|
-
<div class="todo-card" data-id="${esc(t.id)}">
|
|
2160
|
-
<div class="todo-main">
|
|
2161
|
-
<div class="summary">${esc(t.summary)}</div>
|
|
2162
|
-
<div class="meta">
|
|
2163
|
-
${t.ts ? `<span>${esc(timeAgo(t.ts))}</span>` : ''}
|
|
2164
|
-
${t.action ? `<span>${esc(t.action)}</span>` : ''}
|
|
2165
|
-
${source} ${pr}
|
|
2166
|
-
${t.suggestedSkill ? `<span>skill: ${esc(t.suggestedSkill)}</span>` : ''}
|
|
2167
|
-
</div>
|
|
2168
|
-
</div>
|
|
2169
|
-
<button class="btn-dark" data-todo-action="start" title="Start a task from this follow-up">▶ Run</button>
|
|
2170
|
-
<button class="btn-text" data-todo-action="remove" title="Check off (remove)">Dismiss</button>
|
|
2171
|
-
</div>`;
|
|
2172
|
-
})
|
|
2173
|
-
.join('')
|
|
2174
|
-
: '<div class="dim" style="padding:16px 0">Inbox empty — agents drop follow-up suggestions here when they finish a task.</div>'
|
|
2175
|
-
}
|
|
2176
|
-
</div>`;
|
|
2177
|
-
}
|
|
2178
|
-
|
|
2179
|
-
// ---- GitHub view -------------------------------------------------------------
|
|
2180
|
-
|
|
2181
|
-
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';
|
|
2182
|
-
const GH_PR_ICON =
|
|
2183
|
-
'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';
|
|
2184
|
-
|
|
2185
|
-
function bindGithubView() {
|
|
2186
|
-
const view = $('#view-github');
|
|
2187
|
-
view.addEventListener('click', async (e) => {
|
|
2188
|
-
const refresh = e.target.closest('[data-gh-refresh]');
|
|
2189
|
-
if (refresh) {
|
|
2190
|
-
await loadGithub(true);
|
|
2191
|
-
return;
|
|
2192
|
-
}
|
|
2193
|
-
const tab = e.target.closest('button[data-gh-view]');
|
|
2194
|
-
if (tab) {
|
|
2195
|
-
state.ghView = tab.dataset.ghView;
|
|
2196
|
-
state.ghSel = null;
|
|
2197
|
-
renderGithub();
|
|
2198
|
-
return;
|
|
2199
|
-
}
|
|
2200
|
-
const row = e.target.closest('.gh-row');
|
|
2201
|
-
if (row) {
|
|
2202
|
-
state.ghSel = row.dataset.url;
|
|
2203
|
-
renderGithub();
|
|
2204
|
-
return;
|
|
2205
|
-
}
|
|
2206
|
-
const wf = e.target.closest('button[data-gh-workflow]');
|
|
2207
|
-
if (wf) {
|
|
2208
|
-
// Click the selected chip again to deselect — no workflow means the
|
|
2209
|
-
// run uses the toggled skills (or quick-task).
|
|
2210
|
-
state.ghWorkflow = state.ghWorkflow === wf.dataset.ghWorkflow ? null : wf.dataset.ghWorkflow;
|
|
2211
|
-
renderGithub();
|
|
2212
|
-
return;
|
|
2213
|
-
}
|
|
2214
|
-
const sk = e.target.closest('button[data-gh-skill]');
|
|
2215
|
-
if (sk) {
|
|
2216
|
-
const name = sk.dataset.ghSkill;
|
|
2217
|
-
if (state.ghSkills.has(name)) state.ghSkills.delete(name);
|
|
2218
|
-
else state.ghSkills.add(name);
|
|
2219
|
-
renderGithub();
|
|
2220
|
-
return;
|
|
2221
|
-
}
|
|
2222
|
-
const viewRun = e.target.closest('button[data-gh-view-run]');
|
|
2223
|
-
if (viewRun) {
|
|
2224
|
-
showRunsView();
|
|
2225
|
-
selectRun(viewRun.dataset.ghViewRun);
|
|
2226
|
-
return;
|
|
2227
|
-
}
|
|
2228
|
-
const runBtn = e.target.closest('button[data-gh-run]');
|
|
2229
|
-
if (runBtn) {
|
|
2230
|
-
await runOnGithub(runBtn);
|
|
2231
|
-
return;
|
|
2232
|
-
}
|
|
2233
|
-
});
|
|
2234
|
-
|
|
2235
|
-
// Live filter over the skill chips — re-renders only the chip box so the
|
|
2236
|
-
// input keeps focus (and the page keeps its scroll).
|
|
2237
|
-
view.addEventListener('input', (e) => {
|
|
2238
|
-
if (e.target.id !== 'gh-skill-filter') return;
|
|
2239
|
-
state.ghSkillQuery = e.target.value;
|
|
2240
|
-
const box = $('#gh-skill-chips');
|
|
2241
|
-
if (box) box.innerHTML = ghSkillChipsHtml();
|
|
2242
|
-
});
|
|
2243
|
-
|
|
2244
|
-
// Drag an issue/PR row into the task box — it prefills the same prompt
|
|
2245
|
-
// "Run agent on this issue" uses; skill/workflow you pick in the form.
|
|
2246
|
-
view.addEventListener('dragstart', (e) => {
|
|
2247
|
-
const row = e.target.closest('.gh-row[data-url]');
|
|
2248
|
-
if (!row) return;
|
|
2249
|
-
const item = ghItems().find((i) => i.url === row.dataset.url);
|
|
2250
|
-
if (!item) return;
|
|
2251
|
-
try {
|
|
2252
|
-
e.dataTransfer.setData('text/plain', ghTaskPrompt(item));
|
|
2253
|
-
e.dataTransfer.effectAllowed = 'copy';
|
|
2254
|
-
} catch {
|
|
2255
|
-
// older engines — drag just won't carry the prompt
|
|
2256
|
-
}
|
|
2257
|
-
});
|
|
2258
|
-
}
|
|
2259
|
-
|
|
2260
|
-
/* Two-shot load (feedback 2026-07-11 — the 30-item gh default hid the rest):
|
|
2261
|
-
the first fast fetch paints the tab, then a background "everything open"
|
|
2262
|
-
fetch (limit 1000) replaces it and fixes the counts. */
|
|
2263
|
-
async function loadGithub(refresh = false) {
|
|
2264
|
-
const view = $('#view-github');
|
|
2265
|
-
if (!state.gh || refresh) {
|
|
2266
|
-
if (!state.gh) view.innerHTML = '<div class="gh-unavailable">Loading GitHub…</div>';
|
|
2267
|
-
try {
|
|
2268
|
-
// Skill chips ride along — same catalog as the Skills tab.
|
|
2269
|
-
const [gh, skills] = await Promise.all([
|
|
2270
|
-
getJson(`/api/github${refresh ? '?refresh=1' : ''}`),
|
|
2271
|
-
state.skillsList ? Promise.resolve(state.skillsList) : getJson('/api/skills').catch(() => []),
|
|
2272
|
-
]);
|
|
2273
|
-
state.gh = gh;
|
|
2274
|
-
state.skillsList = skills;
|
|
2275
|
-
state.ghFull = false;
|
|
2276
|
-
} catch (err) {
|
|
2277
|
-
view.innerHTML = `<div class="gh-unavailable">✗ ${esc(err.message)}</div>`;
|
|
2278
|
-
return;
|
|
2279
|
-
}
|
|
2280
|
-
}
|
|
2281
|
-
renderGithub();
|
|
2282
|
-
if (state.gh?.available && !state.ghFull) void loadGithubFull();
|
|
2283
|
-
}
|
|
2284
|
-
|
|
2285
|
-
async function loadGithubFull() {
|
|
2286
|
-
if (state.ghFullLoading) return;
|
|
2287
|
-
state.ghFullLoading = true;
|
|
2288
|
-
try {
|
|
2289
|
-
const full = await getJson('/api/github?limit=1000');
|
|
2290
|
-
if (full.available) {
|
|
2291
|
-
state.gh = full;
|
|
2292
|
-
state.ghFull = true;
|
|
2293
|
-
renderGithub();
|
|
2294
|
-
}
|
|
2295
|
-
} catch {
|
|
2296
|
-
// the fast batch stays — counts just keep their "+"
|
|
2297
|
-
} finally {
|
|
2298
|
-
state.ghFullLoading = false;
|
|
2299
|
-
}
|
|
2300
|
-
}
|
|
2301
|
-
|
|
2302
|
-
function ghItems() {
|
|
2303
|
-
if (!state.gh) return [];
|
|
2304
|
-
return state.ghView === 'issues' ? state.gh.issues : state.gh.prs;
|
|
2305
|
-
}
|
|
2306
|
-
|
|
2307
|
-
function renderGithub() {
|
|
2308
|
-
const view = $('#view-github');
|
|
2309
|
-
const gh = state.gh;
|
|
2310
|
-
if (!gh) return;
|
|
2311
|
-
if (!gh.available) {
|
|
2312
|
-
view.innerHTML = `
|
|
2313
|
-
<div class="gh-unavailable">
|
|
2314
|
-
<div style="font-family:var(--serif);font-size:21px;color:var(--text);margin-bottom:10px">GitHub</div>
|
|
2315
|
-
GitHub is unavailable here — ${esc(gh.reason ?? 'unknown reason')}.<br><br>
|
|
2316
|
-
The tab needs the <span class="mono">gh</span> CLI, logged in (<span class="mono">gh auth login</span>),
|
|
2317
|
-
and a repo with a GitHub remote. Everything else in cezar works without it.
|
|
2318
|
-
<div style="margin-top:14px"><button class="btn-ghost" data-gh-refresh>⟳ Try again</button></div>
|
|
2319
|
-
</div>`;
|
|
2320
|
-
return;
|
|
2321
|
-
}
|
|
2322
|
-
|
|
2323
|
-
const items = ghItems();
|
|
2324
|
-
let sel = items.find((i) => i.url === state.ghSel) ?? items[0] ?? null;
|
|
2325
|
-
if (sel) state.ghSel = sel.url;
|
|
2326
|
-
|
|
2327
|
-
const rows = items.length
|
|
2328
|
-
? items
|
|
2329
|
-
.map((i) => {
|
|
2330
|
-
const queuedRun = state.ghQueued.get(i.url);
|
|
2331
|
-
return `
|
|
2332
|
-
<div class="gh-row ${sel && i.url === sel.url ? 'selected' : ''}" data-url="${esc(i.url)}" draggable="true" title="Drag into the task box to prefill a task">
|
|
2333
|
-
<div class="row1">
|
|
2334
|
-
<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>
|
|
2335
|
-
<span class="t">${esc(i.title)}</span>
|
|
2336
|
-
</div>
|
|
2337
|
-
<div class="row2">
|
|
2338
|
-
<span>#${i.number}</span><span>${esc(i.author)}</span><span>${esc(shortAgo(i.createdAt))}</span>
|
|
2339
|
-
${queuedRun ? '<span class="queued-flag">↗ run queued</span>' : ''}
|
|
2340
|
-
</div>
|
|
2341
|
-
</div>`;
|
|
2342
|
-
})
|
|
2343
|
-
.join('')
|
|
2344
|
-
: `<div class="dim" style="padding:12px">No open ${state.ghView === 'issues' ? 'issues' : 'pull requests'}.</div>`;
|
|
2345
|
-
|
|
2346
|
-
const detail = sel ? ghDetailHtml(sel) : '<div class="empty">Nothing selected.</div>';
|
|
2347
|
-
|
|
2348
|
-
// A full re-render must not jump the scroll (feedback 2026-07-11: toggling
|
|
2349
|
-
// a skill chip yanked the detail back to the top).
|
|
2350
|
-
const rowsScroll = view.querySelector('.split-rows')?.scrollTop ?? 0;
|
|
2351
|
-
const detailScroll = view.querySelector('.split-detail')?.scrollTop ?? 0;
|
|
2352
|
-
|
|
2353
|
-
// Until the background full fetch lands, a count at the fast-batch cap is
|
|
2354
|
-
// really "30 of who knows" — say so.
|
|
2355
|
-
const countLabel = (n) => `${n}${!state.ghFull && n >= 30 ? '+' : ''}`;
|
|
2356
|
-
|
|
2357
|
-
view.innerHTML = `
|
|
2358
|
-
<div class="split">
|
|
2359
|
-
<div class="split-list">
|
|
2360
|
-
<div class="split-head">
|
|
2361
|
-
<div class="head-row">
|
|
2362
|
-
<h1>GitHub</h1>
|
|
2363
|
-
<span class="mono dim" style="font-size:10.5px">${esc(gh.repo ?? '')}</span>
|
|
2364
|
-
<button class="head-note" data-gh-refresh style="border:none;background:transparent;cursor:pointer" title="Refresh from GitHub">
|
|
2365
|
-
<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>
|
|
2366
|
-
synced ${esc(gh.syncedAt ? shortAgo(gh.syncedAt) : '?')} ago${state.ghFullLoading ? ' · loading all…' : ''}
|
|
2367
|
-
</button>
|
|
2368
|
-
</div>
|
|
2369
|
-
<div class="sub-tabs">
|
|
2370
|
-
<button data-gh-view="issues" class="${state.ghView === 'issues' ? 'active' : ''}">Issues · ${countLabel(gh.issues.length)}</button>
|
|
2371
|
-
<button data-gh-view="prs" class="${state.ghView === 'prs' ? 'active' : ''}">Pull requests · ${countLabel(gh.prs.length)}</button>
|
|
2372
|
-
</div>
|
|
2373
|
-
</div>
|
|
2374
|
-
<div class="split-rows">${rows}</div>
|
|
2375
|
-
</div>
|
|
2376
|
-
<div class="split-detail">${detail}</div>
|
|
2377
|
-
</div>`;
|
|
2378
|
-
|
|
2379
|
-
const rowsEl = view.querySelector('.split-rows');
|
|
2380
|
-
if (rowsEl) rowsEl.scrollTop = rowsScroll;
|
|
2381
|
-
const detailEl = view.querySelector('.split-detail');
|
|
2382
|
-
if (detailEl) detailEl.scrollTop = detailScroll;
|
|
2383
|
-
}
|
|
2384
|
-
|
|
2385
|
-
function ghDetailHtml(item) {
|
|
2386
|
-
const kindWord = item.kind === 'pr' ? 'pull request' : 'issue';
|
|
2387
|
-
const diffStat =
|
|
2388
|
-
item.kind === 'pr' && (item.additions || item.deletions) ? ` · +${item.additions} −${item.deletions}` : '';
|
|
2389
|
-
const checks = item.checks
|
|
2390
|
-
? `<span class="gh-checks ${esc(item.checks)}">${
|
|
2391
|
-
item.checks === 'passing' ? '✓ checks passing' : item.checks === 'failing' ? '✗ checks failing' : '○ checks pending'
|
|
2392
|
-
}</span>`
|
|
2393
|
-
: '';
|
|
2394
|
-
const skills = (state.skillsList ?? []).map((s) => s.name);
|
|
2395
|
-
const queuedRun = state.ghQueued.get(item.url);
|
|
2396
|
-
return `
|
|
2397
|
-
<div class="inner">
|
|
2398
|
-
<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>
|
|
2399
|
-
<h1 style="margin-top:8px">${esc(item.title)}</h1>
|
|
2400
|
-
<div style="display:flex;align-items:center;gap:7px;margin-top:12px;flex-wrap:wrap">
|
|
2401
|
-
${item.labels.map((l) => `<span class="gh-label">${esc(l)}</span>`).join('')}
|
|
2402
|
-
${checks}
|
|
2403
|
-
</div>
|
|
2404
|
-
<div class="gh-body md">${item.body ? renderMarkdown(item.body) : '<p class="dim">(no description)</p>'}</div>
|
|
2405
|
-
<div class="gh-hand">
|
|
2406
|
-
<div class="hand-label">
|
|
2407
|
-
<svg viewBox="0 0 24 24"><path d="M13 2L4.5 13.5h5.5L9 22l8.5-11.5H12L13 2z"/></svg>
|
|
2408
|
-
Hand this to the agent
|
|
2409
|
-
</div>
|
|
2410
|
-
<div class="hand-row">
|
|
2411
|
-
<span class="k" style="padding-top:0;align-self:center">workflow</span>
|
|
2412
|
-
<div class="chips">
|
|
2413
|
-
${state.workflows
|
|
2414
|
-
.map(
|
|
2415
|
-
(w) =>
|
|
2416
|
-
`<button class="chip-toggle ${state.ghWorkflow === w.name ? 'on' : ''}" data-gh-workflow="${esc(w.name)}" title="${esc(w.description ?? '')}${state.ghWorkflow === w.name ? ' — click again to deselect' : ''}">${esc(w.name)}</button>`,
|
|
2417
|
-
)
|
|
2418
|
-
.join('')}
|
|
2419
|
-
</div>
|
|
2420
|
-
</div>
|
|
2421
|
-
${
|
|
2422
|
-
skills.length
|
|
2423
|
-
? `<div class="hand-row">
|
|
2424
|
-
<span class="k">skills</span>
|
|
2425
|
-
<div style="flex:1;min-width:0">
|
|
2426
|
-
${
|
|
2427
|
-
skills.length > 10
|
|
2428
|
-
? `<input id="gh-skill-filter" class="filter-input" style="width:min(260px,100%);margin:0 0 8px" placeholder="Filter skills…" value="${esc(state.ghSkillQuery)}">`
|
|
2429
|
-
: ''
|
|
2430
|
-
}
|
|
2431
|
-
<div class="chips" id="gh-skill-chips">${ghSkillChipsHtml()}</div>
|
|
2432
|
-
</div>
|
|
2433
|
-
</div>`
|
|
2434
|
-
: ''
|
|
2435
|
-
}
|
|
2436
|
-
<div class="go-row">
|
|
2437
|
-
<button class="btn-dark" data-gh-run="${esc(item.url)}">▶ Run agent on this ${item.kind === 'pr' ? 'PR' : 'issue'}</button>
|
|
2438
|
-
${
|
|
2439
|
-
queuedRun
|
|
2440
|
-
? `<span class="queued-ok">✓ queued</span><button class="btn-text" data-gh-view-run="${esc(queuedRun)}">View in Runs →</button>`
|
|
2441
|
-
: ''
|
|
2442
|
-
}
|
|
2443
|
-
</div>
|
|
2444
|
-
</div>
|
|
2445
|
-
</div>`;
|
|
2446
|
-
}
|
|
2447
|
-
|
|
2448
|
-
/* Skill chips, filterable — toggled-on chips always stay visible so the
|
|
2449
|
-
filter can't hide your selection. */
|
|
2450
|
-
function ghSkillChipsHtml() {
|
|
2451
|
-
const q = state.ghSkillQuery.trim().toLowerCase();
|
|
2452
|
-
const names = (state.skillsList ?? []).map((s) => s.name);
|
|
2453
|
-
const shown = names.filter((n) => state.ghSkills.has(n) || !q || n.toLowerCase().includes(q));
|
|
2454
|
-
if (!shown.length) return '<span class="dim" style="font-size:11.5px">No skills match.</span>';
|
|
2455
|
-
return shown
|
|
2456
|
-
.map(
|
|
2457
|
-
(name) =>
|
|
2458
|
-
`<button class="chip-toggle ${state.ghSkills.has(name) ? 'on' : ''}" data-gh-skill="${esc(name)}">${esc(name)}</button>`,
|
|
2459
|
-
)
|
|
2460
|
-
.join('');
|
|
2461
|
-
}
|
|
2462
|
-
|
|
2463
|
-
/* The prompt handed to the agent — shared by "Run agent on this …" and the
|
|
2464
|
-
drag-into-the-task-box path. */
|
|
2465
|
-
function ghTaskPrompt(item, skillNames = []) {
|
|
2466
|
-
let task = `${item.kind === 'pr' ? 'Address GitHub pull request' : 'Fix GitHub issue'} #${item.number}: ${item.title}\n\n${item.url}`;
|
|
2467
|
-
if (item.body?.trim()) task += `\n\n---\n\n${item.body.trim()}`;
|
|
2468
|
-
if (skillNames.length) task += `\n\nUse these skills where relevant: ${skillNames.join(', ')}.`;
|
|
2469
|
-
return task;
|
|
2470
|
-
}
|
|
2471
|
-
|
|
2472
|
-
async function runOnGithub(btn) {
|
|
2473
|
-
const item = ghItems().find((i) => i.url === btn.dataset.ghRun);
|
|
2474
|
-
if (!item) return;
|
|
2475
|
-
const skills = [...state.ghSkills].filter((name) => (state.skillsList ?? []).some((s) => s.name === name));
|
|
2476
|
-
// Workflow chip set → that workflow (skills ride along as a prompt hint).
|
|
2477
|
-
// No workflow but skills toggled → the skills ARE the chain (spec 008).
|
|
2478
|
-
// Nothing selected → quick-task.
|
|
2479
|
-
let body;
|
|
2480
|
-
if (state.ghWorkflow) {
|
|
2481
|
-
body = { workflow: state.ghWorkflow, task: ghTaskPrompt(item, skills) };
|
|
2482
|
-
} else if (skills.length) {
|
|
2483
|
-
const steps = [];
|
|
2484
|
-
for (const name of skills.slice(0, 8)) steps.push(wbSkillStep(name, steps));
|
|
2485
|
-
body = { steps, task: ghTaskPrompt(item) };
|
|
2486
|
-
} else {
|
|
2487
|
-
body = { workflow: 'quick-task', task: ghTaskPrompt(item) };
|
|
2488
|
-
}
|
|
2489
|
-
btn.disabled = true;
|
|
2490
|
-
try {
|
|
2491
|
-
const res = await fetch('/api/runs', {
|
|
2492
|
-
method: 'POST',
|
|
2493
|
-
headers: { 'content-type': 'application/json' },
|
|
2494
|
-
body: JSON.stringify(body),
|
|
2495
|
-
});
|
|
2496
|
-
const data = await res.json();
|
|
2497
|
-
if (!res.ok) throw new Error(data.error ?? `HTTP ${res.status}`);
|
|
2498
|
-
state.runs.set(data.id, data);
|
|
2499
|
-
state.ghQueued.set(item.url, data.id);
|
|
2500
|
-
state.lastGhRun = data.id;
|
|
2501
|
-
renderRunList();
|
|
2502
|
-
renderGithub();
|
|
2503
|
-
} catch (err) {
|
|
2504
|
-
alertBar(err.message);
|
|
2505
|
-
btn.disabled = false;
|
|
2506
|
-
}
|
|
2507
|
-
}
|
|
2508
|
-
|
|
2509
|
-
// ---- repo view ---------------------------------------------------------------
|
|
2510
|
-
|
|
2511
|
-
function statusClass(status) {
|
|
2512
|
-
if (/A|\?/.test(status)) return 'added';
|
|
2513
|
-
if (/D/.test(status)) return 'deleted';
|
|
2514
|
-
return '';
|
|
2515
|
-
}
|
|
2516
|
-
|
|
2517
|
-
async function loadRepo() {
|
|
2518
|
-
const view = $('#view-repo');
|
|
2519
|
-
view.innerHTML = '<div class="page dim">Loading…</div>';
|
|
2520
|
-
try {
|
|
2521
|
-
const [repo, diff] = await Promise.all([
|
|
2522
|
-
getJson('/api/repo'),
|
|
2523
|
-
fetch('/api/repo/diff').then((r) => r.text()),
|
|
2524
|
-
]);
|
|
2525
|
-
if (!repo.info) {
|
|
2526
|
-
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>';
|
|
2527
|
-
return;
|
|
2528
|
-
}
|
|
2529
|
-
view.innerHTML = `
|
|
2530
|
-
<div class="page">
|
|
2531
|
-
<h1>Repository</h1>
|
|
2532
|
-
<p class="lead mono" style="font-size:12px">${esc(repo.info.root)} · ${esc(repo.info.branch)}${repo.info.remote ? ` · ${esc(repo.info.remote)}` : ''}</p>
|
|
2533
|
-
|
|
2534
|
-
<div class="section-label">Agent base branch</div>
|
|
2535
|
-
<div class="base-branch-row">
|
|
2536
|
-
<select id="base-branch">
|
|
2537
|
-
<option value="" ${repo.baseBranch ? '' : 'selected'}>current checkout (${esc(repo.info.branch)})</option>
|
|
2538
|
-
${(repo.branches ?? [])
|
|
2539
|
-
.map((b) => `<option value="${esc(b)}" ${repo.baseBranch === b ? 'selected' : ''}>${esc(b)}</option>`)
|
|
2540
|
-
.join('')}
|
|
2541
|
-
</select>
|
|
2542
|
-
<span class="dim" style="font-size:11.5px;line-height:1.5">Task worktrees fork from this branch and draft PRs target it. Saved to <span class="mono">.ai/cezar/config.json</span>.</span>
|
|
2543
|
-
</div>
|
|
2544
|
-
|
|
2545
|
-
<div class="section-label">Working tree · ${repo.status.length ? `${repo.status.length} changed` : 'clean'}</div>
|
|
2546
|
-
${
|
|
2547
|
-
repo.status.length
|
|
2548
|
-
? repo.status
|
|
2549
|
-
.map(
|
|
2550
|
-
(s) =>
|
|
2551
|
-
`<div class="repo-file"><span class="st ${statusClass(s.status)}">${esc(s.status)}</span><span class="p">${esc(s.path)}</span></div>`,
|
|
2552
|
-
)
|
|
2553
|
-
.join('')
|
|
2554
|
-
: '<div class="dim" style="font-size:12.5px">Nothing modified.</div>'
|
|
2555
|
-
}
|
|
2556
|
-
${
|
|
2557
|
-
diff.trim()
|
|
2558
|
-
? `<details class="repo-diff"><summary>Diff vs HEAD</summary><div class="diff-wrap">${renderDiff(diff)}</div></details>`
|
|
2559
|
-
: ''
|
|
2560
|
-
}
|
|
2561
|
-
|
|
2562
|
-
<div class="section-label">Recent commits</div>
|
|
2563
|
-
${repo.log
|
|
2564
|
-
.map(
|
|
2565
|
-
(l) => `
|
|
2566
|
-
<div class="commit-row clickable" data-sha="${esc(l.hash)}" title="Show this commit">
|
|
2567
|
-
<svg class="chev" viewBox="0 0 24 24"><path d="M9 6l6 6-6 6"/></svg>
|
|
2568
|
-
<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>
|
|
2569
|
-
</div>
|
|
2570
|
-
<div class="commit-diff" data-diff-for="${esc(l.hash)}" hidden></div>`,
|
|
2571
|
-
)
|
|
2572
|
-
.join('')}
|
|
2573
|
-
</div>`;
|
|
2574
|
-
view.dataset.ghBase = githubBaseUrl(repo.info.remote) ?? '';
|
|
2575
|
-
} catch (err) {
|
|
2576
|
-
view.innerHTML = `<div class="page">✗ ${esc(err.message)}</div>`;
|
|
2577
|
-
}
|
|
2578
|
-
}
|
|
2579
|
-
|
|
2580
|
-
/* `git@github.com:o/r.git` / `https://github.com/o/r(.git)` → https://github.com/o/r */
|
|
2581
|
-
function githubBaseUrl(remote) {
|
|
2582
|
-
const m = /github\.com[:/]([^/\s]+)\/([^/\s]+?)(?:\.git)?$/.exec(remote ?? '');
|
|
2583
|
-
return m ? `https://github.com/${m[1]}/${m[2]}` : null;
|
|
2584
|
-
}
|
|
2585
|
-
|
|
2586
|
-
/* Click a commit row → expand its message + patch inline (spec: Repo view).
|
|
2587
|
-
Bound once; loadRepo re-renders the innerHTML under it. */
|
|
2588
|
-
function bindRepoView() {
|
|
2589
|
-
const view = $('#view-repo');
|
|
2590
|
-
|
|
2591
|
-
// Base-branch picker → PUT /api/config (empty value clears back to "current").
|
|
2592
|
-
view.addEventListener('change', async (e) => {
|
|
2593
|
-
if (e.target.id !== 'base-branch') return;
|
|
2594
|
-
const value = e.target.value || null;
|
|
2595
|
-
try {
|
|
2596
|
-
const res = await fetch('/api/config', {
|
|
2597
|
-
method: 'PUT',
|
|
2598
|
-
headers: { 'content-type': 'application/json' },
|
|
2599
|
-
body: JSON.stringify({ baseBranch: value }),
|
|
2600
|
-
});
|
|
2601
|
-
const data = await res.json().catch(() => ({}));
|
|
2602
|
-
if (!res.ok) throw new Error(data.error ?? `HTTP ${res.status}`);
|
|
2603
|
-
alertBar(value ? `New tasks will branch off "${value}" (PRs target it too).` : 'Base branch cleared — tasks follow the current checkout.');
|
|
2604
|
-
} catch (err) {
|
|
2605
|
-
alertBar(err.message);
|
|
2606
|
-
}
|
|
2607
|
-
});
|
|
2608
|
-
|
|
2609
|
-
view.addEventListener('click', async (e) => {
|
|
2610
|
-
if (e.target.closest('a')) return; // the GitHub link inside an expanded diff
|
|
2611
|
-
const row = e.target.closest('.commit-row[data-sha]');
|
|
2612
|
-
if (!row) return;
|
|
2613
|
-
const sha = row.dataset.sha;
|
|
2614
|
-
const box = view.querySelector(`.commit-diff[data-diff-for="${CSS.escape(sha)}"]`);
|
|
2615
|
-
if (!box) return;
|
|
2616
|
-
if (!box.hidden) {
|
|
2617
|
-
box.hidden = true;
|
|
2618
|
-
row.classList.remove('open');
|
|
2619
|
-
return;
|
|
2620
|
-
}
|
|
2621
|
-
row.classList.add('open');
|
|
2622
|
-
box.hidden = false;
|
|
2623
|
-
if (!box.dataset.loaded) {
|
|
2624
|
-
box.innerHTML = '<div class="dim" style="padding:8px 2px;font-size:12px">Loading…</div>';
|
|
2625
|
-
try {
|
|
2626
|
-
const text = await fetch(`/api/repo/commit/${encodeURIComponent(sha)}`).then((r) => r.text());
|
|
2627
|
-
// `git show` = message + stat, then the patch — split so renderDiff
|
|
2628
|
-
// doesn't swallow the preamble.
|
|
2629
|
-
const at = text.indexOf('\ndiff --git ');
|
|
2630
|
-
const head = at === -1 ? text : text.slice(0, at);
|
|
2631
|
-
const patch = at === -1 ? '' : text.slice(at + 1);
|
|
2632
|
-
const ghBase = view.dataset.ghBase;
|
|
2633
|
-
box.innerHTML = `
|
|
2634
|
-
${ghBase ? `<a class="commit-gh" href="${esc(ghBase)}/commit/${esc(sha)}" target="_blank" rel="noopener">View on GitHub ↗</a>` : ''}
|
|
2635
|
-
<pre class="commit-head">${esc(head.trim())}</pre>
|
|
2636
|
-
${patch ? `<div class="diff-wrap">${renderDiff(patch)}</div>` : ''}`;
|
|
2637
|
-
box.dataset.loaded = '1';
|
|
2638
|
-
} catch (err) {
|
|
2639
|
-
box.innerHTML = `<div class="error-text">✗ ${esc(err.message)}</div>`;
|
|
2640
|
-
}
|
|
2641
|
-
}
|
|
2642
|
-
});
|
|
2643
|
-
}
|
|
2644
|
-
|
|
2645
|
-
// ---- skills view ---------------------------------------------------------------
|
|
2646
|
-
|
|
2647
|
-
const SKILL_ICON = 'M12 3l2 5.5L19.5 10 14 12l-2 5.5L10 12l-5.5-2L10 8.5 12 3z';
|
|
2648
|
-
|
|
2649
|
-
function bindSkillsView() {
|
|
2650
|
-
const view = $('#view-skills');
|
|
2651
|
-
view.addEventListener('click', (e) => {
|
|
2652
|
-
if (e.target.closest('#skills-refresh')) {
|
|
2653
|
-
void loadSkills(true);
|
|
2654
|
-
return;
|
|
2655
|
-
}
|
|
2656
|
-
const row = e.target.closest('.skill-row');
|
|
2657
|
-
if (row) {
|
|
2658
|
-
state.skillSel = row.dataset.skill;
|
|
2659
|
-
renderSkills();
|
|
2660
|
-
return;
|
|
2661
|
-
}
|
|
2662
|
-
});
|
|
2663
|
-
// Filter without re-rendering the input (it would lose focus).
|
|
2664
|
-
view.addEventListener('input', (e) => {
|
|
2665
|
-
if (e.target.id !== 'skill-filter') return;
|
|
2666
|
-
state.skillQuery = e.target.value;
|
|
2667
|
-
const rows = $('#skill-rows');
|
|
2668
|
-
if (rows) rows.innerHTML = skillRowsHtml();
|
|
2669
|
-
});
|
|
2670
|
-
}
|
|
2671
|
-
|
|
2672
|
-
async function loadSkills(refresh = false) {
|
|
2673
|
-
const view = $('#view-skills');
|
|
2674
|
-
if (!state.skillsList || refresh) {
|
|
2675
|
-
if (!state.skillsList) view.innerHTML = '<div class="gh-unavailable">Loading…</div>';
|
|
2676
|
-
try {
|
|
2677
|
-
state.skillsList = refresh
|
|
2678
|
-
? await fetch('/api/skills/refresh', { method: 'POST' }).then((r) => {
|
|
2679
|
-
if (!r.ok) throw new Error(`refresh → ${r.status}`);
|
|
2680
|
-
return r.json();
|
|
2681
|
-
})
|
|
2682
|
-
: await getJson('/api/skills');
|
|
2683
|
-
if (refresh) alertBar('Team skills refreshed.');
|
|
2684
|
-
renderTaskPills(); // the form's source pill lists the same catalog
|
|
2685
|
-
} catch (err) {
|
|
2686
|
-
view.innerHTML = `<div class="gh-unavailable">✗ ${esc(err.message)}</div>`;
|
|
2687
|
-
return;
|
|
2688
|
-
}
|
|
2689
|
-
}
|
|
2690
|
-
renderSkills();
|
|
2691
|
-
}
|
|
2692
|
-
|
|
2693
|
-
function filteredSkills() {
|
|
2694
|
-
const q = state.skillQuery.trim().toLowerCase();
|
|
2695
|
-
return (state.skillsList ?? []).filter(
|
|
2696
|
-
(s) => !q || s.name.toLowerCase().includes(q) || (s.description ?? '').toLowerCase().includes(q),
|
|
2697
|
-
);
|
|
2698
|
-
}
|
|
2699
|
-
|
|
2700
|
-
function skillRowsHtml() {
|
|
2701
|
-
const skills = filteredSkills();
|
|
2702
|
-
const rows = skills
|
|
2703
|
-
.map(
|
|
2704
|
-
(s) => `
|
|
2705
|
-
<div class="skill-row ${state.skillSel === s.name ? 'selected' : ''}" data-skill="${esc(s.name)}">
|
|
2706
|
-
<div class="row1">
|
|
2707
|
-
<svg viewBox="0 0 24 24"><path d="${SKILL_ICON}"/></svg>
|
|
2708
|
-
<span class="name">${esc(s.name)}</span>
|
|
2709
|
-
<span class="skill-tag ${esc(s.source)}">${esc(s.source)}</span>
|
|
2710
|
-
</div>
|
|
2711
|
-
${s.description ? `<div class="desc">${esc(s.description)}</div>` : ''}
|
|
2712
|
-
</div>`,
|
|
2713
|
-
)
|
|
2714
|
-
.join('');
|
|
2715
|
-
const empty = state.skillsList?.length
|
|
2716
|
-
? '<div class="dim" style="padding:12px">(no skills match)</div>'
|
|
2717
|
-
: `<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>`;
|
|
2718
|
-
return rows || empty;
|
|
2719
|
-
}
|
|
2720
|
-
|
|
2721
|
-
/* Always-visible entry below the (scrollable) skill list — spec 011 must not
|
|
2722
|
-
drown under a long team catalog. */
|
|
2723
|
-
function bookmarkletRowHtml() {
|
|
2724
|
-
return `
|
|
2725
|
-
<div class="skill-row pinned ${state.skillSel === '__bm' ? 'selected' : ''}" data-skill="__bm">
|
|
2726
|
-
<div class="row1">
|
|
2727
|
-
<svg viewBox="0 0 24 24" style="stroke:var(--accent)"><path d="M13 2L4.5 13.5h5.5L9 22l8.5-11.5H12L13 2z"/></svg>
|
|
2728
|
-
<span class="name">Run from GitHub</span>
|
|
2729
|
-
<span class="skill-tag">bookmarklets</span>
|
|
2730
|
-
</div>
|
|
2731
|
-
<div class="desc">One-click skill launch from any GitHub PR or issue.</div>
|
|
2732
|
-
</div>`;
|
|
2733
|
-
}
|
|
2734
|
-
|
|
2735
|
-
/* Workflows referencing this skill — "fix-and-verify › Verify". */
|
|
2736
|
-
function skillUsedBy(name) {
|
|
2737
|
-
const out = [];
|
|
2738
|
-
for (const w of state.workflows) {
|
|
2739
|
-
for (const s of w.steps ?? []) {
|
|
2740
|
-
if (s.skill === name) out.push(`${w.name} › ${s.name ?? s.id}`);
|
|
2741
|
-
}
|
|
2742
|
-
}
|
|
2743
|
-
return out;
|
|
2744
|
-
}
|
|
2745
|
-
|
|
2746
|
-
function renderSkills() {
|
|
2747
|
-
const view = $('#view-skills');
|
|
2748
|
-
const skills = state.skillsList ?? [];
|
|
2749
|
-
if (state.skillSel !== '__bm' && !skills.some((s) => s.name === state.skillSel)) {
|
|
2750
|
-
state.skillSel = skills[0]?.name ?? '__bm';
|
|
2751
|
-
}
|
|
2752
|
-
|
|
2753
|
-
view.innerHTML = `
|
|
2754
|
-
<div class="split">
|
|
2755
|
-
<div class="split-list">
|
|
2756
|
-
<div class="split-head">
|
|
2757
|
-
<div class="head-row">
|
|
2758
|
-
<h1>Skills</h1>
|
|
2759
|
-
<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>
|
|
2760
|
-
</div>
|
|
2761
|
-
</div>
|
|
2762
|
-
<input id="skill-filter" class="filter-input" placeholder="Filter skills…" value="${esc(state.skillQuery)}">
|
|
2763
|
-
<div class="split-rows" id="skill-rows">${skillRowsHtml()}</div>
|
|
2764
|
-
<div class="list-foot">${bookmarkletRowHtml()}</div>
|
|
2765
|
-
</div>
|
|
2766
|
-
<div class="split-detail" id="skill-detail">${state.skillSel === '__bm' ? bookmarkletShellHtml() : skillDetailHtml()}</div>
|
|
2767
|
-
</div>`;
|
|
2768
|
-
|
|
2769
|
-
if (state.skillSel === '__bm') void bindBookmarklets(skills);
|
|
2770
|
-
}
|
|
2771
|
-
|
|
2772
|
-
function skillDetailHtml() {
|
|
2773
|
-
const skill = (state.skillsList ?? []).find((s) => s.name === state.skillSel);
|
|
2774
|
-
if (!skill) return '<div class="empty">No skill selected.</div>';
|
|
2775
|
-
const usedBy = skillUsedBy(skill.name);
|
|
2776
|
-
return `
|
|
2777
|
-
<div class="inner skill-detail">
|
|
2778
|
-
<div class="title-row">
|
|
2779
|
-
<h1>${esc(skill.name)}</h1>
|
|
2780
|
-
<span class="skill-tag ${esc(skill.source)}">${esc(skill.source)}</span>
|
|
2781
|
-
</div>
|
|
2782
|
-
<div class="path-line">${esc(skill.path)}${skill.team ? ` · from ${esc(skill.team.repo)}` : ''}</div>
|
|
2783
|
-
${skill.description ? `<div class="desc">${esc(skill.description)}</div>` : ''}
|
|
2784
|
-
<div class="section-label">Used by</div>
|
|
2785
|
-
${
|
|
2786
|
-
usedBy.length
|
|
2787
|
-
? usedBy
|
|
2788
|
-
.map(
|
|
2789
|
-
(u) =>
|
|
2790
|
-
`<div class="used-row"><svg viewBox="0 0 24 24"><path d="M4 12h12M12 6l6 6-6 6"/></svg>${esc(u)}</div>`,
|
|
2791
|
-
)
|
|
2792
|
-
.join('')
|
|
2793
|
-
: '<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>'
|
|
2794
|
-
}
|
|
2795
|
-
<div class="content-head">
|
|
2796
|
-
<span class="section-label">Content</span>
|
|
2797
|
-
<span class="spacer"></span>
|
|
2798
|
-
</div>
|
|
2799
|
-
<pre class="content">${esc(skill.body)}</pre>
|
|
2800
|
-
</div>`;
|
|
2801
|
-
}
|
|
2802
|
-
|
|
2803
|
-
// ---- workflow builder (spec 012) ---------------------------------------------
|
|
2804
|
-
|
|
2805
|
-
/* The Workflows tab: a workflow is (usually) a portable stack of skills the
|
|
2806
|
-
agent applies top to bottom. Drag skills in from the palette, reorder by
|
|
2807
|
-
drag, import/export the YAML, save to `.ai/cezar/workflows/`. Workflows
|
|
2808
|
-
richer than a skill stack (checks, custom prompts — YAML-land) still load,
|
|
2809
|
-
reorder and save; they just serialize in the full `steps:` form instead of
|
|
2810
|
-
the compact `skills:` one. */
|
|
2811
|
-
|
|
2812
|
-
const WB_MAX_STEPS = 8; // the server's save/run step limit
|
|
2813
|
-
|
|
2814
|
-
function wbEmpty() {
|
|
2815
|
-
return {
|
|
2816
|
-
name: 'my-workflow',
|
|
2817
|
-
description: '',
|
|
2818
|
-
steps: [],
|
|
2819
|
-
query: '',
|
|
2820
|
-
importOpen: false,
|
|
2821
|
-
importText: '',
|
|
2822
|
-
importError: '',
|
|
2823
|
-
copied: false,
|
|
2824
|
-
};
|
|
2825
|
-
}
|
|
2826
|
-
|
|
2827
|
-
function wbFrom(w) {
|
|
2828
|
-
return {
|
|
2829
|
-
...wbEmpty(),
|
|
2830
|
-
name: w.name,
|
|
2831
|
-
description: w.description ?? '',
|
|
2832
|
-
steps: structuredClone(w.steps ?? []),
|
|
2833
|
-
};
|
|
2834
|
-
}
|
|
2835
|
-
|
|
2836
|
-
/* First visit seeds the canvas with the repo's first saved workflow — "open
|
|
2837
|
-
the tab, see your flow". No files yet → an empty canvas + the drop hint. */
|
|
2838
|
-
function openWorkflowsView() {
|
|
2839
|
-
if (!state.wb) {
|
|
2840
|
-
const first = state.workflows.find((w) => w.source === 'file');
|
|
2841
|
-
state.wb = first ? wbFrom(first) : wbEmpty();
|
|
2842
|
-
}
|
|
2843
|
-
renderWorkflowsView();
|
|
2844
|
-
}
|
|
2845
|
-
|
|
2846
|
-
/* Client mirror of the server's skillStackOf(): a pure "apply skill to the
|
|
2847
|
-
task" chain can be written in the compact `skills:` YAML form. */
|
|
2848
|
-
function wbSkillStack(steps) {
|
|
2849
|
-
const skills = [];
|
|
2850
|
-
for (const s of steps) {
|
|
2851
|
-
if (s.command || !s.skill) return null;
|
|
2852
|
-
if (s.prompt !== undefined && s.prompt !== '{{task}}') return null;
|
|
2853
|
-
if (s.name !== undefined && s.name !== s.skill) return null;
|
|
2854
|
-
if (s.model || s.runner || s.allowedTools || s.bashAllowlist || s.onFail) return null;
|
|
2855
|
-
skills.push(s.skill);
|
|
2856
|
-
}
|
|
2857
|
-
return skills.length ? skills : null;
|
|
2858
|
-
}
|
|
2859
|
-
|
|
2860
|
-
/* Quote only when the plain form would be ambiguous YAML — special characters,
|
|
2861
|
-
or a scalar YAML would read as a boolean/number/null instead of a string.
|
|
2862
|
-
JSON strings are valid YAML double-quoted scalars, so JSON.stringify is the
|
|
2863
|
-
escape hatch. */
|
|
2864
|
-
function yamlScalar(v) {
|
|
2865
|
-
const s = String(v);
|
|
2866
|
-
const plain = /^[A-Za-z0-9._][A-Za-z0-9 ._/-]*$/.test(s) && !s.endsWith(' ');
|
|
2867
|
-
const looksTyped = /^(true|false|yes|no|on|off|null|~)$/i.test(s) || /^[-+.]?\d/.test(s);
|
|
2868
|
-
return plain && !looksTyped ? s : JSON.stringify(s);
|
|
2869
|
-
}
|
|
2870
|
-
|
|
2871
|
-
function yamlBlock(key, text, indent) {
|
|
2872
|
-
const pad = ' '.repeat(indent);
|
|
2873
|
-
if (!text.includes('\n')) return [`${pad}${key}: ${yamlScalar(text)}`];
|
|
2874
|
-
return [`${pad}${key}: |`, ...text.split('\n').map((l) => `${pad} ${l}`)];
|
|
2875
|
-
}
|
|
2876
|
-
|
|
2877
|
-
function wbYamlText() {
|
|
2878
|
-
const wb = state.wb;
|
|
2879
|
-
const lines = [`name: ${yamlScalar(wb.name.trim() || 'my-workflow')}`];
|
|
2880
|
-
if (wb.description.trim()) lines.push(...yamlBlock('description', wb.description.trim(), 0));
|
|
2881
|
-
const stack = wbSkillStack(wb.steps);
|
|
2882
|
-
if (stack) {
|
|
2883
|
-
lines.push('skills:');
|
|
2884
|
-
for (const s of stack) lines.push(` - ${yamlScalar(s)}`);
|
|
2885
|
-
} else if (wb.steps.length) {
|
|
2886
|
-
lines.push('steps:');
|
|
2887
|
-
for (const s of wb.steps) {
|
|
2888
|
-
lines.push(` - id: ${yamlScalar(s.id)}`);
|
|
2889
|
-
if (s.name && s.name !== s.id) lines.push(` name: ${yamlScalar(s.name)}`);
|
|
2890
|
-
if (s.skill) lines.push(` skill: ${yamlScalar(s.skill)}`);
|
|
2891
|
-
if (s.prompt) lines.push(...yamlBlock('prompt', s.prompt, 4));
|
|
2892
|
-
if (s.model) lines.push(` model: ${yamlScalar(s.model)}`);
|
|
2893
|
-
if (s.runner) lines.push(` runner: ${yamlScalar(s.runner)}`);
|
|
2894
|
-
if (s.allowedTools) lines.push(` allowedTools: [${s.allowedTools.map(yamlScalar).join(', ')}]`);
|
|
2895
|
-
if (s.bashAllowlist) lines.push(` bashAllowlist: [${s.bashAllowlist.map(yamlScalar).join(', ')}]`);
|
|
2896
|
-
if (s.command) lines.push(...yamlBlock('command', s.command, 4));
|
|
2897
|
-
if (s.onFail) {
|
|
2898
|
-
lines.push(' onFail:', ` retry: ${yamlScalar(s.onFail.retry)}`, ` max: ${s.onFail.max ?? 2}`);
|
|
2899
|
-
}
|
|
2900
|
-
}
|
|
2901
|
-
} else {
|
|
2902
|
-
lines.push('skills: []');
|
|
2903
|
-
}
|
|
2904
|
-
return `${lines.join('\n')}\n`;
|
|
2905
|
-
}
|
|
2906
|
-
|
|
2907
|
-
function wbCountLabel() {
|
|
2908
|
-
const n = state.wb.steps.length;
|
|
2909
|
-
const noun = wbSkillStack(state.wb.steps) ? 'skill' : 'step';
|
|
2910
|
-
return `${n} ${noun}${n === 1 ? '' : 's'}`;
|
|
2911
|
-
}
|
|
2912
|
-
|
|
2913
|
-
function wbGapHtml(i) {
|
|
2914
|
-
return `<div class="wb-gap" data-gap="${i}"><div class="wb-gap-inner">drop to insert</div></div>`;
|
|
2915
|
-
}
|
|
2916
|
-
|
|
2917
|
-
function wbStepCardHtml(s, i) {
|
|
2918
|
-
const lib = (state.skillsList ?? []).find((k) => k.name === s.skill);
|
|
2919
|
-
let title;
|
|
2920
|
-
let desc;
|
|
2921
|
-
let badge = '';
|
|
2922
|
-
if (s.command) {
|
|
2923
|
-
title = s.name ?? s.id;
|
|
2924
|
-
desc = `$ ${s.command}${s.onFail ? ` — on fail retry from "${s.onFail.retry}" (×${s.onFail.max ?? 2})` : ''}`;
|
|
2925
|
-
badge = '<span class="wb-badge check">check</span>';
|
|
2926
|
-
} else if (s.skill) {
|
|
2927
|
-
title = s.skill;
|
|
2928
|
-
desc = lib
|
|
2929
|
-
? (lib.description ?? '')
|
|
2930
|
-
: 'Not in this repo or the team skills — the step runs on its plain prompt.';
|
|
2931
|
-
if (!lib) badge = '<span class="wb-badge unknown">unknown</span>';
|
|
2932
|
-
} else {
|
|
2933
|
-
title = s.name ?? s.id;
|
|
2934
|
-
desc = oneLine(s.prompt ?? '', 90);
|
|
2935
|
-
badge = '<span class="wb-badge">prompt</span>';
|
|
2936
|
-
}
|
|
2937
|
-
const icon = s.command
|
|
2938
|
-
? '<svg class="wb-ic" viewBox="0 0 24 24" style="stroke:var(--green)"><path d="M5 12l5 5 9-11"/></svg>'
|
|
2939
|
-
: `<svg class="wb-ic" viewBox="0 0 24 24"><path d="${SKILL_ICON}"/></svg>`;
|
|
2940
|
-
return `
|
|
2941
|
-
${wbGapHtml(i)}
|
|
2942
|
-
<div class="wb-step" draggable="true" data-idx="${i}">
|
|
2943
|
-
<svg class="grip" width="10" height="14" viewBox="0 0 10 16"><circle cx="2.5" cy="2.5" r="1.4"/><circle cx="7.5" cy="2.5" r="1.4"/><circle cx="2.5" cy="8" r="1.4"/><circle cx="7.5" cy="8" r="1.4"/><circle cx="2.5" cy="13.5" r="1.4"/><circle cx="7.5" cy="13.5" r="1.4"/></svg>
|
|
2944
|
-
<span class="num mono">${String(i + 1).padStart(2, '0')}</span>
|
|
2945
|
-
${icon}
|
|
2946
|
-
<div class="wb-step-main">
|
|
2947
|
-
<div class="wb-step-name mono">${esc(title)}</div>
|
|
2948
|
-
${desc ? `<div class="wb-step-desc">${esc(desc)}</div>` : ''}
|
|
2949
|
-
</div>
|
|
2950
|
-
${badge}
|
|
2951
|
-
<button type="button" class="wb-remove" data-wb-remove="${i}" title="Remove from flow">×</button>
|
|
2952
|
-
</div>`;
|
|
2953
|
-
}
|
|
2954
|
-
|
|
2955
|
-
function wbStepsHtml() {
|
|
2956
|
-
const steps = state.wb.steps;
|
|
2957
|
-
if (!steps.length) {
|
|
2958
|
-
return '<div class="wb-gap tall" data-gap="0"><div class="wb-gap-inner">drop a skill here — or Import a workflow.yaml</div></div>';
|
|
2959
|
-
}
|
|
2960
|
-
return `${steps.map(wbStepCardHtml).join('')}${wbGapHtml(steps.length)}
|
|
2961
|
-
<div class="wb-flow-note"><svg viewBox="0 0 24 24" width="12" height="12"><path d="M12 5v14M6 13l6 6 6-6"/></svg>runs top to bottom</div>`;
|
|
2962
|
-
}
|
|
2963
|
-
|
|
2964
|
-
function wbPaletteHtml() {
|
|
2965
|
-
const q = state.wb.query.trim().toLowerCase();
|
|
2966
|
-
const inFlow = new Set(state.wb.steps.map((s) => s.skill).filter(Boolean));
|
|
2967
|
-
const skills = (state.skillsList ?? []).filter(
|
|
2968
|
-
(s) => !q || s.name.toLowerCase().includes(q) || (s.description ?? '').toLowerCase().includes(q),
|
|
2969
|
-
);
|
|
2970
|
-
if (!skills.length) {
|
|
2971
|
-
return (state.skillsList ?? []).length
|
|
2972
|
-
? '<div class="dim" style="font-size:11.5px;padding:4px 2px 8px">No skills match.</div>'
|
|
2973
|
-
: '<div class="dim" style="font-size:11.5px;padding:4px 2px 8px;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>.</div>';
|
|
2974
|
-
}
|
|
2975
|
-
return skills
|
|
2976
|
-
.map(
|
|
2977
|
-
(s) => `
|
|
2978
|
-
<div class="wb-skill" draggable="true" data-skill="${esc(s.name)}" title="${esc(s.description ?? '')}">
|
|
2979
|
-
<svg class="wb-ic" viewBox="0 0 24 24"><path d="${SKILL_ICON}"/></svg>
|
|
2980
|
-
<span class="name mono">${esc(s.name)}</span>
|
|
2981
|
-
<span class="wb-skill-right">
|
|
2982
|
-
${inFlow.has(s.name) ? '<svg class="in-flow" viewBox="0 0 24 24"><path d="M5 12l5 5 9-11"/></svg>' : ''}
|
|
2983
|
-
<svg class="dots" viewBox="0 0 24 24"><circle cx="9" cy="5" r="1.6"/><circle cx="15" cy="5" r="1.6"/><circle cx="9" cy="12" r="1.6"/><circle cx="15" cy="12" r="1.6"/><circle cx="9" cy="19" r="1.6"/><circle cx="15" cy="19" r="1.6"/></svg>
|
|
2984
|
-
</span>
|
|
2985
|
-
</div>`,
|
|
2986
|
-
)
|
|
2987
|
-
.join('');
|
|
2988
|
-
}
|
|
2989
|
-
|
|
2990
|
-
function wbLoadChipsHtml() {
|
|
2991
|
-
const chips = state.workflows
|
|
2992
|
-
.map(
|
|
2993
|
-
(w) =>
|
|
2994
|
-
`<button type="button" class="chip-toggle ${state.wb.name === w.name ? 'on' : ''}" data-wb-load="${esc(w.name)}" title="${esc(w.description ?? '')}">${esc(w.name)}</button>`,
|
|
2995
|
-
)
|
|
2996
|
-
.join('');
|
|
2997
|
-
return `<span class="wb-k">edit</span>${chips}<button type="button" class="chip-toggle" data-wb-load="__new" title="Start an empty workflow">+ new</button>`;
|
|
2998
|
-
}
|
|
2999
|
-
|
|
3000
|
-
function renderWorkflowsView() {
|
|
3001
|
-
const wb = state.wb;
|
|
3002
|
-
$('#view-workflows').innerHTML = `
|
|
3003
|
-
<div class="wb-grid">
|
|
3004
|
-
<div class="wb-main"><div class="wb-inner">
|
|
3005
|
-
<div class="wb-head">
|
|
3006
|
-
<h1>Workflow builder</h1>
|
|
3007
|
-
${
|
|
3008
|
-
state.workflows.some((w) => w.name === wb.name.trim() && w.source === 'file')
|
|
3009
|
-
? `<button type="button" class="btn-text danger" data-wb-action="delete" title="Delete the saved workflow file">${BI.trash}Delete</button>`
|
|
3010
|
-
: ''
|
|
3011
|
-
}
|
|
3012
|
-
<button type="button" class="btn-text" data-wb-action="import">⬆ Import</button>
|
|
3013
|
-
<button type="button" class="btn-text" data-wb-action="export">⬇ Export</button>
|
|
3014
|
-
<button type="button" class="btn-dark" data-wb-action="save">✓ Save</button>
|
|
3015
|
-
</div>
|
|
3016
|
-
<div class="wb-meta">
|
|
3017
|
-
<div class="wb-name-pill">
|
|
3018
|
-
<svg viewBox="0 0 24 24"><path d="M4 6h16M4 12h10M4 18h13"/></svg>
|
|
3019
|
-
<input id="wb-name" value="${esc(wb.name)}" spellcheck="false" aria-label="Workflow name">
|
|
3020
|
-
</div>
|
|
3021
|
-
<span class="mono dim" id="wb-count">${wbCountLabel()}</span>
|
|
3022
|
-
</div>
|
|
3023
|
-
<div class="wb-load" id="wb-load">${wbLoadChipsHtml()}</div>
|
|
3024
|
-
<div class="wb-import" ${wb.importOpen ? '' : 'hidden'}>
|
|
3025
|
-
<div class="wb-import-label">Import workflow YAML</div>
|
|
3026
|
-
<textarea id="wb-import-text" rows="6" spellcheck="false" placeholder="name: my-flow skills: - test-conventions - commit-style">${esc(wb.importText)}</textarea>
|
|
3027
|
-
<div class="error-text" style="margin-top:7px" ${wb.importError ? '' : 'hidden'}>${esc(wb.importError)}</div>
|
|
3028
|
-
<div class="wb-import-actions">
|
|
3029
|
-
<button type="button" class="btn-dark" data-wb-action="do-import">Import</button>
|
|
3030
|
-
<button type="button" class="btn-ghost" data-wb-action="cancel-import">Cancel</button>
|
|
3031
|
-
</div>
|
|
3032
|
-
</div>
|
|
3033
|
-
<div id="wb-steps">${wbStepsHtml()}</div>
|
|
3034
|
-
</div></div>
|
|
3035
|
-
<aside class="wb-aside">
|
|
3036
|
-
<div class="section-label" style="margin:0 0 4px">Skills</div>
|
|
3037
|
-
<div class="wb-hint">Drag into the flow. Order is execution order — the agent applies them top to bottom.</div>
|
|
3038
|
-
<input id="wb-filter" class="filter-input" style="width:100%;margin:0 0 9px" placeholder="Filter skills…" value="${esc(wb.query)}">
|
|
3039
|
-
<div id="wb-palette">${wbPaletteHtml()}</div>
|
|
3040
|
-
<div class="wb-yaml-head">
|
|
3041
|
-
<span class="section-label" style="margin:0">workflow.yaml</span>
|
|
3042
|
-
<span class="spacer"></span>
|
|
3043
|
-
<button type="button" class="btn-text" id="wb-copy" data-wb-action="copy">${wb.copied ? '✓ Copied' : 'Copy'}</button>
|
|
3044
|
-
</div>
|
|
3045
|
-
<pre class="wb-yaml" id="wb-yaml"></pre>
|
|
3046
|
-
<div class="wb-note">
|
|
3047
|
-
<svg viewBox="0 0 24 24" width="12" height="12"><circle cx="12" cy="12" r="9"/><path d="M12 8v.5M12 11v5"/></svg>
|
|
3048
|
-
<span>Portable — export this file and import it in any repo running cezar.</span>
|
|
3049
|
-
</div>
|
|
3050
|
-
</aside>
|
|
3051
|
-
</div>`;
|
|
3052
|
-
$('#wb-yaml').textContent = wbYamlText();
|
|
3053
|
-
}
|
|
3054
|
-
|
|
3055
|
-
/* Structural change (add/move/remove/save): refresh the pieces that depend on
|
|
3056
|
-
the step list without touching the inputs — they keep focus. */
|
|
3057
|
-
function wbRefresh() {
|
|
3058
|
-
$('#wb-steps').innerHTML = wbStepsHtml();
|
|
3059
|
-
$('#wb-palette').innerHTML = wbPaletteHtml();
|
|
3060
|
-
$('#wb-count').textContent = wbCountLabel();
|
|
3061
|
-
$('#wb-load').innerHTML = wbLoadChipsHtml();
|
|
3062
|
-
$('#wb-yaml').textContent = wbYamlText();
|
|
3063
|
-
}
|
|
3064
|
-
|
|
3065
|
-
function wbSkillStep(skill, steps) {
|
|
3066
|
-
const used = new Set(steps.map((s) => s.id));
|
|
3067
|
-
let id = skill;
|
|
3068
|
-
for (let n = 2; used.has(id); n++) id = `${skill}-${n}`;
|
|
3069
|
-
return { id, name: skill, skill, prompt: '{{task}}' };
|
|
3070
|
-
}
|
|
3071
|
-
|
|
3072
|
-
function bindWorkflowsView() {
|
|
3073
|
-
const view = $('#view-workflows');
|
|
3074
|
-
|
|
3075
|
-
view.addEventListener('click', (e) => {
|
|
3076
|
-
if (!state.wb) return;
|
|
3077
|
-
const rm = e.target.closest('[data-wb-remove]');
|
|
3078
|
-
if (rm) {
|
|
3079
|
-
state.wb.steps.splice(Number(rm.dataset.wbRemove), 1);
|
|
3080
|
-
wbRefresh();
|
|
3081
|
-
return;
|
|
3082
|
-
}
|
|
3083
|
-
const load = e.target.closest('[data-wb-load]');
|
|
3084
|
-
if (load) {
|
|
3085
|
-
const name = load.dataset.wbLoad;
|
|
3086
|
-
const w = state.workflows.find((x) => x.name === name);
|
|
3087
|
-
state.wb = name === '__new' ? wbEmpty() : w ? wbFrom(w) : state.wb;
|
|
3088
|
-
renderWorkflowsView();
|
|
3089
|
-
return;
|
|
3090
|
-
}
|
|
3091
|
-
const btn = e.target.closest('[data-wb-action]');
|
|
3092
|
-
if (!btn) return;
|
|
3093
|
-
const action = btn.dataset.wbAction;
|
|
3094
|
-
if (action === 'import') {
|
|
3095
|
-
state.wb.importOpen = !state.wb.importOpen;
|
|
3096
|
-
state.wb.importError = '';
|
|
3097
|
-
renderWorkflowsView();
|
|
3098
|
-
if (state.wb.importOpen) $('#wb-import-text')?.focus();
|
|
3099
|
-
} else if (action === 'cancel-import') {
|
|
3100
|
-
state.wb.importOpen = false;
|
|
3101
|
-
state.wb.importText = '';
|
|
3102
|
-
state.wb.importError = '';
|
|
3103
|
-
renderWorkflowsView();
|
|
3104
|
-
} else if (action === 'do-import') void wbImport(btn);
|
|
3105
|
-
else if (action === 'export') wbExport();
|
|
3106
|
-
else if (action === 'copy') wbCopy();
|
|
3107
|
-
else if (action === 'save') void wbSave(btn);
|
|
3108
|
-
else if (action === 'delete') void wbDelete(btn);
|
|
3109
|
-
});
|
|
3110
|
-
|
|
3111
|
-
view.addEventListener('input', (e) => {
|
|
3112
|
-
if (!state.wb) return;
|
|
3113
|
-
if (e.target.id === 'wb-name') {
|
|
3114
|
-
state.wb.name = e.target.value;
|
|
3115
|
-
$('#wb-yaml').textContent = wbYamlText();
|
|
3116
|
-
$('#wb-load').innerHTML = wbLoadChipsHtml();
|
|
3117
|
-
} else if (e.target.id === 'wb-filter') {
|
|
3118
|
-
state.wb.query = e.target.value;
|
|
3119
|
-
$('#wb-palette').innerHTML = wbPaletteHtml();
|
|
3120
|
-
} else if (e.target.id === 'wb-import-text') {
|
|
3121
|
-
state.wb.importText = e.target.value;
|
|
3122
|
-
}
|
|
3123
|
-
});
|
|
3124
|
-
|
|
3125
|
-
// Drag & drop — palette pills copy in, step cards move; gaps between cards
|
|
3126
|
-
// are the drop targets. Class flips only (no re-render mid-drag: replacing
|
|
3127
|
-
// the dragged node cancels an HTML5 drag).
|
|
3128
|
-
view.addEventListener('dragstart', (e) => {
|
|
3129
|
-
const skill = e.target.closest('.wb-skill');
|
|
3130
|
-
const step = e.target.closest('.wb-step');
|
|
3131
|
-
if (!skill && !step) return;
|
|
3132
|
-
try {
|
|
3133
|
-
e.dataTransfer.setData('text/plain', 'x'); // Firefox needs data to drag
|
|
3134
|
-
e.dataTransfer.effectAllowed = skill ? 'copyMove' : 'move';
|
|
3135
|
-
} catch {
|
|
3136
|
-
// older engines — the drag still works
|
|
3137
|
-
}
|
|
3138
|
-
state.wbDrag = skill
|
|
3139
|
-
? { from: 'palette', skill: skill.dataset.skill }
|
|
3140
|
-
: { from: 'step', index: Number(step.dataset.idx) };
|
|
3141
|
-
// Deferred: repainting the dragged node in the same tick aborts the drag.
|
|
3142
|
-
setTimeout(() => {
|
|
3143
|
-
view.classList.add('wb-dragging');
|
|
3144
|
-
step?.classList.add('drag-src');
|
|
3145
|
-
}, 0);
|
|
3146
|
-
});
|
|
3147
|
-
view.addEventListener('dragover', (e) => {
|
|
3148
|
-
const gap = e.target.closest('.wb-gap');
|
|
3149
|
-
if (!gap || !state.wbDrag) return;
|
|
3150
|
-
e.preventDefault();
|
|
3151
|
-
e.dataTransfer.dropEffect = state.wbDrag.from === 'palette' ? 'copy' : 'move';
|
|
3152
|
-
gap.classList.add('over');
|
|
3153
|
-
});
|
|
3154
|
-
view.addEventListener('dragleave', (e) => {
|
|
3155
|
-
e.target.closest('.wb-gap')?.classList.remove('over');
|
|
3156
|
-
});
|
|
3157
|
-
view.addEventListener('drop', (e) => {
|
|
3158
|
-
const gap = e.target.closest('.wb-gap');
|
|
3159
|
-
const d = state.wbDrag;
|
|
3160
|
-
state.wbDrag = null;
|
|
3161
|
-
view.classList.remove('wb-dragging');
|
|
3162
|
-
if (!gap || !d) return;
|
|
3163
|
-
e.preventDefault();
|
|
3164
|
-
const at = Number(gap.dataset.gap);
|
|
3165
|
-
const steps = state.wb.steps;
|
|
3166
|
-
if (d.from === 'step') {
|
|
3167
|
-
const [moved] = steps.splice(d.index, 1);
|
|
3168
|
-
steps.splice(d.index < at ? at - 1 : at, 0, moved);
|
|
3169
|
-
} else {
|
|
3170
|
-
if (steps.length >= WB_MAX_STEPS) {
|
|
3171
|
-
alertBar(`A workflow holds at most ${WB_MAX_STEPS} steps.`);
|
|
3172
|
-
wbRefresh();
|
|
3173
|
-
return;
|
|
3174
|
-
}
|
|
3175
|
-
steps.splice(at, 0, wbSkillStep(d.skill, steps));
|
|
3176
|
-
}
|
|
3177
|
-
wbRefresh();
|
|
3178
|
-
});
|
|
3179
|
-
view.addEventListener('dragend', () => {
|
|
3180
|
-
state.wbDrag = null;
|
|
3181
|
-
view.classList.remove('wb-dragging');
|
|
3182
|
-
for (const el of view.querySelectorAll('.over, .drag-src')) el.classList.remove('over', 'drag-src');
|
|
3183
|
-
});
|
|
3184
|
-
}
|
|
3185
|
-
|
|
3186
|
-
async function wbImport(btn) {
|
|
3187
|
-
const text = state.wb.importText.trim();
|
|
3188
|
-
if (!text) {
|
|
3189
|
-
$('#wb-import-text')?.focus();
|
|
3190
|
-
return;
|
|
3191
|
-
}
|
|
3192
|
-
btn.disabled = true;
|
|
3193
|
-
try {
|
|
3194
|
-
const res = await fetch('/api/workflows/parse', {
|
|
3195
|
-
method: 'POST',
|
|
3196
|
-
headers: { 'content-type': 'application/json' },
|
|
3197
|
-
body: JSON.stringify({ yaml: text }),
|
|
3198
|
-
});
|
|
3199
|
-
const data = await res.json();
|
|
3200
|
-
if (!res.ok) throw new Error(data.error ?? `HTTP ${res.status}`);
|
|
3201
|
-
state.wb = { ...wbEmpty(), name: data.name, description: data.description ?? '', steps: data.steps };
|
|
3202
|
-
renderWorkflowsView();
|
|
3203
|
-
alertBar(`Imported "${data.name}" — review, then Save.`);
|
|
3204
|
-
} catch (err) {
|
|
3205
|
-
state.wb.importError = err.message;
|
|
3206
|
-
renderWorkflowsView();
|
|
3207
|
-
} finally {
|
|
3208
|
-
btn.disabled = false;
|
|
3209
|
-
}
|
|
3210
|
-
}
|
|
3211
|
-
|
|
3212
|
-
function wbSlug() {
|
|
3213
|
-
return (
|
|
3214
|
-
state.wb.name
|
|
3215
|
-
.trim()
|
|
3216
|
-
.toLowerCase()
|
|
3217
|
-
.replace(/[^a-z0-9]+/g, '-')
|
|
3218
|
-
.replace(/^-+|-+$/g, '') || 'workflow'
|
|
3219
|
-
);
|
|
3220
|
-
}
|
|
3221
|
-
|
|
3222
|
-
function wbExport() {
|
|
3223
|
-
const blob = new Blob([wbYamlText()], { type: 'text/yaml' });
|
|
3224
|
-
const url = URL.createObjectURL(blob);
|
|
3225
|
-
const a = document.createElement('a');
|
|
3226
|
-
a.href = url;
|
|
3227
|
-
a.download = `${wbSlug()}.yaml`;
|
|
3228
|
-
document.body.appendChild(a);
|
|
3229
|
-
a.click();
|
|
3230
|
-
a.remove();
|
|
3231
|
-
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
|
3232
|
-
}
|
|
3233
|
-
|
|
3234
|
-
let wbCopyTimer = null;
|
|
3235
|
-
function wbCopy() {
|
|
3236
|
-
void navigator.clipboard?.writeText(wbYamlText()).catch(() => {});
|
|
3237
|
-
state.wb.copied = true;
|
|
3238
|
-
const btn = $('#wb-copy');
|
|
3239
|
-
if (btn) btn.textContent = '✓ Copied';
|
|
3240
|
-
clearTimeout(wbCopyTimer);
|
|
3241
|
-
wbCopyTimer = setTimeout(() => {
|
|
3242
|
-
if (state.wb) state.wb.copied = false;
|
|
3243
|
-
const b = $('#wb-copy');
|
|
3244
|
-
if (b) b.textContent = 'Copy';
|
|
3245
|
-
}, 1600);
|
|
3246
|
-
}
|
|
3247
|
-
|
|
3248
|
-
async function wbSave(btn) {
|
|
3249
|
-
const wb = state.wb;
|
|
3250
|
-
const name = wb.name.trim();
|
|
3251
|
-
if (!name) {
|
|
3252
|
-
$('#wb-name')?.focus();
|
|
3253
|
-
return;
|
|
3254
|
-
}
|
|
3255
|
-
if (!wb.steps.length) {
|
|
3256
|
-
alertBar('Add at least one step first.');
|
|
3257
|
-
return;
|
|
3258
|
-
}
|
|
3259
|
-
const stack = wbSkillStack(wb.steps);
|
|
3260
|
-
const body = {
|
|
3261
|
-
name,
|
|
3262
|
-
...(wb.description.trim() ? { description: wb.description.trim() } : {}),
|
|
3263
|
-
...(stack ? { skills: stack } : { steps: wb.steps }),
|
|
3264
|
-
};
|
|
3265
|
-
const post = (payload) =>
|
|
3266
|
-
fetch('/api/workflows', {
|
|
3267
|
-
method: 'POST',
|
|
3268
|
-
headers: { 'content-type': 'application/json' },
|
|
3269
|
-
body: JSON.stringify(payload),
|
|
3270
|
-
});
|
|
3271
|
-
btn.disabled = true;
|
|
3272
|
-
try {
|
|
3273
|
-
let res = await post(body);
|
|
3274
|
-
let data = await res.json();
|
|
3275
|
-
if (res.status === 409 && data.exists) {
|
|
3276
|
-
if (!confirm(`"${name}" already exists — overwrite it?`)) return;
|
|
3277
|
-
res = await post({ ...body, overwrite: true });
|
|
3278
|
-
data = await res.json();
|
|
3279
|
-
}
|
|
3280
|
-
if (!res.ok) throw new Error(data.error ?? `HTTP ${res.status}`);
|
|
3281
|
-
alertBar(`Saved — ${String(data.path).split('/').pop()}`);
|
|
3282
|
-
const workflowsRes = await getJson('/api/workflows');
|
|
3283
|
-
setWorkflowOptions(workflowsRes.workflows);
|
|
3284
|
-
renderWorkflowsView(); // chips + the Delete button now reflect the file
|
|
3285
|
-
} catch (err) {
|
|
3286
|
-
alertBar(err.message);
|
|
3287
|
-
} finally {
|
|
3288
|
-
btn.disabled = false;
|
|
3289
|
-
}
|
|
3290
|
-
}
|
|
3291
|
-
|
|
3292
|
-
async function wbDelete(btn) {
|
|
3293
|
-
const name = state.wb.name.trim();
|
|
3294
|
-
const fileWf = state.workflows.find((w) => w.name === name && w.source === 'file');
|
|
3295
|
-
if (!fileWf) return;
|
|
3296
|
-
if (!confirm(`Delete workflow "${name}" (${String(fileWf.path ?? '').split('/').pop()})?`)) return;
|
|
3297
|
-
btn.disabled = true;
|
|
3298
|
-
try {
|
|
3299
|
-
const res = await fetch(`/api/workflows/${encodeURIComponent(name)}`, { method: 'DELETE' });
|
|
3300
|
-
const data = await res.json().catch(() => ({}));
|
|
3301
|
-
if (!res.ok) throw new Error(data.error ?? `HTTP ${res.status}`);
|
|
3302
|
-
alertBar(`Deleted "${name}".`);
|
|
3303
|
-
const workflowsRes = await getJson('/api/workflows');
|
|
3304
|
-
setWorkflowOptions(workflowsRes.workflows);
|
|
3305
|
-
state.wb = wbEmpty();
|
|
3306
|
-
renderWorkflowsView();
|
|
3307
|
-
} catch (err) {
|
|
3308
|
-
alertBar(err.message);
|
|
3309
|
-
btn.disabled = false;
|
|
3310
|
-
}
|
|
3311
|
-
}
|
|
3312
|
-
|
|
3313
|
-
// ---- bookmarklet generator (spec 011) ------------------------------------------
|
|
3314
|
-
|
|
3315
|
-
/* The javascript: URL dragged to the bookmarks bar. On a GitHub PR/issue it
|
|
3316
|
-
scans localhost:4321–4330 for cockpits (GET /api/health, 800 ms timeout,
|
|
3317
|
-
CORS-enabled server-side), picks the one whose repo.remote matches the
|
|
3318
|
-
page's owner/repo (fallback: first alive) and opens /new there. The whole
|
|
3319
|
-
program is one URI-encoded expression — no external state. */
|
|
3320
|
-
function bookmarkletUrl(skillName, auto, key) {
|
|
3321
|
-
const enc = (s) => encodeURIComponent(s).replaceAll("'", '%27'); // ' survives encodeURIComponent — would break the JS string
|
|
3322
|
-
const query = `${skillName ? `skill=${enc(skillName)}&` : ''}auto=${auto ? '1' : '0'}&key=${enc(key)}&ref=`;
|
|
3323
|
-
const code =
|
|
3324
|
-
`(async()=>{const m=location.href.match(/^https:\\/\\/github\\.com\\/([^\\/]+)\\/([^\\/]+)\\/(pull|issues)\\/\\d+/);` +
|
|
3325
|
-
`if(!m){alert('Open a GitHub PR or issue first');return;}` +
|
|
3326
|
-
`const q='${query}'+encodeURIComponent(location.href);` +
|
|
3327
|
-
`const f=async p=>{try{const c=new AbortController();setTimeout(()=>c.abort(),800);` +
|
|
3328
|
-
`const r=await fetch('http://localhost:'+p+'/api/health',{signal:c.signal});const h=await r.json();` +
|
|
3329
|
-
`return{p,remote:(h.repo&&h.repo.remote)||''}}catch(e){return null}};` +
|
|
3330
|
-
`const rs=(await Promise.all([4321,4322,4323,4324,4325,4326,4327,4328,4329,4330].map(f))).filter(Boolean);` +
|
|
3331
|
-
`if(!rs.length){alert('cezar cockpit is not running - start it with: npx cezar');return;}` +
|
|
3332
|
-
`const t=rs.find(r=>r.remote.includes(m[1]+'/'+m[2]))||rs[0];` +
|
|
3333
|
-
`open('http://localhost:'+t.p+'/new?'+q,'_blank');})();`;
|
|
3334
|
-
return `javascript:${encodeURIComponent(code)}`;
|
|
3335
|
-
}
|
|
3336
|
-
|
|
3337
|
-
function bookmarkletShellHtml() {
|
|
3338
|
-
return `
|
|
3339
|
-
<div class="inner" id="bm-panel">
|
|
3340
|
-
<h1>Run from GitHub</h1>
|
|
3341
|
-
<div class="bm-help">Drag a button below to your browser's bookmarks bar.
|
|
3342
|
-
On any GitHub PR or issue, click it — it finds your running cockpit on
|
|
3343
|
-
localhost (ports 4321–4330) and picks the one serving that repo.
|
|
3344
|
-
The cockpit must be running: <span class="mono">npx cezar</span>.</div>
|
|
3345
|
-
<label class="bm-auto"><input type="checkbox" id="bm-auto">
|
|
3346
|
-
One-click launch (auto-submit) <span class="dim">— re-drag the buttons after changing this</span></label>
|
|
3347
|
-
<div id="bm-generic"></div>
|
|
3348
|
-
<input id="bm-filter" class="bm-filter" placeholder="Filter skills…">
|
|
3349
|
-
<div id="bm-list"></div>
|
|
3350
|
-
</div>`;
|
|
3351
|
-
}
|
|
3352
|
-
|
|
3353
|
-
function bmRowHtml(label, url, hint) {
|
|
3354
|
-
return `
|
|
3355
|
-
<div class="bm-row">
|
|
3356
|
-
<a class="bm" draggable="true" href="${esc(url)}" title="Drag me to your bookmarks bar">⚡ ${esc(label)}</a>
|
|
3357
|
-
<button type="button" class="bm-copy" data-copy="${esc(url)}" title="Copy the bookmarklet URL">Copy</button>
|
|
3358
|
-
${hint ? `<span class="dim bm-hint">${esc(hint)}</span>` : ''}
|
|
3359
|
-
</div>`;
|
|
3360
|
-
}
|
|
3361
|
-
|
|
3362
|
-
function renderBmLinks(skills) {
|
|
3363
|
-
const key = state.launchKey ?? '';
|
|
3364
|
-
// Generic launcher: no skill, auto forced off — it only prefills the form.
|
|
3365
|
-
$('#bm-generic').innerHTML = bmRowHtml(
|
|
3366
|
-
'cezar: this PR/issue',
|
|
3367
|
-
bookmarkletUrl('', false, key),
|
|
3368
|
-
'prefills the form — nothing starts by itself',
|
|
3369
|
-
);
|
|
3370
|
-
const filter = state.bmFilter.trim().toLowerCase();
|
|
3371
|
-
const filtered = skills.filter((s) => s.name.toLowerCase().includes(filter));
|
|
3372
|
-
$('#bm-list').innerHTML = filtered.length
|
|
3373
|
-
? filtered
|
|
3374
|
-
.map((s) => bmRowHtml(`/${s.name}`, bookmarkletUrl(s.name, state.bmAuto, key), s.source))
|
|
3375
|
-
.join('')
|
|
3376
|
-
: `<div class="dim">${skills.length ? '(no skills match)' : '(no skills yet — the generic launcher above still works)'}</div>`;
|
|
3377
|
-
}
|
|
3378
|
-
|
|
3379
|
-
async function bindBookmarklets(skills) {
|
|
3380
|
-
if (state.launchKey === null) {
|
|
3381
|
-
try {
|
|
3382
|
-
state.launchKey = (await getJson('/api/launch-key')).key;
|
|
3383
|
-
} catch {
|
|
3384
|
-
state.launchKey = ''; // degraded: bookmarklets still work, auto-start won't
|
|
3385
|
-
}
|
|
3386
|
-
}
|
|
3387
|
-
const panel = $('#bm-panel');
|
|
3388
|
-
if (!panel) return; // skills view re-rendered meanwhile
|
|
3389
|
-
const autoBox = $('#bm-auto');
|
|
3390
|
-
autoBox.checked = state.bmAuto;
|
|
3391
|
-
autoBox.addEventListener('change', () => {
|
|
3392
|
-
state.bmAuto = autoBox.checked;
|
|
3393
|
-
renderBmLinks(skills);
|
|
3394
|
-
});
|
|
3395
|
-
const filterBox = $('#bm-filter');
|
|
3396
|
-
filterBox.value = state.bmFilter;
|
|
3397
|
-
filterBox.addEventListener('input', () => {
|
|
3398
|
-
state.bmFilter = filterBox.value;
|
|
3399
|
-
renderBmLinks(skills);
|
|
3400
|
-
});
|
|
3401
|
-
panel.addEventListener('click', async (e) => {
|
|
3402
|
-
const link = e.target.closest('a.bm');
|
|
3403
|
-
if (link) {
|
|
3404
|
-
// The cockpit page never executes the javascript: URL itself — the
|
|
3405
|
-
// links are only a drag source (spec 011 §5).
|
|
3406
|
-
e.preventDefault();
|
|
3407
|
-
alertBar('Drag me to your bookmarks bar');
|
|
3408
|
-
return;
|
|
3409
|
-
}
|
|
3410
|
-
const copy = e.target.closest('button[data-copy]');
|
|
3411
|
-
if (copy) {
|
|
3412
|
-
try {
|
|
3413
|
-
await navigator.clipboard.writeText(copy.dataset.copy);
|
|
3414
|
-
alertBar('Bookmarklet URL copied.');
|
|
3415
|
-
} catch {
|
|
3416
|
-
alertBar('Copy failed — drag the button instead.');
|
|
3417
|
-
}
|
|
3418
|
-
}
|
|
3419
|
-
});
|
|
3420
|
-
renderBmLinks(skills);
|
|
3421
|
-
}
|
|
3422
|
-
|
|
3423
|
-
init().catch((err) => {
|
|
3424
|
-
document.body.insertAdjacentHTML(
|
|
3425
|
-
'beforeend',
|
|
3426
|
-
`<div style="position:fixed;bottom:12px;left:12px;color:#f85149">init failed: ${esc(err.message)}</div>`,
|
|
3427
|
-
);
|
|
3428
|
-
});
|