Browse Source

B2: plan-review remarks queue on the goal page

The task page's collect-remarks pattern, ported: highlighting plan text
opens the remark popover (selection-quoting feeds the composer), 'Add
remark' appends to a per-block queue rendered at the bottom of the plan,
and the queue persists across pulse refreshes (the maps live outside
statum re-renders; the DOM list rebuilds after every render cycle).
Reject-with-feedback submits every queued remark appended NUMBERED —
each with its quote prefixed like a comment — under the stable
'Plan feedback received:' verdict wording, then clears the queue; the
optional textarea appends after the remarks. Accept (Approve plan)
ignores and clears the queue. The pending plan body no longer feeds the
comment popover — it belongs to the remarks composer.
clanker 1 month ago
parent
commit
9163db10f5
1 changed files with 220 additions and 16 deletions
  1. 220 16
      src/pages/goal_detail.html

+ 220 - 16
src/pages/goal_detail.html

@@ -57,7 +57,11 @@
             if (window.initQuoteComments) {
                 window.initQuoteComments({
                     matches: function (node) {
-                        return node.closest && node.closest('.goal-body') != null;
+                        // The pending plan body feeds the REMARKS
+                        // popover instead (below) — everything else
+                        // long-form on the goal quote-comments.
+                        return node.closest && node.closest('.goal-body') != null
+                            && node.closest('[data-plan-body]') == null;
                     },
                     action: function () {
                         var goal = statum.read('goal');
@@ -65,6 +69,202 @@
                     }
                 });
             }
