taskplane 0.1.5 → 0.1.6
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/dashboard/public/app.js +284 -40
- package/dashboard/public/index.html +8 -2
- package/dashboard/public/style.css +186 -2
- package/dashboard/server.cjs +54 -0
- package/package.json +1 -1
package/dashboard/public/app.js
CHANGED
|
@@ -142,6 +142,11 @@ const $historyBody = $("history-body");
|
|
|
142
142
|
let historyList = []; // compact batch summaries
|
|
143
143
|
let viewingHistoryId = null; // batchId if viewing history, null if live
|
|
144
144
|
|
|
145
|
+
// ─── Viewer State ───────────────────────────────────────────────────────────
|
|
146
|
+
|
|
147
|
+
let viewerMode = null; // "conversation" | "status-md" | null
|
|
148
|
+
let viewerTarget = null; // session name (conversation) or taskId (status-md)
|
|
149
|
+
|
|
145
150
|
// ─── Render: Header ─────────────────────────────────────────────────────────
|
|
146
151
|
|
|
147
152
|
function renderHeader(batch) {
|
|
@@ -300,7 +305,8 @@ function renderLanesTasks(batch, tmuxSessions) {
|
|
|
300
305
|
html += ` <div class="lane-right">`;
|
|
301
306
|
html += ` <span class="tmux-dot ${alive ? "alive" : "dead"}" title="${alive ? "tmux alive" : "tmux dead"}"></span>`;
|
|
302
307
|
// View button: shows conversation stream if available, else tmux pane
|
|
303
|
-
|
|
308
|
+
const isViewingConv = viewerMode === 'conversation' && viewerTarget === lane.tmuxSessionName;
|
|
309
|
+
html += ` <button class="tmux-view-btn${isViewingConv ? ' active' : ''}" onclick="viewConversation('${escapeHtml(lane.tmuxSessionName)}')" title="View worker conversation">👁 View</button>`;
|
|
304
310
|
if (alive) {
|
|
305
311
|
html += ` <span class="tmux-cmd" data-tmux="${escapeHtml(lane.tmuxSessionName)}" onclick="copyTmuxCmd('${escapeHtml(lane.tmuxSessionName)}')" title="Click to copy">${escapeHtml(tmuxCmd)}</span>`;
|
|
306
312
|
} else {
|
|
@@ -387,9 +393,15 @@ function renderLanesTasks(batch, tmuxSessions) {
|
|
|
387
393
|
workerHtml = `<div class="worker-stats"><span class="worker-stat" style="color:var(--red)">✗ Worker error</span></div>`;
|
|
388
394
|
}
|
|
389
395
|
|
|
396
|
+
const isViewingStatus = viewerMode === 'status-md' && viewerTarget === task.taskId;
|
|
397
|
+
const eyeHtml = task.status !== 'pending'
|
|
398
|
+
? `<button class="viewer-eye-btn${isViewingStatus ? ' active' : ''}" onclick="viewStatusMd('${escapeHtml(task.taskId)}')" title="View STATUS.md">👁</button>`
|
|
399
|
+
: '';
|
|
400
|
+
|
|
390
401
|
html += `
|
|
391
402
|
<div class="task-row">
|
|
392
403
|
<span class="task-icon"><span class="status-dot ${task.status}"></span></span>
|
|
404
|
+
<span class="task-actions">${eyeHtml}</span>
|
|
393
405
|
<span class="task-id status-${task.status}">${escapeHtml(task.taskId)}</span>
|
|
394
406
|
<span><span class="status-badge status-${task.status}"><span class="status-dot ${task.status}"></span> ${task.status}</span></span>
|
|
395
407
|
<span class="task-duration">${dur}</span>
|
|
@@ -601,65 +613,236 @@ function connect() {
|
|
|
601
613
|
};
|
|
602
614
|
}
|
|
603
615
|
|
|
604
|
-
// ───
|
|
616
|
+
// ─── Viewer Panel (Conversation + STATUS.md) ────────────────────────────────
|
|
605
617
|
|
|
606
618
|
const $terminalPanel = document.getElementById("terminal-panel");
|
|
607
619
|
const $terminalTitle = document.getElementById("terminal-title");
|
|
608
620
|
const $terminalBody = document.getElementById("terminal-body");
|
|
609
621
|
const $terminalClose = document.getElementById("terminal-close");
|
|
622
|
+
const $autoScrollCheckbox = document.getElementById("auto-scroll-checkbox");
|
|
623
|
+
const $autoScrollText = document.getElementById("auto-scroll-text");
|
|
624
|
+
|
|
625
|
+
// Viewer state
|
|
626
|
+
let viewerTimer = null;
|
|
627
|
+
let autoScrollOn = false;
|
|
628
|
+
let isProgrammaticScroll = false;
|
|
629
|
+
|
|
630
|
+
// Conversation append-only state
|
|
631
|
+
let convRenderedLines = 0;
|
|
610
632
|
|
|
611
|
-
|
|
612
|
-
let
|
|
633
|
+
// STATUS.md diff-and-skip state
|
|
634
|
+
let lastStatusMdText = "";
|
|
635
|
+
|
|
636
|
+
// ── Open conversation viewer ────────────────────────────────────────────────
|
|
613
637
|
|
|
614
638
|
function viewConversation(sessionName) {
|
|
615
|
-
|
|
639
|
+
// Toggle off if already viewing this session
|
|
640
|
+
if (viewerMode === 'conversation' && viewerTarget === sessionName && $terminalPanel.style.display !== 'none') {
|
|
641
|
+
closeViewer();
|
|
642
|
+
return;
|
|
643
|
+
}
|
|
616
644
|
|
|
617
|
-
|
|
645
|
+
closeViewer();
|
|
646
|
+
|
|
647
|
+
viewerMode = 'conversation';
|
|
648
|
+
viewerTarget = sessionName;
|
|
649
|
+
autoScrollOn = true;
|
|
650
|
+
convRenderedLines = 0;
|
|
618
651
|
|
|
619
|
-
activeSession = sessionName;
|
|
620
652
|
$terminalTitle.textContent = `Worker Conversation — ${sessionName}`;
|
|
621
|
-
$
|
|
653
|
+
$autoScrollText.textContent = 'Follow feed';
|
|
654
|
+
$autoScrollCheckbox.checked = true;
|
|
655
|
+
$terminalPanel.style.display = '';
|
|
656
|
+
$terminalBody.innerHTML = '<div class="conv-stream"></div>';
|
|
622
657
|
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
conversationTimer = setInterval(() => loadConversation(sessionName), 2000);
|
|
658
|
+
pollConversation();
|
|
659
|
+
viewerTimer = setInterval(pollConversation, 2000);
|
|
626
660
|
|
|
627
|
-
$terminalPanel.scrollIntoView({ behavior:
|
|
661
|
+
$terminalPanel.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
|
628
662
|
}
|
|
629
663
|
|
|
630
|
-
function
|
|
631
|
-
fetch(`/api/conversation/${encodeURIComponent(
|
|
664
|
+
function pollConversation() {
|
|
665
|
+
fetch(`/api/conversation/${encodeURIComponent(viewerTarget)}`)
|
|
632
666
|
.then(r => r.text())
|
|
633
|
-
.then(text =>
|
|
667
|
+
.then(text => {
|
|
668
|
+
if (!text.trim()) {
|
|
669
|
+
if (convRenderedLines === 0) {
|
|
670
|
+
$terminalBody.innerHTML = '<div class="conv-empty">No conversation events yet…</div>';
|
|
671
|
+
}
|
|
672
|
+
return;
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
const lines = text.trim().split('\n');
|
|
676
|
+
|
|
677
|
+
// File was reset (new task on same lane) — full re-render
|
|
678
|
+
if (lines.length < convRenderedLines) {
|
|
679
|
+
convRenderedLines = 0;
|
|
680
|
+
const container = $terminalBody.querySelector('.conv-stream');
|
|
681
|
+
if (container) container.innerHTML = '';
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
// Nothing new
|
|
685
|
+
if (lines.length === convRenderedLines) return;
|
|
686
|
+
|
|
687
|
+
// Ensure container exists
|
|
688
|
+
let container = $terminalBody.querySelector('.conv-stream');
|
|
689
|
+
if (!container) {
|
|
690
|
+
$terminalBody.innerHTML = '';
|
|
691
|
+
container = document.createElement('div');
|
|
692
|
+
container.className = 'conv-stream';
|
|
693
|
+
$terminalBody.appendChild(container);
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
// Append only new events
|
|
697
|
+
const newLines = lines.slice(convRenderedLines);
|
|
698
|
+
for (const line of newLines) {
|
|
699
|
+
try {
|
|
700
|
+
const event = JSON.parse(line);
|
|
701
|
+
const html = renderConvEvent(event);
|
|
702
|
+
if (html) container.insertAdjacentHTML('beforeend', html);
|
|
703
|
+
} catch { continue; }
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
convRenderedLines = lines.length;
|
|
707
|
+
|
|
708
|
+
// Auto-scroll to bottom
|
|
709
|
+
if (autoScrollOn) {
|
|
710
|
+
isProgrammaticScroll = true;
|
|
711
|
+
$terminalBody.scrollTop = $terminalBody.scrollHeight;
|
|
712
|
+
requestAnimationFrame(() => { isProgrammaticScroll = false; });
|
|
713
|
+
}
|
|
714
|
+
})
|
|
634
715
|
.catch(() => {});
|
|
635
716
|
}
|
|
636
717
|
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
718
|
+
// ── Open STATUS.md viewer ───────────────────────────────────────────────────
|
|
719
|
+
|
|
720
|
+
function viewStatusMd(taskId) {
|
|
721
|
+
// Toggle off if already viewing this task
|
|
722
|
+
if (viewerMode === 'status-md' && viewerTarget === taskId && $terminalPanel.style.display !== 'none') {
|
|
723
|
+
closeViewer();
|
|
640
724
|
return;
|
|
641
725
|
}
|
|
642
726
|
|
|
643
|
-
|
|
644
|
-
let html = '<div class="conv-stream">';
|
|
727
|
+
closeViewer();
|
|
645
728
|
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
729
|
+
viewerMode = 'status-md';
|
|
730
|
+
viewerTarget = taskId;
|
|
731
|
+
autoScrollOn = false;
|
|
732
|
+
lastStatusMdText = '';
|
|
733
|
+
|
|
734
|
+
$terminalTitle.textContent = `STATUS.md — ${taskId}`;
|
|
735
|
+
$autoScrollText.textContent = 'Track progress';
|
|
736
|
+
$autoScrollCheckbox.checked = false;
|
|
737
|
+
$terminalPanel.style.display = '';
|
|
738
|
+
$terminalBody.innerHTML = '<div class="conv-empty">Loading…</div>';
|
|
739
|
+
|
|
740
|
+
pollStatusMd();
|
|
741
|
+
viewerTimer = setInterval(pollStatusMd, 2000);
|
|
742
|
+
|
|
743
|
+
$terminalPanel.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
function pollStatusMd() {
|
|
747
|
+
fetch(`/api/status-md/${encodeURIComponent(viewerTarget)}`)
|
|
748
|
+
.then(r => {
|
|
749
|
+
if (!r.ok) throw new Error('not found');
|
|
750
|
+
return r.text();
|
|
751
|
+
})
|
|
752
|
+
.then(text => {
|
|
753
|
+
// Diff-and-skip: no change, no DOM update
|
|
754
|
+
if (text === lastStatusMdText) return;
|
|
755
|
+
lastStatusMdText = text;
|
|
756
|
+
|
|
757
|
+
const { html, hasLastChecked } = renderStatusMd(text);
|
|
758
|
+
$terminalBody.innerHTML = html;
|
|
759
|
+
|
|
760
|
+
// Update tracking highlight
|
|
761
|
+
updateTrackingHighlight();
|
|
762
|
+
|
|
763
|
+
// Auto-scroll to last checked item
|
|
764
|
+
if (autoScrollOn && hasLastChecked) {
|
|
765
|
+
scrollToLastChecked();
|
|
766
|
+
}
|
|
767
|
+
})
|
|
768
|
+
.catch(() => {
|
|
769
|
+
if (!lastStatusMdText) {
|
|
770
|
+
$terminalBody.innerHTML = '<div class="conv-empty">STATUS.md not found</div>';
|
|
771
|
+
}
|
|
772
|
+
});
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
// ── STATUS.md renderer ──────────────────────────────────────────────────────
|
|
776
|
+
|
|
777
|
+
function renderStatusMd(markdown) {
|
|
778
|
+
const lines = markdown.split('\n');
|
|
779
|
+
let lastCheckedIdx = -1;
|
|
780
|
+
|
|
781
|
+
// First pass: find last checked item
|
|
782
|
+
for (let i = 0; i < lines.length; i++) {
|
|
783
|
+
if (/^\s*-\s*\[x\]/i.test(lines[i])) lastCheckedIdx = i;
|
|
651
784
|
}
|
|
652
785
|
|
|
653
|
-
html
|
|
786
|
+
let html = '<div class="status-md-content">';
|
|
787
|
+
|
|
788
|
+
for (let i = 0; i < lines.length; i++) {
|
|
789
|
+
const line = lines[i];
|
|
654
790
|
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
791
|
+
// Headings
|
|
792
|
+
const hMatch = line.match(/^(#{1,6})\s+(.+)/);
|
|
793
|
+
if (hMatch) {
|
|
794
|
+
const lvl = Math.min(hMatch[1].length, 4);
|
|
795
|
+
html += `<div class="status-md-h${lvl}">${renderInlineMd(hMatch[2])}</div>`;
|
|
796
|
+
continue;
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
// Checked checkbox
|
|
800
|
+
if (/^\s*-\s*\[x\]/i.test(line)) {
|
|
801
|
+
const text = line.replace(/^\s*-\s*\[x\]\s*/i, '');
|
|
802
|
+
const isLast = i === lastCheckedIdx;
|
|
803
|
+
const cls = isLast ? 'status-md-check checked last-checked' : 'status-md-check checked';
|
|
804
|
+
const id = isLast ? ' id="last-checked"' : '';
|
|
805
|
+
html += `<div class="${cls}"${id}><span class="check-box">☑</span><span>${renderInlineMd(text)}</span></div>`;
|
|
806
|
+
continue;
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
// Unchecked checkbox
|
|
810
|
+
if (/^\s*-\s*\[\s\]/.test(line)) {
|
|
811
|
+
const text = line.replace(/^\s*-\s*\[\s\]\s*/, '');
|
|
812
|
+
html += `<div class="status-md-check unchecked"><span class="check-box">☐</span><span>${renderInlineMd(text)}</span></div>`;
|
|
813
|
+
continue;
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
// List item
|
|
817
|
+
const liMatch = line.match(/^\s*-\s+(.*)/);
|
|
818
|
+
if (liMatch) {
|
|
819
|
+
html += `<div class="status-md-li">• ${renderInlineMd(liMatch[1])}</div>`;
|
|
820
|
+
continue;
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
// Empty line
|
|
824
|
+
if (!line.trim()) {
|
|
825
|
+
html += '<div class="status-md-spacer"></div>';
|
|
826
|
+
continue;
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
// Plain text
|
|
830
|
+
html += `<div class="status-md-text">${renderInlineMd(line)}</div>`;
|
|
660
831
|
}
|
|
832
|
+
|
|
833
|
+
html += '</div>';
|
|
834
|
+
return { html, hasLastChecked: lastCheckedIdx >= 0 };
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
function renderInlineMd(text) {
|
|
838
|
+
let s = escapeHtml(text);
|
|
839
|
+
s = s.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
|
|
840
|
+
s = s.replace(/`(.+?)`/g, '<code class="status-md-code">$1</code>');
|
|
841
|
+
return s;
|
|
661
842
|
}
|
|
662
843
|
|
|
844
|
+
// ── Conversation event renderer ─────────────────────────────────────────────
|
|
845
|
+
|
|
663
846
|
function renderConvEvent(event) {
|
|
664
847
|
switch (event.type) {
|
|
665
848
|
case "message_update": {
|
|
@@ -705,20 +888,81 @@ function renderConvEvent(event) {
|
|
|
705
888
|
}
|
|
706
889
|
}
|
|
707
890
|
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
891
|
+
// ── Auto-scroll logic ───────────────────────────────────────────────────────
|
|
892
|
+
|
|
893
|
+
function scrollToLastChecked() {
|
|
894
|
+
const el = document.getElementById('last-checked');
|
|
895
|
+
if (!el) return;
|
|
896
|
+
isProgrammaticScroll = true;
|
|
897
|
+
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
898
|
+
setTimeout(() => { isProgrammaticScroll = false; }, 600);
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
function updateTrackingHighlight() {
|
|
902
|
+
const container = $terminalBody.querySelector('.status-md-content');
|
|
903
|
+
if (container) {
|
|
904
|
+
container.classList.toggle('tracking', autoScrollOn && viewerMode === 'status-md');
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
$autoScrollCheckbox.addEventListener('change', () => {
|
|
909
|
+
autoScrollOn = $autoScrollCheckbox.checked;
|
|
910
|
+
if (autoScrollOn) {
|
|
911
|
+
if (viewerMode === 'conversation') {
|
|
912
|
+
isProgrammaticScroll = true;
|
|
913
|
+
$terminalBody.scrollTop = $terminalBody.scrollHeight;
|
|
914
|
+
requestAnimationFrame(() => { isProgrammaticScroll = false; });
|
|
915
|
+
} else if (viewerMode === 'status-md') {
|
|
916
|
+
scrollToLastChecked();
|
|
917
|
+
updateTrackingHighlight();
|
|
918
|
+
}
|
|
919
|
+
} else {
|
|
920
|
+
updateTrackingHighlight();
|
|
921
|
+
}
|
|
922
|
+
});
|
|
923
|
+
|
|
924
|
+
$terminalBody.addEventListener('scroll', () => {
|
|
925
|
+
if (isProgrammaticScroll) return;
|
|
926
|
+
|
|
927
|
+
if (viewerMode === 'conversation') {
|
|
928
|
+
const isAtBottom = $terminalBody.scrollTop + $terminalBody.clientHeight >= $terminalBody.scrollHeight - 30;
|
|
929
|
+
if (isAtBottom && !autoScrollOn) {
|
|
930
|
+
autoScrollOn = true;
|
|
931
|
+
$autoScrollCheckbox.checked = true;
|
|
932
|
+
} else if (!isAtBottom && autoScrollOn) {
|
|
933
|
+
autoScrollOn = false;
|
|
934
|
+
$autoScrollCheckbox.checked = false;
|
|
935
|
+
}
|
|
936
|
+
} else if (viewerMode === 'status-md') {
|
|
937
|
+
if (autoScrollOn) {
|
|
938
|
+
autoScrollOn = false;
|
|
939
|
+
$autoScrollCheckbox.checked = false;
|
|
940
|
+
updateTrackingHighlight();
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
});
|
|
944
|
+
|
|
945
|
+
// ── Close viewer ────────────────────────────────────────────────────────────
|
|
946
|
+
|
|
947
|
+
function closeViewer() {
|
|
948
|
+
if (viewerTimer) {
|
|
949
|
+
clearInterval(viewerTimer);
|
|
950
|
+
viewerTimer = null;
|
|
712
951
|
}
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
952
|
+
viewerMode = null;
|
|
953
|
+
viewerTarget = null;
|
|
954
|
+
autoScrollOn = false;
|
|
955
|
+
convRenderedLines = 0;
|
|
956
|
+
lastStatusMdText = '';
|
|
957
|
+
$terminalPanel.style.display = 'none';
|
|
958
|
+
$terminalBody.innerHTML = '';
|
|
716
959
|
}
|
|
717
960
|
|
|
718
|
-
$terminalClose.addEventListener(
|
|
961
|
+
$terminalClose.addEventListener('click', closeViewer);
|
|
719
962
|
|
|
720
|
-
// Make
|
|
963
|
+
// Make viewer functions available globally for onclick handlers
|
|
721
964
|
window.viewConversation = viewConversation;
|
|
965
|
+
window.viewStatusMd = viewStatusMd;
|
|
722
966
|
|
|
723
967
|
// ─── History ────────────────────────────────────────────────────────────────
|
|
724
968
|
|
|
@@ -62,11 +62,17 @@
|
|
|
62
62
|
</div>
|
|
63
63
|
</div>
|
|
64
64
|
|
|
65
|
-
<!--
|
|
65
|
+
<!-- Viewer Panel: Conversation + STATUS.md (hidden until activated) -->
|
|
66
66
|
<div class="panel terminal-panel" id="terminal-panel" style="display:none;">
|
|
67
67
|
<div class="panel-header">
|
|
68
68
|
<span id="terminal-title">Terminal</span>
|
|
69
|
-
<
|
|
69
|
+
<div class="terminal-controls">
|
|
70
|
+
<label class="auto-scroll-label">
|
|
71
|
+
<input type="checkbox" id="auto-scroll-checkbox">
|
|
72
|
+
<span id="auto-scroll-text">Follow feed</span>
|
|
73
|
+
</label>
|
|
74
|
+
<button class="terminal-close" id="terminal-close" title="Close viewer">✕</button>
|
|
75
|
+
</div>
|
|
70
76
|
</div>
|
|
71
77
|
<div class="panel-body" id="terminal-body"></div>
|
|
72
78
|
</div>
|
|
@@ -439,7 +439,7 @@ body {
|
|
|
439
439
|
|
|
440
440
|
.task-row {
|
|
441
441
|
display: grid;
|
|
442
|
-
grid-template-columns: 36px 100px 90px 80px 200px 1fr;
|
|
442
|
+
grid-template-columns: 36px 24px 100px 90px 80px 200px 1fr;
|
|
443
443
|
align-items: center;
|
|
444
444
|
gap: 8px;
|
|
445
445
|
padding: 8px 14px;
|
|
@@ -700,6 +700,190 @@ body {
|
|
|
700
700
|
color: var(--text-faint);
|
|
701
701
|
}
|
|
702
702
|
|
|
703
|
+
/* ─── Viewer Eye Button (task row) ─────────────────────────────────────── */
|
|
704
|
+
|
|
705
|
+
.task-actions {
|
|
706
|
+
display: flex;
|
|
707
|
+
align-items: center;
|
|
708
|
+
justify-content: center;
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
.viewer-eye-btn {
|
|
712
|
+
background: none;
|
|
713
|
+
border: none;
|
|
714
|
+
cursor: pointer;
|
|
715
|
+
font-size: 0.75rem;
|
|
716
|
+
padding: 2px;
|
|
717
|
+
border-radius: var(--radius-sm);
|
|
718
|
+
opacity: 0.35;
|
|
719
|
+
transition: opacity 0.2s, background 0.2s;
|
|
720
|
+
line-height: 1;
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
.viewer-eye-btn:hover {
|
|
724
|
+
opacity: 0.8;
|
|
725
|
+
background: rgba(88,166,255,0.1);
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
.viewer-eye-btn.active {
|
|
729
|
+
opacity: 1;
|
|
730
|
+
background: rgba(88,166,255,0.15);
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
.tmux-view-btn.active {
|
|
734
|
+
background: var(--accent);
|
|
735
|
+
border-color: var(--accent);
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
/* ─── Viewer Controls (auto-scroll checkbox + close) ───────────────────── */
|
|
739
|
+
|
|
740
|
+
.terminal-controls {
|
|
741
|
+
display: flex;
|
|
742
|
+
align-items: center;
|
|
743
|
+
gap: 12px;
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
.auto-scroll-label {
|
|
747
|
+
display: flex;
|
|
748
|
+
align-items: center;
|
|
749
|
+
gap: 6px;
|
|
750
|
+
cursor: pointer;
|
|
751
|
+
font-size: 0.75rem;
|
|
752
|
+
color: var(--text-muted);
|
|
753
|
+
user-select: none;
|
|
754
|
+
white-space: nowrap;
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
.auto-scroll-label input[type="checkbox"] {
|
|
758
|
+
appearance: none;
|
|
759
|
+
-webkit-appearance: none;
|
|
760
|
+
width: 14px;
|
|
761
|
+
height: 14px;
|
|
762
|
+
border: 1px solid var(--border);
|
|
763
|
+
border-radius: 3px;
|
|
764
|
+
background: var(--bg-inset);
|
|
765
|
+
cursor: pointer;
|
|
766
|
+
position: relative;
|
|
767
|
+
flex-shrink: 0;
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
.auto-scroll-label input[type="checkbox"]:checked {
|
|
771
|
+
background: var(--accent-dim);
|
|
772
|
+
border-color: var(--accent);
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
.auto-scroll-label input[type="checkbox"]:checked::after {
|
|
776
|
+
content: '✓';
|
|
777
|
+
position: absolute;
|
|
778
|
+
top: -1px;
|
|
779
|
+
left: 2px;
|
|
780
|
+
font-size: 11px;
|
|
781
|
+
color: #fff;
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
/* ─── STATUS.md Rendered Content ───────────────────────────────────────── */
|
|
785
|
+
|
|
786
|
+
.status-md-content {
|
|
787
|
+
padding: 12px 16px;
|
|
788
|
+
font-family: var(--font-sans);
|
|
789
|
+
font-size: 0.85rem;
|
|
790
|
+
line-height: 1.6;
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
.status-md-h1 {
|
|
794
|
+
font-size: 1.15rem;
|
|
795
|
+
font-weight: 700;
|
|
796
|
+
color: var(--text);
|
|
797
|
+
margin: 16px 0 8px;
|
|
798
|
+
padding-bottom: 4px;
|
|
799
|
+
border-bottom: 1px solid var(--border-subtle);
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
.status-md-h1:first-child { margin-top: 0; }
|
|
803
|
+
|
|
804
|
+
.status-md-h2 {
|
|
805
|
+
font-size: 1rem;
|
|
806
|
+
font-weight: 600;
|
|
807
|
+
color: var(--text);
|
|
808
|
+
margin: 14px 0 6px;
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
.status-md-h3 {
|
|
812
|
+
font-size: 0.9rem;
|
|
813
|
+
font-weight: 600;
|
|
814
|
+
color: var(--text-muted);
|
|
815
|
+
margin: 12px 0 4px;
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
.status-md-h4 {
|
|
819
|
+
font-size: 0.85rem;
|
|
820
|
+
font-weight: 600;
|
|
821
|
+
color: var(--text-muted);
|
|
822
|
+
margin: 10px 0 4px;
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
.status-md-check {
|
|
826
|
+
display: flex;
|
|
827
|
+
align-items: flex-start;
|
|
828
|
+
gap: 8px;
|
|
829
|
+
padding: 3px 8px;
|
|
830
|
+
border-radius: var(--radius-sm);
|
|
831
|
+
border-left: 2px solid transparent;
|
|
832
|
+
transition: background 0.2s, border-color 0.2s;
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
.status-md-check.checked {
|
|
836
|
+
color: var(--text-muted);
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
.status-md-check.checked .check-box {
|
|
840
|
+
color: var(--green);
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
.status-md-check.unchecked {
|
|
844
|
+
color: var(--text);
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
.status-md-check.unchecked .check-box {
|
|
848
|
+
color: var(--text-faint);
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
.check-box {
|
|
852
|
+
flex-shrink: 0;
|
|
853
|
+
font-size: 0.85rem;
|
|
854
|
+
line-height: 1.6;
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
/* Progress frontier highlight — only when tracking is active */
|
|
858
|
+
.status-md-content.tracking .status-md-check.last-checked {
|
|
859
|
+
border-left-color: var(--accent);
|
|
860
|
+
background: rgba(88, 166, 255, 0.06);
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
.status-md-li {
|
|
864
|
+
padding: 2px 8px;
|
|
865
|
+
color: var(--text);
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
.status-md-text {
|
|
869
|
+
padding: 2px 8px;
|
|
870
|
+
color: var(--text);
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
.status-md-spacer {
|
|
874
|
+
height: 8px;
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
.status-md-code {
|
|
878
|
+
font-family: var(--font-mono);
|
|
879
|
+
font-size: 0.8rem;
|
|
880
|
+
padding: 1px 5px;
|
|
881
|
+
background: var(--bg-surface);
|
|
882
|
+
border: 1px solid var(--border-subtle);
|
|
883
|
+
border-radius: 3px;
|
|
884
|
+
color: var(--accent);
|
|
885
|
+
}
|
|
886
|
+
|
|
703
887
|
/* ─── Errors Panel ─────────────────────────────────────────────────────── */
|
|
704
888
|
|
|
705
889
|
.errors-panel .panel-header { color: var(--red); }
|
|
@@ -750,7 +934,7 @@ body {
|
|
|
750
934
|
|
|
751
935
|
@media (max-width: 900px) {
|
|
752
936
|
.task-row {
|
|
753
|
-
grid-template-columns: 36px 90px 80px 70px 1fr;
|
|
937
|
+
grid-template-columns: 36px 24px 90px 80px 70px 1fr;
|
|
754
938
|
}
|
|
755
939
|
.task-row .task-step { display: none; }
|
|
756
940
|
.progress-bar-bg { width: 160px; }
|
package/dashboard/server.cjs
CHANGED
|
@@ -426,6 +426,57 @@ function serveHistoryEntry(req, res, batchId) {
|
|
|
426
426
|
res.end(JSON.stringify(entry));
|
|
427
427
|
}
|
|
428
428
|
|
|
429
|
+
/** GET /api/status-md/:taskId — return raw STATUS.md content for a task. */
|
|
430
|
+
function serveStatusMd(req, res, taskId) {
|
|
431
|
+
if (!/^[\w-]+$/.test(taskId)) {
|
|
432
|
+
res.writeHead(400, { "Content-Type": "text/plain" });
|
|
433
|
+
res.end("Invalid task ID");
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
const state = loadBatchState();
|
|
438
|
+
if (!state) {
|
|
439
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
440
|
+
res.end(JSON.stringify({ error: "No batch state" }));
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
const task = (state.tasks || []).find(t => t.taskId === taskId);
|
|
445
|
+
if (!task) {
|
|
446
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
447
|
+
res.end(JSON.stringify({ error: "Task not found" }));
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
const effectiveFolder = resolveTaskFolder(task, state);
|
|
452
|
+
if (!effectiveFolder) {
|
|
453
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
454
|
+
res.end(JSON.stringify({ error: "Cannot resolve task folder" }));
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// Try effective folder, then archive
|
|
459
|
+
const candidates = [effectiveFolder];
|
|
460
|
+
const archiveBase = effectiveFolder.replace(/[/\\]tasks[/\\][^/\\]+$/, "/tasks/archive/" + taskId);
|
|
461
|
+
if (archiveBase !== effectiveFolder) candidates.push(archiveBase);
|
|
462
|
+
|
|
463
|
+
for (const folder of candidates) {
|
|
464
|
+
const statusPath = path.join(folder, "STATUS.md");
|
|
465
|
+
try {
|
|
466
|
+
const content = fs.readFileSync(statusPath, "utf-8");
|
|
467
|
+
res.writeHead(200, {
|
|
468
|
+
"Content-Type": "text/plain; charset=utf-8",
|
|
469
|
+
"Access-Control-Allow-Origin": "*",
|
|
470
|
+
});
|
|
471
|
+
res.end(content);
|
|
472
|
+
return;
|
|
473
|
+
} catch { continue; }
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
477
|
+
res.end(JSON.stringify({ error: "STATUS.md not found" }));
|
|
478
|
+
}
|
|
479
|
+
|
|
429
480
|
// ─── HTTP Server ────────────────────────────────────────────────────────────
|
|
430
481
|
|
|
431
482
|
function createServer() {
|
|
@@ -452,6 +503,9 @@ function createServer() {
|
|
|
452
503
|
} else if (pathname.startsWith("/api/history/") && req.method === "GET") {
|
|
453
504
|
const batchId = decodeURIComponent(pathname.slice("/api/history/".length));
|
|
454
505
|
serveHistoryEntry(req, res, batchId);
|
|
506
|
+
} else if (pathname.startsWith("/api/status-md/") && req.method === "GET") {
|
|
507
|
+
const taskId = decodeURIComponent(pathname.slice("/api/status-md/".length));
|
|
508
|
+
serveStatusMd(req, res, taskId);
|
|
455
509
|
} else {
|
|
456
510
|
serveStatic(req, res);
|
|
457
511
|
}
|