claude-pager 0.2.2 → 0.3.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 (41) hide show
  1. package/README.md +42 -2
  2. package/dist/channels/telegram/provider.d.ts.map +1 -1
  3. package/dist/channels/telegram/provider.js +14 -0
  4. package/dist/channels/telegram/provider.js.map +1 -1
  5. package/dist/cli/index.js +45 -0
  6. package/dist/cli/index.js.map +1 -1
  7. package/dist/daemon/server.d.ts.map +1 -1
  8. package/dist/daemon/server.js +157 -0
  9. package/dist/daemon/server.js.map +1 -1
  10. package/dist/dashboard/enricher.d.ts +3 -0
  11. package/dist/dashboard/enricher.d.ts.map +1 -1
  12. package/dist/dashboard/enricher.js +9 -5
  13. package/dist/dashboard/enricher.js.map +1 -1
  14. package/dist/dashboard/git-status.d.ts +1 -1
  15. package/dist/dashboard/git-status.d.ts.map +1 -1
  16. package/dist/dashboard/git-status.js +14 -12
  17. package/dist/dashboard/git-status.js.map +1 -1
  18. package/dist/dashboard/html.d.ts +1 -1
  19. package/dist/dashboard/html.d.ts.map +1 -1
  20. package/dist/dashboard/html.js +481 -6
  21. package/dist/dashboard/html.js.map +1 -1
  22. package/dist/dashboard/routes.d.ts.map +1 -1
  23. package/dist/dashboard/routes.js +12 -0
  24. package/dist/dashboard/routes.js.map +1 -1
  25. package/dist/dashboard/sse.d.ts +5 -0
  26. package/dist/dashboard/sse.d.ts.map +1 -0
  27. package/dist/dashboard/sse.js +45 -0
  28. package/dist/dashboard/sse.js.map +1 -0
  29. package/dist/hooks/index.js +19 -5
  30. package/dist/hooks/index.js.map +1 -1
  31. package/dist/notes/__tests__/store.test.d.ts +2 -0
  32. package/dist/notes/__tests__/store.test.d.ts.map +1 -0
  33. package/dist/notes/__tests__/store.test.js +88 -0
  34. package/dist/notes/__tests__/store.test.js.map +1 -0
  35. package/dist/notes/store.d.ts +27 -0
  36. package/dist/notes/store.d.ts.map +1 -0
  37. package/dist/notes/store.js +174 -0
  38. package/dist/notes/store.js.map +1 -0
  39. package/dist/types.d.ts +1 -0
  40. package/dist/types.d.ts.map +1 -1
  41. package/package.json +1 -1
