spectoflow 0.21.0 → 0.22.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 +126 -76
- package/bin/spectoflow.js +30 -3
- package/package.json +1 -1
- package/templates/dashboard/files.js +108 -0
- package/templates/dashboard/public/app.js +280 -13
- package/templates/dashboard/public/designs/console.css +35 -9
- package/templates/dashboard/public/designs/console.js +37 -0
- package/templates/dashboard/public/i18n.js +24 -24
- package/templates/dashboard/public/icons.js +8 -6
- package/templates/dashboard/public/index.html +83 -32
- package/templates/dashboard/public/styles.css +66 -21
- package/templates/dashboard/server.js +29 -18
- package/templates/dashboard/summarize.js +25 -6
- package/templates/lib/store.js +43 -0
|
@@ -55,19 +55,32 @@ function chatContainers(){ return [$('#chatLog'),$('#chatTabLog')].filter(Boolea
|
|
|
55
55
|
function scrollChat(container){ container.scrollTop=container.scrollHeight; }
|
|
56
56
|
function clearIdle(container){ const i=container.querySelector('.chat-idle'); if(i) i.remove(); }
|
|
57
57
|
function bubble(m){
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
58
|
+
let node;
|
|
59
|
+
if(m.role==='user'){ node=el('div','msg you'); node.append(el('div','bubble',m.text)); }
|
|
60
|
+
else{
|
|
61
|
+
node=el('div','msg agentmsg k-'+(m.kind||'message'));
|
|
62
|
+
node.append(el('div','msg-role', m.role + (m.agent&&m.agent!==m.role?(' · '+m.agent):'')));
|
|
63
|
+
node.append(el('div','bubble',m.text));
|
|
64
|
+
}
|
|
65
|
+
node.dataset.id=m.id; // lets renderChatLog tell a stale bubble from a genuinely new one
|
|
66
|
+
return node;
|
|
63
67
|
}
|
|
68
|
+
function idleBlock(){ const d=el('div','chat-idle'); d.innerHTML=t('chat.idle'); return d; }
|
|
64
69
|
function renderChatLog(container){
|
|
65
70
|
if(!container) return;
|
|
66
71
|
const st=stateFor(container); const msgs=(P.runtime&&P.runtime.messages)||[];
|
|
67
|
-
|
|
72
|
+
const ids=new Set(msgs.map(m=>m.id));
|
|
73
|
+
// Summarize/Clear REPLACE the server-side log (a digest that leaves the old messages sitting right
|
|
74
|
+
// below it wouldn't condense anything) — if anything we already rendered no longer exists, the log
|
|
75
|
+
// was reset under us: rebuild from scratch instead of just appending, or the stale bubbles never go
|
|
76
|
+
// away short of a full page reload.
|
|
77
|
+
const stale=[...st.rendered].some(id=>!ids.has(id));
|
|
78
|
+
if(stale){ container.innerHTML=''; st.rendered=new Set(); st.rawBlock=null; }
|
|
79
|
+
if(!msgs.length){ if(!container.querySelector('.chat-idle')) container.append(idleBlock()); renderApproval(container); return; }
|
|
80
|
+
clearIdle(container);
|
|
68
81
|
let added=false;
|
|
69
82
|
for(const m of msgs){ if(st.rendered.has(m.id)) continue; st.rendered.add(m.id); container.append(bubble(m)); added=true; }
|
|
70
|
-
if(added) scrollChat(container);
|
|
83
|
+
if(added||stale) scrollChat(container);
|
|
71
84
|
renderApproval(container);
|
|
72
85
|
}
|
|
73
86
|
function renderApproval(container){
|
|
@@ -151,7 +164,7 @@ function render(){
|
|
|
151
164
|
if(meter) meter.title=`${t('kpi.globalProgress')}: ${s.pct}% (${s.done}/${s.total} ${t('kpi.tasksLabel')})`;
|
|
152
165
|
renderOverview(); renderBoard(); renderBacklog(); renderWorkflow(); renderTeam();
|
|
153
166
|
renderChatLog($('#chatLog')); renderChatLog($('#chatTabLog'));
|
|
154
|
-
renderSidebar(); renderRequests(); renderAttention(); renderInfo(); renderDocs(); renderSettings();
|
|
167
|
+
renderSidebar(); renderRequests(); renderAttention(); renderInfo(); renderDocs(); renderSettings(); renderFiles();
|
|
155
168
|
renderCustomDashboards(); // adds/removes nav tabs + panels before applyActiveTab() below reads them
|
|
156
169
|
applyActiveTab(); // re-apply the current tab so an SSE-driven re-render never resets to Board
|
|
157
170
|
applyI18nStatic(); // re-translate the static markup (nav, headers, placeholders…) for this tick's language
|
|
@@ -695,6 +708,39 @@ function editAttn(it,txtNode){
|
|
|
695
708
|
ta.addEventListener('keydown',e=>{ if((e.metaKey||e.ctrlKey)&&e.key==='Enter'){ e.preventDefault(); save(); } if(e.key==='Escape'){ done=true; renderAttention(); } });
|
|
696
709
|
}
|
|
697
710
|
async function addAttn(text){ flash(); await fetch('/api/attention',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({text})}); }
|
|
711
|
+
|
|
712
|
+
// ---- Backlog "+ Add task" — a manual checkbox task, no agent involved ----
|
|
713
|
+
function openBacklogAddForm(){
|
|
714
|
+
const form=$('#backlogAddForm'); if(!form) return;
|
|
715
|
+
const list=$('#blPhaseList');
|
|
716
|
+
if(list){ list.innerHTML=''; allPhaseTitles().forEach(ti=> list.append(new Option(ti))); }
|
|
717
|
+
const err=$('#blAddError'); if(err) err.hidden=true;
|
|
718
|
+
form.hidden=false; form.classList.add('is-open');
|
|
719
|
+
$('#blAddTitle').focus();
|
|
720
|
+
}
|
|
721
|
+
function closeBacklogAddForm(){
|
|
722
|
+
const form=$('#backlogAddForm'); if(!form) return;
|
|
723
|
+
form.hidden=true; form.classList.remove('is-open');
|
|
724
|
+
['blAddTitle','blAddPhase','blAddOwner'].forEach(id=>{ const f=$('#'+id); if(f) f.value=''; });
|
|
725
|
+
const lvl=$('#blAddLevel'); if(lvl) lvl.value='standard';
|
|
726
|
+
}
|
|
727
|
+
async function submitBacklogAdd(){
|
|
728
|
+
const err=$('#blAddError');
|
|
729
|
+
const title=($('#blAddTitle').value||'').trim();
|
|
730
|
+
if(!title){ if(err){ err.textContent=t('backlog.addTitleRequired'); err.hidden=false; } $('#blAddTitle').focus(); return; }
|
|
731
|
+
const phase=($('#blAddPhase').value||'').trim();
|
|
732
|
+
const owner=($('#blAddOwner').value||'').trim();
|
|
733
|
+
const level=$('#blAddLevel').value;
|
|
734
|
+
flash();
|
|
735
|
+
const r=await fetch('/api/task',{method:'POST',headers:{'Content-Type':'application/json'},
|
|
736
|
+
body:JSON.stringify({title, phase:phase||undefined, owner:owner||undefined, level})});
|
|
737
|
+
if(!r.ok){
|
|
738
|
+
const j=await r.json().catch(()=>({}));
|
|
739
|
+
if(err){ err.textContent=j.error||t('backlog.addFailed'); err.hidden=false; }
|
|
740
|
+
return;
|
|
741
|
+
}
|
|
742
|
+
closeBacklogAddForm();
|
|
743
|
+
}
|
|
698
744
|
async function patchAttn(id,patch){ flash(); await fetch('/api/attention/'+encodeURIComponent(id),{method:'PATCH',headers:{'Content-Type':'application/json'},body:JSON.stringify(patch)}); }
|
|
699
745
|
async function deleteAttn(id){ flash(); await fetch('/api/attention/'+encodeURIComponent(id),{method:'DELETE'}); }
|
|
700
746
|
async function promoteAttn(id){ flash(); await fetch('/api/attention/'+encodeURIComponent(id)+'/promote',{method:'POST'}); }
|
|
@@ -917,10 +963,14 @@ function czItemsFor(kind){
|
|
|
917
963
|
function renderCustomize(){
|
|
918
964
|
const root=$('#czRoot'); if(!root) return;
|
|
919
965
|
const openKind=root.dataset.open||'';
|
|
966
|
+
// a block with its form open spans the full row (see CSS) — an auto-fit grid would otherwise
|
|
967
|
+
// still reserve empty trailing cells beside it for the two collapsed blocks that no longer fill
|
|
968
|
+
// out a row, so drop to a single column for the whole grid while any block is open.
|
|
969
|
+
root.classList.toggle('has-open', !!openKind);
|
|
920
970
|
root.innerHTML='';
|
|
921
971
|
CZ_KINDS.forEach(({kind})=>{
|
|
922
972
|
const items=czItemsFor(kind);
|
|
923
|
-
const block=el('div','cz-block');
|
|
973
|
+
const block=el('div','cz-block'+(openKind===kind?' is-open':''));
|
|
924
974
|
const head=el('div','cz-head');
|
|
925
975
|
head.append(el('h3',null,t('customize.'+kind+'s')+' ('+items.length+')'));
|
|
926
976
|
const addBtn=el('button','btn cz-add',t('customize.add.'+kind));
|
|
@@ -967,11 +1017,15 @@ async function czSubmit(kind,description,agent){
|
|
|
967
1017
|
// A custom dashboard (Customize page) gets its own tab id "custom:<id>" and its own URL shape
|
|
968
1018
|
// /custom/<id> — kept out of ROUTES (a fixed list) since the set of custom ids is dynamic; recognized
|
|
969
1019
|
// by a dedicated branch in tabFromPath()/navigateTab() instead.
|
|
970
|
-
const ROUTES=['board','requests','attention','backlog','workflow','team','chat','info','docs','
|
|
1020
|
+
const ROUTES=['board','requests','attention','backlog','workflow','team','files','chat','info','docs','personalize'];
|
|
1021
|
+
// the tab used to be named/routed "settings" — old bookmarks and any localStorage value saved
|
|
1022
|
+
// under that name still land on the Personalize tab instead of a blank panel.
|
|
1023
|
+
function normalizeTab(t){ return t==='settings'?'personalize':t; }
|
|
971
1024
|
function tabFromPath(){
|
|
972
1025
|
const s=location.pathname.split('/').filter(Boolean);
|
|
973
1026
|
if(s[0]==='custom'&&s[1]) return 'custom:'+decodeURIComponent(s[1]);
|
|
974
|
-
|
|
1027
|
+
const t=normalizeTab(s[0]);
|
|
1028
|
+
return ROUTES.includes(t)?t:null;
|
|
975
1029
|
}
|
|
976
1030
|
function taskFromPath(){ const s=location.pathname.split('/').filter(Boolean); return (ROUTES.includes(s[0])&&s[1])?decodeURIComponent(s[1]):null; }
|
|
977
1031
|
function navigateTab(tabId,push){
|
|
@@ -986,6 +1040,7 @@ function navigateTab(tabId,push){
|
|
|
986
1040
|
// was scrolled to last (only on an actual switch INTO the tab — applyActiveTab() alone runs on
|
|
987
1041
|
// every SSE render tick too, and re-scrolling/re-focusing there would fight the user's typing).
|
|
988
1042
|
if(tabId==='chat') setTimeout(()=>{ scrollChat($('#chatTabLog')); $('#tabRunPrompt').focus(); },60);
|
|
1043
|
+
if(tabId==='files') renderFiles(); // the tree is fetched lazily — only load it on an actual switch in
|
|
989
1044
|
}
|
|
990
1045
|
function closeNav(){ document.body.classList.remove('nav-open'); const nt=$('#navToggle'); if(nt) nt.setAttribute('aria-expanded','false'); }
|
|
991
1046
|
|
|
@@ -1236,6 +1291,203 @@ function renderDocs(){
|
|
|
1236
1291
|
box.append(note);
|
|
1237
1292
|
}
|
|
1238
1293
|
|
|
1294
|
+
// ---- Files tab: browse the project tree, view/edit any text file (Markdown rendered, HTML
|
|
1295
|
+
// previewed in a sandboxed iframe, everything else as a plain monospace editor). The tree is
|
|
1296
|
+
// fetched lazily (only while this tab is active) and never refetched mid-edit — an SSE 'change'
|
|
1297
|
+
// event refreshes the TREE listing but never overwrites an open file's editor buffer, so an
|
|
1298
|
+
// unrelated agent write elsewhere can't clobber unsaved work here. ----
|
|
1299
|
+
let filesTreeData=null, filesOpenPath=null, filesOpenDirty=false;
|
|
1300
|
+
const filesOpenDirs=new Set(); // persists which tree folders are expanded across a refresh
|
|
1301
|
+
async function loadFilesTree(){
|
|
1302
|
+
try{
|
|
1303
|
+
const r=await fetch('/api/files/tree'); const d=await r.json().catch(()=>({}));
|
|
1304
|
+
filesTreeData = (r.ok && Array.isArray(d.tree)) ? d.tree : [];
|
|
1305
|
+
}catch{ filesTreeData=filesTreeData||[]; }
|
|
1306
|
+
renderFilesTree();
|
|
1307
|
+
}
|
|
1308
|
+
function renderFiles(){
|
|
1309
|
+
// Only the FIRST activation fetches — render() re-runs on every SSE 'change' (a chat message, a
|
|
1310
|
+
// task update, anything) and a full tree rebuild on each of those would yank rows out from under
|
|
1311
|
+
// an in-progress click. Fresh-after-your-own-action is handled by loadFilesTree() calls at the
|
|
1312
|
+
// point of action (filesCreate); the toolbar's Refresh button covers everything else.
|
|
1313
|
+
if(activeTab!=='files' || filesTreeData!=null) return;
|
|
1314
|
+
loadFilesTree();
|
|
1315
|
+
}
|
|
1316
|
+
function fNode(entry){
|
|
1317
|
+
const row=el('div','f-row'+(entry.type==='dir'&&filesOpenDirs.has(entry.path)?' is-open':'')+(entry.path===filesOpenPath?' is-active':''));
|
|
1318
|
+
row.tabIndex=0;
|
|
1319
|
+
if(entry.type==='dir'){
|
|
1320
|
+
const chev=document.createElementNS('http://www.w3.org/2000/svg','svg');
|
|
1321
|
+
chev.setAttribute('viewBox','0 0 18 18'); chev.setAttribute('class','f-chevron'); chev.setAttribute('fill','none'); chev.setAttribute('stroke','currentColor'); chev.setAttribute('stroke-width','1.8');
|
|
1322
|
+
chev.innerHTML='<path d="M6.5 4l6 5-6 5"/>';
|
|
1323
|
+
row.append(chev);
|
|
1324
|
+
} else row.append(el('span',null,''));
|
|
1325
|
+
row.append(el('span','f-name',entry.name));
|
|
1326
|
+
const wrap=el('div','f-node');
|
|
1327
|
+
wrap.append(row);
|
|
1328
|
+
if(entry.type==='dir'){
|
|
1329
|
+
const kids=el('div','f-children'); kids.hidden=!filesOpenDirs.has(entry.path);
|
|
1330
|
+
(entry.children||[]).forEach(c=> kids.append(fNode(c)));
|
|
1331
|
+
wrap.append(kids);
|
|
1332
|
+
row.addEventListener('click',()=>{
|
|
1333
|
+
const open=filesOpenDirs.has(entry.path);
|
|
1334
|
+
if(open) filesOpenDirs.delete(entry.path); else filesOpenDirs.add(entry.path);
|
|
1335
|
+
row.classList.toggle('is-open',!open); kids.hidden=open;
|
|
1336
|
+
});
|
|
1337
|
+
} else {
|
|
1338
|
+
row.addEventListener('click',()=> openFilesFile(entry.path));
|
|
1339
|
+
}
|
|
1340
|
+
return wrap;
|
|
1341
|
+
}
|
|
1342
|
+
function renderFilesTree(){
|
|
1343
|
+
const box=$('#filesTree'); if(!box) return;
|
|
1344
|
+
box.innerHTML='';
|
|
1345
|
+
if(!filesTreeData || !filesTreeData.length){ box.append(el('div','empty',t('files.empty'))); return; }
|
|
1346
|
+
filesTreeData.forEach(e=> box.append(fNode(e)));
|
|
1347
|
+
}
|
|
1348
|
+
function filesExt(p){ const m=/\.([a-z0-9]+)$/i.exec(p||''); return m?m[1].toLowerCase():''; }
|
|
1349
|
+
async function openFilesFile(relPath){
|
|
1350
|
+
// no native confirm() dialog (it blocks the whole tab, including our own SSE/automation) — a
|
|
1351
|
+
// dirty editor just refuses to switch until the user explicitly saves or discards.
|
|
1352
|
+
if(filesOpenDirty){
|
|
1353
|
+
const actions=$('#filesContent .files-actions');
|
|
1354
|
+
if(actions && !actions.querySelector('.files-error-tip')){
|
|
1355
|
+
const tip=el('span','files-error-tip',t('files.discardConfirm')); actions.append(tip);
|
|
1356
|
+
}
|
|
1357
|
+
return;
|
|
1358
|
+
}
|
|
1359
|
+
filesOpenPath=relPath; filesOpenDirty=false;
|
|
1360
|
+
renderFilesTree();
|
|
1361
|
+
const box=$('#filesContent'); box.innerHTML='';
|
|
1362
|
+
box.append(el('div','files-empty',t('drawer.loading')));
|
|
1363
|
+
let data;
|
|
1364
|
+
try{ const r=await fetch('/api/files/read?'+new URLSearchParams({path:relPath})); data=await r.json().catch(()=>({})); if(!r.ok) throw new Error(data.error||'error'); }
|
|
1365
|
+
catch(err){ box.innerHTML=''; box.append(el('div','files-empty',err.message||t('files.loadError'))); return; }
|
|
1366
|
+
box.innerHTML='';
|
|
1367
|
+
const bar=el('div','files-toolbar-row');
|
|
1368
|
+
bar.append(el('div','files-path',relPath));
|
|
1369
|
+
const actions=el('div','files-actions'); bar.append(actions);
|
|
1370
|
+
box.append(bar);
|
|
1371
|
+
if(data.binary){ box.append(el('div','files-binary',t('files.binary'))); return; }
|
|
1372
|
+
const ext=filesExt(relPath);
|
|
1373
|
+
const content=data.content||'';
|
|
1374
|
+
if(ext==='md'||ext==='markdown'){ renderFilesMd(box,actions,relPath,content); }
|
|
1375
|
+
else if(ext==='html'||ext==='htm'){ renderFilesHtml(box,actions,relPath,content); }
|
|
1376
|
+
else { renderFilesText(box,actions,relPath,content); }
|
|
1377
|
+
}
|
|
1378
|
+
function filesSavedTip(actions){
|
|
1379
|
+
const tip=el('span','files-saved-tip',t('files.saved')); actions.append(tip);
|
|
1380
|
+
setTimeout(()=>tip.remove(),1500);
|
|
1381
|
+
}
|
|
1382
|
+
// Explicit, non-blocking way to abandon local edits (no confirm() dialog) — re-fetches the file
|
|
1383
|
+
// fresh from disk and clears the dirty flag so switching tree files is unblocked again.
|
|
1384
|
+
function filesDiscardBtn(relPath){
|
|
1385
|
+
const btn=el('button',null,t('files.discard')); btn.type='button';
|
|
1386
|
+
btn.addEventListener('click',()=>{ filesOpenDirty=false; openFilesFile(relPath); });
|
|
1387
|
+
return btn;
|
|
1388
|
+
}
|
|
1389
|
+
async function filesSave(relPath,content,actions){
|
|
1390
|
+
try{
|
|
1391
|
+
const r=await fetch('/api/files/write',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({path:relPath,content})});
|
|
1392
|
+
const d=await r.json().catch(()=>({}));
|
|
1393
|
+
if(!r.ok) throw new Error(d.error||'error');
|
|
1394
|
+
filesOpenDirty=false; filesSavedTip(actions);
|
|
1395
|
+
}catch(err){
|
|
1396
|
+
const tip=el('span','files-error-tip',err.message||t('files.saveError')); actions.append(tip);
|
|
1397
|
+
}
|
|
1398
|
+
}
|
|
1399
|
+
// Markdown: rendered preview by default (via the same mdLite renderer the Agents & Skills drawer
|
|
1400
|
+
// uses), with an Edit toggle that swaps in a plain-text editor plus a tiny insert-at-cursor toolbar.
|
|
1401
|
+
function renderFilesMd(box,actions,relPath,content){
|
|
1402
|
+
let editing=false;
|
|
1403
|
+
const editBtn=el('button','btn',t('files.edit'));
|
|
1404
|
+
actions.append(editBtn);
|
|
1405
|
+
const body=el('div','files-body-col');
|
|
1406
|
+
box.append(body);
|
|
1407
|
+
const showPreview=()=>{ body.innerHTML=''; const md=el('div','files-view md-body'); md.innerHTML=mdLite(content); body.append(md); editBtn.textContent=t('files.edit'); };
|
|
1408
|
+
const showEditor=()=>{
|
|
1409
|
+
body.innerHTML='';
|
|
1410
|
+
const tb=el('div','files-md-toolbar');
|
|
1411
|
+
const ta=el('textarea','files-editor'); ta.value=content;
|
|
1412
|
+
const wrapSel=(before,after)=>{ const s=ta.selectionStart,e=ta.selectionEnd; const v=ta.value; ta.value=v.slice(0,s)+before+v.slice(s,e)+after+v.slice(e); ta.focus(); ta.selectionStart=s+before.length; ta.selectionEnd=e+before.length; content=ta.value; filesOpenDirty=true; };
|
|
1413
|
+
[['B','**','**'],['I','_','_'],['H','## ',''],['Link','[','](url)']].forEach(([label,a,b])=>{
|
|
1414
|
+
const bt=el('button',null,label); bt.type='button'; bt.addEventListener('click',()=>wrapSel(a,b)); tb.append(bt);
|
|
1415
|
+
});
|
|
1416
|
+
const saveBtn=el('button',null,t('files.save')); saveBtn.type='button'; saveBtn.addEventListener('click',()=>{ content=ta.value; filesSave(relPath,content,actions); });
|
|
1417
|
+
tb.append(saveBtn,filesDiscardBtn(relPath));
|
|
1418
|
+
ta.addEventListener('input',()=>{ content=ta.value; filesOpenDirty=true; });
|
|
1419
|
+
body.append(tb,ta); ta.focus(); editBtn.textContent=t('files.preview');
|
|
1420
|
+
};
|
|
1421
|
+
editBtn.addEventListener('click',()=>{ editing=!editing; if(editing) showEditor(); else showPreview(); });
|
|
1422
|
+
showPreview();
|
|
1423
|
+
}
|
|
1424
|
+
// HTML: sandboxed iframe preview by default, plain-text editor on toggle (no WYSIWYG — the
|
|
1425
|
+
// dashboard stays zero-dependency, no editor library is loaded).
|
|
1426
|
+
function renderFilesHtml(box,actions,relPath,content){
|
|
1427
|
+
let editing=false;
|
|
1428
|
+
const editBtn=el('button','btn',t('files.edit'));
|
|
1429
|
+
actions.append(editBtn);
|
|
1430
|
+
const body=el('div','files-body-col');
|
|
1431
|
+
box.append(body);
|
|
1432
|
+
const showPreview=()=>{
|
|
1433
|
+
body.innerHTML='';
|
|
1434
|
+
const frame=document.createElement('iframe');
|
|
1435
|
+
frame.className='files-iframe'; frame.setAttribute('sandbox',''); frame.srcdoc=content;
|
|
1436
|
+
body.append(frame); editBtn.textContent=t('files.edit');
|
|
1437
|
+
};
|
|
1438
|
+
const showEditor=()=>{
|
|
1439
|
+
body.innerHTML='';
|
|
1440
|
+
const ta=el('textarea','files-editor'); ta.value=content;
|
|
1441
|
+
ta.addEventListener('input',()=>{ content=ta.value; filesOpenDirty=true; });
|
|
1442
|
+
const saveBtn=el('button','btn primary',t('files.save')); saveBtn.type='button'; saveBtn.addEventListener('click',()=>filesSave(relPath,content,actions));
|
|
1443
|
+
const tb=el('div','files-md-toolbar'); tb.append(saveBtn,filesDiscardBtn(relPath));
|
|
1444
|
+
body.append(tb,ta); ta.focus(); editBtn.textContent=t('files.preview');
|
|
1445
|
+
};
|
|
1446
|
+
editBtn.addEventListener('click',()=>{ editing=!editing; if(editing) showEditor(); else showPreview(); });
|
|
1447
|
+
showPreview();
|
|
1448
|
+
}
|
|
1449
|
+
// Anything else that reads as text: a plain monospace editor, editable straight away.
|
|
1450
|
+
function renderFilesText(box,actions,relPath,content){
|
|
1451
|
+
const ta=el('textarea','files-editor'); ta.value=content;
|
|
1452
|
+
ta.addEventListener('input',()=>{ filesOpenDirty=true; });
|
|
1453
|
+
const saveBtn=el('button','btn primary',t('files.save')); saveBtn.type='button';
|
|
1454
|
+
saveBtn.addEventListener('click',()=>filesSave(relPath,ta.value,actions));
|
|
1455
|
+
actions.append(saveBtn,filesDiscardBtn(relPath));
|
|
1456
|
+
box.append(ta); ta.focus();
|
|
1457
|
+
}
|
|
1458
|
+
// "+ File"/"+ Folder" open the same inline form (never a native prompt()/alert() — those block the
|
|
1459
|
+
// whole tab, including our own SSE connection, until dismissed).
|
|
1460
|
+
let filesCreateKind='file';
|
|
1461
|
+
function openFilesCreateForm(kind){
|
|
1462
|
+
filesCreateKind=kind;
|
|
1463
|
+
const form=$('#filesCreateForm'); if(!form) return;
|
|
1464
|
+
const input=$('#filesCreateInput');
|
|
1465
|
+
input.placeholder = kind==='dir' ? t('files.newFolderPrompt') : t('files.newFilePrompt');
|
|
1466
|
+
input.value='';
|
|
1467
|
+
const err=$('#filesCreateError'); if(err) err.hidden=true;
|
|
1468
|
+
form.hidden=false; form.classList.add('is-open');
|
|
1469
|
+
input.focus();
|
|
1470
|
+
}
|
|
1471
|
+
function closeFilesCreateForm(){
|
|
1472
|
+
const form=$('#filesCreateForm'); if(!form) return;
|
|
1473
|
+
form.hidden=true; form.classList.remove('is-open');
|
|
1474
|
+
}
|
|
1475
|
+
async function submitFilesCreate(){
|
|
1476
|
+
const input=$('#filesCreateInput'); const err=$('#filesCreateError');
|
|
1477
|
+
const name=(input.value||'').trim();
|
|
1478
|
+
if(!name){ if(err){ err.textContent=t('backlog.addTitleRequired'); err.hidden=false; } return; }
|
|
1479
|
+
const rel=name.replace(/^\/+/,'');
|
|
1480
|
+
const kind=filesCreateKind;
|
|
1481
|
+
const endpoint = kind==='dir' ? '/api/files/mkdir' : '/api/files/write';
|
|
1482
|
+
const payload = kind==='dir' ? {path:rel} : {path:rel,content:''};
|
|
1483
|
+
const r=await fetch(endpoint,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)});
|
|
1484
|
+
const d=await r.json().catch(()=>({}));
|
|
1485
|
+
if(!r.ok){ if(err){ err.textContent=d.error||t('files.saveError'); err.hidden=false; } return; }
|
|
1486
|
+
closeFilesCreateForm();
|
|
1487
|
+
await loadFilesTree();
|
|
1488
|
+
if(kind!=='dir') openFilesFile(rel);
|
|
1489
|
+
}
|
|
1490
|
+
|
|
1239
1491
|
function openDrawer(id,keep){
|
|
1240
1492
|
// named `task`, not `t` — `t` is the global translation function (see i18n.js) and this whole
|
|
1241
1493
|
// function calls it repeatedly below; shadowing it with a task variable would break every call.
|
|
@@ -1283,7 +1535,7 @@ const cssv=(v)=> getComputedStyle(document.documentElement).getPropertyValue(v).
|
|
|
1283
1535
|
// too instead of ever resetting to Board; this is what keeps a tab selected across a race with a
|
|
1284
1536
|
// 'change'/'message' event that lands right after a click.
|
|
1285
1537
|
// initial tab: the URL path wins (deep-link / refresh), else the persisted tab, else board
|
|
1286
|
-
let activeTab = tabFromPath() || (()=>{ try{ return localStorage.getItem('spf-tab')||'board'; }catch{ return 'board'; } })();
|
|
1538
|
+
let activeTab = tabFromPath() || (()=>{ try{ return normalizeTab(localStorage.getItem('spf-tab'))||'board'; }catch{ return 'board'; } })();
|
|
1287
1539
|
openTaskId = taskFromPath(); // deep-link straight to a task drawer
|
|
1288
1540
|
function applyActiveTab(){
|
|
1289
1541
|
$$('#tabs .tab').forEach(t=> t.classList.toggle('is-active', t.dataset.tab===activeTab));
|
|
@@ -1308,6 +1560,9 @@ window.addEventListener('popstate',()=>{
|
|
|
1308
1560
|
// brand logo → Board (SPA nav, no full reload)
|
|
1309
1561
|
const brandLogo=$('.brand-logo'); if(brandLogo) brandLogo.addEventListener('click',e=>{ e.preventDefault(); navigateTab('board'); });
|
|
1310
1562
|
applyActiveTab(); // sync to the resolved tab before the first render
|
|
1563
|
+
// an old bookmark/share to the pre-rename "/settings" URL: swap the address bar to the real
|
|
1564
|
+
// route once resolved, so the visible URL matches the "Personalize" tab it landed on.
|
|
1565
|
+
if(location.pathname.split('/').filter(Boolean)[0]==='settings') history.replaceState(null,'','/personalize');
|
|
1311
1566
|
// filters (status chips + search) — client-side only, does not write anything
|
|
1312
1567
|
$$('#statusChips .fchip').forEach(b=> b.addEventListener('click', ()=>{ filter.status=b.dataset.status; renderBoard(); }));
|
|
1313
1568
|
$('#search').addEventListener('input', e=>{ filter.q=e.target.value; renderBoard(); });
|
|
@@ -1330,12 +1585,24 @@ $$('#backlogTable thead th').forEach(th=> th.addEventListener('click', ()=>{
|
|
|
1330
1585
|
else { backlogSort.col=col; backlogSort.dir='asc'; }
|
|
1331
1586
|
backlogPage=1; renderBacklog();
|
|
1332
1587
|
}));
|
|
1588
|
+
// files tab: create a new file/folder (path relative to the project root, e.g. "notes/todo.md")
|
|
1589
|
+
const filesNewFileBtn=$('#filesNewFile'); if(filesNewFileBtn) filesNewFileBtn.addEventListener('click',()=>openFilesCreateForm('file'));
|
|
1590
|
+
const filesNewFolderBtn=$('#filesNewFolder'); if(filesNewFolderBtn) filesNewFolderBtn.addEventListener('click',()=>openFilesCreateForm('dir'));
|
|
1591
|
+
const filesRefreshBtn=$('#filesRefresh'); if(filesRefreshBtn) filesRefreshBtn.addEventListener('click',loadFilesTree);
|
|
1592
|
+
const filesCreateGoBtn=$('#filesCreateGo'); if(filesCreateGoBtn) filesCreateGoBtn.addEventListener('click',submitFilesCreate);
|
|
1593
|
+
const filesCreateCancelBtn=$('#filesCreateCancel'); if(filesCreateCancelBtn) filesCreateCancelBtn.addEventListener('click',closeFilesCreateForm);
|
|
1594
|
+
const filesCreateInputEl=$('#filesCreateInput'); if(filesCreateInputEl) filesCreateInputEl.addEventListener('keydown',e=>{ if(e.key==='Enter') submitFilesCreate(); });
|
|
1595
|
+
// backlog: manual "+ Add task" form (title required, phase/owner/level optional)
|
|
1596
|
+
$('#backlogAddBtn').addEventListener('click', ()=>{ const form=$('#backlogAddForm'); (form&&!form.hidden)?closeBacklogAddForm():openBacklogAddForm(); });
|
|
1597
|
+
$('#blAddCancel').addEventListener('click', closeBacklogAddForm);
|
|
1598
|
+
$('#blAddSubmit').addEventListener('click', submitBacklogAdd);
|
|
1599
|
+
$('#blAddTitle').addEventListener('keydown', e=>{ if(e.key==='Enter') submitBacklogAdd(); });
|
|
1333
1600
|
// attention tab: add a note + filter chips
|
|
1334
1601
|
$('#attnAddBtn').addEventListener('click',()=>{ const t=$('#attnInput'); const v=t.value.trim(); if(v){ addAttn(v); t.value=''; } });
|
|
1335
1602
|
$('#attnInput').addEventListener('keydown',e=>{ if((e.metaKey||e.ctrlKey)&&e.key==='Enter'){ const v=e.target.value.trim(); if(v){ addAttn(v); e.target.value=''; } } });
|
|
1336
1603
|
$$('.attn-filters .fchip').forEach(b=> b.addEventListener('click',()=>{ attnFilter=b.dataset.attn; renderAttention(); }));
|
|
1337
1604
|
// settings — the footer link opens the Settings tab; selects save on change
|
|
1338
|
-
const footerSettingsBtn=$('#footerSettings'); if(footerSettingsBtn) footerSettingsBtn.addEventListener('click',()=>navigateTab('
|
|
1605
|
+
const footerSettingsBtn=$('#footerSettings'); if(footerSettingsBtn) footerSettingsBtn.addEventListener('click',()=>navigateTab('personalize'));
|
|
1339
1606
|
const footerLogo=$('.footer-logo'); if(footerLogo) footerLogo.addEventListener('click',e=>{ e.preventDefault(); navigateTab('board'); });
|
|
1340
1607
|
function onModeSelectChange(e){ setModeSelects(e.target.value); saveSettings(); }
|
|
1341
1608
|
function onLangSelectChange(e){ setLangSelect(e.target.value); saveSettings(); }
|
|
@@ -31,33 +31,32 @@ html[data-design="console"][data-theme="light"] {
|
|
|
31
31
|
--cx-grid:rgba(15,23,40,.06);
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
-
/* ============ AMBIENT BACKDROP ============ */
|
|
34
|
+
/* ============ AMBIENT BACKDROP — a plain grid texture, no color glow ============ */
|
|
35
35
|
html[data-design="console"] body { background:var(--bg); position:relative; }
|
|
36
36
|
html[data-design="console"] body::before {
|
|
37
37
|
content:""; position:fixed; inset:0; pointer-events:none; z-index:0;
|
|
38
38
|
background:
|
|
39
|
-
radial-gradient(900px 500px at 10% -10%, color-mix(in srgb,var(--signal) 14%,transparent), transparent 60%),
|
|
40
|
-
radial-gradient(800px 500px at 100% 0%, color-mix(in srgb,var(--cool) 14%,transparent), transparent 60%),
|
|
41
39
|
linear-gradient(var(--cx-grid) 1px, transparent 1px), linear-gradient(90deg, var(--cx-grid) 1px, transparent 1px);
|
|
42
|
-
background-size:
|
|
40
|
+
background-size:32px 32px,32px 32px;
|
|
43
41
|
}
|
|
44
42
|
html[data-design="console"] .topbar,
|
|
45
43
|
html[data-design="console"] .stage { position:relative; z-index:1; }
|
|
46
44
|
|
|
47
45
|
/* ============ LEFT ICON RAIL ============ */
|
|
48
46
|
html[data-design="console"] nav.tabs#tabs {
|
|
49
|
-
position:fixed; left:0; top:0; bottom:
|
|
47
|
+
position:fixed; left:0; top:0; bottom:44px; z-index:30; width:var(--cx-rail-w);
|
|
50
48
|
display:flex; flex-direction:column; align-items:center; gap:6px;
|
|
51
49
|
padding:16px 10px; overflow-y:auto; overflow-x:hidden; max-width:none;
|
|
52
50
|
background:color-mix(in srgb,var(--surface) 84%,transparent); backdrop-filter:blur(10px);
|
|
53
51
|
border-right:1px solid var(--line); border-bottom:0;
|
|
52
|
+
transition:width .18s ease;
|
|
54
53
|
}
|
|
55
|
-
html[data-design="console"] .topbar { padding-left:calc(var(--cx-rail-w) + 16px); }
|
|
56
|
-
html[data-design="console"] .stage { margin-left:var(--cx-rail-w); }
|
|
54
|
+
html[data-design="console"] .topbar { padding-left:calc(var(--cx-rail-w) + 16px); transition:padding-left .18s ease; }
|
|
55
|
+
html[data-design="console"] .stage { margin-left:var(--cx-rail-w); transition:margin-left .18s ease; }
|
|
57
56
|
/* .app-footer is a sibling of .stage, not inside it — the rail is fixed to the viewport (not just
|
|
58
57
|
.stage), so without this it clips the footer's left edge (the brand name loses its first letters)
|
|
59
58
|
at every scroll position, not just at the top. */
|
|
60
|
-
html[data-design="console"] .app-footer { margin-left:var(--cx-rail-w); }
|
|
59
|
+
html[data-design="console"] .app-footer { margin-left:var(--cx-rail-w); transition:margin-left .18s ease; }
|
|
61
60
|
/* The header brand mark reads too small at the base 26px against this design's darker, denser
|
|
62
61
|
topbar — give it more presence. The footer's own (deliberately smaller) logo keeps its existing,
|
|
63
62
|
more specific rule and is unaffected. */
|
|
@@ -73,7 +72,7 @@ html[data-design="console"] nav.tabs#tabs .tab.is-active {
|
|
|
73
72
|
color:var(--signal); background:var(--surface-2);
|
|
74
73
|
box-shadow:inset 3px 0 0 var(--signal);
|
|
75
74
|
}
|
|
76
|
-
html[data-design="console"] nav.tabs#tabs .tab-ico { width:19px; height:19px; }
|
|
75
|
+
html[data-design="console"] nav.tabs#tabs .tab-ico { width:19px; height:19px; flex-shrink:0; }
|
|
77
76
|
html[data-design="console"] nav.tabs#tabs .tab-label {
|
|
78
77
|
position:absolute; left:60px; top:50%; transform:translateY(-50%) translateX(-4px);
|
|
79
78
|
background:var(--surface-3); color:var(--ink); padding:5px 9px; border-radius:7px;
|
|
@@ -89,6 +88,32 @@ html[data-design="console"] nav.tabs#tabs .tab-badge {
|
|
|
89
88
|
position:absolute; top:2px; right:4px; margin-left:0; min-width:15px; height:15px; font-size:9.5px;
|
|
90
89
|
}
|
|
91
90
|
|
|
91
|
+
/* ---- rail expand/collapse toggle — icon-only stays the default; expanded shows menu names ---- */
|
|
92
|
+
html[data-design="console"] #cxRailToggle {
|
|
93
|
+
position:fixed; left:0; bottom:0; z-index:31; width:var(--cx-rail-w); height:44px;
|
|
94
|
+
display:flex; align-items:center; justify-content:center; gap:8px; padding:0;
|
|
95
|
+
background:color-mix(in srgb,var(--surface) 92%,transparent); backdrop-filter:blur(10px);
|
|
96
|
+
border-right:1px solid var(--line); border-top:1px solid var(--line);
|
|
97
|
+
color:var(--muted); cursor:pointer; transition:width .18s ease,color .18s ease;
|
|
98
|
+
}
|
|
99
|
+
html[data-design="console"] #cxRailToggle:hover,
|
|
100
|
+
html[data-design="console"] #cxRailToggle:focus-visible { color:var(--ink); }
|
|
101
|
+
html[data-design="console"] #cxRailToggle svg { width:16px; height:16px; flex-shrink:0; transition:transform .18s ease; }
|
|
102
|
+
html[data-design="console"] #cxRailToggle .cx-rail-toggle-label { display:none; font-size:11.5px; font-weight:600; white-space:nowrap; }
|
|
103
|
+
html[data-design="console"][data-rail="expanded"] { --cx-rail-w:208px; }
|
|
104
|
+
html[data-design="console"][data-rail="expanded"] nav.tabs#tabs { align-items:stretch; }
|
|
105
|
+
html[data-design="console"][data-rail="expanded"] nav.tabs#tabs .tab {
|
|
106
|
+
flex-direction:row; justify-content:flex-start; align-items:center; gap:12px;
|
|
107
|
+
width:100%; height:42px; padding:0 12px;
|
|
108
|
+
}
|
|
109
|
+
html[data-design="console"][data-rail="expanded"] nav.tabs#tabs .tab-label {
|
|
110
|
+
position:static; opacity:1; pointer-events:none; transform:none; background:none; color:inherit;
|
|
111
|
+
border:0; box-shadow:none; padding:0; font-weight:500;
|
|
112
|
+
}
|
|
113
|
+
html[data-design="console"][data-rail="expanded"] #cxRailToggle { justify-content:flex-start; padding:0 15px; }
|
|
114
|
+
html[data-design="console"][data-rail="expanded"] #cxRailToggle svg { transform:rotate(180deg); }
|
|
115
|
+
html[data-design="console"][data-rail="expanded"] #cxRailToggle .cx-rail-toggle-label { display:inline; }
|
|
116
|
+
|
|
92
117
|
/* ============ REVEAL + HOVER LIFT (bento tiles / cards) ============ */
|
|
93
118
|
html[data-design="console"] .kpi,
|
|
94
119
|
html[data-design="console"] .ocard,
|
|
@@ -190,6 +215,7 @@ html[data-design="console"][data-theme="light"] .cx-cmdk { background:rgba(15,23
|
|
|
190
215
|
html[data-design="console"] .stage { margin-left:0; margin-bottom:64px; }
|
|
191
216
|
html[data-design="console"] .app-footer { margin-left:0; }
|
|
192
217
|
html[data-design="console"] nav.tabs#tabs .tab-label { display:none !important; }
|
|
218
|
+
html[data-design="console"] #cxRailToggle { display:none; } /* the bottom bar has no room for it, and there's no rail width left to expand */
|
|
193
219
|
}
|
|
194
220
|
|
|
195
221
|
@media (prefers-reduced-motion: reduce) {
|
|
@@ -131,11 +131,47 @@
|
|
|
131
131
|
tabsHome = null;
|
|
132
132
|
}
|
|
133
133
|
|
|
134
|
+
// ---- rail expand/collapse — icon-only stays the default; a viewer can opt into showing the
|
|
135
|
+
// menu names too, persisted per browser (like the Board's List/Kanban toggle). ----
|
|
136
|
+
var railToggle = null;
|
|
137
|
+
function railExpanded() {
|
|
138
|
+
try { return localStorage.getItem('spf-console-rail') === 'expanded'; } catch (e) { return false; }
|
|
139
|
+
}
|
|
140
|
+
function applyRailState() {
|
|
141
|
+
var expanded = railExpanded();
|
|
142
|
+
document.documentElement.setAttribute('data-rail', expanded ? 'expanded' : 'collapsed');
|
|
143
|
+
if (railToggle) railToggle.setAttribute('aria-label', expanded ? 'Collapse sidebar' : 'Expand sidebar');
|
|
144
|
+
var label = railToggle && railToggle.querySelector('.cx-rail-toggle-label');
|
|
145
|
+
if (label) label.textContent = expanded ? 'Collapse' : 'Expand';
|
|
146
|
+
}
|
|
147
|
+
function toggleRail() {
|
|
148
|
+
var expanded = !railExpanded();
|
|
149
|
+
try { localStorage.setItem('spf-console-rail', expanded ? 'expanded' : 'collapsed'); } catch (e) {}
|
|
150
|
+
applyRailState();
|
|
151
|
+
}
|
|
152
|
+
function injectRailToggle() {
|
|
153
|
+
if (railToggle || document.getElementById('cxRailToggle')) return;
|
|
154
|
+
railToggle = document.createElement('button');
|
|
155
|
+
railToggle.id = 'cxRailToggle';
|
|
156
|
+
railToggle.type = 'button';
|
|
157
|
+
railToggle.innerHTML =
|
|
158
|
+
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18l6-6-6-6"/></svg>' +
|
|
159
|
+
'<span class="cx-rail-toggle-label"></span>';
|
|
160
|
+
railToggle.addEventListener('click', toggleRail);
|
|
161
|
+
document.body.appendChild(railToggle);
|
|
162
|
+
applyRailState();
|
|
163
|
+
}
|
|
164
|
+
function removeRailToggle() {
|
|
165
|
+
if (railToggle) { railToggle.remove(); railToggle = null; }
|
|
166
|
+
document.documentElement.removeAttribute('data-rail');
|
|
167
|
+
}
|
|
168
|
+
|
|
134
169
|
function activate() {
|
|
135
170
|
if (active) return;
|
|
136
171
|
active = true;
|
|
137
172
|
dockRail();
|
|
138
173
|
injectButton();
|
|
174
|
+
injectRailToggle();
|
|
139
175
|
document.addEventListener('keydown', onKeydown);
|
|
140
176
|
}
|
|
141
177
|
|
|
@@ -144,6 +180,7 @@
|
|
|
144
180
|
active = false;
|
|
145
181
|
close();
|
|
146
182
|
removeButton();
|
|
183
|
+
removeRailToggle();
|
|
147
184
|
undockRail();
|
|
148
185
|
document.removeEventListener('keydown', onKeydown);
|
|
149
186
|
}
|