sapper-iq 1.2.0 → 1.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/sapper-ui.mjs +507 -10
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sapper-iq",
3
- "version": "1.2.0",
3
+ "version": "1.2.1",
4
4
  "description": "AI-powered development assistant that executes commands and builds projects",
5
5
  "main": "sapper.mjs",
6
6
  "bin": {
package/sapper-ui.mjs CHANGED
@@ -245,6 +245,33 @@ function buildHTML() {
245
245
  position: relative; }
246
246
  .files-toolbar .ftb sup { font-size: 9px; color: var(--green); margin-left: 1px; }
247
247
  .files-toolbar .ftb:hover { background: rgba(255,255,255,.05); color: var(--fg); border-color: var(--border); }
248
+ .files-toolbar .ftb.on { color: var(--accent); border-color: var(--accent); background: rgba(88,166,255,.08); }
249
+
250
+ /* Activity feed */
251
+ #activityPanel { display: none; border-bottom: 1px solid var(--border);
252
+ background: var(--panel2); max-height: 180px; overflow-y: auto;
253
+ font-family: ui-monospace, 'SF Mono', monospace; font-size: 11px; }
254
+ #activityPanel.on { display: block; }
255
+ #activityPanel .ah { display: flex; align-items: center; padding: 5px 10px;
256
+ border-bottom: 1px solid var(--border); color: var(--dim); font-size: 10px;
257
+ text-transform: uppercase; letter-spacing: .5px; position: sticky; top: 0;
258
+ background: var(--panel2); z-index: 1; }
259
+ #activityPanel .ah .acl { margin-left: auto; color: var(--accent); cursor: pointer;
260
+ text-transform: none; letter-spacing: 0; font-size: 10px; }
261
+ #activityPanel .ai { display: flex; align-items: center; gap: 6px; padding: 4px 10px;
262
+ color: var(--muted); cursor: pointer; border-left: 2px solid transparent; }
263
+ #activityPanel .ai:hover { background: rgba(255,255,255,.04); color: var(--fg); }
264
+ #activityPanel .ai .ak { font-size: 9px; text-transform: uppercase; letter-spacing: .5px;
265
+ width: 56px; flex-shrink: 0; font-weight: 600; }
266
+ #activityPanel .ai .ap { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
267
+ #activityPanel .ai .at { color: var(--dim); font-size: 9px; flex-shrink: 0; }
268
+ #activityPanel .ai.kind-created { border-left-color: var(--green); }
269
+ #activityPanel .ai.kind-modified { border-left-color: var(--yellow); }
270
+ #activityPanel .ai.kind-deleted { border-left-color: var(--red); }
271
+ #activityPanel .ai.kind-created .ak { color: var(--green); }
272
+ #activityPanel .ai.kind-modified .ak { color: var(--yellow); }
273
+ #activityPanel .ai.kind-deleted .ak { color: var(--red); }
274
+ #activityPanel .empty { padding: 12px; color: var(--dim); text-align: center; font-size: 11px; }
248
275
  .tree { font-family: ui-monospace, 'SF Mono', monospace; font-size: 12px; padding-bottom: 12px; }
249
276
  .row { display: flex; align-items: center; gap: 4px; padding: 3px 8px; cursor: pointer; color: var(--muted);
250
277
  white-space: nowrap; user-select: none; position: relative; }