@@ -438,6 +438,152 @@ exports.DASHBOARD_HTML = `<!DOCTYPE html>
438
438
  margin-left: auto;
439
439
  }
440
440
 
441
+ .notes-panel {
442
+ background: #1a1e2e;
443
+ border: 1px solid #2d333b;
444
+ border-radius: 8px;
445
+ padding: 12px;
446
+ min-width: 0;
447
+ }
448
+
449
+ .notes-header {
450
+ display: flex;
451
+ align-items: center;
452
+ justify-content: space-between;
453
+ margin-bottom: 8px;
454
+ font-size: 12px;
455
+ font-weight: 600;
456
+ color: #8b949e;
457
+ }
458
+
459
+ .notes-header .count {
460
+ background: #2d333b;
461
+ color: #c9d1d9;
462
+ font-size: 10px;
463
+ padding: 1px 7px;
464
+ border-radius: 8px;
465
+ }
466
+
467
+ .note-item {
468
+ display: flex;
469
+ align-items: center;
470
+ gap: 8px;
471
+ padding: 6px 8px;
472
+ border-radius: 4px;
473
+ font-size: 12px;
474
+ color: #c9d1d9;
475
+ transition: background 0.15s;
476
+ }
477
+
478
+ .note-item:hover {
479
+ background: #21262d;
480
+ }
481
+
482
+ .note-item .note-text {
483
+ flex: 1;
484
+ overflow: hidden;
485
+ text-overflow: ellipsis;
486
+ white-space: nowrap;
487
+ cursor: text;
488
+ }
489
+
490
+ .note-clock {
491
+ font-size: 11px;
492
+ cursor: default;
493
+ flex-shrink: 0;
494
+ }
495
+
496
+ .note-thumb {
497
+ width: 32px;
498
+ height: 32px;
499
+ object-fit: cover;
500
+ border-radius: 4px;
501
+ cursor: pointer;
502
+ flex-shrink: 0;
503
+ border: 1px solid #30363d;
504
+ transition: transform 0.15s;
505
+ }
506
+
507
+ .note-thumb:hover {
508
+ transform: scale(1.1);
509
+ border-color: #58a6ff;
510
+ }
511
+
512
+ .note-lightbox {
513
+ position: fixed;
514
+ top: 0; left: 0; right: 0; bottom: 0;
515
+ background: rgba(0,0,0,0.85);
516
+ display: flex;
517
+ align-items: center;
518
+ justify-content: center;
519
+ z-index: 1000;
520
+ cursor: pointer;
521
+ }
522
+
523
+ .note-lightbox img {
524
+ max-width: 90vw;
525
+ max-height: 90vh;
526
+ border-radius: 8px;
527
+ box-shadow: 0 0 40px rgba(0,0,0,0.5);
528
+ }
529
+
530
+ .note-grip {
531
+ cursor: grab;
532
+ color: #484f58;
533
+ font-size: 10px;
534
+ user-select: none;
535
+ }
536
+ .note-item.dragging {
537
+ opacity: 0.4;
538
+ }
539
+ .note-item.drag-over {
540
+ border-top: 2px solid #58a6ff;
541
+ margin-top: -2px;
542
+ }
543
+ .note-btn.move {
544
+ background: none;
545
+ color: #484f58;
546
+ padding: 0 2px;
547
+ font-size: 8px;
548
+ min-width: 16px;
549
+ }
550
+ .note-btn.move:hover {
551
+ color: #58a6ff;
552
+ }
553
+ .note-btn {
554
+ font-family: 'JetBrains Mono', monospace;
555
+ font-size: 10px;
556
+ font-weight: 600;
557
+ padding: 2px 8px;
558
+ border-radius: 4px;
559
+ border: none;
560
+ cursor: pointer;
561
+ transition: opacity 0.2s;
562
+ }
563
+
564
+ .note-btn:hover { opacity: 0.85; }
565
+
566
+ .note-btn.send {
567
+ background: #238636;
568
+ color: #fff;
569
+ }
570
+
571
+ .note-btn.delete {
572
+ background: #21262d;
573
+ color: #8b949e;
574
+ }
575
+
576
+ .note-btn.delete:hover {
577
+ background: #da3633;
578
+ color: #fff;
579
+ }
580
+
581
+ .note-add-row {
582
+ display: flex;
583
+ gap: 6px;
584
+ margin-top: 8px;
585
+ }
586
+
441
587
  .empty {
442
588
  text-align: center;
443
589
  padding: 60px 20px;
@@ -547,6 +693,7 @@ exports.DASHBOARD_HTML = `<!DOCTYPE html>
547
693
 
548
694
  <script>
549
695
  let data = null;
696
+ const sentMessages = new Map(); // sessionId → { icon, text, at }
550
697
 
