spectoflow 0.12.0

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.
Files changed (44) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +133 -0
  3. package/bin/spectoflow.js +170 -0
  4. package/lib/adapters.js +120 -0
  5. package/lib/detect.js +34 -0
  6. package/lib/manifest.js +36 -0
  7. package/lib/ownership.js +34 -0
  8. package/lib/update.js +78 -0
  9. package/package.json +30 -0
  10. package/templates/AGENTS.md +66 -0
  11. package/templates/agents/architect.md +54 -0
  12. package/templates/agents/business-analyst.md +53 -0
  13. package/templates/agents/code-reviewer.md +58 -0
  14. package/templates/agents/developer.md +73 -0
  15. package/templates/agents/devops.md +59 -0
  16. package/templates/agents/product-manager.md +53 -0
  17. package/templates/agents/qa-engineer.md +63 -0
  18. package/templates/agents/security-engineer.md +59 -0
  19. package/templates/agents/tech-lead.md +52 -0
  20. package/templates/agents/ux-designer.md +50 -0
  21. package/templates/capabilities.md +15 -0
  22. package/templates/config.json +10 -0
  23. package/templates/dashboard/orchestrator.js +117 -0
  24. package/templates/dashboard/public/app.js +747 -0
  25. package/templates/dashboard/public/charts.js +192 -0
  26. package/templates/dashboard/public/icons.js +28 -0
  27. package/templates/dashboard/public/index.html +226 -0
  28. package/templates/dashboard/public/stats.js +32 -0
  29. package/templates/dashboard/public/styles.css +426 -0
  30. package/templates/dashboard/runner.js +77 -0
  31. package/templates/dashboard/server.js +115 -0
  32. package/templates/lib/store.js +289 -0
  33. package/templates/policy.md +11 -0
  34. package/templates/skills/analyze-requirements/SKILL.md +67 -0
  35. package/templates/skills/brainstorm/SKILL.md +58 -0
  36. package/templates/skills/code-review/SKILL.md +67 -0
  37. package/templates/skills/implement/SKILL.md +80 -0
  38. package/templates/skills/security-review/SKILL.md +70 -0
  39. package/templates/skills/write-adr/SKILL.md +61 -0
  40. package/templates/skills/write-e2e-tests/SKILL.md +99 -0
  41. package/templates/skills/write-plan/SKILL.md +66 -0
  42. package/templates/skills/write-spec/SKILL.md +66 -0
  43. package/templates/skills/write-tests/SKILL.md +80 -0
  44. package/templates/workflow.md +15 -0