@@ -255,6 +282,19 @@ function buildHTML() {
255
282
  .row .name { overflow: hidden; text-overflow: ellipsis; }
256
283
  .row .badge { margin-left: auto; font-size: 9px; color: var(--yellow); opacity: 0; transition: opacity .2s; }
257
284
  .row.changed .badge { opacity: 1; }
285
+ .row .actdot { display: none; width: 7px; height: 7px; border-radius: 50%;
286
+ margin-left: 4px; flex-shrink: 0; box-shadow: 0 0 6px currentColor; }
287
+ .row.act-created .actdot { display: inline-block; background: var(--green); color: var(--green); }
288
+ .row.act-modified .actdot { display: inline-block; background: var(--yellow); color: var(--yellow); }
289
+ .row.act-deleted .actdot { display: inline-block; background: var(--red); color: var(--red); }
290
+ .row.act-fresh .actdot { animation: pulse 1.4s ease-out 2; }
291
+ .row.act-created .name { color: #56d364; }
292
+ .row.act-modified .name { color: #e3b341; }
293
+ .row.act-deleted .name { color: #ffa198; text-decoration: line-through; opacity: .7; }
294
+ @keyframes pulse { 0%{transform:scale(1);} 50%{transform:scale(1.6);} 100%{transform:scale(1);} }
295
+ .row .actcount { display: none; font-size: 9px; color: var(--dim);
296
+ font-family: ui-monospace, monospace; margin-left: 2px; }
297
+ .row.act-multi .actcount { display: inline-block; }
258
298
  .row .rmenu { margin-left: auto; color: var(--dim); font-size: 14px; padding: 0 4px;
259
299
  opacity: 0; flex-shrink: 0; line-height: 1; border-radius: 3px; }
260
300
  .row.changed .rmenu { margin-left: 4px; }
@@ -344,7 +384,24 @@ function buildHTML() {
344
384
 
345
385
  /* ─── Terminal area ─── */
346
386
  #center { flex: 1; min-width: 0; min-height: 0; display: flex;
347
- flex-direction: column; background: var(--bg); overflow: hidden; }
387
+ flex-direction: column; background: var(--bg); overflow: hidden; position: relative; }
388
+ #qa { display: flex; align-items: center; gap: 6px; padding: 6px 10px;
389
+ background: var(--panel2); border-bottom: 1px solid var(--border); flex-shrink: 0; }
390
+ #qa .qabtn { background: transparent; color: var(--muted); border: 1px solid var(--border);
391
+ border-radius: 5px; padding: 4px 10px; font-size: 11px; cursor: pointer;
392
+ display: inline-flex; align-items: center; gap: 5px; font-family: inherit; line-height: 1; }
393
+ #qa .qabtn:hover { color: var(--accent); border-color: var(--accent); }
394
+ #qa .qabtn .qaico { font-size: 13px; }
395
+ #qa .qabtn.rec.on { color: var(--red); border-color: var(--red); background: rgba(248,81,73,.08); }
396
+ #qa .qa-sp { flex: 1; }
397
+ #qa .rec-dot { display: none; width: 8px; height: 8px; border-radius: 50%;
398
+ background: var(--red); animation: blink 1s infinite; }
399
+ #qa .rec-dot.on { display: inline-block; }
400
+ #qa .rec-time { display: none; font-family: ui-monospace, 'SF Mono', monospace;
401
+ font-size: 11px; color: var(--red); font-variant-numeric: tabular-nums; }
402
+ #qa .rec-time.on { display: inline-block; }
403
+ @keyframes blink { 0%,100%{opacity:1;} 50%{opacity:.3;} }
404
+
348
405
  #term-wrap { flex: 1; min-height: 0; min-width: 0; padding: 6px 0 0 10px;
349
406
  overflow: hidden; position: relative; }
350
407
  #term-wrap .terminal, #term-wrap .xterm { height: 100% !important; width: 100% !important; }
@@ -354,6 +411,16 @@ function buildHTML() {
354
411
  .xterm-viewport::-webkit-scrollbar-thumb { background: var(--border2); border-radius: 4px; }
355
412
  .xterm-viewport::-webkit-scrollbar-track { background: transparent; }
356
413
 
414
+ /* drag-drop overlay */
415
+ #dropOverlay { position: absolute; inset: 0; display: none; z-index: 200;
416
+ background: rgba(10,14,20,.85); align-items: center; justify-content: center;
417
+ border: 2px dashed var(--accent); pointer-events: none; }
418
+ #dropOverlay.on { display: flex; }
419
+ #dropOverlay .drop-card { text-align: center; }
420
+ #dropOverlay .drop-icon { font-size: 48px; margin-bottom: 8px; }
421
+ #dropOverlay .drop-text { color: var(--accent); font-size: 16px; font-weight: 600; }
422
+ #dropOverlay .drop-text span { color: var(--muted); font-weight: 400; font-size: 12px; }
423
+
357
424
  /* ─── Preview panel ─── */
358
425
  #preview {
359
426
  width: 480px; flex-shrink: 0; display: flex; flex-direction: column;