+
+            // Plan 14 B2: the plan-review remarks queue — the
+            // task-page collect-remarks pattern (the feedbackByBlock
+            // maps) ported to the goal's plan review. The queue lives
+            // in these maps (keyed by goal block id) so statum
+            // re-renders never lose it; the DOM list is rebuilt after
+            // every render cycle.
+            var remarksByBlock = {};
+            var rejectOpenByBlock = {};
+            var remarkPopover = null;
+
+            statum.afterEvaluate(syncPlanRemarks);
+
+            document.addEventListener('click', function (event) {
+                if (!event.target.closest) return;
+                var accept = event.target.closest('[data-plan-accept]');
+                if (accept) {
+                    // Accept ignores/clears the queued remarks.
+                    var accepted = accept.closest('[data-plan-review]');
+                    if (accepted) {
+                        var acceptId = accepted.getAttribute('data-block-id');
+                        remarksByBlock[acceptId] = [];
+                        rejectOpenByBlock[acceptId] = false;
+                    }
+                    return;
+                }
+                var toggle = event.target.closest('[data-plan-toggle]');
+                if (toggle) {
+                    hideRemarkPopover();
+                    var review = toggle.closest('[data-plan-review]');
+                    var id = review && review.getAttribute('data-block-id');
+                    if (id) rejectOpenByBlock[id] = !rejectOpenByBlock[id];
+                    syncPlanRemarks();
+                    return;
+                }
+                var submit = event.target.closest('[data-plan-submit]');
+                if (submit) {
+                    submitPlanRemarks(submit.closest('[data-plan-review]'));
+                    return;
+                }
+                var remove = event.target.closest('[data-plan-remove]');
+                if (remove) {
+                    var entry = remove.closest('[data-plan-remark-id]');
+                    var blockId = remove.closest('[data-plan-review]').getAttribute('data-block-id');
+                    var list = remarksByBlock[blockId] || [];
+                    var index = parseInt(entry.getAttribute('data-plan-remark-id'), 10);
+                    if (!isNaN(index) && list[index]) list.splice(index, 1);
+                    remarksByBlock[blockId] = list;
+                    syncPlanRemarks();
+                }
+            });
+
+            document.addEventListener('mouseup', function (event) {
+                if (!event.target.closest) return;
+                if (remarkPopover && remarkPopover.contains(event.target)) return;
+                var body = event.target.closest('[data-plan-body]');
+                if (!body) {
+                    hideRemarkPopover();
+                    return;
+                }
+                var review = body.closest('[data-plan-review]');
+                if (!review || review.getAttribute('data-plan-open') !== '1') return;
+                var selection = window.getSelection();
+                var text = selection ? String(selection.toString()).trim() : '';
+                if (text.length === 0) {
+                    hideRemarkPopover();
+                    return;
+                }
+                showRemarkPopover(event.pageX, event.pageY, text, review);
+            });
+
+            document.addEventListener('mousedown', function (event) {
+                if (remarkPopover && event.target.closest
+                        && event.target.closest('.plan-popover')) return;
+                hideRemarkPopover();
+            });
+
+            function showRemarkPopover(x, y, quote, review) {
+                hideRemarkPopover();
+                remarkPopover = document.createElement('div');
+                remarkPopover.className = 'plan-popover';
+                remarkPopover.style.left = Math.min(x, window.innerWidth - 140) + 'px';
+                remarkPopover.style.top = y + 'px';
+
+                var quoted = document.createElement('blockquote');
+                quoted.textContent = quote;
+                remarkPopover.appendChild(quoted);
+
+                var body = document.createElement('textarea');
+                body.placeholder = 'Your remark on the selected plan text';
+                remarkPopover.appendChild(body);
+
+                var actions = document.createElement('div');
+                actions.className = 'actions';
+                var add = document.createElement('button');
+                add.type = 'button';
+                add.className = 'primary';
+                add.textContent = 'Add remark';
+                add.addEventListener('click', function () {
+                    if (body.value.trim().length === 0) return;
+                    var id = review.getAttribute('data-block-id');
+                    var list = remarksByBlock[id] || [];
+                    list.push({ quote: quote, body: body.value.trim() });
+                    remarksByBlock[id] = list;
+                    hideRemarkPopover();
+                    syncPlanRemarks();
+                });
+                var cancel = document.createElement('button');
+                cancel.type = 'button';
+                cancel.textContent = 'Cancel';
+                cancel.addEventListener('click', hideRemarkPopover);
+                actions.appendChild(add);
+                actions.appendChild(cancel);
+                remarkPopover.appendChild(actions);
+
+                document.body.appendChild(remarkPopover);
+                body.focus();
+            }
+
+            function hideRemarkPopover() {
+                if (remarkPopover) {
+                    remarkPopover.remove();
+                    remarkPopover = null;
+                }
+            }
+
+            function submitPlanRemarks(review) {
+                if (!review) return;
+                var id = review.getAttribute('data-block-id');
+                var list = remarksByBlock[id] || [];
+                if (list.length === 0) {
+                    if (window.showToast) window.showToast('Highlight some plan text and add a remark first', 'error');
+                    return;
+                }
+                var goal = statum.read('goal');
+                var block = null;
+                if (goal && goal.plan_pending_block
+                        && String(goal.plan_pending_block.id) === String(id)) {
+                    block = goal.plan_pending_block;
+                }
+                if (!block || !block.answer_action) return;
+                // Every queued remark rides the rejection NUMBERED, in
+                // queue order, each with its selection quote prefixed
+                // like a comment; the wrapped answer IS the resume
+                // notice the agent reads.
+                var answer = 'Plan feedback received:';
+                list.forEach(function (item, index) {
+                    answer += '\n\n';
+                    if (item.quote) {
+                        answer += '> ' + item.quote.replace(/\n/g, '\n> ') + '\n\n';
+                    }
+                    answer += (index + 1) + '. ' + item.body;
+                });
+                var extra = review.querySelector('[data-plan-feedback] textarea[name="feedback"]');
+                if (extra && extra.value.trim().length > 0) {
+                    answer += '\n\n' + extra.value.trim();
+                }
+                remarksByBlock[id] = [];
+                rejectOpenByBlock[id] = false;
+                statum.act(block.answer_action, { answer: answer }).catch(function () { });
+            }
+
+            function syncPlanRemarks() {
+                document.querySelectorAll('[data-plan-review]').forEach(function (review) {
+                    var id = review.getAttribute('data-block-id');
+                    var open = rejectOpenByBlock[id] ? '1' : '0';
+                    review.setAttribute('data-plan-open', open);
+                    var feedback = review.querySelector('[data-plan-feedback]');
+                    if (feedback) {
+                        if (rejectOpenByBlock[id]) feedback.removeAttribute('hidden');
+                        else feedback.setAttribute('hidden', '');
+                    }
+                    var list = review.querySelector('[data-plan-remarks]');
+                    if (!list) return;
+                    while (list.firstChild) list.removeChild(list.firstChild);
+                    (remarksByBlock[id] || []).forEach(function (item, index) {
+                        var entry = document.createElement('li');
+                        entry.setAttribute('data-plan-remark-id', String(index));
+                        if (item.quote) {
+                            var quoted = document.createElement('blockquote');
+                            quoted.textContent = item.quote;
+                            entry.appendChild(quoted);
+                        }
+                        var body = document.createElement('div');
+                        body.textContent = item.body;
+                        entry.appendChild(body);
+                        var remove = document.createElement('button');
+                        remove.type = 'button';
+                        remove.className = 'inline';
+                        remove.setAttribute('data-plan-remove', '');
+                        remove.textContent = 'Remove';
+                        entry.appendChild(remove);
+                        list.appendChild(entry);
+                    });
+                });
+            }
         })();
     }
 </script>