551
698
  function getPinnedOrder() {
552
699
  try { return JSON.parse(localStorage.getItem('dashboard-pin-order') || '[]'); }
@@ -599,6 +746,32 @@ exports.DASHBOARD_HTML = `<!DOCTYPE html>
599
746
  return Math.floor(s / 86400) + 'd ago';
600
747
  }
601
748
 
749
+ function noteAgeColor(epochMs) {
750
+ const m = (Date.now() - epochMs) / 60000;
751
+ if (m < 10) return '#3fb950'; // green — fresh
752
+ if (m < 60) return '#d29922'; // yellow — aging
753
+ if (m < 360) return '#f0883e'; // orange — old
754
+ return '#f85149'; // red — stale
755
+ }
756
+
757
+ function formatToolInput(input) {
758
+ if (!input) return '';
759
+ const hasDiff = input.includes('--- old') && input.includes('+++ new');
760
+ if (hasDiff) {
761
+ const lines = input.slice(0, 800).split('\\n');
762
+ const html = lines.map(line => {
763
+ const escaped = escapeHtml(line);
764
+ if (line.startsWith('+++ new')) return '<span style="color:#3fb950;font-weight:600">' + escaped + '</span>';
765
+ if (line.startsWith('--- old')) return '<span style="color:#f85149;font-weight:600">' + escaped + '</span>';
766
+ if (line.startsWith('+')) return '<span style="color:#3fb950">' + escaped + '</span>';
767
+ if (line.startsWith('-')) return '<span style="color:#f85149">' + escaped + '</span>';
768
+ return escaped;
769
+ }).join('\\n');
770
+ return '<pre style="font-size:10px;color:#8b949e;word-break:break-all;white-space:pre-wrap;margin:4px 0;max-height:200px;overflow-y:auto">' + html + '</pre>';
771
+ }
772
+ return '<code style="font-size:10px;color:#8b949e;word-break:break-all">' + escapeHtml(input.slice(0, 400)) + '</code>';
773
+ }
774
+
602
775
  function stateLabel(state) {
603
776
  const labels = {
604
777
  working: 'Working',
@@ -621,9 +794,12 @@ exports.DASHBOARD_HTML = `<!DOCTYPE html>
621
794
  if (s.pendingQuestion) {
622
795
  const q = s.pendingQuestion;
623
796
  const isPermission = q.type === 'permission_prompt';
797
+ const contextInfo = q.context
798
+ ? '<div style="font-size:11px;color:#c9d1d9;margin-bottom:6px;white-space:pre-wrap">' + escapeHtml(q.context.slice(-300)) + '</div>'
799
+ : '';
624
800
  const toolInfo = q.toolName
625
- ? '<span class="tool">' + escapeHtml(q.toolName) + '</span>' +
626
- (q.toolInput ? '<br><code style="font-size:10px;color:#8b949e;word-break:break-all">' + escapeHtml(q.toolInput.slice(0, 200)) + '</code>' : '')
801
+ ? contextInfo + '<span class="tool">' + escapeHtml(q.toolName) + '</span>' +
802
+ (q.toolInput ? '<br>' + formatToolInput(q.toolInput) : '')
627
803
  : escapeHtml(q.message.slice(0, 150));
628
804
 
629
805
  const actions = isPermission
@@ -682,6 +858,16 @@ exports.DASHBOARD_HTML = `<!DOCTYPE html>
682
858
  <button class="dismiss-btn" onclick="dismissSession('\${s.sessionId}')" title="Dismiss session">🗑</button>
683
859
  </div>
684
860
  \${expandBtn}
861
+ \${(() => {
862
+ const sent = sentMessages.get(s.sessionId);
863
+ if (sent && Date.now() - sent.at < 300_000) {
864
+ const label = sent.icon === '▶' ? 'Working on' : 'Replied';
865
+ return '<div style="background:#0d2818;border:1px solid #238636;border-radius:6px;padding:6px 10px;margin:8px 0;font-size:12px;color:#c9d1d9;white-space:pre-wrap">'
866
+ + '<span style="color:#3fb950;font-weight:600">' + label + ' :</span> '
867
+ + escapeHtml(sent.text.length > 150 ? sent.text.slice(0, 150) + '...' : sent.text) + '</div>';
868
+ }
869
+ return '';
870
+ })()}
685
871
  \${pending}
686
872
  \${idleInput}
687
873
  <div class="card-footer">
@@ -712,6 +898,52 @@ exports.DASHBOARD_HTML = `<!DOCTYPE html>
712
898
  return main + staging;
713
899
  }
714
900
 
901
+ function renderNotes(project, notes) {
902
+ if (!notes || notes.length === 0) {
903
+ return \`
904
+ <div class="notes-panel">
905
+ <div class="notes-header">Notes</div>
906
+ <div class="note-add-row">
907
+ <input type="text" class="reply-input" id="note-add-\${escapeHtml(project)}" placeholder="Add a note..." onkeydown="if(event.key==='Enter')addNote('\${escapeHtml(project)}',this.value,this)" onpaste="handleNotePaste(event,'\${escapeHtml(project)}')">
908
+ <button class="note-btn send" onclick="var i=document.getElementById('note-add-\${escapeHtml(project)}');addNote('\${escapeHtml(project)}',i.value,i)">+</button>
909
+ </div>
910
+ </div>
911
+ \`;
912
+ }
913
+
914
+ const items = notes.map((n, idx) => {
915
+ const thumb = n.image
916
+ ? '<img src="/api/v1/notes/images/' + escapeHtml(n.image) + '" class="note-thumb" onclick="openNoteImage(this.src)" title="Click to enlarge">'
917
+ : '';
918
+ return \`
919
+ <div class="note-item" draggable="true" data-note-id="\${n.id}" data-project="\${escapeHtml(project)}"
920
+ ondragstart="onNoteDragStart(event)" ondragover="onNoteDragOver(event)" ondrop="onNoteDrop(event)" ondragend="onNoteDragEnd(event)">
921
+ <span class="note-grip" title="Drag to reorder">⠿</span>
922
+ \${thumb}
923
+ <span class="note-text" title="\${escapeHtml(n.text)}" onclick="editNote(this,'\${n.id}')">\${escapeHtml(n.text)}</span>
924
+ <span class="note-clock" title="\${timeAgo(n.createdAt)}" style="color:\${noteAgeColor(n.createdAt)}">⏱</span>
925
+ \${idx > 0 ? '<button class="note-btn move" onclick="moveNote(\\'' + escapeHtml(project) + '\\',' + idx + ',-1)" title="Move up">▲</button>' : '<span class="note-btn move" style="visibility:hidden">▲</span>'}
926
+ \${idx < notes.length - 1 ? '<button class="note-btn move" onclick="moveNote(\\'' + escapeHtml(project) + '\\',' + idx + ',1)" title="Move down">▼</button>' : '<span class="note-btn move" style="visibility:hidden">▼</span>'}
927
+ <button class="note-btn send" onclick="sendNote('\${n.id}',this)" title="Send to session">▶</button>
928
+ <button class="note-btn delete" onclick="deleteNote('\${n.id}')" title="Delete">✕</button>
929
+ </div>
930
+ \`}).join('');
931
+
932
+ return \`
933
+ <div class="notes-panel">
934
+ <div class="notes-header">
935
+ <span>Notes</span>
936
+ <span class="count">\${notes.length}</span>
937
+ </div>
938
+ \${items}
939
+ <div class="note-add-row">
940
+ <input type="text" class="reply-input" id="note-add-\${escapeHtml(project)}" placeholder="Add a note..." onkeydown="if(event.key==='Enter')addNote('\${escapeHtml(project)}',this.value,this)" onpaste="handleNotePaste(event,'\${escapeHtml(project)}')">
941
+ <button class="note-btn send" onclick="var i=document.getElementById('note-add-\${escapeHtml(project)}');addNote('\${escapeHtml(project)}',i.value,i)">+</button>
942
+ </div>
943
+ </div>
944
+ \`;
945
+ }
946
+
715
947
  function renderProject(p) {
716
948
  const isPinned = getPinnedOrder().includes(p.name);
717
949
  const anyNeedsTesting = p.sessions.some(s => s.needsTesting);
@@ -730,6 +962,7 @@ exports.DASHBOARD_HTML = `<!DOCTYPE html>
730
962
  \${infoRow}
731
963
  <div class="sessions">
732
964
  \${p.sessions.map(renderSession).join('')}
965
+ \${renderNotes(p.name, p.notes)}
733
966
  </div>
734
967
  </div>
735
968
  \`;
@@ -767,10 +1000,17 @@ exports.DASHBOARD_HTML = `<!DOCTYPE html>
767
1000
  allowAllBtn.style.display = 'none';
768
1001
  }
769
1002
 
770
- // Skip DOM update if user is typing in an input field
771
- if (document.activeElement && document.activeElement.tagName === 'INPUT') return;
1003
+ // Skip DOM update if user is focused on an input field
1004
+ const focused = document.activeElement;
1005
+ if (focused && focused.tagName === 'INPUT' && focused.classList.contains('reply-input')) return;
772
1006
 
773
- // Preserve expanded title state across re-renders
1007
+ // Preserve input values and cursor position across re-renders
1008
+ const savedInputs = new Map();
1009
+ const focusedId = focused?.id;
1010
+ const cursorPos = focused?.selectionStart;
1011
+ document.querySelectorAll('.reply-input').forEach(inp => {
1012
+ if (inp.id && inp.value.trim()) savedInputs.set(inp.id, inp.value);
1013
+ });
774
1014
  const expandedTitles = new Set();
775
1015
  document.querySelectorAll('.card-title.expanded').forEach(el => expandedTitles.add(el.id));
776
1016
 
@@ -785,10 +1025,32 @@ exports.DASHBOARD_HTML = `<!DOCTYPE html>
785
1025
  if (btn) btn.textContent = '▲';
786
1026
  }
787
1027
  });
1028
+
1029
+ // Restore input values and focus
1030
+ savedInputs.forEach((val, id) => {
1031
+ const inp = document.getElementById(id);
1032
+ if (inp) inp.value = val;
1033
+ });
1034
+ if (focusedId) {
1035
+ const el = document.getElementById(focusedId);
1036
+ if (el) { el.focus(); if (cursorPos != null) el.selectionStart = el.selectionEnd = cursorPos; }
1037
+ }
1038
+ }
1039
+
1040
+ function showSentBanner(card, icon, text, sessionId) {
1041
+ if (!card) return;
1042
+ // Track for persistent rendering across re-renders
1043
+ const sid = sessionId || card.querySelector('.card-title')?.id?.replace('title-', '');
1044
+ if (sid) {
1045
+ // Find full sessionId from data
1046
+ const fullId = data?.projects?.flatMap(p => p.sessions).find(s => s.sessionId.startsWith(sid))?.sessionId || sid;
1047
+ sentMessages.set(fullId, { icon, text, at: Date.now() });
1048
+ }
788
1049
  }
789
1050
 
790
1051
  async function respondTo(eventId, response, btn) {
791
1052
  if (btn) btn.disabled = true;
1053
+ const card = btn?.closest('.card');
792
1054
  try {
793
1055
  const res = await fetch('/api/v1/respond-to', {
794
1056
  method: 'POST',
@@ -796,6 +1058,8 @@ exports.DASHBOARD_HTML = `<!DOCTYPE html>
796
1058
  body: JSON.stringify({ eventId, response }),
797
1059
  });
