spectoflow 0.17.1 → 0.18.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.
- package/README.md +23 -6
- package/package.json +1 -1
- package/templates/AGENTS.md +29 -3
- package/templates/README.md +9 -1
- package/templates/agents/framework-curator.md +94 -0
- package/templates/agents/qa-engineer.md +8 -5
- package/templates/capabilities.md +10 -1
- package/templates/dashboard/custom/.gitkeep +3 -0
- package/templates/dashboard/public/app.js +343 -128
- package/templates/dashboard/public/designs/console.css +10 -0
- package/templates/dashboard/public/designs/orbit.css +35 -18
- package/templates/dashboard/public/designs/orbit.js +30 -5
- package/templates/dashboard/public/i18n.js +607 -0
- package/templates/dashboard/public/index.html +127 -102
- package/templates/dashboard/public/styles.css +42 -1
- package/templates/dashboard/server.js +8 -1
- package/templates/lib/custom-dashboard.js +76 -0
- package/templates/lib/store.js +29 -5
- package/templates/skills/generate-agent/SKILL.md +135 -0
- package/templates/skills/generate-dashboard/SKILL.md +152 -0
- package/templates/skills/generate-skill/SKILL.md +153 -0
- package/templates/skills/propose-customizations/SKILL.md +72 -0
- package/templates/skills/write-e2e-tests/SKILL.md +72 -17
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
'use strict';
|
|
2
|
+
// Kept as a mutable object (not reassigned) so every existing STATUS[key] lookup keeps working
|
|
3
|
+
// unchanged; updateStatusLabels() (called at the top of every render()) refreshes its values from
|
|
4
|
+
// the current UI language, so a language change re-labels every status chip/column/legend for free.
|
|
2
5
|
const STATUS = { todo:'To do', in_progress:'In progress', to_validate:'To validate', to_analyze:'To analyze', done:'Done', blocked:'Blocked' };
|
|
6
|
+
function updateStatusLabels(){ for(const k of Object.keys(STATUS)) STATUS[k]=t('status.'+k); }
|
|
3
7
|
let P = null, openTaskId = null;
|
|
4
8
|
let filter = { status: 'all', q: '' }; // board filter state — client-side only, read-only
|
|
5
9
|
let boardView = (()=>{ try{ return localStorage.getItem('spf-board-view')||'list'; }catch{ return 'list'; } })(); // 'list' | 'kanban'
|
|
@@ -73,8 +77,8 @@ function renderApproval(container){
|
|
|
73
77
|
if(!o || o.status!=='awaiting_approval') return;
|
|
74
78
|
row=el('div','approval');
|
|
75
79
|
row.append(el('div','msg-role','orchestrator · awaiting approval'));
|
|
76
|
-
const a=el('button','btn primary','
|
|
77
|
-
const c=el('button','btn','
|
|
80
|
+
const a=el('button','btn primary',t('action.approve')); a.addEventListener('click',()=>approve('approve'));
|
|
81
|
+
const c=el('button','btn',t('action.cancel')); c.addEventListener('click',()=>approve('cancel'));
|
|
78
82
|
const acts=el('div','c-actions'); acts.append(a,c); row.append(acts);
|
|
79
83
|
container.append(row); scrollChat(container);
|
|
80
84
|
}
|
|
@@ -117,9 +121,11 @@ function flash(){ const s=$('#sync'); s.classList.add('saving'); $('#syncLabel')
|
|
|
117
121
|
|
|
118
122
|
function render(){
|
|
119
123
|
const c = P.config||{};
|
|
120
|
-
|
|
124
|
+
i18nSetLang(c.language||'en'); updateStatusLabels(); // language drives the whole UI, not just agent output
|
|
125
|
+
$('#projectName').textContent = P.projectName || c.projectType || 'project';
|
|
121
126
|
const bv=$('#brandVer'); if(bv){ if(P.version){ bv.textContent='v'+P.version; bv.hidden=false; } else { bv.hidden=true; } }
|
|
122
|
-
|
|
127
|
+
// mode/language are edited live from the bar itself — see the #topMode/#topLang selects synced
|
|
128
|
+
// in renderSettings() below (called on every render tick) and saved via saveSettings().
|
|
123
129
|
// agent select — widget (#runAgent) and Chat tab (#tabRunAgent) each get their own populated
|
|
124
130
|
// <select>, since an id can't be shared by two elements; same option list, same source of truth.
|
|
125
131
|
const runners=Object.keys((c.runners)||{claude:1});
|
|
@@ -131,11 +137,13 @@ function render(){
|
|
|
131
137
|
const meterFill=$('#globalMeterFill');
|
|
132
138
|
if(meterFill) meterFill.style.width=(s.pct||0)+'%';
|
|
133
139
|
const meter=$('#globalMeter');
|
|
134
|
-
if(meter) meter.title
|
|
140
|
+
if(meter) meter.title=`${t('kpi.globalProgress')}: ${s.pct}% (${s.done}/${s.total} ${t('kpi.tasksLabel')})`;
|
|
135
141
|
renderOverview(); renderBoard(); renderBacklog(); renderWorkflow(); renderTeam();
|
|
136
142
|
renderChatLog($('#chatLog')); renderChatLog($('#chatTabLog'));
|
|
137
143
|
renderSidebar(); renderRequests(); renderAttention(); renderInfo(); renderSettings();
|
|
144
|
+
renderCustomDashboards(); // adds/removes nav tabs + panels before applyActiveTab() below reads them
|
|
138
145
|
applyActiveTab(); // re-apply the current tab so an SSE-driven re-render never resets to Board
|
|
146
|
+
applyI18nStatic(); // re-translate the static markup (nav, headers, placeholders…) for this tick's language
|
|
139
147
|
}
|
|
140
148
|
|
|
141
149
|
// ---- Requests tab: tasks awaiting review/input (to_validate / to_analyze) ----
|
|
@@ -144,7 +152,7 @@ function renderRequests(){
|
|
|
144
152
|
const toAsk=SpectoStats.stats(P).toAsk||[];
|
|
145
153
|
const count=$('#requestsCount'); if(count) count.textContent=toAsk.length;
|
|
146
154
|
list.innerHTML='';
|
|
147
|
-
if(!toAsk.length){ list.append(li('empty','
|
|
155
|
+
if(!toAsk.length){ list.append(li('empty',t('requests.empty'))); return; }
|
|
148
156
|
toAsk.forEach(t=>{
|
|
149
157
|
const row=el('li','request-row'); row.tabIndex=0;
|
|
150
158
|
row.append(el('span','request-id',t.id));
|
|
@@ -159,20 +167,35 @@ function renderRequests(){
|
|
|
159
167
|
}
|
|
160
168
|
|
|
161
169
|
// ---- right sidebar: "Journal" (read-only runtime.messages feed) ----
|
|
170
|
+
// Capped to the 5 most recent entries by default — a long-running project's journal used to render
|
|
171
|
+
// its entire history inline and blow up the sidebar. "See more" reveals the rest for this session
|
|
172
|
+
// (client-side only; not persisted, so the rail opens compact again next visit).
|
|
173
|
+
const JOURNAL_PAGE=5;
|
|
174
|
+
let journalExpanded=false;
|
|
162
175
|
function renderSidebar(){ renderJournal(); }
|
|
163
176
|
function renderJournal(){
|
|
164
177
|
const box=$('#journal'); if(!box) return;
|
|
165
178
|
const msgs=((P.runtime&&P.runtime.messages)||[]).slice().reverse(); // reverse-chronological
|
|
166
179
|
$('#journalCount').textContent=msgs.length;
|
|
167
180
|
box.innerHTML='';
|
|
168
|
-
if(!msgs.length){ box.append(el('div','empty','
|
|
169
|
-
msgs.
|
|
181
|
+
if(!msgs.length){ box.append(el('div','empty',t('journal.empty'))); journalToggleBtn(0,0); return; }
|
|
182
|
+
const shown = journalExpanded ? msgs : msgs.slice(0,JOURNAL_PAGE);
|
|
183
|
+
shown.forEach(m=>{
|
|
170
184
|
const row=el('div','journal-row'+(m.role==='user'?' j-you':' k-'+(m.kind||'message')));
|
|
171
185
|
row.append(el('div','journal-head', m.role + (m.agent&&m.agent!==m.role?(' · '+m.agent):'')));
|
|
172
186
|
row.append(el('div','journal-text', m.text));
|
|
173
187
|
box.append(row);
|
|
174
188
|
});
|
|
189
|
+
journalToggleBtn(msgs.length, shown.length);
|
|
175
190
|
}
|
|
191
|
+
function journalToggleBtn(total,shownCount){
|
|
192
|
+
const btn=$('#journalMore'); if(!btn) return;
|
|
193
|
+
if(total<=JOURNAL_PAGE){ btn.hidden=true; return; }
|
|
194
|
+
btn.hidden=false;
|
|
195
|
+
btn.textContent = journalExpanded ? t('journal.seeLess') : t('journal.seeMore')+` (${total-shownCount})`;
|
|
196
|
+
}
|
|
197
|
+
const journalMoreBtn=$('#journalMore');
|
|
198
|
+
if(journalMoreBtn) journalMoreBtn.addEventListener('click',()=>{ journalExpanded=!journalExpanded; renderJournal(); });
|
|
176
199
|
|
|
177
200
|
// ---- SVG helpers ----------------------------------------------------------
|
|
178
201
|
// The chart *markup* (donut/ring/bars arcs & rows) is built by the pure,
|
|
@@ -230,20 +253,20 @@ function renderOverview(){
|
|
|
230
253
|
|
|
231
254
|
// KPI row
|
|
232
255
|
const kpis=el('div','kpi-row');
|
|
233
|
-
kpis.append(kpiCard('
|
|
234
|
-
kpis.append(kpiCard('
|
|
235
|
-
kpis.append(kpiCard('
|
|
256
|
+
kpis.append(kpiCard(t('kpi.globalProgress'), ring(s.pct,72), `${s.done}/${s.total} ${t('kpi.tasksLabel')}`, cssv('--s-done')));
|
|
257
|
+
kpis.append(kpiCard(t('status.in_progress'), numBlock(s.byStatus.in_progress||0,'var(--s-in_progress)'), t('kpi.tasksLabel'), cssv('--s-in_progress')));
|
|
258
|
+
kpis.append(kpiCard(t('status.to_validate'), numBlock(s.byStatus.to_validate||0,'var(--s-to_validate)'), t('kpi.awaitingReview'), cssv('--s-to_validate')));
|
|
236
259
|
const r=s.running||{};
|
|
237
|
-
let runVal='—', runSub='
|
|
238
|
-
if(r.agents>0){ runVal=`${r.agents} running`; runSub='
|
|
239
|
-
else if(r.orchestration&&r.orchestration.status){ runVal=r.orchestration.status; runSub='orchestration'; }
|
|
240
|
-
else if(r.lastRun){ runVal=r.lastRun.status||'—'; runSub='
|
|
241
|
-
kpis.append(kpiCard('
|
|
260
|
+
let runVal='—', runSub=t('kpi.noRunsYet');
|
|
261
|
+
if(r.agents>0){ runVal=`${r.agents} ${t('kpi.running').toLowerCase()}`; runSub=t('kpi.agentsActive'); }
|
|
262
|
+
else if(r.orchestration&&r.orchestration.status){ runVal=r.orchestration.status; runSub=t('kpi.orchestration'); }
|
|
263
|
+
else if(r.lastRun){ runVal=r.lastRun.status||'—'; runSub=t('kpi.lastPrefix')+(r.lastRun.tool||'—'); }
|
|
264
|
+
kpis.append(kpiCard(t('kpi.running'), numBlock(runVal, r.agents>0?'var(--signal)':'var(--muted)', true), runSub, cssv('--signal')));
|
|
242
265
|
box.append(kpis);
|
|
243
266
|
|
|
244
267
|
// Status donut + legend
|
|
245
268
|
const segments=s.statuses.map(k=>({key:k,value:s.byStatus[k]||0,color:cssv('--s-'+k)}));
|
|
246
|
-
const d=donut(segments,140,{center:String(s.total),sub:'
|
|
269
|
+
const d=donut(segments,140,{center:String(s.total),sub:t('chart.tasksSub')});
|
|
247
270
|
const legend=el('div','legend');
|
|
248
271
|
segments.forEach(seg=>{
|
|
249
272
|
const item=el('div','legend-item');
|
|
@@ -260,19 +283,19 @@ function renderOverview(){
|
|
|
260
283
|
if(hist.length===1){ hist=[{date:hist[0].date,total:0,done:0},hist[0]]; }
|
|
261
284
|
const area=htmlBlock('area-wrap', hist.length
|
|
262
285
|
? SpectoCharts.area(
|
|
263
|
-
[{name:'
|
|
264
|
-
{name:'
|
|
286
|
+
[{name:t('chart.scope'),color:cssv('--cool'),data:hist.map(h=>h.total)},
|
|
287
|
+
{name:t('chart.delivered'),color:cssv('--signal'),data:hist.map(h=>h.done)}],
|
|
265
288
|
hist.map(h=>(h.date||'').slice(5)))
|
|
266
|
-
: '<div class="empty">
|
|
289
|
+
: '<div class="empty">'+t('chart.noHistory')+'</div>');
|
|
267
290
|
// enrich the area's hit-rect tooltips with the actual scope/delivered values
|
|
268
291
|
// for that point (charts.js keeps them pure — just the date label)
|
|
269
292
|
area.querySelectorAll('.area-hit').forEach((hit,i)=>{
|
|
270
293
|
const h=hist[i]; if(!h) return;
|
|
271
|
-
hit.dataset.tip = `<b>${h.date||''}</b><br
|
|
294
|
+
hit.dataset.tip = `<b>${h.date||''}</b><br>${t('chart.scope')}: ${h.total||0} · ${t('chart.delivered')}: ${h.done||0}`;
|
|
272
295
|
});
|
|
273
296
|
const topRow=el('div','overview-top');
|
|
274
|
-
topRow.append(ocard('
|
|
275
|
-
topRow.append(ocard('
|
|
297
|
+
topRow.append(ocard(t('chart.statusDistribution'), donutRow));
|
|
298
|
+
topRow.append(ocard(t('chart.scopeVsDelivered'), area));
|
|
276
299
|
box.append(topRow);
|
|
277
300
|
|
|
278
301
|
// Workflow-at-a-glance strip (reuses the wf-arrow flow animation)
|
|
@@ -284,8 +307,8 @@ function renderOverview(){
|
|
|
284
307
|
strip.append(node);
|
|
285
308
|
if(i<steps.length-1){ const a=el('div','wf-arrow'+(st.enabled&&steps[i+1].enabled?'':' off')); strip.append(a); }
|
|
286
309
|
});
|
|
287
|
-
if(!steps.length) strip.append(el('div','empty','
|
|
288
|
-
box.append(ocard('
|
|
310
|
+
if(!steps.length) strip.append(el('div','empty',t('board.noWorkflow')));
|
|
311
|
+
box.append(ocard(t('board.workflowGlance'), strip));
|
|
289
312
|
|
|
290
313
|
// Per-phase progress bars — only phases that actually hold tasks (headings with no checkbox tasks
|
|
291
314
|
// are noise, not phases), and cap the list height with an internal scroll so a big project with
|
|
@@ -294,7 +317,7 @@ function renderOverview(){
|
|
|
294
317
|
if(phaseRows.length){
|
|
295
318
|
const barsEl=bars(phaseRows);
|
|
296
319
|
if(phaseRows.length>8) barsEl.classList.add('scroll-cap');
|
|
297
|
-
box.append(ocard(
|
|
320
|
+
box.append(ocard(t('board.phaseProgress',{n:phaseRows.length}), barsEl));
|
|
298
321
|
}
|
|
299
322
|
}
|
|
300
323
|
|
|
@@ -313,12 +336,12 @@ function renderBoard(){
|
|
|
313
336
|
updateFilterChips();
|
|
314
337
|
|
|
315
338
|
const specs=$('#specs'); specs.innerHTML=''; $('#specsCount').textContent=(P.specs||[]).length;
|
|
316
|
-
if(!(P.specs||[]).length) specs.append(li('empty','
|
|
339
|
+
if(!(P.specs||[]).length) specs.append(li('empty',t('board.noneYet')));
|
|
317
340
|
(P.specs||[]).forEach(s=> specs.append(li(null,s)));
|
|
318
341
|
|
|
319
342
|
const running = (P.runtime&&P.runtime.agents||[]).filter(a=>a.status==='running');
|
|
320
343
|
const rl=$('#running'); rl.innerHTML=''; $('#runCount').textContent=running.length;
|
|
321
|
-
if(!running.length) rl.append(li('empty','
|
|
344
|
+
if(!running.length) rl.append(li('empty',t('board.noAgentRunning')));
|
|
322
345
|
running.forEach(a=>{ const e=li('run-live',''); e.innerHTML=`<b>${a.tool}</b> · ${a.task||'—'}`; rl.append(e); });
|
|
323
346
|
|
|
324
347
|
const board=$('#board'); board.innerHTML='';
|
|
@@ -357,8 +380,8 @@ function renderKanban(board, tasks){
|
|
|
357
380
|
}
|
|
358
381
|
function updateBoardViewToggle(){ $$('#boardViewToggle .vt-btn').forEach(b=> b.classList.toggle('active', b.dataset.view===boardView)); }
|
|
359
382
|
function li(cls,txt){ const e=el('li',cls); e.textContent=txt; return e; }
|
|
360
|
-
function emptyState(){ const d=el('div','empty'); d.style.padding='40px'; d.textContent='
|
|
361
|
-
function noMatchState(){ const d=el('div','empty'); d.style.padding='40px'; d.textContent='
|
|
383
|
+
function emptyState(){ const d=el('div','empty'); d.style.padding='40px'; d.textContent=t('board.noPlans'); return d; }
|
|
384
|
+
function noMatchState(){ const d=el('div','empty'); d.style.padding='40px'; d.textContent=t('board.noTasksMatch'); return d; }
|
|
362
385
|
|
|
363
386
|
// ---- Backlog tab: flat, sortable, filterable table of every task ----------
|
|
364
387
|
// Rows are built from P.plans (not allTasks()) so each task carries its phase title.
|
|
@@ -408,21 +431,21 @@ function renderBacklog(){
|
|
|
408
431
|
if(backlogPage>pages) backlogPage=pages;
|
|
409
432
|
if(backlogPage<1) backlogPage=1;
|
|
410
433
|
body.innerHTML='';
|
|
411
|
-
if(!all.length){ body.append(backlogEmptyRow('
|
|
412
|
-
if(!filtered.length){ body.append(backlogEmptyRow('
|
|
434
|
+
if(!all.length){ body.append(backlogEmptyRow(t('board.noPlans'))); return renderBacklogPager(0,1); }
|
|
435
|
+
if(!filtered.length){ body.append(backlogEmptyRow(t('board.noTasksMatch'))); return renderBacklogPager(0,1); }
|
|
413
436
|
const start=(backlogPage-1)*BACKLOG_PAGE;
|
|
414
437
|
filtered.slice(start,start+BACKLOG_PAGE).forEach(r=> body.append(backlogRow(r)));
|
|
415
438
|
renderBacklogPager(filtered.length,pages);
|
|
416
439
|
}
|
|
417
440
|
function renderBacklogPager(total,pages){
|
|
418
441
|
const pager=$('#backlogPager'); if(!pager) return; pager.innerHTML='';
|
|
419
|
-
if(total<=BACKLOG_PAGE){ if(total) pager.append(el('span','pager-info'
|
|
420
|
-
const prev=el('button','pager-btn','
|
|
442
|
+
if(total<=BACKLOG_PAGE){ if(total) pager.append(el('span','pager-info', t(total>1?'backlog.pagerTasks':'backlog.pagerTask',{n:total}))); return; }
|
|
443
|
+
const prev=el('button','pager-btn',t('backlog.prev')); prev.disabled=backlogPage<=1;
|
|
421
444
|
prev.addEventListener('click',()=>{ backlogPage--; renderBacklog(); });
|
|
422
|
-
const next=el('button','pager-btn','
|
|
445
|
+
const next=el('button','pager-btn',t('backlog.next')); next.disabled=backlogPage>=pages;
|
|
423
446
|
next.addEventListener('click',()=>{ backlogPage++; renderBacklog(); });
|
|
424
447
|
const from=(backlogPage-1)*BACKLOG_PAGE+1, to=Math.min(total,backlogPage*BACKLOG_PAGE);
|
|
425
|
-
pager.append(prev, el('span','pager-info'
|
|
448
|
+
pager.append(prev, el('span','pager-info',t('backlog.pagerRange',{from,to,total,page:backlogPage,pages})), next);
|
|
426
449
|
}
|
|
427
450
|
function backlogEmptyRow(txt){
|
|
428
451
|
const tr=el('tr','backlog-empty-row'); const td=el('td',null,txt); td.colSpan=7; tr.append(td); return tr;
|
|
@@ -451,12 +474,12 @@ function loadExpanded(){
|
|
|
451
474
|
}
|
|
452
475
|
function saveExpanded(set){ try{ localStorage.setItem('spf-expanded', JSON.stringify([...set])); }catch{} }
|
|
453
476
|
let expandedPhases=loadExpanded();
|
|
454
|
-
function allPhaseTitles(){ const
|
|
477
|
+
function allPhaseTitles(){ const set=new Set(); (P.plans||[]).forEach(pl=> pl.phases.forEach(ph=> set.add(ph.title))); return [...set]; }
|
|
455
478
|
function updatePhaseToggleAll(){
|
|
456
479
|
const btn=$('#phaseToggleAll'); if(!btn) return;
|
|
457
480
|
const titles=allPhaseTitles();
|
|
458
|
-
const allOpen = titles.length>0 && titles.every(
|
|
459
|
-
btn.textContent = allOpen ? '
|
|
481
|
+
const allOpen = titles.length>0 && titles.every(x=> expandedPhases.has(x));
|
|
482
|
+
btn.textContent = allOpen ? t('board.collapseAll') : t('board.expandAll');
|
|
460
483
|
btn.dataset.state = allOpen ? 'open' : 'closed';
|
|
461
484
|
}
|
|
462
485
|
|
|
@@ -515,17 +538,17 @@ function renderTask(t){
|
|
|
515
538
|
// detail zone teaches the user what the pipeline is, not just that a step exists.
|
|
516
539
|
function wfDesc(s){
|
|
517
540
|
const n=String(s.name||'').toLowerCase();
|
|
518
|
-
if(/brainstorm|idea|intake/.test(n)) return '
|
|
519
|
-
if(/analy/.test(n)) return '
|
|
520
|
-
if(/spec/.test(n)) return '
|
|
521
|
-
if(/plan/.test(n)) return '
|
|
522
|
-
if(/develop|implement|\bcode\b/.test(n)) return '
|
|
523
|
-
if(/integration/.test(n)) return '
|
|
524
|
-
if(/end.?to.?end|e2e/.test(n)) return '
|
|
525
|
-
if(/unit/.test(n)||/\btest/.test(n)) return '
|
|
526
|
-
if(/review/.test(n)) return '
|
|
527
|
-
if(/deploy|ship|release|publish/.test(n)) return '
|
|
528
|
-
return '
|
|
541
|
+
if(/brainstorm|idea|intake/.test(n)) return t('wf.desc.brainstorm');
|
|
542
|
+
if(/analy/.test(n)) return t('wf.desc.analysis');
|
|
543
|
+
if(/spec/.test(n)) return t('wf.desc.spec');
|
|
544
|
+
if(/plan/.test(n)) return t('wf.desc.plan');
|
|
545
|
+
if(/develop|implement|\bcode\b/.test(n)) return t('wf.desc.develop');
|
|
546
|
+
if(/integration/.test(n)) return t('wf.desc.integration');
|
|
547
|
+
if(/end.?to.?end|e2e/.test(n)) return t('wf.desc.e2e');
|
|
548
|
+
if(/unit/.test(n)||/\btest/.test(n)) return t('wf.desc.unit');
|
|
549
|
+
if(/review/.test(n)) return t('wf.desc.review');
|
|
550
|
+
if(/deploy|ship|release|publish/.test(n)) return t('wf.desc.deploy');
|
|
551
|
+
return t('wf.desc.fallback');
|
|
529
552
|
}
|
|
530
553
|
let wfPopStep=null; // name of the step whose click-popover is open (null = closed)
|
|
531
554
|
|
|
@@ -536,11 +559,11 @@ let wfPopStep=null; // name of the step whose click-popover is open (null = clos
|
|
|
536
559
|
function renderWorkflow(){
|
|
537
560
|
const box=$('#wfDiagram'); box.innerHTML='';
|
|
538
561
|
const steps=P.workflow||[];
|
|
539
|
-
if(!steps.length){ box.append(el('div','empty','
|
|
562
|
+
if(!steps.length){ box.append(el('div','empty',t('board.noWorkflow'))); closeWfPop(); return; }
|
|
540
563
|
const enabledCount=steps.filter(s=>s.enabled).length;
|
|
541
564
|
const legend=el('div','wf-legend');
|
|
542
565
|
legend.append(el('span','wf-legend-dot'));
|
|
543
|
-
legend.append(el('span','wf-legend-txt'
|
|
566
|
+
legend.append(el('span','wf-legend-txt',t('workflow.stepsEnabled',{enabled:enabledCount,total:steps.length})));
|
|
544
567
|
box.append(legend);
|
|
545
568
|
const pipe=el('div','wf-pipeline');
|
|
546
569
|
steps.forEach((s,i)=>{
|
|
@@ -551,7 +574,7 @@ function renderWorkflow(){
|
|
|
551
574
|
circle.innerHTML=(typeof ICON!=='undefined'&&ICON.wf)?ICON.wf(s.name):'';
|
|
552
575
|
step.append(circle);
|
|
553
576
|
const cap=el('div','wf-caption'); cap.append(document.createTextNode(s.name));
|
|
554
|
-
if(s.optional) cap.append(el('span','wf-opt2','optional'));
|
|
577
|
+
if(s.optional) cap.append(el('span','wf-opt2',t('workflow.optional')));
|
|
555
578
|
step.append(cap);
|
|
556
579
|
const sel=(e)=>{ if(e) e.stopPropagation(); if(wfPopStep===s.name) closeWfPop(); else openWfPop(s.name); };
|
|
557
580
|
step.addEventListener('click',sel);
|
|
@@ -570,19 +593,19 @@ function wfPopFill(pop, s, idx){
|
|
|
570
593
|
const head=el('div','wf-detail-head');
|
|
571
594
|
head.append(el('span','wf-detail-num',String(idx+1)));
|
|
572
595
|
head.append(el('span','wf-detail-name',s.name));
|
|
573
|
-
head.append(el('span','wf-detail-status '+(s.enabled?'on':'off'), s.enabled?'enabled':'disabled'));
|
|
574
|
-
if(s.optional) head.append(el('span','wf-opt','optional'));
|
|
596
|
+
head.append(el('span','wf-detail-status '+(s.enabled?'on':'off'), s.enabled?t('workflow.enabled'):t('workflow.disabled')));
|
|
597
|
+
if(s.optional) head.append(el('span','wf-opt',t('workflow.optional')));
|
|
575
598
|
pop.append(head);
|
|
576
599
|
pop.append(el('p','wf-detail-desc', wfDesc(s)));
|
|
577
600
|
const grid=el('div','wf-detail-grid');
|
|
578
|
-
grid.append(wfDetailRow('
|
|
579
|
-
grid.append(wfDetailRow('
|
|
580
|
-
grid.append(wfDetailRow('
|
|
581
|
-
if(skill&&skill.standard) grid.append(wfDetailRow('
|
|
582
|
-
if(skill&&(skill.inputs||skill.outputs)) grid.append(wfDetailRow('
|
|
601
|
+
grid.append(wfDetailRow(t('workflow.capability'), s.cap||'—'));
|
|
602
|
+
grid.append(wfDetailRow(t('workflow.handledBy'), agent?(agent.title||agent.name):'—'));
|
|
603
|
+
grid.append(wfDetailRow(t('workflow.skill'), s.skill||'—'));
|
|
604
|
+
if(skill&&skill.standard) grid.append(wfDetailRow(t('workflow.standard'), skill.standard));
|
|
605
|
+
if(skill&&(skill.inputs||skill.outputs)) grid.append(wfDetailRow(t('workflow.flow'), (skill.inputs||'—')+' → '+(skill.outputs||'—')));
|
|
583
606
|
pop.append(grid);
|
|
584
|
-
if(skill&&skill.description){ const sk=el('div','wf-detail-skill'); sk.append(el('b',null,'
|
|
585
|
-
const btn=el('button','btn '+(s.enabled?'':'primary')+' wf-detail-btn', s.enabled?'
|
|
607
|
+
if(skill&&skill.description){ const sk=el('div','wf-detail-skill'); sk.append(el('b',null,t('workflow.skillPrefix',{name:skill.name}))); sk.append(document.createTextNode(skill.description)); pop.append(sk); }
|
|
608
|
+
const btn=el('button','btn '+(s.enabled?'':'primary')+' wf-detail-btn', s.enabled?t('workflow.disableStep'):t('workflow.enableStep'));
|
|
586
609
|
btn.addEventListener('click',(e)=>{ e.stopPropagation(); toggleStep(s.name); });
|
|
587
610
|
const actions=el('div','wf-pop-actions'); actions.append(btn); pop.append(actions);
|
|
588
611
|
}
|
|
@@ -629,27 +652,27 @@ function renderAttention(){
|
|
|
629
652
|
$$('.attn-filters .fchip').forEach(b=> b.classList.toggle('active', b.dataset.attn===attnFilter));
|
|
630
653
|
const shown=items.filter(i=> attnFilter==='all' ? true : attnFilter==='resolved' ? i.status==='resolved' : i.status!=='resolved');
|
|
631
654
|
list.innerHTML='';
|
|
632
|
-
if(!shown.length){ list.append(el('div','empty', attnFilter==='resolved'?'
|
|
655
|
+
if(!shown.length){ list.append(el('div','empty', attnFilter==='resolved'?t('attn.emptyResolved'):t('attn.emptyOpen'))); return; }
|
|
633
656
|
shown.forEach(it=> list.append(attnRow(it)));
|
|
634
657
|
}
|
|
635
658
|
function attnRow(it){
|
|
636
659
|
const row=el('div','attn-row'+(it.status==='resolved'?' is-resolved':'')+(it.source==='agent'?' from-agent':''));
|
|
637
660
|
const head=el('div','attn-head');
|
|
638
|
-
head.append(el('span','attn-src '+(it.source==='agent'?'is-agent':'is-user'), it.source==='agent'?('⚑ '+(it.by||'
|
|
661
|
+
head.append(el('span','attn-src '+(it.source==='agent'?'is-agent':'is-user'), it.source==='agent'?('⚑ '+(it.by||t('attn.agentFallback'))):('✎ '+t('attn.you'))));
|
|
639
662
|
if(it.at) head.append(el('span','attn-time',(String(it.at).replace('T',' ')).slice(0,16)));
|
|
640
|
-
if(it.status==='resolved') head.append(el('span','chip s-done', it.promotedTo?('→ '+it.promotedTo):'
|
|
663
|
+
if(it.status==='resolved') head.append(el('span','chip s-done', it.promotedTo?('→ '+it.promotedTo):t('attn.resolvedChip')));
|
|
641
664
|
row.append(head);
|
|
642
665
|
const txt=el('div','attn-text', it.text); row.append(txt);
|
|
643
666
|
const acts=el('div','attn-actions');
|
|
644
667
|
if(it.status!=='resolved'){
|
|
645
|
-
const val=el('button','btn primary','
|
|
646
|
-
const res=el('button','btn','
|
|
647
|
-
const edit=el('button','btn','
|
|
668
|
+
const val=el('button','btn primary',t('action.validateToTask')); val.addEventListener('click',()=>promoteAttn(it.id));
|
|
669
|
+
const res=el('button','btn',t('action.resolve')); res.addEventListener('click',()=>patchAttn(it.id,{status:'resolved'}));
|
|
670
|
+
const edit=el('button','btn',t('action.edit')); edit.addEventListener('click',()=>editAttn(it,txt));
|
|
648
671
|
acts.append(val,res,edit);
|
|
649
672
|
}else{
|
|
650
|
-
const re=el('button','btn','
|
|
673
|
+
const re=el('button','btn',t('action.reopen')); re.addEventListener('click',()=>patchAttn(it.id,{status:'open'})); acts.append(re);
|
|
651
674
|
}
|
|
652
|
-
const del=el('button','btn danger','
|
|
675
|
+
const del=el('button','btn danger',t('action.delete')); del.addEventListener('click',()=>deleteAttn(it.id));
|
|
653
676
|
acts.append(del); row.append(acts);
|
|
654
677
|
return row;
|
|
655
678
|
}
|
|
@@ -665,12 +688,19 @@ async function patchAttn(id,patch){ flash(); await fetch('/api/attention/'+encod
|
|
|
665
688
|
async function deleteAttn(id){ flash(); await fetch('/api/attention/'+encodeURIComponent(id),{method:'DELETE'}); }
|
|
666
689
|
async function promoteAttn(id){ flash(); await fetch('/api/attention/'+encodeURIComponent(id)+'/promote',{method:'POST'}); }
|
|
667
690
|
|
|
668
|
-
// ---- Settings tab: change autonomy mode + output language (writes config.json) ----
|
|
669
|
-
|
|
670
|
-
|
|
691
|
+
// ---- Settings tab + topbar quick-switch: change autonomy mode + output language (writes config.json) ----
|
|
692
|
+
// Mode/language can be changed from two places — the Settings tab (#setMode/#setLang) and the
|
|
693
|
+
// always-visible topbar selects (#topMode/#topLang, in .brand-sub, present in every design). Both
|
|
694
|
+
// pairs are kept in sync: renderSettings() (every render tick) writes the current config into all
|
|
695
|
+
// four; a change on any one immediately mirrors into its sibling before saving, so saveSettings()
|
|
696
|
+
// never reads a stale value from the pair the user didn't touch.
|
|
697
|
+
function fillLangSelect(sel,lang){
|
|
698
|
+
if(!sel) return;
|
|
671
699
|
if(![...sel.options].some(o=>o.value===lang)){ const o=document.createElement('option'); o.value=lang; o.textContent=lang; sel.append(o); }
|
|
672
700
|
sel.value=lang;
|
|
673
701
|
}
|
|
702
|
+
function setLangSelect(lang){ fillLangSelect($('#setLang'),lang); fillLangSelect($('#topLang'),lang); }
|
|
703
|
+
function setModeSelects(mode){ [$('#setMode'),$('#topMode')].forEach(s=>{ if(s) s.value=mode; }); }
|
|
674
704
|
// ---- design skins (data-design) — switchable, persisted per viewer + as the project default ----
|
|
675
705
|
function currentDesign(){ return document.documentElement.getAttribute('data-design')||'console'; }
|
|
676
706
|
function applyDesign(id){ document.documentElement.setAttribute('data-design',id); try{ localStorage.setItem('spf-design',id); }catch{} }
|
|
@@ -678,7 +708,7 @@ async function saveDesign(id){ applyDesign(id); if(P) render(); /* re-read token
|
|
|
678
708
|
|
|
679
709
|
function renderSettings(){
|
|
680
710
|
const c=(P&&P.config)||{};
|
|
681
|
-
|
|
711
|
+
setModeSelects(c.mode||'semi');
|
|
682
712
|
setLangSelect(c.language||'en');
|
|
683
713
|
// design switcher — options from the DESIGNS registry (designs.js)
|
|
684
714
|
const dsel=$('#setDesign');
|
|
@@ -694,26 +724,205 @@ function renderSettings(){
|
|
|
694
724
|
const box=$('#settingsReadonly');
|
|
695
725
|
if(box){
|
|
696
726
|
box.innerHTML='';
|
|
697
|
-
const rows=[['
|
|
698
|
-
['
|
|
699
|
-
['
|
|
727
|
+
const rows=[[t('info.activeAgent'),c.agent||'—'],[t('info.projectType'),c.projectType||'—'],
|
|
728
|
+
[t('field.plansFolder'),c.plansDir||'plans'],[t('field.specsFolder'),c.specsDir||'specs'],
|
|
729
|
+
[t('field.frameworkVersion'), P&&P.version?('v'+P.version):'—']];
|
|
700
730
|
rows.forEach(([k,v])=>{ const r=el('div','settings-ro-row'); r.append(el('span','settings-ro-k',k), el('span','settings-ro-v',String(v))); box.append(r); });
|
|
701
731
|
}
|
|
702
732
|
const fv=$('#footerVer'); if(fv) fv.textContent = (P&&P.version) ? ('v'+P.version) : '';
|
|
733
|
+
renderCustomize();
|
|
703
734
|
}
|
|
704
735
|
async function saveSettings(){
|
|
705
|
-
flash();
|
|
736
|
+
flash();
|
|
737
|
+
const mode=($('#setMode')||$('#topMode')).value, language=($('#setLang')||$('#topLang')).value;
|
|
706
738
|
await fetch('/api/settings',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({mode,language})});
|
|
707
739
|
const s=$('#settingsSaved'); if(s){ s.hidden=false; setTimeout(()=>{ s.hidden=true; },1500); }
|
|
708
740
|
}
|
|
709
741
|
|
|
742
|
+
// ---- Custom dashboards (Customize page → generate-dashboard skill) ---------------------------
|
|
743
|
+
// A custom dashboard is a DECLARATIVE block spec (.spectoflow/dashboard/custom/<id>.json, embedded
|
|
744
|
+
// in P.customDashboards by the server) — never raw HTML/CSS/JS. Every block below reuses the exact
|
|
745
|
+
// same components the built-in Board renders with (kpiCard/ocard/bars/donut/statTile/mdLite/el), so a
|
|
746
|
+
// generated dashboard automatically matches the active design — and any design switched to later —
|
|
747
|
+
// with zero page-specific styling. Mirrors the schema in .spectoflow/lib/custom-dashboard.js (the
|
|
748
|
+
// Node-side validator); this is the independent browser-side reader for the same shape.
|
|
749
|
+
function resolveBind(s,bindPath,fallback){
|
|
750
|
+
if(bindPath==null) return fallback;
|
|
751
|
+
let v=s;
|
|
752
|
+
for(const p of String(bindPath).split('.')){ if(v==null) return fallback; v=v[p]; }
|
|
753
|
+
return v==null?fallback:v;
|
|
754
|
+
}
|
|
755
|
+
function renderCustomBlock(b,s){
|
|
756
|
+
switch(b.type){
|
|
757
|
+
case 'markdown': return htmlBlock('cd-markdown', mdLite(b.content||''));
|
|
758
|
+
case 'kpi-row': {
|
|
759
|
+
const row=el('div','kpi-row');
|
|
760
|
+
(b.items||[]).forEach(it=>{
|
|
761
|
+
const val=it.bind!=null?resolveBind(s,it.bind,it.value):it.value;
|
|
762
|
+
row.append(kpiCard(it.label||'', numBlock(val==null?'—':val,it.color||'var(--signal)'), it.sub||'', cssv(it.colorVar||'--signal')));
|
|
763
|
+
});
|
|
764
|
+
return row;
|
|
765
|
+
}
|
|
766
|
+
case 'chart-bars': {
|
|
767
|
+
const rows=(b.rows||[]).map(r=>({label:r.label||'', pct:r.bind!=null?(resolveBind(s,r.bind,r.pct||0)):(r.pct||0), sub:r.sub||''}));
|
|
768
|
+
return ocard(b.title||'', bars(rows));
|
|
769
|
+
}
|
|
770
|
+
case 'chart-donut': {
|
|
771
|
+
const segs=(b.segments||[]).map(g=>({key:g.key||'', value:g.bind!=null?(resolveBind(s,g.bind,g.value||0)):(g.value||0), color:cssv(g.colorVar||'--muted')}));
|
|
772
|
+
const total=segs.reduce((a,x)=>a+(Number(x.value)||0),0);
|
|
773
|
+
const d=donut(segs,140,{center:String(total),sub:t('chart.tasksSub')});
|
|
774
|
+
const legend=el('div','legend');
|
|
775
|
+
segs.forEach(seg=>{ const item=el('div','legend-item'); const sw=el('span','legend-swatch'); sw.style.background=seg.color; item.append(sw, el('span','legend-label',seg.key), el('span','legend-count',String(seg.value))); legend.append(item); });
|
|
776
|
+
const row=el('div','donut-row'); row.append(d.wrap,legend);
|
|
777
|
+
return ocard(b.title||'', row);
|
|
778
|
+
}
|
|
779
|
+
case 'table': {
|
|
780
|
+
const wrap=el('div','table-scroll'); const tbl=el('table','backlog-table');
|
|
781
|
+
const thead=el('thead'); const htr=el('tr'); (b.columns||[]).forEach(c=>htr.append(el('th',null,String(c)))); thead.append(htr); tbl.append(thead);
|
|
782
|
+
const tbody=el('tbody'); (b.rows||[]).forEach(r=>{ const tr=el('tr'); (r||[]).forEach(c=>tr.append(el('td',null,String(c)))); tbody.append(tr); }); tbl.append(tbody);
|
|
783
|
+
wrap.append(tbl);
|
|
784
|
+
return b.title? ocard(b.title,wrap) : wrap;
|
|
785
|
+
}
|
|
786
|
+
case 'list': {
|
|
787
|
+
const ul=el('ul','flatlist'); (b.items||[]).forEach(x=>ul.append(li(null,String(x))));
|
|
788
|
+
return b.title? ocard(b.title,ul) : ul;
|
|
789
|
+
}
|
|
790
|
+
case 'stat-tile-row': {
|
|
791
|
+
const row=el('div','stat-tiles');
|
|
792
|
+
(b.items||[]).forEach(it=>{ const val=it.bind!=null?resolveBind(s,it.bind,it.value):it.value; row.append(statTile(val==null?'—':String(val), it.label||'', it.sub||'')); });
|
|
793
|
+
return row;
|
|
794
|
+
}
|
|
795
|
+
default: return el('div','empty','Unknown block type: '+b.type);
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
function customDashboardTabs(){ return $$('#tabs .tab[data-tab^="custom:"]'); }
|
|
799
|
+
// Adds/removes nav tabs + panels to match P.customDashboards, and refreshes an existing tab's label
|
|
800
|
+
// if the dashboard was regenerated with a new title. Console's rail and Orbit's radial menu both read
|
|
801
|
+
// #tabs live, so a custom tab appears in either design's navigation with no design-specific change.
|
|
802
|
+
function syncCustomTabs(list){
|
|
803
|
+
const tabsNav=$('#tabs'); if(!tabsNav) return;
|
|
804
|
+
const wanted=new Set(list.map(spec=>'custom:'+spec.id));
|
|
805
|
+
customDashboardTabs().forEach(btn=>{
|
|
806
|
+
const id=btn.dataset.tab;
|
|
807
|
+
if(!wanted.has(id)){ const panel=$('.panel[data-panel="'+id+'"]'); if(panel) panel.remove(); btn.remove(); }
|
|
808
|
+
});
|
|
809
|
+
list.forEach(spec=>{
|
|
810
|
+
const id='custom:'+spec.id;
|
|
811
|
+
let btn=$('#tabs .tab[data-tab="'+id+'"]');
|
|
812
|
+
if(!btn){
|
|
813
|
+
btn=el('button','tab'); btn.dataset.tab=id;
|
|
814
|
+
const icoKey=spec.icon||'info';
|
|
815
|
+
const ico=el('span','tab-ico'); ico.dataset.icon=icoKey; if(typeof ICON!=='undefined'&&ICON[icoKey]) ico.innerHTML=ICON[icoKey];
|
|
816
|
+
btn.append(ico, el('span','tab-label',spec.title||spec.id));
|
|
817
|
+
btn.addEventListener('click',()=>navigateTab(id));
|
|
818
|
+
tabsNav.append(btn);
|
|
819
|
+
const panel=el('section','panel'); panel.dataset.panel=id;
|
|
820
|
+
const wrap=el('div','custom-dash-wrap');
|
|
821
|
+
wrap.append(el('h2','panel-title',spec.title||spec.id));
|
|
822
|
+
wrap.append(el('div','custom-dash-body'));
|
|
823
|
+
panel.append(wrap);
|
|
824
|
+
$('.stage').append(panel);
|
|
825
|
+
} else {
|
|
826
|
+
const lbl=btn.querySelector('.tab-label'); if(lbl) lbl.textContent=spec.title||spec.id;
|
|
827
|
+
const title=$('.panel[data-panel="'+id+'"] .panel-title'); if(title) title.textContent=spec.title||spec.id;
|
|
828
|
+
}
|
|
829
|
+
});
|
|
830
|
+
}
|
|
831
|
+
function renderCustomDashboards(){
|
|
832
|
+
const list=P.customDashboards||[];
|
|
833
|
+
syncCustomTabs(list);
|
|
834
|
+
const s=SpectoStats.stats(P);
|
|
835
|
+
list.forEach(spec=>{
|
|
836
|
+
const box=$('.panel[data-panel="custom:'+spec.id+'"] .custom-dash-body'); if(!box) return;
|
|
837
|
+
box.innerHTML='';
|
|
838
|
+
(spec.blocks||[]).forEach(b=> box.append(renderCustomBlock(b,s)));
|
|
839
|
+
});
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
// ---- Settings → Customize: add dashboards/skills/agents by description, or "Auto" -------------
|
|
843
|
+
// Generation itself is real agent work (research, clarify, write a file) — this UI never does it
|
|
844
|
+
// client-side. It just constructs a plain-language prompt (recognized by AGENTS.md's Router, which
|
|
845
|
+
// hands it to framework-curator) and sends it through the SAME /api/run + group-chat pipeline every
|
|
846
|
+
// other "Run" already uses, then jumps to Chat so the requester watches it happen and can answer any
|
|
847
|
+
// clarifying question there — no separate conversational UI to build or keep in sync.
|
|
848
|
+
const CZ_KINDS=[
|
|
849
|
+
{ kind:'dashboard', promptAdd:(d)=>'Add a custom dashboard: '+d, promptAuto:'Propose dashboard candidates for this project (Auto customize)' },
|
|
850
|
+
{ kind:'skill', promptAdd:(d)=>'Create a new skill: '+d, promptAuto:'Propose skill candidates for this project (Auto customize)' },
|
|
851
|
+
{ kind:'agent', promptAdd:(d)=>'Create a new agent: '+d, promptAuto:'Propose agent candidates for this project (Auto customize)' },
|
|
852
|
+
];
|
|
853
|
+
function czItemsFor(kind){
|
|
854
|
+
if(kind==='dashboard') return (P.customDashboards||[]).map(spec=>({ title:spec.title||spec.id, sub:(spec.blocks||[]).length+' '+t('customize.blocksSub'), open:()=>navigateTab('custom:'+spec.id) }));
|
|
855
|
+
if(kind==='skill') return (P.skills||[]).filter(s=>s.custom).map(s=>({ title:s.name, sub:s.description||'', open:()=>openFileDrawer('skill',s) }));
|
|
856
|
+
return (P.agents||[]).filter(a=>a.custom).map(a=>({ title:a.title||a.name, sub:a.description||'', open:()=>openFileDrawer('agent',a) }));
|
|
857
|
+
}
|
|
858
|
+
function renderCustomize(){
|
|
859
|
+
const root=$('#czRoot'); if(!root) return;
|
|
860
|
+
const openKind=root.dataset.open||'';
|
|
861
|
+
root.innerHTML='';
|
|
862
|
+
CZ_KINDS.forEach(({kind})=>{
|
|
863
|
+
const items=czItemsFor(kind);
|
|
864
|
+
const block=el('div','cz-block');
|
|
865
|
+
const head=el('div','cz-head');
|
|
866
|
+
head.append(el('h3',null,t('customize.'+kind+'s')+' ('+items.length+')'));
|
|
867
|
+
const addBtn=el('button','btn cz-add',t('customize.add.'+kind));
|
|
868
|
+
addBtn.setAttribute('aria-expanded',String(openKind===kind));
|
|
869
|
+
addBtn.addEventListener('click',()=>{ root.dataset.open=(openKind===kind)?'':kind; renderCustomize(); });
|
|
870
|
+
head.append(addBtn); block.append(head);
|
|
871
|
+
const list=el('div','cz-list');
|
|
872
|
+
if(!items.length) list.append(el('div','empty',t('customize.empty.'+kind)));
|
|
873
|
+
items.forEach(it=>{
|
|
874
|
+
const row=el('div','cz-item'); row.tabIndex=0;
|
|
875
|
+
row.append(el('span','cz-item-title',it.title));
|
|
876
|
+
if(it.sub) row.append(el('span','cz-item-sub',it.sub));
|
|
877
|
+
row.addEventListener('click',it.open);
|
|
878
|
+
row.addEventListener('keydown',(e)=>{ if(e.key==='Enter') it.open(); });
|
|
879
|
+
list.append(row);
|
|
880
|
+
});
|
|
881
|
+
block.append(list);
|
|
882
|
+
if(openKind===kind){
|
|
883
|
+
const form=el('div','cz-form');
|
|
884
|
+
const ta=el('textarea','chat-ta'); ta.placeholder=t('customize.describePh');
|
|
885
|
+
const sel=el('select','chat-agent');
|
|
886
|
+
const runners=Object.keys((P.config&&P.config.runners)||{claude:1});
|
|
887
|
+
runners.forEach((k)=>{ const o=document.createElement('option'); o.value=k; o.textContent=k; sel.append(o); });
|
|
888
|
+
if(P.config&&P.config.agent) sel.value=P.config.agent;
|
|
889
|
+
const actions=el('div','cz-form-actions');
|
|
890
|
+
const autoBtn=el('button','btn',t('customize.auto'));
|
|
891
|
+
const goBtn=el('button','btn primary',t('customize.generate'));
|
|
892
|
+
autoBtn.addEventListener('click',()=>czSubmit(kind,null,sel.value));
|
|
893
|
+
goBtn.addEventListener('click',()=>{ const v=ta.value.trim(); if(v) czSubmit(kind,v,sel.value); });
|
|
894
|
+
actions.append(sel,autoBtn,goBtn);
|
|
895
|
+
form.append(ta,actions);
|
|
896
|
+
block.append(form);
|
|
897
|
+
}
|
|
898
|
+
root.append(block);
|
|
899
|
+
});
|
|
900
|
+
}
|
|
901
|
+
async function czSubmit(kind,description,agent){
|
|
902
|
+
const cfg=CZ_KINDS.find((c)=>c.kind===kind);
|
|
903
|
+
const prompt=description?cfg.promptAdd(description):cfg.promptAuto;
|
|
904
|
+
await fetch('/api/run',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({prompt,agent})});
|
|
905
|
+
const root=$('#czRoot'); if(root) root.dataset.open='';
|
|
906
|
+
navigateTab('chat');
|
|
907
|
+
}
|
|
908
|
+
|
|
710
909
|
// ---- client-side routing: /<tab>[/<taskId>] via the History API ------------
|
|
910
|
+
// A custom dashboard (Customize page) gets its own tab id "custom:<id>" and its own URL shape
|
|
911
|
+
// /custom/<id> — kept out of ROUTES (a fixed list) since the set of custom ids is dynamic; recognized
|
|
912
|
+
// by a dedicated branch in tabFromPath()/navigateTab() instead.
|
|
711
913
|
const ROUTES=['board','requests','attention','backlog','workflow','team','chat','info','settings'];
|
|
712
|
-
function tabFromPath(){
|
|
914
|
+
function tabFromPath(){
|
|
915
|
+
const s=location.pathname.split('/').filter(Boolean);
|
|
916
|
+
if(s[0]==='custom'&&s[1]) return 'custom:'+decodeURIComponent(s[1]);
|
|
917
|
+
return ROUTES.includes(s[0])?s[0]:null;
|
|
918
|
+
}
|
|
713
919
|
function taskFromPath(){ const s=location.pathname.split('/').filter(Boolean); return (ROUTES.includes(s[0])&&s[1])?decodeURIComponent(s[1]):null; }
|
|
714
|
-
function navigateTab(
|
|
715
|
-
activeTab=
|
|
716
|
-
if(push!==false)
|
|
920
|
+
function navigateTab(tabId,push){
|
|
921
|
+
activeTab=tabId; try{ localStorage.setItem('spf-tab',tabId); }catch{}
|
|
922
|
+
if(push!==false){
|
|
923
|
+
const isCustom=tabId.indexOf('custom:')===0;
|
|
924
|
+
history.pushState(null,'', isCustom ? '/custom/'+encodeURIComponent(tabId.slice(7)) : '/'+tabId);
|
|
925
|
+
}
|
|
717
926
|
applyActiveTab();
|
|
718
927
|
closeNav(); // a tab pick closes the mobile menu
|
|
719
928
|
}
|
|
@@ -731,8 +940,8 @@ function agentCard(a){
|
|
|
731
940
|
const c=el('div','card'); c.tabIndex=0;
|
|
732
941
|
c.append(el('div','ct',a.title||a.name));
|
|
733
942
|
if(a.capability) c.append(el('div','cc',a.capability));
|
|
734
|
-
const std=chipRow('
|
|
735
|
-
const uses=chipRow('
|
|
943
|
+
const std=chipRow(t('team.standardsLabel'),a.standards); if(std) c.append(std);
|
|
944
|
+
const uses=chipRow(t('team.usesLabel'),a.uses); if(uses) c.append(uses);
|
|
736
945
|
if(a.description) c.append(el('div','cd',a.description));
|
|
737
946
|
const open=()=>openFileDrawer('agent',a);
|
|
738
947
|
c.addEventListener('click',open);
|
|
@@ -743,7 +952,7 @@ function skillCard(s){
|
|
|
743
952
|
const c=el('div','card'); c.tabIndex=0;
|
|
744
953
|
c.append(el('div','ct',s.name));
|
|
745
954
|
if(s.capability) c.append(el('div','cc',s.capability));
|
|
746
|
-
const std=chipRow('
|
|
955
|
+
const std=chipRow(t('team.standardLabel'), s.standard?[s.standard]:null); if(std) c.append(std);
|
|
747
956
|
if(s.inputs||s.outputs) c.append(el('div','io', (s.inputs||'—')+' → '+(s.outputs||'—')));
|
|
748
957
|
if(s.description) c.append(el('div','cd',s.description));
|
|
749
958
|
const open=()=>openFileDrawer('skill',s);
|
|
@@ -803,19 +1012,19 @@ async function openFileDrawer(kind,obj){
|
|
|
803
1012
|
openTaskId=null;
|
|
804
1013
|
const rel = kind==='agent' ? ('agents/'+(obj.file||(obj.name+'.md'))) : ('skills/'+obj.name+'/SKILL.md');
|
|
805
1014
|
const b=$('#drawerBody'); b.innerHTML='';
|
|
806
|
-
b.append(el('div','d-id', kind==='agent'?'
|
|
1015
|
+
b.append(el('div','d-id', kind==='agent'?t('drawer.agent'):t('drawer.skill')));
|
|
807
1016
|
b.append(el('div','d-title', obj.title||obj.name));
|
|
808
|
-
const sec=el('div','d-section'); sec.append(el('div','d-label','
|
|
809
|
-
const body=el('div','md-body'); body.append(el('div','empty','
|
|
1017
|
+
const sec=el('div','d-section'); sec.append(el('div','d-label',t('drawer.file',{rel})));
|
|
1018
|
+
const body=el('div','md-body'); body.append(el('div','empty',t('drawer.loading')));
|
|
810
1019
|
sec.append(body); b.append(sec);
|
|
811
1020
|
$('#drawer').setAttribute('aria-hidden','false');
|
|
812
1021
|
try{
|
|
813
1022
|
const r=await fetch('/api/agentfile?path='+encodeURIComponent(rel));
|
|
814
1023
|
const data=await r.json().catch(()=>({}));
|
|
815
|
-
if(!r.ok){ body.innerHTML=''; body.append(el('div','empty', data.error||'
|
|
1024
|
+
if(!r.ok){ body.innerHTML=''; body.append(el('div','empty', data.error||t('drawer.loadError'))); return; }
|
|
816
1025
|
body.innerHTML=mdLite(data.content||'');
|
|
817
1026
|
}catch(err){
|
|
818
|
-
body.innerHTML=''; body.append(el('div','empty','
|
|
1027
|
+
body.innerHTML=''; body.append(el('div','empty',t('drawer.loadError')));
|
|
819
1028
|
}
|
|
820
1029
|
}
|
|
821
1030
|
|
|
@@ -836,11 +1045,11 @@ function infoRow(label,value){
|
|
|
836
1045
|
return row;
|
|
837
1046
|
}
|
|
838
1047
|
function statTile(value,label,sub){
|
|
839
|
-
const
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
if(sub)
|
|
843
|
-
return
|
|
1048
|
+
const tile=el('div','stat-tile');
|
|
1049
|
+
tile.append(el('div','stat-tile-val',String(value)));
|
|
1050
|
+
tile.append(el('div','stat-tile-label',label));
|
|
1051
|
+
if(sub) tile.append(el('div','stat-tile-sub',sub));
|
|
1052
|
+
return tile;
|
|
844
1053
|
}
|
|
845
1054
|
function renderInfo(){
|
|
846
1055
|
const box=$('#infoGrid'); if(!box) return;
|
|
@@ -852,39 +1061,39 @@ function renderInfo(){
|
|
|
852
1061
|
|
|
853
1062
|
// Project: mode/language/agent/type from config
|
|
854
1063
|
const projRows=el('div','info-rows');
|
|
855
|
-
projRows.append(infoRow('
|
|
856
|
-
projRows.append(infoRow('
|
|
857
|
-
projRows.append(infoRow('
|
|
858
|
-
projRows.append(infoRow('
|
|
859
|
-
box.append(infoSection('
|
|
1064
|
+
projRows.append(infoRow(t('info.projectType'), c.projectType||'—'));
|
|
1065
|
+
projRows.append(infoRow(t('info.mode'), c.mode||'—'));
|
|
1066
|
+
projRows.append(infoRow(t('info.language'), c.language||'—'));
|
|
1067
|
+
projRows.append(infoRow(t('info.activeAgent'), c.agent||'—'));
|
|
1068
|
+
box.append(infoSection(t('info.project'),'info',projRows));
|
|
860
1069
|
|
|
861
1070
|
// Runners: agent → command, monospace
|
|
862
1071
|
const runners=c.runners||{};
|
|
863
1072
|
const runnerKeys=Object.keys(runners);
|
|
864
1073
|
const runnerRows=el('div','info-rows info-rows-mono');
|
|
865
|
-
if(!runnerKeys.length) runnerRows.append(el('div','empty','
|
|
1074
|
+
if(!runnerKeys.length) runnerRows.append(el('div','empty',t('info.noRunners')));
|
|
866
1075
|
runnerKeys.forEach(k=> runnerRows.append(infoRow(k, runners[k])));
|
|
867
|
-
box.append(infoSection('
|
|
1076
|
+
box.append(infoSection(t('info.runners'),'run',runnerRows));
|
|
868
1077
|
|
|
869
1078
|
// Counts: tasks/specs/agents/skills/enabled workflow steps
|
|
870
1079
|
const tiles=el('div','stat-tiles');
|
|
871
|
-
tiles.append(statTile(`${s.done}/${s.total}`,'
|
|
872
|
-
tiles.append(statTile(String((P.specs||[]).length),'
|
|
873
|
-
tiles.append(statTile(String((P.agents||[]).length),'
|
|
874
|
-
tiles.append(statTile(String((P.skills||[]).length),'
|
|
875
|
-
tiles.append(statTile(`${enabledSteps.length}/${steps.length}`,'
|
|
876
|
-
box.append(infoSection('
|
|
1080
|
+
tiles.append(statTile(`${s.done}/${s.total}`,t('info.tasksLabel'),t('info.doneSub',{pct:s.pct})));
|
|
1081
|
+
tiles.append(statTile(String((P.specs||[]).length),t('info.specsLabel'),t('info.filesSub')));
|
|
1082
|
+
tiles.append(statTile(String((P.agents||[]).length),t('info.agentsLabel'),t('info.personasSub')));
|
|
1083
|
+
tiles.append(statTile(String((P.skills||[]).length),t('info.skillsLabel'),t('info.proceduresSub')));
|
|
1084
|
+
tiles.append(statTile(`${enabledSteps.length}/${steps.length}`,t('info.workflowLabel'),t('info.stepsEnabledSub')));
|
|
1085
|
+
box.append(infoSection(t('info.counts'),'board',tiles));
|
|
877
1086
|
|
|
878
1087
|
// Specs: the P.specs filename list
|
|
879
1088
|
const specsList=el('ul','flatlist');
|
|
880
1089
|
const specs=P.specs||[];
|
|
881
|
-
if(!specs.length) specsList.append(li('empty','
|
|
1090
|
+
if(!specs.length) specsList.append(li('empty',t('board.noneYet')));
|
|
882
1091
|
specs.forEach(sp=> specsList.append(li(null,sp)));
|
|
883
|
-
box.append(infoSection('
|
|
1092
|
+
box.append(infoSection(t('info.specsLabel'),'backlog',specsList));
|
|
884
1093
|
|
|
885
1094
|
// Workflow: compact list of enabled steps (name + cap/skill)
|
|
886
1095
|
const wfList=el('div','info-wf-list');
|
|
887
|
-
if(!enabledSteps.length) wfList.append(el('div','empty','
|
|
1096
|
+
if(!enabledSteps.length) wfList.append(el('div','empty',t('info.noEnabledSteps')));
|
|
888
1097
|
enabledSteps.forEach(st=>{
|
|
889
1098
|
const row=el('div','info-wf-row');
|
|
890
1099
|
row.append(el('span','info-wf-name',st.name));
|
|
@@ -892,42 +1101,44 @@ function renderInfo(){
|
|
|
892
1101
|
if(meta) row.append(el('span','info-wf-meta',meta));
|
|
893
1102
|
wfList.append(row);
|
|
894
1103
|
});
|
|
895
|
-
box.append(infoSection('
|
|
1104
|
+
box.append(infoSection(t('info.workflowLabel'),'workflow',wfList));
|
|
896
1105
|
}
|
|
897
1106
|
|
|
898
1107
|
function openDrawer(id,keep){
|
|
899
|
-
|
|
1108
|
+
// named `task`, not `t` — `t` is the global translation function (see i18n.js) and this whole
|
|
1109
|
+
// function calls it repeatedly below; shadowing it with a task variable would break every call.
|
|
1110
|
+
const task=allTasks().find(x=>x.id===id); if(!task) return;
|
|
900
1111
|
openTaskId=id;
|
|
901
1112
|
if(!keep && taskFromPath()!==id) history.pushState(null,'','/'+activeTab+'/'+encodeURIComponent(id));
|
|
902
1113
|
const b=$('#drawerBody'); const prev=keep?$('.drawer-panel').scrollTop:0; b.innerHTML='';
|
|
903
|
-
b.append(el('div','d-id',
|
|
904
|
-
b.append(el('div','d-title',
|
|
905
|
-
const sSec=el('div','d-section'); sSec.append(el('div','d-label','
|
|
1114
|
+
b.append(el('div','d-id',task.id+' · '+(task.level||'standard')+' · '+task.file));
|
|
1115
|
+
b.append(el('div','d-title',task.title));
|
|
1116
|
+
const sSec=el('div','d-section'); sSec.append(el('div','d-label',t('task.status')));
|
|
906
1117
|
const sr=el('div','status-row');
|
|
907
1118
|
Object.keys(STATUS).forEach(k=>{
|
|
908
|
-
const btn=el('button','status-btn'+(
|
|
909
|
-
if(
|
|
1119
|
+
const btn=el('button','status-btn'+(task.status===k?' active':''),STATUS[k]);
|
|
1120
|
+
if(task.status===k) btn.style.background=cssv('--s-'+k);
|
|
910
1121
|
btn.addEventListener('click',()=>patchTask(id,{status:k}));
|
|
911
1122
|
sr.append(btn);
|
|
912
1123
|
});
|
|
913
1124
|
sSec.append(sr); b.append(sSec);
|
|
914
1125
|
|
|
915
1126
|
const tr=runtimeTests(id);
|
|
916
|
-
if(tr){ const ts=el('div','d-section'); ts.append(el('div','d-label','
|
|
917
|
-
ts.append(el('div','d-text', tr.failed
|
|
1127
|
+
if(tr){ const ts=el('div','d-section'); ts.append(el('div','d-label',t('task.tests')));
|
|
1128
|
+
ts.append(el('div','d-text', tr.failed?t('task.failingPassing',{f:tr.failed,p:tr.passed||0}):t('task.passing',{n:tr.passed||0}))); b.append(ts); }
|
|
918
1129
|
|
|
919
|
-
const cSec=el('div','d-section'); cSec.append(el('div','d-label','
|
|
1130
|
+
const cSec=el('div','d-section'); cSec.append(el('div','d-label',t('task.comments')));
|
|
920
1131
|
const list=el('div','comments');
|
|
921
|
-
(
|
|
922
|
-
if(!(
|
|
1132
|
+
(task.comments||[]).forEach(cm=> list.append(el('div','comment',cm)));
|
|
1133
|
+
if(!(task.comments||[]).length) list.append(el('div','empty',t('task.noComments')));
|
|
923
1134
|
cSec.append(list);
|
|
924
|
-
const box=el('div','c-box'); const ta=el('textarea'); ta.placeholder='
|
|
1135
|
+
const box=el('div','c-box'); const ta=el('textarea'); ta.placeholder=t('task.addCommentPlaceholder');
|
|
925
1136
|
const actions=el('div','c-actions');
|
|
926
|
-
const add=el('button','btn','
|
|
1137
|
+
const add=el('button','btn',t('action.add')); const an=el('button','btn primary',t('action.addToAnalyze'));
|
|
927
1138
|
add.addEventListener('click',()=>{ if(ta.value.trim())addComment(id,ta.value.trim(),'note'); });
|
|
928
1139
|
an.addEventListener('click',()=>{ if(ta.value.trim())addComment(id,ta.value.trim(),'analyze'); });
|
|
929
1140
|
actions.append(add,an); box.append(ta,actions);
|
|
930
|
-
box.append(el('div','empty','
|
|
1141
|
+
box.append(el('div','empty',t('task.toAnalyzeHint')));
|
|
931
1142
|
cSec.append(box); b.append(cSec);
|
|
932
1143
|
$('#drawer').setAttribute('aria-hidden','false');
|
|
933
1144
|
if(keep) $('.drawer-panel').scrollTop=prev;
|
|
@@ -994,8 +1205,12 @@ $$('.attn-filters .fchip').forEach(b=> b.addEventListener('click',()=>{ attnFilt
|
|
|
994
1205
|
// settings — the footer link opens the Settings tab; selects save on change
|
|
995
1206
|
const footerSettingsBtn=$('#footerSettings'); if(footerSettingsBtn) footerSettingsBtn.addEventListener('click',()=>navigateTab('settings'));
|
|
996
1207
|
const footerLogo=$('.footer-logo'); if(footerLogo) footerLogo.addEventListener('click',e=>{ e.preventDefault(); navigateTab('board'); });
|
|
997
|
-
|
|
998
|
-
|
|
1208
|
+
function onModeSelectChange(e){ setModeSelects(e.target.value); saveSettings(); }
|
|
1209
|
+
function onLangSelectChange(e){ setLangSelect(e.target.value); saveSettings(); }
|
|
1210
|
+
$('#setMode').addEventListener('change',onModeSelectChange);
|
|
1211
|
+
$('#setLang').addEventListener('change',onLangSelectChange);
|
|
1212
|
+
const topModeSel=$('#topMode'); if(topModeSel) topModeSel.addEventListener('change',onModeSelectChange);
|
|
1213
|
+
const topLangSel=$('#topLang'); if(topLangSel) topLangSel.addEventListener('change',onLangSelectChange);
|
|
999
1214
|
const setDesignSel=$('#setDesign'); if(setDesignSel) setDesignSel.addEventListener('change',()=>saveDesign(setDesignSel.value));
|
|
1000
1215
|
// theme
|
|
1001
1216
|
(function(){ const s=localStorage.getItem('spf-theme'); if(s)document.documentElement.setAttribute('data-theme',s);
|