@@ -462,10 +529,16 @@ function buildHTML() {
462
529
  <div class="files-toolbar">
463
530
  <button class="ftb" title="New file" onclick="newItemPrompt('file','')">&#128462;<sup>+</sup></button>
464
531
  <button class="ftb" title="New folder" onclick="newItemPrompt('folder','')">&#128193;<sup>+</sup></button>
532
+ <button class="ftb" id="ftbAct" title="Show activity log" onclick="toggleActivity()">&#9737;</button>
465
533
  <span class="ftb-spacer"></span>
534
+ <button class="ftb" title="Clear change marks" onclick="clearAllMarks()">&#10005;</button>
466
535
  <button class="ftb" title="Refresh tree" onclick="loadTree()">&#8634;</button>
467
536
  <button class="ftb" title="Collapse all" onclick="collapseAll()">&#8676;</button>
468
537
  </div>
538
+ <div id="activityPanel">
539
+ <div class="ah">Recent activity<span class="acl" onclick="clearActivity()">clear</span></div>
540
+ <div id="activityList"></div>
541
+ </div>
469
542
  <div class="tree" id="tree"></div>
470
543
  </div>
471
544
  <div class="pane" id="pane-config">
@@ -501,7 +574,28 @@ function buildHTML() {
501
574
 
502
575
  <!-- Center: terminal -->
503
576
  <main id="center">
577
+ <div id="qa">
578
+ <button class="qabtn" title="Attach files (sends @path to Sapper)" onclick="pickAndUpload()">
579
+ <span class="qaico">&#128206;</span><span class="qalbl">Attach</span>
580
+ </button>
581
+ <button class="qabtn rec" title="Record voice (auto-transcribed by Sapper)" onclick="toggleRecord()" id="qaRec">
582
+ <span class="qaico">&#127908;</span><span class="qalbl">Record</span>
583
+ </button>
584
+ <span id="recDot" class="rec-dot"></span>
585
+ <span id="recTime" class="rec-time"></span>
586
+ <span class="qa-sp"></span>
587
+ <button class="qabtn" title="Send /attach (interactive)" onclick="sendCmd('/attach')">/attach</button>
588
+ <button class="qabtn" title="Open file by path" onclick="sendOpenPrompt()">/open</button>
589
+ <button class="qabtn" title="Compact context" onclick="sendCmd('/summary')">/summary</button>
590
+ <input type="file" id="qaFile" multiple style="display:none">
591
+ </div>
504
592
  <div id="term-wrap"></div>
593
+ <div id="dropOverlay">
594
+ <div class="drop-card">
595
+ <div class="drop-icon">&#128229;</div>
596
+ <div class="drop-text">Drop files to upload<br><span>They will be attached to Sapper with <code>@path</code></span></div>
597
+ </div>
598
+ </div>
505
599
  </main>
506
600
 
507
601
  <!-- Right: preview -->
@@ -543,6 +637,9 @@ var state = {
543
637
  editing: false,
544
638
  expanded: { '': true },
545
639
  fsWS: null,
640
+ marks: {}, // path -> { kind, count, ts }
641
+ activity: [], // ordered list of {kind, path, isDir, ts}
642
+ activityOpen: false,
546
643
  };
547
644
 
548
645
  function esc(s) {
@@ -679,20 +776,21 @@ function handleStats(msg) {
679
776
  }
680
777
 
681
778
  function handleFsEvent(msg) {
682
- if (!msg || !msg.path) return;
683
- // Flag the file in the tree
684
- var row = document.querySelector('.row[data-path="' + cssEscape(msg.path) + '"]');
685
- if (row) {
686
- row.classList.add('changed');
687
- setTimeout(function(){ row.classList.remove('changed'); }, 4000);
779
+ if (!msg) return;
780
+ if (msg.type === 'activity-replay') {
781
+ if (Array.isArray(msg.items)) msg.items.forEach(applyActivityItem);
782
+ return;
688
783
  }
689
- // Refresh tree (parent dir) if a file was added/removed
690
- if (msg.event === 'rename') {
784
+ if (!msg.path) return;
785
+ applyActivityItem(msg);
786
+ // Re-fetch tree (parent dir) for create/delete so the new/removed file appears
787
+ if (msg.kind === 'created' || msg.kind === 'deleted') {
691
788
  var parent = msg.path.split('/').slice(0, -1).join('/');
692
789
  refreshDir(parent);
693
790
  }
694
791
  // If the current preview file changed, auto-refresh (or show indicator if editing)
695
792
  if (state.currentFile === msg.path) {
793
+ if (msg.kind === 'deleted') return; // file gone; leave preview state
696
794
  if (state.editing) {
697
795
  document.getElementById('pInd').classList.add('show');
698
796
  } else {
@@ -701,6 +799,119 @@ function handleFsEvent(msg) {
701
799
  }
702
800
  }
703
801
 
802
+ function applyActivityItem(item) {
803
+ // bump persistent mark
804
+ var prev = state.marks[item.path];
805
+ var count = prev ? (prev.count + 1) : 1;
806
+ state.marks[item.path] = { kind: item.kind, count: count, ts: item.ts };
807
+ var row = document.querySelector('.row[data-path="' + cssEscape(item.path) + '"]');
808
+ if (row) applyMark(row, state.marks[item.path]);
809
+ // push into activity log (dedupe consecutive entries for same path)
810
+ var last = state.activity[state.activity.length - 1];
811
+ if (last && last.path === item.path && last.kind === item.kind && (item.ts - last.ts) < 1500) {
812
+ last.count = (last.count || 1) + 1;
813
+ last.ts = item.ts;
814
+ } else {
815
+ state.activity.push({ kind: item.kind, path: item.path, isDir: item.isDir, ts: item.ts, count: 1 });
816
+ if (state.activity.length > 100) state.activity.shift();
817
+ }
818
+ renderActivity();
819
+ // Highlight parent dirs subtly so user notices nested change even when collapsed
820
+ var parts = item.path.split('/');
821
+ for (var i = 1; i < parts.length; i++) {
822
+ var dirPath = parts.slice(0, i).join('/');
823
+ var dirRow = document.querySelector('.row[data-path="' + cssEscape(dirPath) + '"]');
824
+ if (dirRow && !dirRow.classList.contains('act-created') && !dirRow.classList.contains('act-modified') && !dirRow.classList.contains('act-deleted')) {
825
+ dirRow.classList.add('act-modified', 'act-fresh');
826
+ setTimeout((function(r){ return function(){ r.classList.remove('act-fresh'); }; })(dirRow), 1500);
827
+ }
828
+ }
829
+ }
830
+
831
+ function applyMark(row, mark) {
832
+ row.classList.remove('act-created', 'act-modified', 'act-deleted', 'act-multi', 'act-fresh');
833
+ row.classList.add('act-' + mark.kind, 'act-fresh');
834
+ if (mark.count > 1) {
835
+ row.classList.add('act-multi');
836
+ var cnt = row.querySelector('.actcount');
837
+ if (cnt) cnt.textContent = String(mark.count);
838
+ }
839
+ setTimeout(function(){ row.classList.remove('act-fresh'); }, 1500);
840
+ }
841
+
842
+ function renderActivity() {
843
+ var host = document.getElementById('activityList');
844
+ if (!host) return;
845
+ if (!state.activity.length) {
846
+ host.innerHTML = '<div class="empty">No changes yet. Ask Sapper to edit something.</div>';
847
+ return;
848
+ }
849
+ var items = state.activity.slice(-30).reverse();
850
+ host.innerHTML = items.map(function(a){
851
+ var rel = relTime(a.ts);
852
+ var ct = a.count > 1 ? ' &times;' + a.count : '';
853
+ return '<div class="ai kind-' + a.kind + '" data-path="' + esc(a.path) + '">' +
854
+ '<span class="ak">' + a.kind + ct + '</span>' +
855
+ '<span class="ap">' + esc(a.path) + '</span>' +
856
+ '<span class="at">' + rel + '</span></div>';
857
+ }).join('');
858
+ Array.from(host.querySelectorAll('.ai')).forEach(function(el){
859
+ el.addEventListener('click', function(){
860
+ var p = el.dataset.path;
861
+ var mark = state.marks[p];
862
+ if (mark && mark.kind === 'deleted') { showToast(p + ' (deleted)'); return; }
863
+ // expand ancestor dirs then open
864
+ var parts = p.split('/');
865
+ var soFar = '';
866
+ for (var i = 0; i < parts.length - 1; i++) {
867
+ soFar = soFar ? soFar + '/' + parts[i] : parts[i];
868
+ state.expanded[soFar] = true;
869
+ }
870
+ loadTree();
871
+ setTimeout(function(){ openFile(p); }, 80);
872
+ // clear that file's mark since the user has acknowledged it
873
+ clearMark(p);
874
+ });
875
+ });
876
+ }
877
+
878
+ function relTime(ts) {
879
+ var s = Math.floor((Date.now() - ts) / 1000);
880
+ if (s < 5) return 'now';
881
+ if (s < 60) return s + 's';
882
+ if (s < 3600) return Math.floor(s / 60) + 'm';
883
+ return Math.floor(s / 3600) + 'h';
884
+ }
885
+
886
+ function clearMark(path) {
887
+ delete state.marks[path];
888
+ var row = document.querySelector('.row[data-path="' + cssEscape(path) + '"]');
889
+ if (row) row.classList.remove('act-created', 'act-modified', 'act-deleted', 'act-multi', 'act-fresh');
890
+ }
891
+
892
+ window.toggleActivity = function() {
893
+ state.activityOpen = !state.activityOpen;
894
+ document.getElementById('activityPanel').classList.toggle('on', state.activityOpen);
895
+ document.getElementById('ftbAct').classList.toggle('on', state.activityOpen);
896
+ if (state.activityOpen) renderActivity();
897
+ };
898
+
899
+ window.clearActivity = function() {
900
+ state.activity = [];
901
+ renderActivity();
902
+ };
903
+
904
+ window.clearAllMarks = function() {
905
+ state.marks = {};
906
+ document.querySelectorAll('.row').forEach(function(r){
907
+ r.classList.remove('act-created', 'act-modified', 'act-deleted', 'act-multi', 'act-fresh');
908
+ });
909
+ showToast('Cleared change marks');
910
+ };
911
+
912
+ // Periodically refresh "rel time" labels in the activity panel
913
+ setInterval(function(){ if (state.activityOpen) renderActivity(); }, 30000);
914
+
704
915
  function cssEscape(s) { return s.replace(/(["\\\\])/g, '\\\\$1'); }
705
916
 
706
917
  // ─── Sidebar tabs ────────────────────────────────────────────
@@ -785,6 +996,8 @@ function renderEntries(container, basePath, entries, depth) {
785
996
  '<span class="chev">' + chev + '</span>' +
786
997
  '<span class="ico">' + fileIcon(entry.name, entry.isDir) + '</span>' +
787
998
  '<span class="name">' + esc(entry.name) + '</span>' +
999
+ '<span class="actdot"></span>' +
1000
+ '<span class="actcount"></span>' +
788
1001
  '<span class="badge">&#9679;</span>' +
789
1002
  '<span class="rmenu" title="Options">&#8943;</span>';
790
1003
  row.addEventListener('click', function(ev){
@@ -801,6 +1014,9 @@ function renderEntries(container, basePath, entries, depth) {
801
1014
  openRowMenu({ getBoundingClientRect: function(){ return { left: ev.clientX, bottom: ev.clientY, right: ev.clientX, top: ev.clientY }; } }, path, entry.isDir);
802
1015
  });
803
1016
  container.appendChild(row);
1017
+ // Re-apply any persistent activity mark for this path
1018
+ var m = state.marks[path];
1019
+ if (m) applyMark(row, m);
804
1020
  if (entry.isDir && state.expanded[path]) {
805
1021
  // Load children if not already loaded
806
1022
  fetch('/api/tree?path=' + encodeURIComponent(path)).then(function(r){return r.json();}).then(function(d){
@@ -1055,6 +1271,8 @@ window.openFile = function(path, isReload) {
1055
1271
  document.querySelectorAll('.row.active').forEach(function(r){ r.classList.remove('active'); });
1056
1272
  var row = document.querySelector('.row[data-path="' + cssEscape(path) + '"]');
1057
1273
  if (row) row.classList.add('active');
1274
+ // Clear any pending change-mark for this file since the user is acknowledging it
1275
+ if (!isReload && state.marks[path]) clearMark(path);
1058
1276
 
1059
1277
  state.currentFile = path;
1060
1278
  state.editing = false;
@@ -1298,6 +1516,199 @@ function loadSkills() {
1298
1516
  });
1299
1517
  }
1300
1518
 
1519
+ // ─── Quick actions: upload + voice record ────────────────────
1520
+
1521
+ function uploadBlob(blob, filename, targetDir) {
1522
+ return fetch('/api/upload', {
1523
+ method: 'POST',
1524
+ headers: {
1525
+ 'Content-Type': blob.type || 'application/octet-stream',
1526
+ 'X-Filename': encodeURIComponent(filename),
1527
+ 'X-Target-Dir': encodeURIComponent(targetDir || 'uploads'),
1528
+ },
1529
+ body: blob,
1530
+ }).then(function(r){ return r.json(); }).then(function(d){
1531
+ if (d.error) throw new Error(d.error);
1532
+ return d.path;
1533
+ });
1534
+ }
1535
+
1536
+ window.pickAndUpload = function() {
1537
+ var inp = document.getElementById('qaFile');
1538
+ inp.value = '';
1539
+ inp.onchange = function() {
1540
+ var files = Array.from(inp.files || []);
1541
+ if (!files.length) return;
1542
+ uploadFileList(files, 'uploads');
1543
+ };
1544
+ inp.click();
1545
+ };
1546
+
1547
+ async function uploadFileList(files, targetDir) {
1548
+ var paths = [];
1549
+ for (var i = 0; i < files.length; i++) {
1550
+ var f = files[i];
1551
+ showToast('Uploading ' + f.name + '…');
1552
+ try {
1553
+ var p = await uploadBlob(f, f.name, targetDir);
1554
+ paths.push(p);
1555
+ } catch (e) {
1556
+ showToast('Upload failed: ' + e.message, 'err');
1557
+ }
1558
+ }
1559
+ if (paths.length) {
1560
+ loadTree();
1561
+ // Send "@path1 @path2 " to terminal so user can keep typing
1562
+ if (ws && ws.readyState === 1) {
1563
+ ws.send(paths.map(function(p){ return '@' + p; }).join(' ') + ' ');
1564
+ }
1565
+ showToast(paths.length + ' file' + (paths.length > 1 ? 's' : '') + ' attached');
1566
+ term.focus();
1567
+ }
1568
+ }
1569
+
1570
+ // ─── Drag-drop on terminal area ──────────────────────────────
1571
+ (function setupDropZone(){
1572
+ var center = document.getElementById('center');
1573
+ var ov = document.getElementById('dropOverlay');
1574
+ var depth = 0;
1575
+ function show(){ ov.classList.add('on'); }
1576
+ function hide(){ ov.classList.remove('on'); depth = 0; }
1577
+ center.addEventListener('dragenter', function(e){
1578
+ if (!e.dataTransfer || !e.dataTransfer.types || e.dataTransfer.types.indexOf('Files') < 0) return;
1579
+ e.preventDefault(); depth++; show();
1580
+ });
1581
+ center.addEventListener('dragover', function(e){
1582
+ if (!e.dataTransfer || !e.dataTransfer.types || e.dataTransfer.types.indexOf('Files') < 0) return;
1583
+ e.preventDefault(); e.dataTransfer.dropEffect = 'copy';
1584
+ });
1585
+ center.addEventListener('dragleave', function(e){
1586
+ depth--; if (depth <= 0) hide();
1587
+ });
1588
+ center.addEventListener('drop', function(e){
1589
+ if (!e.dataTransfer || !e.dataTransfer.files || !e.dataTransfer.files.length) { hide(); return; }
1590
+ e.preventDefault(); hide();
1591
+ uploadFileList(Array.from(e.dataTransfer.files), 'uploads');
1592
+ });
1593
+ })();
1594
+
1595
+ // ─── Audio recording (16 kHz mono WAV for Whisper) ───────────
1596
+ var recState = null;
1597
+
1598
+ window.toggleRecord = async function() {
1599
+ if (recState) return stopRecording();
1600
+ await startRecording();
1601
+ };
1602
+
1603
+ async function startRecording() {
1604
+ if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
1605
+ showToast('Microphone API not available (use HTTPS or localhost)', 'err');
1606
+ return;
1607
+ }
1608
+ try {
1609
+ var stream = await navigator.mediaDevices.getUserMedia({
1610
+ audio: { channelCount: 1, echoCancellation: true, noiseSuppression: true, autoGainControl: true }
1611
+ });
1612
+ var Ctx = window.AudioContext || window.webkitAudioContext;
1613
+ var ctx = new Ctx({ sampleRate: 16000 });
1614
+ // Resume in case of autoplay policy
1615
+ if (ctx.state === 'suspended') { try { await ctx.resume(); } catch(_){} }
1616
+ var src = ctx.createMediaStreamSource(stream);
1617
+ var proc = ctx.createScriptProcessor(4096, 1, 1);
1618
+ var chunks = [];
1619
+ proc.onaudioprocess = function(e) {
1620
+ var d = e.inputBuffer.getChannelData(0);
1621
+ chunks.push(new Float32Array(d));
1622
+ };
1623
+ src.connect(proc);
1624
+ proc.connect(ctx.destination);
1625
+ var startedAt = Date.now();
1626
+ recState = { stream: stream, ctx: ctx, src: src, proc: proc, chunks: chunks, sr: ctx.sampleRate, startedAt: startedAt };
1627
+ document.getElementById('qaRec').classList.add('on');
1628
+ document.getElementById('recDot').classList.add('on');
1629
+ document.getElementById('recTime').classList.add('on');
1630
+ recState.timer = setInterval(function(){
1631
+ var sec = Math.floor((Date.now() - startedAt) / 1000);
1632
+ var m = Math.floor(sec / 60), s = sec % 60;
1633
+ document.getElementById('recTime').textContent = m + ':' + (s < 10 ? '0' : '') + s;
1634
+ }, 250);
1635
+ showToast('Recording… click again to stop');
1636
+ } catch (e) {
1637
+ showToast('Mic permission: ' + e.message, 'err');
1638
+ }
1639
+ }
1640
+
1641
+ async function stopRecording() {
1642
+ var r = recState; if (!r) return;
1643
+ recState = null;
1644
+ document.getElementById('qaRec').classList.remove('on');
1645
+ document.getElementById('recDot').classList.remove('on');
1646
+ document.getElementById('recTime').classList.remove('on');
1647
+ document.getElementById('recTime').textContent = '';
1648
+ clearInterval(r.timer);
1649
+ try { r.proc.disconnect(); } catch(_){}
1650
+ try { r.src.disconnect(); } catch(_){}
1651
+ try { r.stream.getTracks().forEach(function(t){ t.stop(); }); } catch(_){}
1652
+ try { await r.ctx.close(); } catch(_){}
1653
+
1654
+ var len = 0; for (var i = 0; i < r.chunks.length; i++) len += r.chunks[i].length;
1655
+ if (len < r.sr / 4) { showToast('Too short (< 250 ms)', 'warn'); return; }
1656
+ var merged = new Float32Array(len);
1657
+ var off = 0;
1658
+ for (var j = 0; j < r.chunks.length; j++) { merged.set(r.chunks[j], off); off += r.chunks[j].length; }
1659
+ var wav = encodeWAV(merged, r.sr);
1660
+ var stamp = new Date().toISOString().replace(/[:.]/g, '-').replace('T', '_').slice(0, 19);
1661
+ showToast('Uploading recording…');
1662
+ try {
1663
+ var rel = await uploadBlob(new Blob([wav], { type: 'audio/wav' }),
1664
+ 'rec-' + stamp + '.wav',
1665
+ '.sapper/voice/incoming');
1666
+ loadTree();
1667
+ sendCmd('/voice file ' + rel);
1668
+ showToast('Sent to Sapper for transcription');
1669
+ } catch (e) {
1670
+ showToast('Upload failed: ' + e.message, 'err');
1671
+ }
1672
+ }
1673
+
1674
+ function encodeWAV(samples, sampleRate) {
1675
+ var bytesPerSample = 2;
1676
+ var buffer = new ArrayBuffer(44 + samples.length * bytesPerSample);
1677
+ var view = new DataView(buffer);
1678
+ function writeStr(o, s) { for (var i = 0; i < s.length; i++) view.setUint8(o + i, s.charCodeAt(i)); }
1679
+ writeStr(0, 'RIFF');
1680
+ view.setUint32(4, 36 + samples.length * bytesPerSample, true);
1681
+ writeStr(8, 'WAVE');
1682
+ writeStr(12, 'fmt ');
1683
+ view.setUint32(16, 16, true);
1684
+ view.setUint16(20, 1, true); // PCM
1685
+ view.setUint16(22, 1, true); // mono
1686
+ view.setUint32(24, sampleRate, true);
1687
+ view.setUint32(28, sampleRate * bytesPerSample, true);
1688
+ view.setUint16(32, bytesPerSample, true);
1689
+ view.setUint16(34, 16, true);
1690
+ writeStr(36, 'data');
1691
+ view.setUint32(40, samples.length * bytesPerSample, true);
1692
+ var o = 44;
1693
+ for (var i = 0; i < samples.length; i++) {
1694
+ var s = Math.max(-1, Math.min(1, samples[i]));
1695
+ view.setInt16(o, s < 0 ? s * 0x8000 : s * 0x7FFF, true);
1696
+ o += 2;
1697
+ }
1698
+ return buffer;
1699
+ }
1700
+
1701
+ window.sendOpenPrompt = async function() {
1702
+ var v = await showModal({
1703
+ title: 'Open file in Sapper',
1704
+ label: 'Path',
1705
+ placeholder: 'src/index.ts',
1706
+ okLabel: 'Open',
1707
+ });
1708
+ if (v == null || !v.trim()) return;
1709
+ sendCmd('/open ' + v.trim());
1710
+ };
1711
+
1301
1712
  // ─── Boot ────────────────────────────────────────────────────
1302
1713
  connectPty();
1303
1714
  connectEvents();
@@ -1498,6 +1909,53 @@ const server = http.createServer(async (req, res) => {
1498
1909
  } catch (e) { return json(res, { error: e.message }, 500); }
1499
1910
  }
1500
1911
 
1912
+ // ── Upload (raw body; headers carry filename + target dir)
1913
+ if (req.method === 'POST' && path === '/api/upload') {
1914
+ try {
1915
+ let name = decodeURIComponent(req.headers['x-filename'] || 'upload.bin');
1916
+ let dir = decodeURIComponent(req.headers['x-target-dir'] || 'uploads');
1917
+ // sanitize filename (strip slashes), keep extension
1918
+ name = name.replace(/[\\/:*?"<>|]/g, '_').slice(0, 200) || 'upload.bin';
1919
+ dir = dir.replace(/^[\\/]+/, '');
1920
+ const absDir = safePath(dir);
1921
+ if (!absDir) return json(res, { error: 'invalid target dir' }, 400);
1922
+ ensureDir(absDir);
1923
+ let target = join(absDir, name);
1924
+ // de-dupe if exists
1925
+ if (fs.existsSync(target)) {
1926
+ const dot = name.lastIndexOf('.');
1927
+ const stem = dot > 0 ? name.slice(0, dot) : name;
1928
+ const ext = dot > 0 ? name.slice(dot) : '';
1929
+ for (let i = 1; i < 1000; i++) {
1930
+ const cand = join(absDir, stem + '-' + i + ext);
1931
+ if (!fs.existsSync(cand)) { target = cand; break; }
1932
+ }
1933
+ }
1934
+ const ws = fs.createWriteStream(target);
1935
+ let size = 0; let aborted = false;
1936
+ const MAX = 50 * 1024 * 1024;
1937
+ req.on('data', (c) => {
1938
+ size += c.length;
1939
+ if (size > MAX && !aborted) {
1940
+ aborted = true;
1941
+ ws.destroy();
1942
+ try { fs.unlinkSync(target); } catch {}
1943
+ json(res, { error: 'upload too large (>50MB)' }, 413);
1944
+ req.destroy();
1945
+ }
1946
+ });
1947
+ req.pipe(ws);
1948
+ await new Promise((resolve, reject) => {
1949
+ ws.on('finish', resolve);
1950
+ ws.on('error', reject);
1951
+ req.on('error', reject);
1952
+ });
1953
+ if (aborted) return;
1954
+ const rel = relative(workingDir, target).split(sep).join('/');
1955
+ return json(res, { ok: true, path: rel, size });
1956
+ } catch (e) { return json(res, { error: e.message }, 500); }
1957
+ }
1958
+
1501
1959
  // ── Config read/write
1502
1960
  if (req.method === 'GET' && path === '/api/config') {
1503
1961
  return json(res, { config: readJSON(CONFIG_FILE, {}), path: relative(workingDir, CONFIG_FILE) });
@@ -1589,8 +2047,33 @@ wssPty.on('connection', (ws) => {
1589
2047
  let watcher = null;
1590
2048
  const eventsClients = new Set();
1591
2049
  const recentEvents = new Map(); // path -> timestamp (dedupe burst events)
2050
+ const knownPaths = new Set(); // paths we have seen exist (for create vs delete detection)
2051
+ const recentActivity = []; // last N classified events for late-joining clients
2052
+
2053
+ function classifyEvent(rawEvent, rel, abs) {
2054
+ // fs.watch only gives 'rename' or 'change'
2055
+ const exists = fs.existsSync(abs);
2056
+ if (rawEvent === 'change') return exists ? 'modified' : 'deleted';
2057
+ // 'rename' = created, deleted, or moved-in/out
2058
+ if (!exists) return 'deleted';
2059
+ return knownPaths.has(rel) ? 'modified' : 'created';
2060
+ }
2061
+
2062
+ function seedKnownPaths(dir, rel = '') {
2063
+ try {
2064
+ for (const ent of fs.readdirSync(dir, { withFileTypes: true })) {
2065
+ if (IGNORE_NAMES.has(ent.name)) continue;
2066
+ const sub = rel ? rel + '/' + ent.name : ent.name;
2067
+ knownPaths.add(sub);
2068
+ if (ent.isDirectory() && knownPaths.size < 20000) {
2069
+ seedKnownPaths(join(dir, ent.name), sub);
2070
+ }
2071
+ }
2072
+ } catch {}
2073
+ }
1592
2074
 
1593
2075
  function startWatcher() {
2076
+ seedKnownPaths(workingDir);
1594
2077
  try {
1595
2078
  watcher = fs.watch(workingDir, { recursive: true }, (event, filename) => {
1596
2079
  if (!filename) return;
@@ -1606,7 +2089,17 @@ function startWatcher() {
1606
2089
  const cutoff = now - 10000;
1607
2090
  for (const [k, t] of recentEvents) if (t < cutoff) recentEvents.delete(k);
1608
2091
  }
1609
- const payload = JSON.stringify({ event, path: rel, ts: now });
2092
+ const abs = pathResolve(workingDir, rel);
2093
+ const kind = classifyEvent(event, rel, abs);
2094
+ if (kind === 'deleted') knownPaths.delete(rel);
2095
+ else knownPaths.add(rel);
2096
+ let isDir = false;
2097
+ try { isDir = fs.statSync(abs).isDirectory(); } catch {}
2098
+ const enriched = { event, kind, path: rel, isDir, ts: now };
2099
+ // remember for new clients (cap at 50)
2100
+ recentActivity.push(enriched);
2101
+ if (recentActivity.length > 50) recentActivity.shift();
2102
+ const payload = JSON.stringify(enriched);
1610
2103
  for (const c of eventsClients) {
1611
2104
  if (c.readyState === c.OPEN) { try { c.send(payload); } catch {} }
1612
2105
  }
@@ -1620,6 +2113,10 @@ function startWatcher() {
1620
2113
  wssEvents.on('connection', (ws) => {
1621
2114
  eventsClients.add(ws);
1622
2115
  dbg('events client connected (total=' + eventsClients.size + ')');
2116
+ // Replay last activity so the new tab sees recent changes
2117
+ if (recentActivity.length) {
2118
+ try { ws.send(JSON.stringify({ type: 'activity-replay', items: recentActivity.slice(-25) })); } catch {}
2119
+ }
1623
2120
  if (lastStats) { try { ws.send(lastStats); } catch {} }
1624
2121
  ws.on('close', () => { eventsClients.delete(ws); });
1625
2122
  });