798
1060
  if (res.ok) {
1061
+ const label = response === 'allow' ? '✓ Allowed' : response === 'deny' ? '✗ Denied' : response;
1062
+ showSentBanner(card, '↩', label);
799
1063
  fetchDashboard();
800
1064
  } else {
801
1065
  const err = await res.json();
@@ -823,6 +1087,9 @@ exports.DASHBOARD_HTML = `<!DOCTYPE html>
823
1087
  async function sendToSession(sessionId, text, btn) {
824
1088
  if (!text || !text.trim()) return;
825
1089
  if (btn) btn.disabled = true;
1090
+ const card = btn?.closest('.card');
1091
+ const input = card?.querySelector('input');
1092
+ if (input) { input.value = ''; input.blur(); }
826
1093
  try {
827
1094
  const res = await fetch('/api/v1/send-to', {
828
1095
  method: 'POST',
@@ -830,6 +1097,7 @@ exports.DASHBOARD_HTML = `<!DOCTYPE html>
830
1097
  body: JSON.stringify({ sessionId, text: text.trim() }),
831
1098
  });
832
1099
  if (res.ok) {
1100
+ showSentBanner(card, '▶', text.trim());
833
1101
  fetchDashboard();
834
1102
  } else {
835
1103
  const err = await res.json();
@@ -841,6 +1109,196 @@ exports.DASHBOARD_HTML = `<!DOCTYPE html>
841
1109
  if (btn) btn.disabled = false;
842
1110
  }
843
1111
 
1112
+ let addingNote = false;
1113
+ async function addNote(project, text, input) {
1114
+ if (!text || !text.trim() || addingNote) return;
1115
+ addingNote = true;
1116
+ if (input) { input.value = ''; input.blur(); }
1117
+ try {
1118
+ await fetch('/api/v1/notes', {
1119
+ method: 'POST',
1120
+ headers: { 'Content-Type': 'application/json' },
1121
+ body: JSON.stringify({ project, text: text.trim(), source: 'dashboard' }),
1122
+ });
1123
+ fetchDashboard();
1124
+ } catch (e) {
1125
+ console.error('add-note error:', e);
1126
+ }
1127
+ addingNote = false;
1128
+ }
1129
+
1130
+ async function handleNotePaste(event, project) {
1131
+ const items = event.clipboardData?.items;
1132
+ if (!items) return;
1133
+ for (const item of items) {
1134
+ if (item.type.startsWith('image/')) {
1135
+ event.preventDefault();
1136
+ const blob = item.getAsFile();
1137
+ if (!blob) return;
1138
+ const reader = new FileReader();
1139
+ reader.onload = async () => {
1140
+ const base64 = reader.result.split(',')[1];
1141
+ const text = event.target.value?.trim() || '';
1142
+ try {
1143
+ await fetch('/api/v1/notes/with-image', {
1144
+ method: 'POST',
1145
+ headers: { 'Content-Type': 'application/json' },
1146
+ body: JSON.stringify({ project, text: text || '(image)', imageBase64: base64, source: 'dashboard' }),
1147
+ });
1148
+ event.target.value = '';
1149
+ fetchDashboard();
1150
+ } catch (e) {
1151
+ console.error('paste-image error:', e);
1152
+ }
1153
+ };
1154
+ reader.readAsDataURL(blob);
1155
+ return;
1156
+ }
1157
+ }
1158
+ }
1159
+
1160
+ function openNoteImage(src) {
1161
+ const lb = document.createElement('div');
1162
+ lb.className = 'note-lightbox';
1163
+ const img = document.createElement('img');
1164
+ img.src = src;
1165
+ lb.appendChild(img);
1166
+ lb.onclick = () => lb.remove();
1167
+ document.body.appendChild(lb);
1168
+ }
1169
+
1170
+ function editNote(span, noteId) {
1171
+ const input = document.createElement('input');
1172
+ input.type = 'text';
1173
+ input.className = 'reply-input';
1174
+ input.value = span.textContent;
1175
+ input.style.cssText = 'flex:1;font-size:12px;';
1176
+ span.replaceWith(input);
1177
+ input.focus();
1178
+ input.select();
1179
+ async function save() {
1180
+ const text = input.value.trim();
1181
+ if (text && text !== span.textContent) {
1182
+ await fetch('/api/v1/notes/' + noteId, {
1183
+ method: 'PATCH',
1184
+ headers: { 'Content-Type': 'application/json' },
1185
+ body: JSON.stringify({ text }),
1186
+ });
1187
+ span.textContent = text;
1188
+ span.title = text;
1189
+ }
1190
+ input.replaceWith(span);
1191
+ }
1192
+ input.onblur = save;
1193
+ input.onkeydown = (e) => { if (e.key === 'Enter') { e.preventDefault(); save(); } if (e.key === 'Escape') input.replaceWith(span); };
1194
+ }
1195
+
1196
+ async function deleteNote(noteId) {
1197
+ try {
1198
+ await fetch('/api/v1/notes/' + noteId, { method: 'DELETE' });
1199
+ fetchDashboard();
1200
+ } catch (e) {
1201
+ console.error('delete-note error:', e);
1202
+ }
1203
+ }
1204
+
1205
+ async function sendNote(noteId, btn) {
1206
+ if (btn) btn.disabled = true;
1207
+ const noteText = btn?.closest('.note-item')?.querySelector('.note-text')?.textContent || '';
1208
+ try {
1209
+ const res = await fetch('/api/v1/notes/' + noteId + '/send', { method: 'POST' });
1210
+ const result = await res.json();
1211
+ if (!res.ok) {
1212
+ showToast(btn, result.error || 'Failed to send', true);
1213
+ } else {
1214
+ if (result.sessionId) {
1215
+ const card = document.querySelector('#title-' + result.sessionId.slice(0, 8))?.closest('.card');
1216
+ showSentBanner(card, '▶', noteText);
1217
+ }
1218
+ showToast(btn, 'Sent', false);
1219
+ }
1220
+ fetchDashboard();
1221
+ } catch (e) {
1222
+ console.error('send-note error:', e);
1223
+ }
1224
+ if (btn) btn.disabled = false;
1225
+ }
1226
+
1227
+ function getProjectNoteIds(project) {
1228
+ return Array.from(document.querySelectorAll('.note-item[data-project="' + project + '"]'))
1229
+ .map(el => el.dataset.noteId);
1230
+ }
1231
+
1232
+ async function saveNoteOrder(project) {
1233
+ const orderedIds = getProjectNoteIds(project);
1234
+ try {
1235
+ await fetch('/api/v1/notes/reorder', {
1236
+ method: 'PATCH',
1237
+ headers: { 'Content-Type': 'application/json' },
1238
+ body: JSON.stringify({ project, orderedIds }),
1239
+ });
1240
+ } catch (e) {
1241
+ console.error('reorder error:', e);
1242
+ }
1243
+ }
1244
+
1245
+ function moveNote(project, idx, direction) {
1246
+ const items = document.querySelectorAll('.note-item[data-project="' + project + '"]');
1247
+ const target = idx + direction;
1248
+ if (target < 0 || target >= items.length) return;
1249
+ const parent = items[0].parentNode;
1250
+ if (direction === -1) parent.insertBefore(items[idx], items[target]);
1251
+ else parent.insertBefore(items[target], items[idx]);
1252
+ saveNoteOrder(project);
1253
+ }
1254
+
1255
+ let draggedNote = null;
1256
+ function onNoteDragStart(e) {
1257
+ draggedNote = e.currentTarget;
1258
+ draggedNote.classList.add('dragging');
1259
+ e.dataTransfer.effectAllowed = 'move';
1260
+ }
1261
+ function onNoteDragOver(e) {
1262
+ e.preventDefault();
1263
+ const item = e.currentTarget;
1264
+ if (item !== draggedNote) item.classList.add('drag-over');
1265
+ }
1266
+ function onNoteDrop(e) {
1267
+ e.preventDefault();
1268
+ const target = e.currentTarget;
1269
+ target.classList.remove('drag-over');
1270
+ if (!draggedNote || target === draggedNote) return;
1271
+ const parent = target.parentNode;
1272
+ const items = Array.from(parent.querySelectorAll('.note-item'));
1273
+ const fromIdx = items.indexOf(draggedNote);
1274
+ const toIdx = items.indexOf(target);
1275
+ if (fromIdx < toIdx) parent.insertBefore(draggedNote, target.nextSibling);
1276
+ else parent.insertBefore(draggedNote, target);
1277
+ saveNoteOrder(draggedNote.dataset.project);
1278
+ }
1279
+ function onNoteDragEnd(e) {
1280
+ document.querySelectorAll('.drag-over').forEach(el => el.classList.remove('drag-over'));
1281
+ if (draggedNote) draggedNote.classList.remove('dragging');
1282
+ draggedNote = null;
1283
+ }
1284
+
1285
+ function showToast(anchor, text, isError) {
1286
+ const toast = document.createElement('span');
1287
+ toast.textContent = (isError ? '✗ ' : '✓ ') + text;
1288
+ toast.style.cssText = 'position:absolute;padding:3px 8px;border-radius:4px;font-size:10px;font-weight:600;z-index:10;pointer-events:none;'
1289
+ + (isError ? 'background:#490202;color:#f85149;' : 'background:#0d2818;color:#3fb950;');
1290
+ if (anchor) {
1291
+ const rect = anchor.getBoundingClientRect();
1292
+ toast.style.left = rect.left + 'px';
1293
+ toast.style.top = (rect.top - 24) + 'px';
1294
+ } else {
1295
+ toast.style.right = '24px';
1296
+ toast.style.top = '70px';
1297
+ }
1298
+ document.body.appendChild(toast);
1299
+ setTimeout(() => toast.remove(), 2000);
1300
+ }
1301
+
844
1302
  async function allowAll() {
845
1303
  if (!data) return;
846
1304
  const pending = [];
@@ -871,7 +1329,24 @@ exports.DASHBOARD_HTML = `<!DOCTYPE html>
871
1329
  }