@@ -0,0 +1,747 @@
1
+ 'use strict';
2
+ const STATUS = { todo:'To do', in_progress:'In progress', to_validate:'To validate', to_analyze:'To analyze', done:'Done', blocked:'Blocked' };
3
+ let P = null, openTaskId = null;
4
+ let filter = { status: 'all', q: '' }; // board filter state — client-side only, read-only
5
+ let backlogFilter = { status: 'all', q: '' }; // backlog filter state — independent from the board's
6
+ let backlogSort = { col: 'id', dir: 'asc' }; // backlog sort state — client-side only
7
+
8
+ const $ = (s,r=document)=>r.querySelector(s);
9
+ const $$ = (s,r=document)=>[...r.querySelectorAll(s)];
10
+ const el=(t,c,x)=>{const e=document.createElement(t); if(c)e.className=c; if(x!=null)e.textContent=x; return e;};
11
+ const allTasks=()=> (P.plans||[]).flatMap(pl=>pl.phases.flatMap(ph=>ph.tasks.map(t=>({...t,file:pl.file}))));
12
+ const runtimeTests=(id)=> (P.runtime&&P.runtime.tests&&P.runtime.tests[id])||null;
13
+
14
+ async function load(){
15
+ const r = await fetch('/api/project'); P = await r.json(); render();
16
+ if(openTaskId) openDrawer(openTaskId,true);
17
+ }
18
+ function connect(){
19
+ const es = new EventSource('/api/events');
20
+ es.onopen = ()=>{ $('#sync').classList.remove('offline'); $('#syncLabel').textContent='live'; };
21
+ es.onmessage = (ev)=>{
22
+ let m; try{ m=JSON.parse(ev.data); }catch{ return; }
23
+ if(m.type==='change'||m.type==='message') return load(); // messages live from runtime.messages
24
+ if(m.type==='run-start'||m.type==='run-end') { chatState.forEach(st=>{ st.rawBlock=null; }); return; }
25
+ if(m.type==='run-line') return appendRaw(m.chunk); // raw output is ephemeral (not logged)
26
+ };
27
+ es.onerror = ()=>{ $('#sync').classList.add('offline'); $('#syncLabel').textContent='offline'; };
28
+ }
29
+ // The chat transcript is the view of runtime.messages, shared by the floating widget (#chatLog)
30
+ // and the Chat tab (#chatTabLog) via renderChatLog(container) — same messages, same markup, so
31
+ // they never drift. Each container tracks its own render/raw state (a container can be rendered
32
+ // into while hidden, e.g. the tab behind the widget) so neither surface clobbers the other.
33
+ // Render incrementally (by message id) so a live raw-output block isn't wiped by a re-render; raw
34
+ // run output streams into an ephemeral <pre> block appended in order, per container.
35
+ const chatState=new Map(); // container element -> { rendered:Set<id>, rawBlock:Element|null }
36
+ function stateFor(container){
37
+ let st=chatState.get(container);
38
+ if(!st){ st={rendered:new Set(),rawBlock:null}; chatState.set(container,st); }
39
+ return st;
40
+ }
41
+ function chatContainers(){ return [$('#chatLog'),$('#chatTabLog')].filter(Boolean); }
42
+ function scrollChat(container){ container.scrollTop=container.scrollHeight; }
43
+ function clearIdle(container){ const i=container.querySelector('.chat-idle'); if(i) i.remove(); }
44
+ function bubble(m){
45
+ if(m.role==='user'){ const d=el('div','msg you'); d.append(el('div','bubble',m.text)); return d; }
46
+ const wrap=el('div','msg agentmsg k-'+(m.kind||'message'));
47
+ wrap.append(el('div','msg-role', m.role + (m.agent&&m.agent!==m.role?(' · '+m.agent):'')));
48
+ wrap.append(el('div','bubble',m.text));
49
+ return wrap;
50
+ }
51
+ function renderChatLog(container){
52
+ if(!container) return;
53
+ const st=stateFor(container); const msgs=(P.runtime&&P.runtime.messages)||[];
54
+ if(msgs.length) clearIdle(container);
55
+ let added=false;
56
+ for(const m of msgs){ if(st.rendered.has(m.id)) continue; st.rendered.add(m.id); container.append(bubble(m)); added=true; }
57
+ if(added) scrollChat(container);
58
+ renderApproval(container);
59
+ }
60
+ function renderApproval(container){
61
+ if(!container) return;
62
+ const o=(P.runtime&&P.runtime.orchestration)||null;
63
+ let row=container.querySelector('.approval'); if(row) row.remove();
64
+ if(!o || o.status!=='awaiting_approval') return;
65
+ row=el('div','approval');
66
+ row.append(el('div','msg-role','orchestrator · awaiting approval'));
67
+ const a=el('button','btn primary','Approve'); a.addEventListener('click',()=>approve('approve'));
68
+ const c=el('button','btn','Cancel'); c.addEventListener('click',()=>approve('cancel'));
69
+ const acts=el('div','c-actions'); acts.append(a,c); row.append(acts);
70
+ container.append(row); scrollChat(container);
71
+ }
72
+ function appendRaw(chunk){
73
+ chatContainers().forEach(container=>{
74
+ const st=stateFor(container);
75
+ clearIdle(container);
76
+ if(!st.rawBlock){ st.rawBlock=el('pre','msg agent'); container.append(st.rawBlock); }
77
+ st.rawBlock.textContent += chunk; // single text node → correct preformatted wrapping
78
+ scrollChat(container);
79
+ });
80
+ }
81
+ function setChat(open){
82
+ $('#chat').setAttribute('aria-hidden', open?'false':'true');
83
+ $('#chatFab').classList.toggle('is-open',open);
84
+ try{ localStorage.setItem('spf-chat', open?'1':'0'); }catch{}
85
+ if(open) setTimeout(()=>$('#runPrompt').focus(),60);
86
+ }
87
+ // doRun/doOrchestrate default to the floating widget's textarea/select; the Chat tab has its own
88
+ // #tabRunPrompt/#tabRunAgent (an id can't be shared by two elements) and passes them explicitly —
89
+ // one code path, same endpoints, for both surfaces.
90
+ async function doRun(promptEl,agentEl){
91
+ promptEl=promptEl||$('#runPrompt'); agentEl=agentEl||$('#runAgent');
92
+ const prompt=promptEl.value.trim(); if(!prompt) return;
93
+ const agent=agentEl.value;
94
+ await fetch('/api/run',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({prompt,agent})});
95
+ promptEl.value=''; // the prompt renders as a bubble from the message log
96
+ }
97
+ async function doOrchestrate(promptEl){
98
+ promptEl=promptEl||$('#runPrompt');
99
+ const prompt=promptEl.value.trim(); if(!prompt) return;
100
+ await fetch('/api/orchestrate',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({request:prompt})});
101
+ promptEl.value='';
102
+ }
103
+ async function approve(decision){ await fetch('/api/orchestrate/approve',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({decision})}); }
104
+ async function patchTask(id,patch){ flash(); await fetch('/api/task/'+encodeURIComponent(id),{method:'PATCH',headers:{'Content-Type':'application/json'},body:JSON.stringify(patch)}); }
105
+ async function addComment(id,text,action){ flash(); await fetch('/api/task/'+encodeURIComponent(id)+'/comment',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({text,action})}); }
106
+ async function toggleStep(name){ flash(); await fetch('/api/workflow/toggle',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name})}); }
107
+ function flash(){ const s=$('#sync'); s.classList.add('saving'); $('#syncLabel').textContent='writing…'; setTimeout(()=>{ s.classList.remove('saving'); $('#syncLabel').textContent='live'; },800); }
108
+
109
+ function render(){
110
+ const c = P.config||{};
111
+ $('#projectName').textContent = c.projectType || 'project';
112
+ $('#agentChip').textContent = c.agent||'claude';
113
+ $('#langChip').textContent = c.language||'en';
114
+ $('#modeChip').textContent = c.mode||'semi';
115
+ $('#brandSub').textContent = (c.mode||'semi') + ' · ' + (c.language||'en');
116
+ // agent select — widget (#runAgent) and Chat tab (#tabRunAgent) each get their own populated
117
+ // <select>, since an id can't be shared by two elements; same option list, same source of truth.
118
+ const runners=Object.keys((c.runners)||{claude:1});
119
+ [$('#runAgent'),$('#tabRunAgent')].forEach(sel=>{
120
+ if(!sel) return;
121
+ if(sel.options.length!==runners.length){ sel.innerHTML=''; runners.forEach(k=>{ const o=document.createElement('option'); o.value=k; o.textContent=k; sel.append(o); }); if(c.agent) sel.value=c.agent; }
122
+ });
123
+ const s=SpectoStats.stats(P);
124
+ const meterFill=$('#globalMeterFill');
125
+ if(meterFill) meterFill.style.width=(s.pct||0)+'%';
126
+ const meter=$('#globalMeter');
127
+ if(meter) meter.title=`Global progress: ${s.pct}% (${s.done}/${s.total} tasks)`;
128
+ renderOverview(); renderBoard(); renderBacklog(); renderWorkflow(); renderTeam();
129
+ renderChatLog($('#chatLog')); renderChatLog($('#chatTabLog'));
130
+ renderSidebar(); renderRequests(); renderInfo();
131
+ applyActiveTab(); // re-apply the current tab so an SSE-driven re-render never resets to Board
132
+ }
133
+
134
+ // ---- Requests tab: tasks awaiting review/input (to_validate / to_analyze) ----
135
+ function renderRequests(){
136
+ const list=$('#requestsList'); if(!list) return;
137
+ const toAsk=SpectoStats.stats(P).toAsk||[];
138
+ const count=$('#requestsCount'); if(count) count.textContent=toAsk.length;
139
+ list.innerHTML='';
140
+ if(!toAsk.length){ list.append(li('empty','Nothing awaiting you.')); return; }
141
+ toAsk.forEach(t=>{
142
+ const row=el('li','request-row'); row.tabIndex=0;
143
+ row.append(el('span','request-id',t.id));
144
+ row.append(el('span','request-title',t.title));
145
+ row.append(el('span','chip s-'+t.status,STATUS[t.status]||t.status));
146
+ row.append(el('span','request-file',t.file));
147
+ const open=()=>openDrawer(t.id);
148
+ row.addEventListener('click',open);
149
+ row.addEventListener('keydown',e=>{ if(e.key==='Enter')open(); });
150
+ list.append(row);
151
+ });
152
+ }
153
+
154
+ // ---- right sidebar: "Journal" (read-only runtime.messages feed) ----
155
+ function renderSidebar(){ renderJournal(); }
156
+ function renderJournal(){
157
+ const box=$('#journal'); if(!box) return;
158
+ const msgs=((P.runtime&&P.runtime.messages)||[]).slice().reverse(); // reverse-chronological
159
+ $('#journalCount').textContent=msgs.length;
160
+ box.innerHTML='';
161
+ if(!msgs.length){ box.append(el('div','empty','No activity yet.')); return; }
162
+ msgs.forEach(m=>{
163
+ const row=el('div','journal-row'+(m.role==='user'?' j-you':' k-'+(m.kind||'message')));
164
+ row.append(el('div','journal-head', m.role + (m.agent&&m.agent!==m.role?(' · '+m.agent):'')));
165
+ row.append(el('div','journal-text', m.text));
166
+ box.append(row);
167
+ });
168
+ }
169
+
170
+ // ---- SVG helpers ----------------------------------------------------------
171
+ // The chart *markup* (donut/ring/bars arcs & rows) is built by the pure,
172
+ // unit-tested SpectoCharts module (charts.js); here we just wrap its
173
+ // SVG-string output in a DOM node and, for bars, drive the count-up.
174
+ function htmlBlock(cls,html){ const d=el('div',cls); d.innerHTML=html; return d; }
175
+
176
+ function ring(pct,size){ return htmlBlock('ring-wrap', SpectoCharts.ring(pct,{size:size||72})); }
177
+
178
+ // Returns {wrap, center:null} — donut's centre label is now baked into the SVG
179
+ // via opts.center/opts.sub (see renderOverview), so there is no DOM centre node.
180
+ function donut(segments,size,opts){
181
+ const wrap=htmlBlock('donut-wrap', SpectoCharts.donut(segments,Object.assign({size:size||140},opts)));
182
+ return {wrap,center:null};
183
+ }
184
+
185
+ // Horizontal progress bars from [{label,pct,sub}]; animates any data-count spans.
186
+ function bars(rows){
187
+ const items=(rows||[]).map(r=>({label:r.label,value:r.pct,sub:r.sub,suffix:r.suffix,color:r.color}));
188
+ const wrap=htmlBlock('bars-block', SpectoCharts.bars(items));
189
+ wrap.querySelectorAll('[data-count]').forEach(countUp);
190
+ return wrap;
191
+ }
192
+ function countUp(node){
193
+ const target=Number(node.dataset.count)||0; const suffix=node.textContent.replace(/^0*/,'');
194
+ let cur=0; const step=Math.max(1,Math.round(target/20));
195
+ const tick=()=>{ cur=Math.min(target,cur+step); node.textContent=cur+suffix; if(cur<target) requestAnimationFrame(tick); };
196
+ requestAnimationFrame(tick);
197
+ }
198
+
199
+ function kpiCard(label,visual,sub){
200
+ const c=el('div','kpi');
201
+ c.append(el('div','kpi-label',label));
202
+ const body=el('div','kpi-body'); body.append(visual); c.append(body);
203
+ if(sub) c.append(el('div','kpi-sub',sub));
204
+ return c;
205
+ }
206
+ function numBlock(val,color,isText){
207
+ const d=el('div','kpi-num'+(isText?' is-text':''),String(val));
208
+ if(color) d.style.color=color;
209
+ return d;
210
+ }
211
+ function ocard(title,content){
212
+ const c=el('div','ocard');
213
+ c.append(el('div','ocard-title',title));
214
+ c.append(content);
215
+ return c;
216
+ }
217
+
218
+ function renderOverview(){
219
+ const box=$('#overview'); if(!box) return;
220
+ const s=SpectoStats.stats(P);
221
+ box.innerHTML='';
222
+
223
+ // KPI row
224
+ const kpis=el('div','kpi-row');
225
+ kpis.append(kpiCard('Global progress', ring(s.pct,72), `${s.done}/${s.total} tasks`));
226
+ kpis.append(kpiCard('In progress', numBlock(s.byStatus.in_progress||0,'var(--s-in_progress)'), 'tasks'));
227
+ kpis.append(kpiCard('To validate', numBlock(s.byStatus.to_validate||0,'var(--s-to_validate)'), 'awaiting review'));
228
+ const r=s.running||{};
229
+ let runVal='—', runSub='no runs yet';
230
+ if(r.agents>0){ runVal=`${r.agents} running`; runSub='agents active'; }
231
+ else if(r.orchestration&&r.orchestration.status){ runVal=r.orchestration.status; runSub='orchestration'; }
232
+ else if(r.lastRun){ runVal=r.lastRun.status||'—'; runSub='last: '+(r.lastRun.tool||'—'); }
233
+ kpis.append(kpiCard('Running', numBlock(runVal, r.agents>0?'var(--signal)':'var(--muted)', true), runSub));
234
+ box.append(kpis);
235
+
236
+ // Status donut + legend
237
+ const segments=s.statuses.map(k=>({key:k,value:s.byStatus[k]||0,color:cssv('--s-'+k)}));
238
+ const d=donut(segments,140,{center:String(s.total),sub:'tasks'});
239
+ const legend=el('div','legend');
240
+ segments.forEach(seg=>{
241
+ const item=el('div','legend-item');
242
+ const sw=el('span','legend-swatch'); sw.style.background=seg.color;
243
+ item.append(sw, el('span','legend-label',STATUS[seg.key]||seg.key), el('span','legend-count',String(seg.value)));
244
+ legend.append(item);
245
+ });
246
+ const donutRow=el('div','donut-row'); donutRow.append(d.wrap,legend);
247
+
248
+ // Scope-vs-delivered area curve — history is a snapshot of {date,total,done}
249
+ // recorded by the server; seed a synthetic prior point when there's only one
250
+ // so the line actually draws instead of a single dot.
251
+ let hist=(P.runtime&&P.runtime.history)||[];
252
+ if(hist.length===1){ hist=[{date:hist[0].date,total:0,done:0},hist[0]]; }
253
+ const area=htmlBlock('area-wrap', hist.length
254
+ ? SpectoCharts.area(
255
+ [{name:'Scope',color:cssv('--cool'),data:hist.map(h=>h.total)},
256
+ {name:'Delivered',color:cssv('--signal'),data:hist.map(h=>h.done)}],
257
+ hist.map(h=>(h.date||'').slice(5)))
258
+ : '<div class="empty">No history yet.</div>');
259
+ // enrich the area's hit-rect tooltips with the actual scope/delivered values
260
+ // for that point (charts.js keeps them pure — just the date label)
261
+ area.querySelectorAll('.area-hit').forEach((hit,i)=>{
262
+ const h=hist[i]; if(!h) return;
263
+ hit.dataset.tip = `<b>${h.date||''}</b><br>Scope: ${h.total||0} · Delivered: ${h.done||0}`;
264
+ });
265
+ const topRow=el('div','overview-top');
266
+ topRow.append(ocard('Status distribution', donutRow));
267
+ topRow.append(ocard('Scope vs delivered', area));
268
+ box.append(topRow);
269
+
270
+ // Workflow-at-a-glance strip (reuses the wf-arrow flow animation)
271
+ const strip=el('div','wf-strip');
272
+ const steps=P.workflow||[];
273
+ steps.forEach((st,i)=>{
274
+ const node=el('div','wf-mini'+(st.enabled?'':' off'));
275
+ node.append(el('span','dot')); node.append(el('span','nm',st.name));
276
+ strip.append(node);
277
+ if(i<steps.length-1){ const a=el('div','wf-arrow'+(st.enabled&&steps[i+1].enabled?'':' off')); strip.append(a); }
278
+ });
279
+ if(!steps.length) strip.append(el('div','empty','No workflow defined.'));
280
+ box.append(ocard('Workflow at a glance', strip));
281
+
282
+ // Per-phase progress bars
283
+ const rows=s.phases.map(ph=>({label:ph.title,pct:ph.pct,sub:`${ph.done}/${ph.total}`}));
284
+ box.append(ocard('Phase progress', bars(rows)));
285
+ }
286
+
287
+ function taskMatches(t){
288
+ if(filter.status!=='all' && t.status!==filter.status) return false;
289
+ const q=filter.q.trim().toLowerCase();
290
+ if(q && !((t.title+' '+t.id).toLowerCase().includes(q))) return false;
291
+ return true;
292
+ }
293
+ function updateFilterChips(){
294
+ $$('#statusChips .fchip').forEach(b=> b.classList.toggle('active', b.dataset.status===filter.status));
295
+ }
296
+
297
+ function renderBoard(){
298
+ const tasks = allTasks(); // unfiltered — used only to tell "no plans" apart from "no matches"
299
+ updateFilterChips();
300
+
301
+ const specs=$('#specs'); specs.innerHTML=''; $('#specsCount').textContent=(P.specs||[]).length;
302
+ if(!(P.specs||[]).length) specs.append(li('empty','none yet'));
303
+ (P.specs||[]).forEach(s=> specs.append(li(null,s)));
304
+
305
+ const running = (P.runtime&&P.runtime.agents||[]).filter(a=>a.status==='running');
306
+ const rl=$('#running'); rl.innerHTML=''; $('#runCount').textContent=running.length;
307
+ if(!running.length) rl.append(li('empty','no agent running'));
308
+ running.forEach(a=>{ const e=li('run-live',''); e.innerHTML=`<b>${a.tool}</b> · ${a.task||'—'}`; rl.append(e); });
309
+
310
+ const board=$('#board'); board.innerHTML='';
311
+ let shown=0;
312
+ (P.plans||[]).forEach(pl=> pl.phases.forEach(ph=>{
313
+ const filtered=ph.tasks.filter(taskMatches);
314
+ shown+=filtered.length;
315
+ if(!filtered.length) return; // hide phases with zero matching tasks
316
+ board.append(renderPhase(ph,pl.file,filtered));
317
+ }));
318
+ if(!tasks.length) board.append(emptyState());
319
+ else if(!shown) board.append(noMatchState());
320
+ }
321
+ function li(cls,txt){ const e=el('li',cls); e.textContent=txt; return e; }
322
+ function emptyState(){ const d=el('div','empty'); d.style.padding='40px'; d.textContent='No plans yet. Ask your agent to build something — it will run Intake and write plans/*.md.'; return d; }
323
+ function noMatchState(){ const d=el('div','empty'); d.style.padding='40px'; d.textContent='No tasks match this filter.'; return d; }
324
+
325
+ // ---- Backlog tab: flat, sortable, filterable table of every task ----------
326
+ // Rows are built from P.plans (not allTasks()) so each task carries its phase title.
327
+ function backlogRows(){
328
+ const rows=[];
329
+ (P.plans||[]).forEach(pl=> pl.phases.forEach(ph=> ph.tasks.forEach(t=>{
330
+ rows.push({ id:t.id, title:t.title, phase:ph.title, status:t.status, owner:t.owner||'', level:t.level||'standard', comments:(t.comments||[]).length, file:pl.file });
331
+ })));
332
+ return rows;
333
+ }
334
+ function backlogMatches(r){
335
+ if(backlogFilter.status!=='all' && r.status!==backlogFilter.status) return false;
336
+ const q=backlogFilter.q.trim().toLowerCase();
337
+ if(q && !((r.id+' '+r.title).toLowerCase().includes(q))) return false;
338
+ return true;
339
+ }
340
+ function sortBacklogRows(rows){
341
+ const {col,dir}=backlogSort; const mul=dir==='asc'?1:-1;
342
+ return rows.slice().sort((a,b)=>{
343
+ if(col==='comments') return (a.comments-b.comments)*mul;
344
+ let av=a[col], bv=b[col];
345
+ if(col==='status'){ av=STATUS[a.status]||a.status; bv=STATUS[b.status]||b.status; }
346
+ av=String(av==null?'':av).toLowerCase(); bv=String(bv==null?'':bv).toLowerCase();
347
+ if(av<bv) return -1*mul;
348
+ if(av>bv) return 1*mul;
349
+ return 0;
350
+ });
351
+ }
352
+ function updateBacklogFilterChips(){
353
+ $$('#backlogStatusChips .fchip').forEach(b=> b.classList.toggle('active', b.dataset.status===backlogFilter.status));
354
+ }
355
+ function updateBacklogSortHeaders(){
356
+ $$('#backlogTable thead th').forEach(th=>{
357
+ const active=th.dataset.col===backlogSort.col;
358
+ th.classList.toggle('is-sorted',active);
359
+ if(active) th.dataset.dir=backlogSort.dir; else th.removeAttribute('data-dir');
360
+ });
361
+ }
362
+ function renderBacklog(){
363
+ const body=$('#backlogBody'); if(!body) return;
364
+ updateBacklogFilterChips(); updateBacklogSortHeaders();
365
+ const all=backlogRows();
366
+ const cnt=$('#backlogCount'); if(cnt) cnt.textContent=all.length;
367
+ const filtered=sortBacklogRows(all.filter(backlogMatches));
368
+ body.innerHTML='';
369
+ if(!all.length){ body.append(backlogEmptyRow('No plans yet. Ask your agent to build something — it will run Intake and write plans/*.md.')); return; }
370
+ if(!filtered.length){ body.append(backlogEmptyRow('No tasks match this filter.')); return; }
371
+ filtered.forEach(r=> body.append(backlogRow(r)));
372
+ }
373
+ function backlogEmptyRow(txt){
374
+ const tr=el('tr','backlog-empty-row'); const td=el('td',null,txt); td.colSpan=7; tr.append(td); return tr;
375
+ }
376
+ function backlogRow(r){
377
+ const tr=el('tr','backlog-row'); tr.tabIndex=0;
378
+ tr.append(el('td','bl-id',r.id));
379
+ tr.append(el('td','bl-title',r.title));
380
+ tr.append(el('td','bl-phase',r.phase));
381
+ const stTd=el('td','bl-status'); stTd.append(el('span','chip s-'+r.status,STATUS[r.status]||r.status)); tr.append(stTd);
382
+ tr.append(el('td','bl-owner', r.owner?('@'+r.owner):'—'));
383
+ tr.append(el('td','bl-level', r.level));
384
+ tr.append(el('td','bl-comments', r.comments?('💬 '+r.comments):''));
385
+ const open=()=>openDrawer(r.id);
386
+ tr.addEventListener('click',open);
387
+ tr.addEventListener('keydown',e=>{ if(e.key==='Enter')open(); });
388
+ return tr;
389
+ }
390
+
391
+ // ---- collapsed-phase state (persisted per phase title, guarded for private mode) ----
392
+ function loadCollapsed(){
393
+ try{ const raw=localStorage.getItem('spf-collapsed'); const arr=raw?JSON.parse(raw):[]; return new Set(Array.isArray(arr)?arr:[]); }
394
+ catch{ return new Set(); }
395
+ }
396
+ function saveCollapsed(set){ try{ localStorage.setItem('spf-collapsed', JSON.stringify([...set])); }catch{} }
397
+ let collapsedPhases=loadCollapsed();
398
+
399
+ function renderPhase(ph,file,filteredTasks){
400
+ const isCollapsed=collapsedPhases.has(ph.title);
401
+ const sec=el('section','phase'+(isCollapsed?' is-collapsed':''));
402
+ const head=el('div','phase-head'); head.tabIndex=0; head.setAttribute('role','button'); head.setAttribute('aria-expanded',String(!isCollapsed));
403
+ head.append(el('span','chevron'));
404
+ head.append(el('span','phase-title',ph.title));
405
+ head.append(el('span','phase-src',file));
406
+ const d=ph.tasks.filter(t=>t.status==='done').length, tot=ph.tasks.length;
407
+ const pct=tot?Math.round((d/tot)*100):0;
408
+ const track=el('div','phase-bar'); const fill=el('div','phase-bar-fill'); fill.style.width=pct+'%'; track.append(fill);
409
+ head.append(track);
410
+ head.append(el('span','phase-stat',`${d}/${tot}`));
411
+ const toggle=()=>{
412
+ const now=sec.classList.toggle('is-collapsed');
413
+ head.setAttribute('aria-expanded',String(!now));
414
+ if(now) collapsedPhases.add(ph.title); else collapsedPhases.delete(ph.title);
415
+ saveCollapsed(collapsedPhases);
416
+ };
417
+ head.addEventListener('click',toggle);
418
+ head.addEventListener('keydown',e=>{ if(e.key==='Enter'||e.key===' '){ e.preventDefault(); toggle(); } });
419
+ sec.append(head);
420
+ const wrap=el('div','tasks');
421
+ filteredTasks.forEach(t=> wrap.append(renderTask(t)));
422
+ sec.append(wrap); return sec;
423
+ }
424
+ function renderTask(t){
425
+ const c=el('div','task'+(t.status==='in_progress'?' is-in_progress':''));
426
+ c.tabIndex=0;
427
+ const top=el('div','task-top');
428
+ top.append(el('span','task-id',t.id));
429
+ top.append(el('span','lvl',t.level||'standard'));
430
+ top.append(el('span','chip s-'+t.status,STATUS[t.status]||t.status));
431
+ c.append(top);
432
+ c.append(el('div','task-title',t.title));
433
+ if(t.tags&&t.tags.length){
434
+ const tags=el('div','task-tags');
435
+ t.tags.forEach(tg=> tags.append(el('span','tag',tg)));
436
+ c.append(tags);
437
+ }
438
+ const foot=el('div','task-foot');
439
+ if(t.owner) foot.append(el('span','owner','@'+t.owner));
440
+ if(t.comments&&t.comments.length) foot.append(el('span','cmt-count','💬 '+t.comments.length));
441
+ const tr=runtimeTests(t.id);
442
+ if(tr) foot.append(el('span','tflag '+(tr.failed?'fail':'pass'), tr.failed?`${tr.failed} failing`:`${tr.passed||0} passing`));
443
+ c.append(foot);
444
+ const open=()=>openDrawer(t.id);
445
+ c.addEventListener('click',open);
446
+ c.addEventListener('keydown',e=>{ if(e.key==='Enter')open(); });
447
+ return c;
448
+ }
449
+
450
+ function renderWorkflow(){
451
+ const box=$('#wfDiagram'); box.innerHTML='';
452
+ const steps=P.workflow||[];
453
+ steps.forEach((s,i)=>{
454
+ const step=el('div','wf-step');
455
+ const node=el('div','wf-node'+(s.enabled?'':' off'));
456
+ node.append(el('span','dot'));
457
+ node.append(el('span','nm',s.name));
458
+ if(s.optional) node.append(el('span','opt','opt'));
459
+ node.addEventListener('click',()=>toggleStep(s.name));
460
+ step.append(node);
461
+ if(i<steps.length-1){ const a=el('div','wf-arrow'+(s.enabled&&steps[i+1].enabled?'':' off')); step.append(a); }
462
+ box.append(step);
463
+ });
464
+ if(!steps.length) box.append(el('div','empty','No workflow defined.'));
465
+ }
466
+
467
+ // chip row: a small uppercase kicker label followed by one chip per item — used for
468
+ // agent standards/uses and the skill standard.
469
+ function chipRow(label,items){
470
+ if(!items||!items.length) return null;
471
+ const row=el('div','cu'); row.append(el('b',null,label));
472
+ items.forEach(x=> row.append(el('span',null,x)));
473
+ return row;
474
+ }
475
+ function agentCard(a){
476
+ const c=el('div','card'); c.tabIndex=0;
477
+ c.append(el('div','ct',a.title||a.name));
478
+ if(a.capability) c.append(el('div','cc',a.capability));
479
+ const std=chipRow('standards',a.standards); if(std) c.append(std);
480
+ const uses=chipRow('uses',a.uses); if(uses) c.append(uses);
481
+ if(a.description) c.append(el('div','cd',a.description));
482
+ const open=()=>openFileDrawer('agent',a);
483
+ c.addEventListener('click',open);
484
+ c.addEventListener('keydown',e=>{ if(e.key==='Enter')open(); });
485
+ return c;
486
+ }
487
+ function skillCard(s){
488
+ const c=el('div','card'); c.tabIndex=0;
489
+ c.append(el('div','ct',s.name));
490
+ if(s.capability) c.append(el('div','cc',s.capability));
491
+ const std=chipRow('standard', s.standard?[s.standard]:null); if(std) c.append(std);
492
+ if(s.inputs||s.outputs) c.append(el('div','io', (s.inputs||'—')+' → '+(s.outputs||'—')));
493
+ if(s.description) c.append(el('div','cd',s.description));
494
+ const open=()=>openFileDrawer('skill',s);
495
+ c.addEventListener('click',open);
496
+ c.addEventListener('keydown',e=>{ if(e.key==='Enter')open(); });
497
+ return c;
498
+ }
499
+ function renderTeam(){
500
+ const ag=$('#agents'); ag.innerHTML=''; $('#agentsCount').textContent=(P.agents||[]).length;
501
+ (P.agents||[]).forEach(a=> ag.append(agentCard(a)));
502
+ const sk=$('#skills'); sk.innerHTML=''; $('#skillsCount').textContent=(P.skills||[]).length;
503
+ (P.skills||[]).forEach(s=> sk.append(skillCard(s)));
504
+ }
505
+
506
+ // ---- Agent/Skill file-body drawer — fetches the real markdown file and renders it with a
507
+ // tiny inline renderer (mdLite). Content is HTML-escaped before any markup is generated, so a
508
+ // crafted agent/skill file can never inject markup into the page.
509
+ function escHtml(s){ return String(s==null?'':s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
510
+ function mdLite(raw){
511
+ let text=String(raw||'');
512
+ // 1) strip YAML front-matter (between the first two '---' lines), if present
513
+ const lines0=text.split('\n');
514
+ if(lines0[0]&&lines0[0].trim()==='---'){
515
+ let end=-1;
516
+ for(let i=1;i<lines0.length;i++){ if(lines0[i].trim()==='---'){ end=i; break; } }
517
+ if(end!==-1) text=lines0.slice(end+1).join('\n');
518
+ }
519
+ text=text.replace(/^\n+/,'');
520
+ if(!text.trim()) return '<pre>(empty file)</pre>';
521
+ // 2) escape HTML — everything below builds markup only from this escaped text
522
+ text=escHtml(text);
523
+ // 3) convert a small markdown subset to HTML
524
+ const inline=s=> s.replace(/`([^`]+)`/g,'<code>$1</code>').replace(/\*\*([^*]+)\*\*/g,'<strong>$1</strong>');
525
+ const lines=text.split('\n');
526
+ let html='', inCode=false, codeBuf=[], listBuf=[], paraBuf=[];
527
+ const flushPara=()=>{ if(paraBuf.length){ html+='<p>'+paraBuf.join(' ')+'</p>'; paraBuf=[]; } };
528
+ const flushList=()=>{ if(listBuf.length){ html+='<ul>'+listBuf.map(x=>'<li>'+x+'</li>').join('')+'</ul>'; listBuf=[]; } };
529
+ for(const line of lines){
530
+ if(/^\s*```/.test(line)){
531
+ if(!inCode){ flushPara(); flushList(); inCode=true; codeBuf=[]; }
532
+ else { html+='<pre><code>'+codeBuf.join('\n')+'</code></pre>'; inCode=false; }
533
+ continue;
534
+ }
535
+ if(inCode){ codeBuf.push(line); continue; }
536
+ const h=line.match(/^(#{1,3})\s+(.*)$/);
537
+ if(h){ flushPara(); flushList(); const lvl=h[1].length; html+=`<h${lvl}>${inline(h[2])}</h${lvl}>`; continue; }
538
+ const li=line.match(/^\s*-\s+(.*)$/);
539
+ if(li){ flushPara(); listBuf.push(inline(li[1])); continue; }
540
+ if(line.trim()===''){ flushPara(); flushList(); continue; }
541
+ flushList(); paraBuf.push(inline(line));
542
+ }
543
+ flushPara(); flushList();
544
+ if(inCode&&codeBuf.length) html+='<pre><code>'+codeBuf.join('\n')+'</code></pre>';
545
+ return html || '<pre>'+text+'</pre>';
546
+ }
547
+ async function openFileDrawer(kind,obj){
548
+ openTaskId=null;
549
+ const rel = kind==='agent' ? ('agents/'+(obj.file||(obj.name+'.md'))) : ('skills/'+obj.name+'/SKILL.md');
550
+ const b=$('#drawerBody'); b.innerHTML='';
551
+ b.append(el('div','d-id', kind==='agent'?'Agent':'Skill'));
552
+ b.append(el('div','d-title', obj.title||obj.name));
553
+ const sec=el('div','d-section'); sec.append(el('div','d-label','File · '+rel));
554
+ const body=el('div','md-body'); body.append(el('div','empty','Loading…'));
555
+ sec.append(body); b.append(sec);
556
+ $('#drawer').setAttribute('aria-hidden','false');
557
+ try{
558
+ const r=await fetch('/api/agentfile?path='+encodeURIComponent(rel));
559
+ const data=await r.json().catch(()=>({}));
560
+ if(!r.ok){ body.innerHTML=''; body.append(el('div','empty', data.error||'Could not load this file.')); return; }
561
+ body.innerHTML=mdLite(data.content||'');
562
+ }catch(err){
563
+ body.innerHTML=''; body.append(el('div','empty','Could not load this file.'));
564
+ }
565
+ }
566
+
567
+ // ---- Info tab: read-only project overview (config, runners, counts, specs, active workflow) ----
568
+ function infoSection(title,iconKey,content){
569
+ const sec=el('div','info-section');
570
+ const head=el('div','info-section-head');
571
+ const ico=el('span','info-section-ico');
572
+ ico.innerHTML=(typeof ICON!=='undefined'&&ICON[iconKey])||'';
573
+ head.append(ico, el('span','info-section-title',title));
574
+ sec.append(head, content);
575
+ return sec;
576
+ }
577
+ function infoRow(label,value){
578
+ const row=el('div','info-row');
579
+ row.append(el('span','info-row-label',label));
580
+ row.append(el('span','info-row-value',String(value)));
581
+ return row;
582
+ }
583
+ function statTile(value,label,sub){
584
+ const t=el('div','stat-tile');
585
+ t.append(el('div','stat-tile-val',String(value)));
586
+ t.append(el('div','stat-tile-label',label));
587
+ if(sub) t.append(el('div','stat-tile-sub',sub));
588
+ return t;
589
+ }
590
+ function renderInfo(){
591
+ const box=$('#infoGrid'); if(!box) return;
592
+ box.innerHTML='';
593
+ const c=P.config||{};
594
+ const s=SpectoStats.stats(P);
595
+ const steps=P.workflow||[];
596
+ const enabledSteps=steps.filter(st=>st.enabled);
597
+
598
+ // Project: mode/language/agent/type from config
599
+ const projRows=el('div','info-rows');
600
+ projRows.append(infoRow('Project type', c.projectType||'—'));
601
+ projRows.append(infoRow('Mode', c.mode||'—'));
602
+ projRows.append(infoRow('Language', c.language||'—'));
603
+ projRows.append(infoRow('Active agent', c.agent||'—'));
604
+ box.append(infoSection('Project','info',projRows));
605
+
606
+ // Runners: agent → command, monospace
607
+ const runners=c.runners||{};
608
+ const runnerKeys=Object.keys(runners);
609
+ const runnerRows=el('div','info-rows info-rows-mono');
610
+ if(!runnerKeys.length) runnerRows.append(el('div','empty','No runners configured.'));
611
+ runnerKeys.forEach(k=> runnerRows.append(infoRow(k, runners[k])));
612
+ box.append(infoSection('Runners','run',runnerRows));
613
+
614
+ // Counts: tasks/specs/agents/skills/enabled workflow steps
615
+ const tiles=el('div','stat-tiles');
616
+ tiles.append(statTile(`${s.done}/${s.total}`,'Tasks',`${s.pct}% done`));
617
+ tiles.append(statTile(String((P.specs||[]).length),'Specs','files'));
618
+ tiles.append(statTile(String((P.agents||[]).length),'Agents','personas'));
619
+ tiles.append(statTile(String((P.skills||[]).length),'Skills','procedures'));
620
+ tiles.append(statTile(`${enabledSteps.length}/${steps.length}`,'Workflow','steps enabled'));
621
+ box.append(infoSection('Counts','board',tiles));
622
+
623
+ // Specs: the P.specs filename list
624
+ const specsList=el('ul','flatlist');
625
+ const specs=P.specs||[];
626
+ if(!specs.length) specsList.append(li('empty','none yet'));
627
+ specs.forEach(sp=> specsList.append(li(null,sp)));
628
+ box.append(infoSection('Specs','backlog',specsList));
629
+
630
+ // Workflow: compact list of enabled steps (name + cap/skill)
631
+ const wfList=el('div','info-wf-list');
632
+ if(!enabledSteps.length) wfList.append(el('div','empty','No enabled workflow steps.'));
633
+ enabledSteps.forEach(st=>{
634
+ const row=el('div','info-wf-row');
635
+ row.append(el('span','info-wf-name',st.name));
636
+ const meta=[st.cap,st.skill].filter(Boolean).join(' · ');
637
+ if(meta) row.append(el('span','info-wf-meta',meta));
638
+ wfList.append(row);
639
+ });
640
+ box.append(infoSection('Workflow','workflow',wfList));
641
+ }
642
+
643
+ function openDrawer(id,keep){
644
+ const t=allTasks().find(x=>x.id===id); if(!t) return;
645
+ openTaskId=id;
646
+ const b=$('#drawerBody'); const prev=keep?$('.drawer-panel').scrollTop:0; b.innerHTML='';
647
+ b.append(el('div','d-id',t.id+' · '+(t.level||'standard')+' · '+t.file));
648
+ b.append(el('div','d-title',t.title));
649
+ const sSec=el('div','d-section'); sSec.append(el('div','d-label','Status'));
650
+ const sr=el('div','status-row');
651
+ Object.keys(STATUS).forEach(k=>{
652
+ const btn=el('button','status-btn'+(t.status===k?' active':''),STATUS[k]);
653
+ if(t.status===k) btn.style.background=cssv('--s-'+k);
654
+ btn.addEventListener('click',()=>patchTask(id,{status:k}));
655
+ sr.append(btn);
656
+ });
657
+ sSec.append(sr); b.append(sSec);
658
+
659
+ const tr=runtimeTests(id);
660
+ if(tr){ const ts=el('div','d-section'); ts.append(el('div','d-label','Tests'));
661
+ ts.append(el('div','d-text', tr.failed?`${tr.failed} failing, ${tr.passed||0} passing`:`${tr.passed||0} passing`)); b.append(ts); }
662
+
663
+ const cSec=el('div','d-section'); cSec.append(el('div','d-label','Comments'));
664
+ const list=el('div','comments');
665
+ (t.comments||[]).forEach(cm=> list.append(el('div','comment',cm)));
666
+ if(!(t.comments||[]).length) list.append(el('div','empty','No comments.'));
667
+ cSec.append(list);
668
+ const box=el('div','c-box'); const ta=el('textarea'); ta.placeholder='Add a comment, a remark, feedback…';
669
+ const actions=el('div','c-actions');
670
+ const add=el('button','btn','Add'); const an=el('button','btn primary','Add + to analyze');
671
+ add.addEventListener('click',()=>{ if(ta.value.trim())addComment(id,ta.value.trim(),'note'); });
672
+ an.addEventListener('click',()=>{ if(ta.value.trim())addComment(id,ta.value.trim(),'analyze'); });
673
+ actions.append(add,an); box.append(ta,actions);
674
+ box.append(el('div','empty','"To analyze" moves the task back so the agent picks it up next round.'));
675
+ cSec.append(box); b.append(cSec);
676
+ $('#drawer').setAttribute('aria-hidden','false');
677
+ if(keep) $('.drawer-panel').scrollTop=prev;
678
+ }
679
+ function closeDrawer(){ openTaskId=null; $('#drawer').setAttribute('aria-hidden','true'); }
680
+ const cssv=(v)=> getComputedStyle(document.documentElement).getPropertyValue(v).trim()||'#888';
681
+
682
+ // tabs — activeTab is the single source of truth (persisted), so a click sets it and applies it,
683
+ // and render()'s SSE-driven re-render (triggered by the snapshot write / polling) re-applies it
684
+ // too instead of ever resetting to Board; this is what keeps a tab selected across a race with a
685
+ // 'change'/'message' event that lands right after a click.
686
+ let activeTab = (()=>{ try{ return localStorage.getItem('spf-tab')||'board'; }catch{ return 'board'; } })();
687
+ function applyActiveTab(){
688
+ $$('#tabs .tab').forEach(t=> t.classList.toggle('is-active', t.dataset.tab===activeTab));
689
+ $$('.panel').forEach(p=> p.classList.toggle('is-active', p.dataset.panel===activeTab));
690
+ }
691
+ $$('#tabs .tab').forEach(tab=> tab.addEventListener('click',()=>{
692
+ activeTab=tab.dataset.tab;
693
+ try{ localStorage.setItem('spf-tab',activeTab); }catch{}
694
+ applyActiveTab();
695
+ }));
696
+ applyActiveTab(); // sync to any persisted tab before the first render
697
+ // filters (status chips + search) — client-side only, does not write anything
698
+ $$('#statusChips .fchip').forEach(b=> b.addEventListener('click', ()=>{ filter.status=b.dataset.status; renderBoard(); }));
699
+ $('#search').addEventListener('input', e=>{ filter.q=e.target.value; renderBoard(); });
700
+ // backlog: independent filters + sortable column headers — client-side only
701
+ $$('#backlogStatusChips .fchip').forEach(b=> b.addEventListener('click', ()=>{ backlogFilter.status=b.dataset.status; renderBacklog(); }));
702
+ $('#backlogSearch').addEventListener('input', e=>{ backlogFilter.q=e.target.value; renderBacklog(); });
703
+ $$('#backlogTable thead th').forEach(th=> th.addEventListener('click', ()=>{
704
+ const col=th.dataset.col;
705
+ if(backlogSort.col===col) backlogSort.dir = backlogSort.dir==='asc'?'desc':'asc';
706
+ else { backlogSort.col=col; backlogSort.dir='asc'; }
707
+ renderBacklog();
708
+ }));
709
+ // theme
710
+ (function(){ const s=localStorage.getItem('spf-theme'); if(s)document.documentElement.setAttribute('data-theme',s);
711
+ $('#themeToggle').addEventListener('click',()=>{ const c=document.documentElement.getAttribute('data-theme'); const n=c==='dark'?'light':'dark'; document.documentElement.setAttribute('data-theme',n); localStorage.setItem('spf-theme',n); }); })();
712
+ // chart tooltip — a single floating layer, shown on hover over any [data-tip]
713
+ // element (area-hit rects, future hit targets) and positioned near the cursor.
714
+ (function(){
715
+ const tip=$('#tooltip'); if(!tip) return;
716
+ let current=null;
717
+ document.addEventListener('mousemove',e=>{
718
+ const target=e.target.closest && e.target.closest('[data-tip],.hit,.area-hit');
719
+ if(!target){ if(current){ tip.hidden=true; current=null; } return; }
720
+ if(target!==current){ tip.innerHTML=target.dataset.tip||''; current=target; }
721
+ tip.hidden=false;
722
+ tip.style.left=(e.clientX+14)+'px';
723
+ tip.style.top=(e.clientY+14)+'px';
724
+ });
725
+ document.addEventListener('mouseleave',()=>{ tip.hidden=true; current=null; });
726
+ })();
727
+ // icons — fill every [data-icon] placeholder from the ICON map (icons.js), once at startup
728
+ (function applyIcons(){
729
+ if(typeof ICON==='undefined') return;
730
+ $$('[data-icon]').forEach(node=>{ const svg=ICON[node.dataset.icon]; if(svg) node.innerHTML=svg; });
731
+ })();
732
+ // chat widget
733
+ $('#runQuickBtn').addEventListener('click',()=> setChat(true));
734
+ $('#chatFab').addEventListener('click',()=> setChat($('#chat').getAttribute('aria-hidden')==='true'));
735
+ $('#chatClose').addEventListener('click',()=> setChat(false));
736
+ try{ if(localStorage.getItem('spf-chat')==='1') setChat(true); }catch{}
737
+ $('#runBtn').addEventListener('click',()=>doRun());
738
+ $('#orchBtn').addEventListener('click',()=>doOrchestrate());
739
+ $('#runPrompt').addEventListener('keydown',e=>{ if((e.metaKey||e.ctrlKey)&&e.key==='Enter')doRun(); });
740
+ // Chat tab — same doRun/doOrchestrate/approve, its own textarea/select (#tabRunPrompt/#tabRunAgent)
741
+ $('#tabRunBtn').addEventListener('click',()=>doRun($('#tabRunPrompt'),$('#tabRunAgent')));
742
+ $('#tabOrchBtn').addEventListener('click',()=>doOrchestrate($('#tabRunPrompt')));
743
+ $('#tabRunPrompt').addEventListener('keydown',e=>{ if((e.metaKey||e.ctrlKey)&&e.key==='Enter')doRun($('#tabRunPrompt'),$('#tabRunAgent')); });
744
+ $('#drawerClose').addEventListener('click',closeDrawer);
745
+ $('#drawerScrim').addEventListener('click',closeDrawer);
746
+ document.addEventListener('keydown',e=>{ if(e.key==='Escape')closeDrawer(); });
747
+ load(); connect();