diff --git a/everyrow-mcp/src/everyrow_mcp/templates.py b/everyrow-mcp/src/everyrow_mcp/templates.py
index c396357e..80817d08 100644
--- a/everyrow-mcp/src/everyrow_mcp/templates.py
+++ b/everyrow-mcp/src/everyrow_mcp/templates.py
@@ -55,13 +55,16 @@
td:hover{background:var(--bg-hover)}
td.has-research::after{content:"";position:absolute;top:6px;right:4px;width:6px;height:6px;border-radius:50%;background:var(--research-dot);opacity:.7}
tr.selected td{background:var(--bg-selected)!important}
+td.cell-focused{outline:2px solid var(--accent);outline-offset:-2px;z-index:2}
tr:nth-child(even) td{background:var(--bg-alt)}
tr:nth-child(even).selected td{background:var(--bg-selected)!important}
a{color:var(--accent);text-decoration:none;word-break:break-all}
a:hover{text-decoration:underline}
-td:first-child{position:sticky;left:0;background:inherit;z-index:1;font-weight:500}
-.hdr-row th:first-child{position:sticky;left:0;z-index:4}
-.flt-row th:first-child{position:sticky;left:0;z-index:4}
+.row-num{position:sticky;left:0;z-index:1;background:var(--bg);width:40px;min-width:40px;max-width:40px;text-align:center;color:var(--text-dim);font-size:11px;font-variant-numeric:tabular-nums;cursor:pointer;user-select:none;padding:6px 4px;box-shadow:2px 0 4px rgba(0,0,0,.06)}
+tr:nth-child(even) .row-num{background:var(--bg-alt)}
+.hdr-row .row-num{z-index:4;font-weight:600;color:var(--text-sec);cursor:default;background:var(--bg-toolbar)}
+.flt-row .row-num{z-index:4;cursor:default;background:var(--bg-toolbar)}
+tr.selected .row-num{background:var(--bg-selected)!important}
.popover{position:fixed;background:var(--pop-bg);border:1px solid var(--border);border-radius:8px;box-shadow:var(--pop-shadow);max-width:420px;min-width:200px;z-index:100;overflow:hidden;opacity:0;transform:translateY(-4px);transition:opacity .15s,transform .15s;pointer-events:none}
.popover.visible{opacity:1;transform:translateY(0);pointer-events:auto}
.pop-hdr{padding:8px 12px;font-size:11px;font-weight:600;color:var(--text-sec);border-bottom:1px solid var(--border-light);background:var(--bg-alt)}
@@ -108,7 +111,8 @@
@@ -145,7 +149,7 @@
let copyFmt="tsv";
const settingsBtn=document.getElementById("settingsBtn");
const settingsDrop=document.getElementById("settingsDrop");
-const S={rows:[],allCols:[],filteredIdx:[],sortCol:null,sortDir:0,filters:{},selected:new Set(),lastClick:null,isFullscreen:false};
+const S={rows:[],allCols:[],filteredIdx:[],sortCol:null,sortDir:0,filters:{},selected:new Set(),lastClick:null,isFullscreen:false,focusedCell:null};
/* --- theming & display mode --- */
app.onhostcontextchanged=(ctx)=>{
@@ -161,7 +165,8 @@
/* --- helpers --- */
function esc(s){const d=document.createElement("div");d.textContent=String(s);return d.innerHTML;}
function escAttr(s){return esc(s).replace(/"/g,""");}
-function linkify(s){return esc(s).replace(/https?:\\/\\/[^\\s<)\\]]+/g,m=>''+m+'');}
+function truncSafe(s,len){if(s.length<=len)return s;let t=s.slice(0,len);const urlRe=/(https?:\\/\\/[^\\s<>"'\\]]+)$/;const m=t.match(urlRe);if(m){const full=s.slice(m.index).match(/^https?:\\/\\/[^\\s<>"'\\]]+/);if(full&&full[0].length>m[1].length)t=s.slice(0,m.index+full[0].length);}return t;}
+function linkify(s){const re=/(https?:\\/\\/[^\\s<>"'\\]]+)/g;let last=0,out="",m;while((m=re.exec(s))!==null){let url=m[1];while(url.endsWith(")")&&(url.split("(").length-1)<(url.split(")").length-1))url=url.slice(0,-1);re.lastIndex=m.index+url.length;if(m.index>last)out+=esc(s.slice(last,m.index));out+=''+esc(url)+"";last=re.lastIndex;}if(last▲':'▼';
h+=''+esc(c)+arrow+' | ';
}
- h+='';
+ h+='
| ';
for(const c of cols){
h+=' | ';
}
h+='
';
+ let rowNum=0;
for(const i of S.filteredIdx){
+ rowNum++;
const row=S.rows[i],sel=S.selected.has(i)?' class="selected"':"";
- h+='';
+ h+='
| '+rowNum+' | ';
for(const c of cols){
const hasR=getResearch(row,c)!=null;
- const v=row.display[c],cls=hasR?' class="has-research"':"",dc=' data-col="'+escAttr(c)+'"';
+ const focused=S.focusedCell&&S.focusedCell.idx===i&&S.focusedCell.col===c;
+ const v=row.display[c];
+ let cls=hasR?(focused?' class="has-research cell-focused"':' class="has-research"'):(focused?' class="cell-focused"':"");
+ const dc=' data-col="'+escAttr(c)+'"';
if(v==null){h+=" | ";}
else{const s=String(v);
- if(s.length>TRUNC)h+=''+linkify(s.slice(0,TRUNC))+'… more | ';
+ if(s.length>TRUNC)h+=''+linkify(truncSafe(s,TRUNC))+'… more | ';
else h+=''+linkify(s)+' | ';
}
}
@@ -292,7 +305,7 @@
if(e.target.closest(".col-resize-handle"))return;
const th=e.target.closest(".hdr-row th");
if(!th)return;
- const col=th.dataset.col;
+ const col=th.dataset.col;if(!col)return;
if(S.sortCol===col){S.sortDir=S.sortDir===1?-1:S.sortDir===-1?0:1;if(S.sortDir===0)S.sortCol=null;}
else{S.sortCol=col;S.sortDir=1;}
applyFilterAndSort();
@@ -316,24 +329,52 @@
const td=less.closest("td"),tr=td.closest("tr");
const idx=parseInt(tr.dataset.idx,10),col=td.dataset.col;
const full=String(S.rows[idx].display[col]);
- td.querySelector(".cell-text").innerHTML=linkify(full.slice(0,TRUNC));
+ td.querySelector(".cell-text").innerHTML=linkify(truncSafe(full,TRUNC));
less.textContent="\\u2026 more";less.className="cell-more";
return;
}
});
-/* --- selection (click toggles, shift extends range) --- */
+/* --- selection (# column click toggles, shift extends range) --- */
tbl.addEventListener("click",e=>{
- if(e.target.closest(".hdr-row")||e.target.closest(".flt-row")||e.target.closest("a")||e.target.closest(".cell-more")||e.target.closest(".cell-less"))return;
- const tr=e.target.closest("tbody tr");if(!tr)return;
+ if(e.target.closest(".hdr-row")||e.target.closest(".flt-row"))return;
+ const td=e.target.closest("td");if(!td)return;
+ const tr=td.closest("tbody tr");if(!tr)return;
const idx=parseInt(tr.dataset.idx,10);if(isNaN(idx))return;
- if(e.shiftKey&&S.lastClick!=null){
- const posA=S.filteredIdx.indexOf(S.lastClick),posB=S.filteredIdx.indexOf(idx);
- if(posA>=0&&posB>=0){const lo=Math.min(posA,posB),hi=Math.max(posA,posB);for(let p=lo;p<=hi;p++)S.selected.add(S.filteredIdx[p]);}
- }else{
- if(S.selected.has(idx))S.selected.delete(idx);else S.selected.add(idx);
+
+ if(td.classList.contains("row-num")){
+ /* row selection via # column */
+ if(S.focusedCell){S.focusedCell=null;tbl.querySelectorAll("td.cell-focused").forEach(c=>c.classList.remove("cell-focused"));}
+ if(e.shiftKey&&S.lastClick!=null){
+ const posA=S.filteredIdx.indexOf(S.lastClick),posB=S.filteredIdx.indexOf(idx);
+ if(posA>=0&&posB>=0){const lo=Math.min(posA,posB),hi=Math.max(posA,posB);for(let p=lo;p<=hi;p++)S.selected.add(S.filteredIdx[p]);}
+ }else{
+ if(S.selected.has(idx))S.selected.delete(idx);else S.selected.add(idx);
+ }
+ S.lastClick=idx;updateSelection();updateCopyBtn();
+ return;
}
- S.lastClick=idx;updateSelection();updateCopyBtn();
+
+ /* cell focus click — skip links and expand toggles */
+ if(e.target.closest("a")||e.target.closest(".cell-more")||e.target.closest(".cell-less"))return;
+ const col=td.dataset.col;if(!col)return;
+ /* toggle focus */
+ const prev=S.focusedCell;
+ if(prev){const oldTd=tbl.querySelector('tbody tr[data-idx="'+prev.idx+'"] td[data-col="'+CSS.escape(prev.col)+'"]');if(oldTd)oldTd.classList.remove("cell-focused");}
+ if(prev&&prev.idx===idx&&prev.col===col){S.focusedCell=null;}
+ else{S.focusedCell={idx,col};td.classList.add("cell-focused");}
+});
+
+/* --- double-click data cell to copy value --- */
+tbl.addEventListener("dblclick",e=>{
+ if(e.target.closest(".col-resize-handle"))return;
+ if(e.target.closest(".hdr-row")||e.target.closest(".flt-row"))return;
+ const td=e.target.closest("tbody td");if(!td||td.classList.contains("row-num"))return;
+ const tr=td.closest("tr");if(!tr)return;
+ const idx=parseInt(tr.dataset.idx,10),col=td.dataset.col;
+ if(isNaN(idx)||!col)return;
+ const v=S.rows[idx]?.display[col];if(v==null)return;
+ copyToClipboard(String(v)).then(ok=>{if(ok)showToast("Cell copied");});
});
function updateSelection(){
@@ -341,7 +382,7 @@
const idx=parseInt(tr.dataset.idx,10);tr.classList.toggle("selected",S.selected.has(idx));
});
}
-function updateCopyBtn(){const n=S.selected.size;const fl=copyFmt.toUpperCase();copyBtn.textContent=n>0?"Copy ("+n+")":"Copy";copyBtn.title="Copy selected rows as "+fl;copyBtn.disabled=n===0;}
+function updateCopyBtn(){const n=S.selected.size;const fl=copyFmt.toUpperCase();copyBtn.textContent=n>0?"Copy "+fl+" ("+n+")":"Copy "+fl;copyBtn.disabled=n===0;}
/* --- select all --- */
selAllBtn.addEventListener("click",()=>{
@@ -434,7 +475,33 @@
}
});
pop.addEventListener("mouseleave",()=>{clearTimeout(popTimer);hidePopover();});
-document.addEventListener("keydown",e=>{if(e.key==="Escape"){if(copyModal.classList.contains("show"))copyModal.classList.remove("show");else if(popVisible)hidePopover();}});
+document.addEventListener("keydown",e=>{
+ if(e.key==="Escape"){
+ if(copyModal.classList.contains("show")){copyModal.classList.remove("show");return;}
+ if(S.focusedCell){S.focusedCell=null;tbl.querySelectorAll("td.cell-focused").forEach(c=>c.classList.remove("cell-focused"));return;}
+ if(popVisible)hidePopover();
+ return;
+ }
+ /* Cmd+C / Ctrl+C — skip if inside input/textarea or copy modal */
+ if((e.metaKey||e.ctrlKey)&&e.key==="c"){
+ const ae=document.activeElement;
+ if(ae&&(ae.tagName==="INPUT"||ae.tagName==="TEXTAREA"))return;
+ if(copyModal.classList.contains("show"))return;
+ /* priority: selected rows > focused cell */
+ if(S.selected.size>0){
+ e.preventDefault();
+ const text=buildCopyText();
+ const msg="Copied "+S.selected.size+" row"+(S.selected.size>1?"s":"")+" as "+copyFmt.toUpperCase();
+ copyToClipboard(text).then(ok=>{if(ok)showToast(msg);else showCopyModal(text);});
+ return;
+ }
+ if(S.focusedCell){
+ e.preventDefault();
+ const v=S.rows[S.focusedCell.idx]?.display[S.focusedCell.col];
+ if(v!=null)copyToClipboard(String(v)).then(ok=>{if(ok)showToast("Cell copied");});
+ }
+ }
+});
/* --- resize handle --- */
let resizing=false,startY=0,startH=0;
@@ -528,7 +595,7 @@
if(e.target.closest(".col-resize-handle"))return;
const th=e.target.closest(".hdr-row th");
if(!th)return;
- dragCol=th.dataset.col;dragStartX=e.clientX;dragStartY=e.clientY;colDragging=false;
+ dragCol=th.dataset.col;if(!dragCol)return;dragStartX=e.clientX;dragStartY=e.clientY;colDragging=false;
document.addEventListener("mousemove",onColDragMove);
document.addEventListener("mouseup",onColDragUp);
});
@@ -544,7 +611,7 @@
}
if(colDragging){
dragGhost.style.left=(e.clientX+12)+"px";dragGhost.style.top=(e.clientY-12)+"px";
- const hdrs=[...tbl.querySelectorAll(".hdr-row th")];
+ const hdrs=[...tbl.querySelectorAll(".hdr-row th")].filter(h=>h.dataset.col);
hdrs.forEach(h=>h.classList.remove("drag-over-left","drag-over-right"));
const target=hdrs.find(h=>{const r=h.getBoundingClientRect();return e.clientX>=r.left&&e.clientX<=r.right;});
if(target&&target.dataset.col!==dragCol){
@@ -557,7 +624,7 @@
document.removeEventListener("mousemove",onColDragMove);
document.removeEventListener("mouseup",onColDragUp);
if(colDragging){
- const hdrs=[...tbl.querySelectorAll(".hdr-row th")];
+ const hdrs=[...tbl.querySelectorAll(".hdr-row th")].filter(h=>h.dataset.col);
hdrs.forEach(h=>h.classList.remove("drag-over-left","drag-over-right"));
const target=hdrs.find(h=>{const r=h.getBoundingClientRect();return e.clientX>=r.left&&e.clientX<=r.right;});
if(target&&target.dataset.col!==dragCol){
@@ -585,6 +652,10 @@
}
function updateDownloadLink(){updateSessionLink();}
+document.getElementById("exportLink")?.addEventListener("click",()=>{
+ if(!csvUrl){showToast("No download link yet");return;}
+ copyToClipboard(csvUrl).then(ok=>{if(ok)showToast("Link copied");});
+});
/* --- row resize (drag bottom border) --- */
let rowResizing=false,rowResizeTr=null,rowStartY=0,rowStartH=0;
diff --git a/everyrow-mcp/tests/test_server.py b/everyrow-mcp/tests/test_server.py
index b17afe10..144c4d1f 100644
--- a/everyrow-mcp/tests/test_server.py
+++ b/everyrow-mcp/tests/test_server.py
@@ -1233,3 +1233,169 @@ async def test_progress_http_returns_text_only(self):
assert len(result) == 1
assert "2/5 complete" in result[0].text
+
+
+class TestResultsWidgetData:
+ """Tests for the HTTP mode widget data in everyrow_results."""
+
+ @pytest.mark.asyncio
+ async def test_http_widget_includes_csv_url(self):
+ """Verify csv_url is present in widget JSON when results are stored."""
+ task_id = str(uuid4())
+ mock_client = _make_mock_client()
+ ctx = make_test_context(mock_client)
+
+ status_response = _make_task_status_response(status="completed")
+ result_response = _make_task_result_response([{"name": "A"}])
+ csv_url = "https://example.com/api/results/123/download?token=abc"
+
+ store_response = [
+ TextContent(
+ type="text",
+ text=json.dumps(
+ {
+ "csv_url": csv_url,
+ "preview": [{"name": "A"}],
+ "total": 1,
+ }
+ ),
+ ),
+ TextContent(type="text", text="Results: 1 rows. All rows shown."),
+ ]
+
+ with (
+ patch(
+ "everyrow_mcp.tools.try_cached_result",
+ new_callable=AsyncMock,
+ return_value=None,
+ ),
+ patch(
+ "everyrow_mcp.tool_helpers.get_task_status_tasks_task_id_status_get.asyncio",
+ new_callable=AsyncMock,
+ return_value=status_response,
+ ),
+ patch(
+ "everyrow_mcp.tool_helpers.get_task_result_tasks_task_id_result_get.asyncio",
+ new_callable=AsyncMock,
+ return_value=result_response,
+ ),
+ patch(
+ "everyrow_mcp.tools.try_store_result",
+ new_callable=AsyncMock,
+ return_value=store_response,
+ ),
+ ):
+ result = await everyrow_results_http(HttpResultsInput(task_id=task_id), ctx)
+
+ assert len(result) == 2
+ widget_data = json.loads(result[0].text)
+ assert widget_data["csv_url"] == csv_url
+
+ @pytest.mark.asyncio
+ async def test_http_widget_omits_session_url_when_unavailable(self):
+ """Verify session_url is omitted when not provided."""
+ task_id = str(uuid4())
+ mock_client = _make_mock_client()
+ ctx = make_test_context(mock_client)
+
+ status_response = _make_task_status_response(status="completed")
+ result_response = _make_task_result_response([{"name": "A"}])
+
+ store_response = [
+ TextContent(
+ type="text",
+ text=json.dumps(
+ {
+ "csv_url": "https://example.com/download",
+ "preview": [{"name": "A"}],
+ "total": 1,
+ }
+ ),
+ ),
+ TextContent(type="text", text="Results: 1 rows. All rows shown."),
+ ]
+
+ with (
+ patch(
+ "everyrow_mcp.tools.try_cached_result",
+ new_callable=AsyncMock,
+ return_value=None,
+ ),
+ patch(
+ "everyrow_mcp.tool_helpers.get_task_status_tasks_task_id_status_get.asyncio",
+ new_callable=AsyncMock,
+ return_value=status_response,
+ ),
+ patch(
+ "everyrow_mcp.tool_helpers.get_task_result_tasks_task_id_result_get.asyncio",
+ new_callable=AsyncMock,
+ return_value=result_response,
+ ),
+ patch(
+ "everyrow_mcp.tools.try_store_result",
+ new_callable=AsyncMock,
+ return_value=store_response,
+ ),
+ ):
+ result = await everyrow_results_http(HttpResultsInput(task_id=task_id), ctx)
+
+ assert len(result) == 2
+ widget_data = json.loads(result[0].text)
+ assert "session_url" not in widget_data
+
+ @pytest.mark.asyncio
+ async def test_http_widget_includes_session_url(self):
+ """Verify session_url is present in widget data when available."""
+ task_id = str(uuid4())
+ session_id = uuid4()
+ mock_client = _make_mock_client()
+ ctx = make_test_context(mock_client)
+ session_url = f"https://everyrow.io/sessions/{session_id}"
+
+ status_response = _make_task_status_response(
+ status="completed", session_id=session_id
+ )
+ result_response = _make_task_result_response([{"name": "A"}])
+
+ store_response = [
+ TextContent(
+ type="text",
+ text=json.dumps(
+ {
+ "csv_url": "https://example.com/download",
+ "preview": [{"name": "A"}],
+ "total": 1,
+ "session_url": session_url,
+ }
+ ),
+ ),
+ TextContent(type="text", text="Results: 1 rows. All rows shown."),
+ ]
+
+ with (
+ patch(
+ "everyrow_mcp.tools.try_cached_result",
+ new_callable=AsyncMock,
+ return_value=None,
+ ),
+ patch(
+ "everyrow_mcp.tool_helpers.get_task_status_tasks_task_id_status_get.asyncio",
+ new_callable=AsyncMock,
+ return_value=status_response,
+ ),
+ patch(
+ "everyrow_mcp.tool_helpers.get_task_result_tasks_task_id_result_get.asyncio",
+ new_callable=AsyncMock,
+ return_value=result_response,
+ ),
+ patch(
+ "everyrow_mcp.tools.try_store_result",
+ new_callable=AsyncMock,
+ return_value=store_response,
+ ),
+ ):
+ result = await everyrow_results_http(HttpResultsInput(task_id=task_id), ctx)
+
+ assert len(result) == 2
+ widget_data = json.loads(result[0].text)
+ assert widget_data["session_url"] == session_url