872
1330
 
873
1331
  fetchDashboard();
874
- setInterval(fetchDashboard, 2000);
1332
+
1333
+ // SSE for instant push — polling as fallback
1334
+ let fallbackTimer = setInterval(fetchDashboard, 10000);
1335
+ function connectSSE() {
1336
+ const es = new EventSource('/api/v1/sse');
1337
+ es.addEventListener('refresh', () => fetchDashboard());
1338
+ es.onopen = () => {
1339
+ clearInterval(fallbackTimer);
1340
+ fallbackTimer = setInterval(fetchDashboard, 10000);
1341
+ };
1342
+ es.onerror = () => {
1343
+ es.close();
1344
+ clearInterval(fallbackTimer);
1345
+ fallbackTimer = setInterval(fetchDashboard, 2000);
1346
+ setTimeout(connectSSE, 3000);
1347
+ };
1348
+ }
1349
+ connectSSE();
875
1350
  </script>
876
1351
  </body>
877
1352
  </html>`;
@@ -1 +1 @@
1
- {"version":3,"file":"html.js","sourceRoot":"","sources":["../../src/dashboard/html.ts"],"names":[],"mappings":";;;AAAa,QAAA,cAAc,GAAG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;QAy2BtB,CAAC"}
1
+ {"version":3,"file":"html.js","sourceRoot":"","sources":["../../src/dashboard/html.ts"],"names":[],"mappings":";;;AAAa,QAAA,cAAc,GAAG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;QAo0CtB,CAAC"}
@@ -1 +1 @@
1
- {"version":3,"file":"routes.d.ts","sourceRoot":"","sources":["../../src/dashboard/routes.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,SAAS,CAAC;AAI/C,wBAAgB,uBAAuB,CAAC,GAAG,EAAE,eAAe,GAAG,IAAI,CAQlE"}
1
+ {"version":3,"file":"routes.d.ts","sourceRoot":"","sources":["../../src/dashboard/routes.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,SAAS,CAAC;AAK/C,wBAAgB,uBAAuB,CAAC,GAAG,EAAE,eAAe,GAAG,IAAI,CAoBlE"}
@@ -3,10 +3,22 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.registerDashboardRoutes = registerDashboardRoutes;
4
4
  const enricher_js_1 = require("./enricher.js");
5
5
  const html_js_1 = require("./html.js");
6
+ const sse_js_1 = require("./sse.js");
6
7
  function registerDashboardRoutes(app) {
7
8
  app.get('/api/v1/dashboard', async () => {
8
9
  return (0, enricher_js_1.getDashboardData)();
9
10
  });
11
+ app.get('/api/v1/sse', async (request, reply) => {
12
+ reply.hijack();
13
+ request.raw.setTimeout(0);
14
+ reply.raw.writeHead(200, {
15
+ 'Content-Type': 'text/event-stream',
16
+ 'Cache-Control': 'no-cache',
17
+ Connection: 'keep-alive',
18
+ });
19
+ reply.raw.write(':\n\n');
20
+ (0, sse_js_1.addSSEClient)(reply);
21
+ });
10
22
  app.get('/dashboard', async (_request, reply) => {
11
23
  reply.type('text/html').send(html_js_1.DASHBOARD_HTML);
12
24
  });
@@ -1 +1 @@
1
- {"version":3,"file":"routes.js","sourceRoot":"","sources":["../../src/dashboard/routes.ts"],"names":[],"mappings":";;AAIA,0DAQC;AAXD,+CAAiD;AACjD,uCAA2C;AAE3C,SAAgB,uBAAuB,CAAC,GAAoB;IAC1D,GAAG,CAAC,GAAG,CAAC,mBAAmB,EAAE,KAAK,IAAI,EAAE;QACtC,OAAO,IAAA,8BAAgB,GAAE,CAAC;IAC5B,CAAC,CAAC,CAAC;IAEH,GAAG,CAAC,GAAG,CAAC,YAAY,EAAE,KAAK,EAAE,QAAQ,EAAE,KAAK,EAAE,EAAE;QAC9C,KAAK,CAAC,IAAI,CAAC,WAAW,CAAC,CAAC,IAAI,CAAC,wBAAc,CAAC,CAAC;IAC/C,CAAC,CAAC,CAAC;AACL,CAAC"}
1
+ {"version":3,"file":"routes.js","sourceRoot":"","sources":["../../src/dashboard/routes.ts"],"names":[],"mappings":";;AAKA,0DAoBC;AAxBD,+CAAiD;AACjD,uCAA2C;AAC3C,qCAAwC;AAExC,SAAgB,uBAAuB,CAAC,GAAoB;IAC1D,GAAG,CAAC,GAAG,CAAC,mBAAmB,EAAE,KAAK,IAAI,EAAE;QACtC,OAAO,IAAA,8BAAgB,GAAE,CAAC;IAC5B,CAAC,CAAC,CAAC;IAEH,GAAG,CAAC,GAAG,CAAC,aAAa,EAAE,KAAK,EAAE,OAAO,EAAE,KAAK,EAAE,EAAE;QAC9C,KAAK,CAAC,MAAM,EAAE,CAAC;QACf,OAAO,CAAC,GAAG,CAAC,UAAU,CAAC,CAAC,CAAC,CAAC;QAC1B,KAAK,CAAC,GAAG,CAAC,SAAS,CAAC,GAAG,EAAE;YACvB,cAAc,EAAE,mBAAmB;YACnC,eAAe,EAAE,UAAU;YAC3B,UAAU,EAAE,YAAY;SACzB,CAAC,CAAC;QACH,KAAK,CAAC,GAAG,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC;QACzB,IAAA,qBAAY,EAAC,KAAK,CAAC,CAAC;IACtB,CAAC,CAAC,CAAC;IAEH,GAAG,CAAC,GAAG,CAAC,YAAY,EAAE,KAAK,EAAE,QAAQ,EAAE,KAAK,EAAE,EAAE;QAC9C,KAAK,CAAC,IAAI,CAAC,WAAW,CAAC,CAAC,IAAI,CAAC,wBAAc,CAAC,CAAC;IAC/C,CAAC,CAAC,CAAC;AACL,CAAC"}
@@ -0,0 +1,5 @@
1
+ import type { FastifyReply } from 'fastify';
2
+ export declare function addSSEClient(reply: FastifyReply): void;
3
+ export declare function broadcastSSE(eventType?: string): void;
4
+ export declare function sseClientCount(): number;
5
+ //# sourceMappingURL=sse.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sse.d.ts","sourceRoot":"","sources":["../../src/dashboard/sse.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;AAsB5C,wBAAgB,YAAY,CAAC,KAAK,EAAE,YAAY,GAAG,IAAI,CAItD;AAED,wBAAgB,YAAY,CAAC,SAAS,SAAY,GAAG,IAAI,CASxD;AAED,wBAAgB,cAAc,IAAI,MAAM,CAEvC"}
@@ -0,0 +1,45 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.addSSEClient = addSSEClient;
4
+ exports.broadcastSSE = broadcastSSE;
5
+ exports.sseClientCount = sseClientCount;
6
+ const clients = new Set();
7
+ let heartbeatTimer = null;
8
+ function startHeartbeat() {
9
+ if (heartbeatTimer)
10
+ return;
11
+ heartbeatTimer = setInterval(() => {
12
+ for (const client of clients) {
13
+ try {
14
+ client.raw.write(':\n\n');
15
+ }
16
+ catch {
17
+ clients.delete(client);
18
+ }
19
+ }
20
+ if (clients.size === 0 && heartbeatTimer) {
21
+ clearInterval(heartbeatTimer);
22
+ heartbeatTimer = null;
23
+ }
24
+ }, 15_000);
25
+ }
26
+ function addSSEClient(reply) {
27
+ clients.add(reply);
28
+ reply.raw.on('close', () => clients.delete(reply));
29
+ startHeartbeat();
30
+ }
31
+ function broadcastSSE(eventType = 'refresh') {
32
+ const payload = `event: ${eventType}\ndata: ${Date.now()}\n\n`;
33
+ for (const client of clients) {
34
+ try {
35
+ client.raw.write(payload);
36
+ }
37
+ catch {
38
+ clients.delete(client);
39
+ }
40
+ }
41
+ }
42
+ function sseClientCount() {
43
+ return clients.size;
44
+ }
45
+ //# sourceMappingURL=sse.js.map