@@ -204,23 +404,27 @@
                 <p stm-if="goal.plan.length == 0 && goal.plan_pending_block == null"
                    class="muted">No approved plan yet.</p>
 
-                <div stm-if="goal.plan_pending_block != null && goal.plan_pending_block != false" class="plan-review">
+                <div stm-if="goal.plan_pending_block != null && goal.plan_pending_block != false" class="plan-review" data-plan-review
+                     stm-attribute.data-block-id="goal.plan_pending_block.id">
                     <h4>Plan submitted for review</h4>
-                    <div class="md rfc-body goal-body" stm-markdown="goal.plan_pending_block.body">revision</div>
-                    <form stm-action="goal.plan_pending_block.answer_action" class="inline">
-                        <input type="hidden" name="answer"
-                               value="Plan approved. Proceed with implementation.">
-                        <button type="submit" class="approve">Approve plan</button>
-                    </form>
-                    <details>
-                        <summary>Reject with feedback</summary>
-                        <form stm-action="goal.plan_pending_block.answer_action">
-                            <div class="form-row">
-                                <textarea name="answer" rows="4" required
-                                          placeholder="What must change before approval?"></textarea></div>
-                            <button type="submit" class="reject">Send feedback</button>
+                    <div class="md rfc-body goal-body" data-plan-body
+                         stm-markdown="goal.plan_pending_block.body">revision</div>
+                    <div class="actions">
+                        <form stm-action="goal.plan_pending_block.answer_action" class="inline">
+                            <input type="hidden" name="answer"
+                                   value="Plan approved. Proceed with implementation.">
+                            <button type="submit" class="approve" data-plan-accept>Approve plan</button>
                         </form>
-                    </details>
+                        <button type="button" data-plan-toggle>Reject with feedback</button>
+                    </div>
+                    <div data-plan-feedback hidden>
+                        <p class="muted">Highlight plan text to attach a remark to it — the queue
+                            rides the rejection numbered, then clears. Approving ignores the queue.</p>
+                        <ul class="plan-comments" data-plan-remarks></ul>
+                        <textarea name="feedback" rows="2"
+                                  placeholder="Additional feedback (optional)"></textarea>
+                        <button type="button" class="primary" data-plan-submit>Submit feedback</button>
+                    </div>
                 </div>
 
                 <details class="history-expander" stm-if="goal.plan_revisions.length > 0">