From 25884ed487fecefb946b3290c235de517e32c134 Mon Sep 17 00:00:00 2001 From: Andreas Mueller Date: Wed, 17 Sep 2025 11:07:23 -0700 Subject: [PATCH 1/3] enable resizing of the sidebar --- scripts/static/js/sidebar.js | 268 ++++++++++++++++++++++++++++++++++- 1 file changed, 267 insertions(+), 1 deletion(-) diff --git a/scripts/static/js/sidebar.js b/scripts/static/js/sidebar.js index 24d98bd33b..185547fc29 100644 --- a/scripts/static/js/sidebar.js +++ b/scripts/static/js/sidebar.js @@ -2,15 +2,279 @@ import { allNodeData, archiveProgramIds, formatMetrics, renderMetricBar, getHigh import { scrollAndSelectNodeById } from './graph.js'; const sidebar = document.getElementById('sidebar'); +// Add a draggable resizer to let users change the sidebar width. +// Creates a slim handle at the left edge of the sidebar and uses pointer events +// to resize. The chosen width is persisted to localStorage under `sidebarWidth`. +(function enableSidebarResizer() { + if (!sidebar) return; + try { + const STORAGE_KEY = 'sidebarWidth'; + const DEFAULT_WIDTH_PX = 360; + const MIN_WIDTH_PX = 200; + const MAX_WIDTH_PX = Math.max(window.innerWidth - 100, 400); + + // Restore saved width (if any) + const saved = localStorage.getItem(STORAGE_KEY); + if (saved) { + sidebar.style.width = saved; + } else if (!sidebar.style.width) { + sidebar.style.width = DEFAULT_WIDTH_PX + 'px'; + } + + // Do not override sidebar positioning from CSS; assume #sidebar styles control placement + + // Create resizer element (left edge) + const resizer = document.createElement('div'); + resizer.id = 'sidebar-resizer'; + resizer.setAttribute('role', 'separator'); + resizer.setAttribute('aria-orientation', 'vertical'); + resizer.setAttribute('tabindex', '0'); + // Make the hit area a bit larger and use flex to center an inner visible handle + Object.assign(resizer.style, { + position: 'fixed', + left: '0px', // will be calculated + top: '0px', + width: '14px', + cursor: 'col-resize', + zIndex: '9999', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + background: 'transparent', + transition: 'background 120ms', + // disable pointerEvents by default so expanding sidebar doesn't immediately capture the mouse + pointerEvents: 'none', + }); + + // Visible inner handle + const handle = document.createElement('div'); + handle.id = 'sidebar-resizer-handle'; + handle.setAttribute('aria-hidden', 'true'); + Object.assign(handle.style, { + width: '6px', + height: '40px', + borderRadius: '6px', + // Use a subtle two-tone gradient and light border so it stands out in dark and light themes + background: 'linear-gradient(180deg, rgba(255,255,255,0.9), rgba(200,200,200,0.6))', + border: '1px solid rgba(0,0,0,0.12)', + boxShadow: '0 1px 4px rgba(0,0,0,0.15)', + transition: 'background 120ms, transform 120ms, box-shadow 120ms', + }); + resizer.appendChild(handle); + resizer.title = 'Drag to resize sidebar'; + + // Hover/focus effects to make it obvious + function _resizerHoverOn() { resizer.style.background = 'rgba(0,0,0,0.04)'; handle.style.transform = 'scale(1.06)'; handle.style.boxShadow = '0 2px 6px rgba(0,0,0,0.2)'; } + function _resizerHoverOff() { resizer.style.background = 'transparent'; handle.style.transform = 'scale(1)'; handle.style.boxShadow = '0 1px 4px rgba(0,0,0,0.15)'; } + resizer.addEventListener('pointerenter', _resizerHoverOn); + resizer.addEventListener('pointerleave', _resizerHoverOff); + resizer.addEventListener('focus', _resizerHoverOn); + resizer.addEventListener('blur', _resizerHoverOff); + + // Insert the resizer as first child so it sits on the left edge + // if (sidebar.firstChild) sidebar.insertBefore(resizer, sidebar.firstChild); + // else sidebar.appendChild(resizer); + // Append to body so it's not clipped by sidebar scrolling/overflow + document.body.appendChild(resizer); + + // Position update function to align the fixed resizer with the sidebar left edge + function updateResizerPosition() { + const rect = sidebar.getBoundingClientRect(); + if (!rect || !isFinite(rect.left) || rect.width === 0) return; + // Consider sidebar hidden if its left edge is at or past the right viewport edge + const viewportRight = window.innerWidth || document.documentElement.clientWidth; + const isOffscreen = rect.left >= (viewportRight - 8); + if (isOffscreen || getComputedStyle(sidebar).display === 'none') { + resizer.style.display = 'none'; + return; + } + // Ensure resizer is shown and aligned with the left edge of the sidebar + resizer.style.display = 'flex'; + const left = Math.round(rect.left - 7); + resizer.style.left = left + 'px'; + resizer.style.top = Math.round(rect.top) + 'px'; + resizer.style.height = Math.max(40, Math.round(rect.height)) + 'px'; + } + // Initial position + updateResizerPosition(); + // Keep in sync on resize and mutation of sidebar attributes + window.addEventListener('resize', updateResizerPosition); + const mo = new MutationObserver(updateResizerPosition); + mo.observe(sidebar, { attributes: true, attributeFilter: ['style', 'class'] }); + + // Continuous updating while sidebar transitions or when mouse moves near the edge + let rafId = null; + function rafLoop() { + updateResizerPosition(); + rafId = requestAnimationFrame(rafLoop); + } + function startContinuousUpdate() { + if (!rafId) rafLoop(); + } + function stopContinuousUpdate() { + if (rafId) { + cancelAnimationFrame(rafId); + rafId = null; + } + } + + // If the sidebar has a CSS transition on transform, run rAF during it to keep alignment + sidebar.addEventListener('transitionstart', startContinuousUpdate); + sidebar.addEventListener('transitionend', function() { updateResizerPosition(); stopContinuousUpdate(); }); + sidebar.addEventListener('transitioncancel', function() { updateResizerPosition(); stopContinuousUpdate(); }); + + // Track last mouse position and proximity-based enabling of pointer events + // to avoid the sidebar itself stealing the pointer when it expands under the cursor. + const PROXIMITY_PX = 28; + let mousePending = false; + let lastMouseX = null; + let lastMouseY = null; + function checkPointerProximity(clientX, clientY) { + if (!resizer || resizer.style.display === 'none') return; + const r = resizer.getBoundingClientRect(); + if (!r || r.width === 0) return; + // Compute distance to the resizer vertical centerline + const dx = Math.max(r.left - clientX, clientX - (r.left + r.width)); + const dy = Math.max(r.top - clientY, clientY - (r.top + r.height)); + const within = (dx <= PROXIMITY_PX && dy <= PROXIMITY_PX) || (clientX >= r.left && clientX <= r.left + r.width && clientY >= r.top && clientY <= r.top + r.height); + if (within) { + if (resizer.style.pointerEvents !== 'auto') { + resizer.style.pointerEvents = 'auto'; + resizer.classList.add('resizer-proximate'); + } + } else { + if (resizer.style.pointerEvents !== 'none' && !isResizing) { + resizer.style.pointerEvents = 'none'; + resizer.classList.remove('resizer-proximate'); + } + } + // Also control the sidebar's pointer events: only enable when within proximity, when resizing, or when sidebar is sticky + try { + const srect = sidebar.getBoundingClientRect(); + const viewportRight = window.innerWidth || document.documentElement.clientWidth; + const isOffscreen = srect.left >= (viewportRight - 8); + if (isOffscreen || getComputedStyle(sidebar).display === 'none') { + sidebar.style.pointerEvents = 'none'; + } else if (within || isResizing || sidebarSticky) { + sidebar.style.pointerEvents = 'auto'; + } else { + // keep the sidebar unclickable unless cursor is near it + sidebar.style.pointerEvents = 'none'; + } + } catch (err) { + // ignore + } + } + document.addEventListener('mousemove', function (e) { + lastMouseX = e.clientX; lastMouseY = e.clientY; + if (!mousePending) { + mousePending = true; + requestAnimationFrame(function () { + updateResizerPosition(); + checkPointerProximity(lastMouseX, lastMouseY); + mousePending = false; + }); + } + }); + + let isResizing = false; + let startX = 0; + let startWidth = 0; + + function clampWidth(w) { + const max = Math.min(MAX_WIDTH_PX, Math.floor(window.innerWidth - 100)); + return Math.max(MIN_WIDTH_PX, Math.min(w, max)); + } + + function onPointerMove(e) { + if (!isResizing) return; + const dx = e.clientX - startX; // positive when moving right + // Since the resizer is on the left edge of a right-aligned sidebar, + // moving pointer to the right should make the sidebar narrower. + // Compute new width as startWidth - dx. + let newWidth = Math.round(startWidth - dx); + newWidth = clampWidth(newWidth); + sidebar.style.width = newWidth + 'px'; + } + + function onPointerUp(e) { + if (!isResizing) return; + isResizing = false; + document.body.style.cursor = ''; + document.body.style.userSelect = ''; + try { localStorage.setItem(STORAGE_KEY, sidebar.style.width); } catch (err) { /* ignore */ } + // Remove global listeners + document.removeEventListener('pointermove', onPointerMove); + document.removeEventListener('pointerup', onPointerUp); + } + + resizer.addEventListener('pointerdown', (e) => { + e.preventDefault(); + isResizing = true; + startX = e.clientX; + startWidth = parseInt(window.getComputedStyle(sidebar).width, 10) || DEFAULT_WIDTH_PX; + document.body.style.cursor = 'col-resize'; + document.body.style.userSelect = 'none'; + document.addEventListener('pointermove', onPointerMove); + document.addEventListener('pointerup', onPointerUp); + // attempt to capture pointer so touch works well + try { e.target.setPointerCapture && e.target.setPointerCapture(e.pointerId); } catch (err) { } + // Update resizer position during drag (useful when width changes) + updateResizerPosition(); + }); + + // Keyboard accessibility: left/right arrows adjust width + resizer.addEventListener('keydown', (e) => { + const step = 20; + let cur = parseInt(window.getComputedStyle(sidebar).width, 10) || DEFAULT_WIDTH_PX; + if (e.key === 'ArrowLeft') { + cur = clampWidth(cur - step); + sidebar.style.width = cur + 'px'; + try { localStorage.setItem(STORAGE_KEY, sidebar.style.width); } catch (err) {} + e.preventDefault(); + } else if (e.key === 'ArrowRight') { + cur = clampWidth(cur + step); + sidebar.style.width = cur + 'px'; + try { localStorage.setItem(STORAGE_KEY, sidebar.style.width); } catch (err) {} + e.preventDefault(); + } else if (e.key === 'Home') { + sidebar.style.width = DEFAULT_WIDTH_PX + 'px'; + try { localStorage.setItem(STORAGE_KEY, sidebar.style.width); } catch (err) {} + e.preventDefault(); + } + }); + + // Make sure the stored max width updates on window resize + window.addEventListener('resize', () => { + const cur = parseInt(window.getComputedStyle(sidebar).width, 10) || DEFAULT_WIDTH_PX; + const clamped = clampWidth(cur); + if (clamped !== cur) { + sidebar.style.width = clamped + 'px'; + try { localStorage.setItem(STORAGE_KEY, sidebar.style.width); } catch (err) {} + } + updateResizerPosition(); + }); + // When sidebar is shown/hidden via showSidebar/hideSidebar functions, keep resizer sync + const showHideObserver = new MutationObserver(updateResizerPosition); + showHideObserver.observe(sidebar, { attributes: true, attributeFilter: ['style', 'class'] }); + } catch (err) { + // don't crash the rest of the sidebar code if resizing support fails + console.warn('sidebar resizer init failed', err); + } +})(); + export let sidebarSticky = false; let lastSidebarTab = null; export function showSidebar() { sidebar.style.transform = 'translateX(0)'; + // When explicitly shown, enable pointer events so controls are interactive + try { sidebar.style.pointerEvents = 'auto'; } catch (e) {} } export function hideSidebar() { sidebar.style.transform = 'translateX(100%)'; sidebarSticky = false; + try { sidebar.style.pointerEvents = 'none'; } catch (e) {} } export function showSidebarContent(d, fromHover = false) { @@ -328,8 +592,10 @@ export function openInNewTab(event, d) { export function setSidebarSticky(val) { sidebarSticky = val; + try { + sidebar.style.pointerEvents = val ? 'auto' : 'none'; + } catch (e) {} } - // Helper to escape HTML so code can be shown verbatim inside
 function escapeHtml(str) {
     if (str === undefined || str === null) return '';

From 9f70b35c47d8c0bb5a524020af1a04e22b97cc3c Mon Sep 17 00:00:00 2001
From: Andreas Mueller 
Date: Wed, 17 Sep 2025 11:16:52 -0700
Subject: [PATCH 2/3] add diff to parent node

---
 scripts/static/js/sidebar.js | 203 ++++++++++++++++++++++-------------
 1 file changed, 131 insertions(+), 72 deletions(-)

diff --git a/scripts/static/js/sidebar.js b/scripts/static/js/sidebar.js
index 185547fc29..7aa0a261cf 100644
--- a/scripts/static/js/sidebar.js
+++ b/scripts/static/js/sidebar.js
@@ -308,84 +308,143 @@ export function showSidebarContent(d, fromHover = false) {
     const clones = allNodeData.filter(n => getBaseId(n.id) === baseId && n.id !== d.id);
     if (clones.length > 0) tabNames.push('Clones');
 
-    let activeTab = lastSidebarTab && tabNames.includes(lastSidebarTab) ? lastSidebarTab : tabNames[0];
-
-    // Helper to render tab content
-    function renderSidebarTabContent(tabName, d, children) {
-        if (tabName === 'Code') {
-            return ``;
-        }
-        if (tabName === 'Prompts') {
-            // Prompt select logic
-            let promptOptions = [];
-            let promptMap = {};
-            if (d.prompts && typeof d.prompts === 'object') {
-                for (const [k, v] of Object.entries(d.prompts)) {
-                    if (v && typeof v === 'object' && !Array.isArray(v)) {
-                        for (const [subKey, subVal] of Object.entries(v)) {
-                            const optLabel = `${k} - ${subKey}`;
-                            promptOptions.push(optLabel);
-                            promptMap[optLabel] = subVal;
-                        }
-                    } else {
-                        const optLabel = `${k}`;
-                        promptOptions.push(optLabel);
-                        promptMap[optLabel] = v;
-                    }
+    // Add a Diff tab when a parent exists with code to compare against
+    const parentNodeForDiff = d.parent_id && d.parent_id !== 'None' ? allNodeData.find(n => n.id == d.parent_id) : null;
+    if (parentNodeForDiff && parentNodeForDiff.code && parentNodeForDiff.code.trim() !== '') {
+        tabNames.push('Diff');
+    }
+ 
+        let activeTab = lastSidebarTab && tabNames.includes(lastSidebarTab) ? lastSidebarTab : tabNames[0];
+ 
+        // Helper to render tab content
+        // Simple line-level LCS diff renderer between two code strings
+        function renderCodeDiff(aCode, bCode) {
+            const a = (aCode || '').split('\n');
+            const b = (bCode || '').split('\n');
+            const m = a.length, n = b.length;
+            // build LCS table
+            const dp = Array.from({length: m+1}, () => new Array(n+1).fill(0));
+            for (let ii = m-1; ii >= 0; --ii) {
+                for (let jj = n-1; jj >= 0; --jj) {
+                    if (a[ii] === b[jj]) dp[ii][jj] = dp[ii+1][jj+1] + 1;
+                    else dp[ii][jj] = Math.max(dp[ii+1][jj], dp[ii][jj+1]);
                 }
             }
-            // Artifacts
-            if (d.artifacts_json) {
-                const optLabel = `artifacts`;
-                promptOptions.push(optLabel);
-                promptMap[optLabel] = d.artifacts_json;
-            }
-            // Get last selected prompt from localStorage, or default to first
-            let lastPromptKey = localStorage.getItem('sidebarPromptSelect') || promptOptions[0] || '';
-            if (!promptMap[lastPromptKey]) lastPromptKey = promptOptions[0] || '';
-            // Build select box
-            let selectHtml = '';
-            if (promptOptions.length > 1) {
-                selectHtml = ``;
-            }
-            // Show only the selected prompt
-            let promptVal = promptMap[lastPromptKey];
-            let promptHtml = ``;
-            return selectHtml + promptHtml;
-        }
-        if (tabName === 'Children') {
-            const metric = (document.getElementById('metric-select') && document.getElementById('metric-select').value) || 'combined_score';
-            let min = 0, max = 1;
-            const vals = children.map(child => (child.metrics && typeof child.metrics[metric] === 'number') ? child.metrics[metric] : null).filter(x => x !== null);
-            if (vals.length > 0) {
-                min = Math.min(...vals);
-                max = Math.max(...vals);
+            // backtrack
+            let i = 0, j = 0;
+            const parts = [];
+            while (i < m && j < n) {
+                if (a[i] === b[j]) {
+                    parts.push({type: 'eq', line: a[i]});
+                    i++; j++; 
+                } else if (dp[i+1][j] >= dp[i][j+1]) {
+                    parts.push({type: 'del', line: a[i]});
+                    i++;
+                } else {
+                    parts.push({type: 'ins', line: b[j]});
+                    j++;
+                }
             }
-            return `
    ` + - children.map(child => { - let val = (child.metrics && typeof child.metrics[metric] === 'number') ? child.metrics[metric].toFixed(4) : '(no value)'; - let bar = (child.metrics && typeof child.metrics[metric] === 'number') ? renderMetricBar(child.metrics[metric], min, max) : ''; - return `
  • ${child.id}

    ${val} ${bar}
  • `; - }).join('') + - `
`; + while (i < m) { parts.push({type: 'del', line: a[i++]}); } + while (j < n) { parts.push({type: 'ins', line: b[j++]}); } + + // Render HTML with inline styles + const htmlLines = parts.map(function(p) { + if (p.type === 'eq') return '
' + escapeHtml(p.line) + '
'; + if (p.type === 'del') return '
- ' + escapeHtml(p.line) + '
'; + return '
+ ' + escapeHtml(p.line) + '
'; + }); + return '
' + + '
Showing diff between program and its parent (parent id: ' + (parentNodeForDiff ? parentNodeForDiff.id : 'N/A') + ')
' + + htmlLines.join('') + '
'; } - if (tabName === 'Clones') { - return `
    ` + - clones.map(clone => - `
  • ${clone.id}
  • ` - ).join('') + - `
`; + + // small helper to escape HTML + function escapeHtml(s) { + return (s+'').replace(/&/g,'&').replace(//g,'>'); } - return ''; - } - if (tabNames.length > 0) { - tabHtml = ''; - tabContentHtml = ``; - } + function renderSidebarTabContent(tabName, d, children) { + if (tabName === 'Code') { + return ``; + } + if (tabName === 'Prompts') { + // Prompt select logic + let promptOptions = []; + let promptMap = {}; + if (d.prompts && typeof d.prompts === 'object') { + for (const [k, v] of Object.entries(d.prompts)) { + if (v && typeof v === 'object' && !Array.isArray(v)) { + for (const [subKey, subVal] of Object.entries(v)) { + const optLabel = `${k} - ${subKey}`; + promptOptions.push(optLabel); + promptMap[optLabel] = subVal; + } + } else { + const optLabel = `${k}`; + promptOptions.push(optLabel); + promptMap[optLabel] = v; + } + } + } + // Artifacts + if (d.artifacts_json) { + const optLabel = `artifacts`; + promptOptions.push(optLabel); + promptMap[optLabel] = d.artifacts_json; + } + // Get last selected prompt from localStorage, or default to first + let lastPromptKey = localStorage.getItem('sidebarPromptSelect') || promptOptions[0] || ''; + if (!promptMap[lastPromptKey]) lastPromptKey = promptOptions[0] || ''; + // Build select box + let selectHtml = ''; + if (promptOptions.length > 1) { + selectHtml = ``; + } + // Show only the selected prompt + let promptVal = promptMap[lastPromptKey]; + let promptHtml = ``; + return selectHtml + promptHtml; + } + if (tabName === 'Children') { + const metric = (document.getElementById('metric-select') && document.getElementById('metric-select').value) || 'combined_score'; + let min = 0, max = 1; + const vals = children.map(child => (child.metrics && typeof child.metrics[metric] === 'number') ? child.metrics[metric] : null).filter(x => x !== null); + if (vals.length > 0) { + min = Math.min(...vals); + max = Math.max(...vals); + } + return `
    ` + + children.map(child => { + let val = (child.metrics && typeof child.metrics[metric] === 'number') ? child.metrics[metric].toFixed(4) : '(no value)'; + let bar = (child.metrics && typeof child.metrics[metric] === 'number') ? renderMetricBar(child.metrics[metric], min, max) : ''; + return `
  • ${child.id}

    ${val} ${bar}
  • `; + }).join('') + + `
`; + } + if (tabName === 'Clones') { + return `
    ` + + clones.map(clone => + `
  • ${clone.id}
  • ` + ).join('') + + `
`; + } + if (tabName === 'Diff') { + const parentNode = parentNodeForDiff; + const parentCode = parentNode ? parentNode.code || '' : ''; + const curCode = d.code || ''; + return renderCodeDiff(parentCode, curCode); + } + return ''; + } + + if (tabNames.length > 0) { + tabHtml = ''; + tabContentHtml = ``; + } let parentIslandHtml = ''; if (d.parent_id && d.parent_id !== 'None') { const parent = allNodeData.find(n => n.id == d.parent_id); From 5da799033cc66b04ba8c55a06967ad13b8310165 Mon Sep 17 00:00:00 2001 From: Andreas Mueller Date: Wed, 17 Sep 2025 16:57:13 -0700 Subject: [PATCH 3/3] fix merge issue --- scripts/static/js/sidebar.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/static/js/sidebar.js b/scripts/static/js/sidebar.js index 7aa0a261cf..d48b2aa298 100644 --- a/scripts/static/js/sidebar.js +++ b/scripts/static/js/sidebar.js @@ -366,7 +366,7 @@ export function showSidebarContent(d, fromHover = false) { function renderSidebarTabContent(tabName, d, children) { if (tabName === 'Code') { - return ``; + return ``; } if (tabName === 'Prompts') { // Prompt select logic