heaplens-cli 0.1.1 → 0.1.2

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.
@@ -1857,9 +1857,10 @@ body {
1857
1857
  <span class="tree-shallow" style="font-weight:bold;">Shallow</span>
1858
1858
  <span class="tree-size" style="font-weight:bold;">Retained</span>
1859
1859
  <span class="tree-bar-wrap"></span>
1860
- <span class="tree-pct" style="font-weight:bold;">%</span>
1860
+ <span class="tree-pct" style="font-weight:bold;" title="Retained size divided by reachable heap. Nested retained sets overlap.">% reachable</span>
1861
1861
  <span class="tree-actions"><span class="tree-action-alive"></span><span class="tree-action-icon"></span><span class="tree-action-icon"></span><span class="tree-action-icon"></span></span>
1862
1862
  </div>
1863
+ <p class="histogram-percentage-note">Percentages use reachable heap, not the sum of these rows. Nested entries can retain the same objects; percentages need not sum to 100%. N/A means unavailable.</p>
1863
1864
  </div>
1864
1865
  <p class="dominator-hint">Top retained entry points from analysis; expand for immediately dominated children. Percentages use reachable heap. Nested rows overlap and must not be added together.</p><div id="dominator-status" role="status" aria-live="polite"></div><div id="dominator-tree"><div class="loading">Waiting for analysis...</div></div>
1865
1866
  <div id="sunburst-chart"></div>
@@ -2056,6 +2057,7 @@ body {
2056
2057
  <div class="chat-input-row">
2057
2058
  <textarea class="chat-input" id="chat-input" placeholder="Ask about your heap dump..." rows="1"></textarea>
2058
2059
  <button class="chat-send" id="chat-send">Send</button>
2060
+ <button class="chat-clear" id="chat-stop" title="Stop the current AI investigation">Stop</button>
2059
2061
  <button class="chat-clear" id="chat-clear" title="Clear chat history">Clear</button>
2060
2062
  </div>
2061
2063
  <p class="chat-privacy-note">AI sends heap metadata and your messages to the configured provider. Automatic string previews and primitive values are omitted. Names and anything you type may still be sensitive; do not paste secrets.</p>
@@ -2090,6 +2092,11 @@ body {
2090
2092
  return request;
2091
2093
  }
2092
2094
  window.heaplensSend = send;
2095
+ window.addEventListener('pagehide',()=>{
2096
+ // Best effort immediately; a per-view host lease handles lost unload requests.
2097
+ void fetch('/api/command',{method:'POST',keepalive:true,credentials:'omit',headers:{'X-HeapLens-Token':token,'X-HeapLens-View':view,'Content-Type':'application/json'},
2098
+ body:JSON.stringify({session,message:{command:'aiStop'}})}).catch(()=>{});
2099
+ });
2093
2100
  document.getElementById('browser-telemetry').onclick=async()=>{
2094
2101
  const selected=await choice('Telemetry','Telemetry is enabled by default (usage and errors). Disable it here for this running host, or launch with --telemetry off to keep it disabled from startup. Filtered records go to HeapLens Azure Application Insights. No paths, source/query/dump text, keys or stable IDs. Network services may process IP addresses. Disabling clears pending delivery.',[['Disable telemetry','off'],['Review local diagnostics','review'],['Cancel',null]]);
2095
2102
  if(selected)send({command:selected==='off'?'telemetryOff':'reviewDiagnostics'});
@@ -2450,7 +2457,8 @@ document.querySelectorAll('.tab-btn').forEach(b => {
2450
2457
  function escapeHtml(str) {
2451
2458
  var div = document.createElement('div');
2452
2459
  div.textContent = str;
2453
- return div.innerHTML;
2460
+ // Also safe inside double-quoted attributes; heap names are untrusted.
2461
+ return div.innerHTML.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
2454
2462
  }
2455
2463
 
2456
2464
  function renderMarkdown(raw) {
@@ -2847,8 +2855,8 @@ document.querySelectorAll('.tab-btn').forEach(b => {
2847
2855
  var section = document.getElementById('diagnosis-section');
2848
2856
  if (!data.summary || !data.classHistogram) { section.innerHTML = ''; return; }
2849
2857
 
2850
- var totalHeap = data.summary.reachable_heap_size || data.summary.total_heap_size;
2851
- if (totalHeap === 0) { section.innerHTML = ''; return; }
2858
+ var totalHeap = data.summary.reachable_heap_size;
2859
+ if (!Number.isFinite(totalHeap) || totalHeap <= 0) { section.innerHTML = ''; return; }
2852
2860
 
2853
2861
  var findings = [];
2854
2862
  var suspects = data.leakSuspects || [];
@@ -2862,6 +2870,7 @@ document.querySelectorAll('.tab-btn').forEach(b => {
2862
2870
 
2863
2871
  var histogram = data.classHistogram || [];
2864
2872
  histogram.forEach(function(entry) {
2873
+ if (!Number.isFinite(entry.retained_size) || entry.retained_size < 0) return;
2865
2874
  var pct = (entry.retained_size / totalHeap) * 100;
2866
2875
  var cn = entry.class_name;
2867
2876
  var cnLower = cn.toLowerCase();
@@ -3786,7 +3795,7 @@ function buildIncidentReport(data, hprofPath) {
3786
3795
  lines.push('## Heap Summary');
3787
3796
  lines.push('');
3788
3797
  lines.push(`- **Total Heap Size:** ${fmt(s.total_heap_size)}`);
3789
- lines.push(`- **Reachable Heap Size:** ${fmt(s.reachable_heap_size || s.total_heap_size)}`);
3798
+ lines.push(`- **Reachable Heap Size:** ${typeof s.reachable_heap_size === 'number' && Number.isFinite(s.reachable_heap_size) && s.reachable_heap_size >= 0 ? fmt(s.reachable_heap_size) : 'Not calculated'}`);
3790
3799
  lines.push(`- **Objects:** ${s.total_instances.toLocaleString()}`);
3791
3800
  lines.push(`- **Classes:** ${s.total_classes.toLocaleString()}`);
3792
3801
  lines.push(`- **Arrays:** ${s.total_arrays.toLocaleString()}`);
@@ -3922,6 +3931,9 @@ onMessage=function(command,handler) {
3922
3931
  });
3923
3932
  };
3924
3933
  function dispatchHost(message) {
3934
+ // Explicit Stop keeps the request fence until its matching terminal event resets the UI.
3935
+ // Navigation retirement is separate: its old panel must not accept a late reply.
3936
+ if(message.command==='stopAi') {hostSend({command:'cancelAiAssistance'});return;}
3925
3937
  if(message.command==='copyReport') {
3926
3938
  if(!objectReady || !analysisData) return;
3927
3939
  hostSend({command:'copyReportText',text:buildIncidentReport(analysisData,analysisData.displayName || 'Heap dump')});return;
@@ -3945,7 +3957,8 @@ function dispatchHost(message) {
3945
3957
  document.querySelectorAll('.suspect-explain-link').forEach(function(link){link.textContent='Explain';});
3946
3958
  return;
3947
3959
  }
3948
- if(channel!=='fix' && !(message.command==='explainLeakSuspect' && message.objectId===undefined) &&
3960
+ var exactExplainId = channel==='explain' && typeof message.objectId==='string' && /^[1-9][0-9]{0,19}$/.test(message.objectId) && BigInt(message.objectId)<=18446744073709551615n;
3961
+ if(channel!=='fix' && !exactExplainId && !(message.command==='explainLeakSuspect' && message.objectId===undefined) &&
3949
3962
  (!Number.isSafeInteger(message.objectId) || message.objectId<(channel==='flame'?0:1))) {
3950
3963
  localStatus('This object ID is unsupported; no request was sent.');return;
3951
3964
  }
@@ -4066,10 +4079,14 @@ document.addEventListener('click',function(e){
4066
4079
  // Self-contained: owns explain-object streaming buffer.
4067
4080
 
4068
4081
  var _explainObjectBuffer = '';
4082
+ var _explainObjectSelection = null;
4069
4083
 
4070
4084
  function openInspector(objectId, className, shallowSize, retainedSize) {
4085
+ if (_explainObjectSelection !== null) vscode.postMessage({command:'stopAi'});
4086
+ _explainObjectSelection = null; _explainObjectBuffer = '';
4071
4087
  var panel = document.getElementById('inspector-panel');
4072
4088
  panel.dataset.objectId = objectId;
4089
+ panel.dataset.exactObjectId = (typeof objectId === 'string' && /^[1-9][0-9]{0,19}$/.test(objectId) || typeof objectId === 'number' && Number.isSafeInteger(objectId) && objectId > 0) ? String(objectId) : '';
4073
4090
  panel.dataset.className = className;
4074
4091
  panel.dataset.shallowSize = shallowSize;
4075
4092
  panel.dataset.retainedSize = retainedSize;
@@ -4082,6 +4099,8 @@ document.addEventListener('click',function(e){
4082
4099
  }
4083
4100
 
4084
4101
  function closeInspector() {
4102
+ if (_explainObjectSelection !== null) vscode.postMessage({command:'stopAi'});
4103
+ _explainObjectSelection = null; _explainObjectBuffer = '';
4085
4104
  var panel = document.getElementById('inspector-panel');
4086
4105
  panel.classList.remove('visible');
4087
4106
  panel.innerHTML = '';
@@ -4090,6 +4109,7 @@ document.addEventListener('click',function(e){
4090
4109
  function renderInspectorFields(objectId, fields) {
4091
4110
  var panel = document.getElementById('inspector-panel');
4092
4111
  if (!panel.classList.contains('visible')) return;
4112
+ if (String(objectId) !== panel.dataset.objectId) return;
4093
4113
 
4094
4114
  var body = panel.querySelector('.inspector-loading');
4095
4115
  if (!body) return;
@@ -4147,13 +4167,12 @@ document.addEventListener('click',function(e){
4147
4167
  explainArea.textContent = '';
4148
4168
 
4149
4169
  var panel = document.getElementById('inspector-panel');
4170
+ if (!panel.dataset.exactObjectId) { explainArea.textContent = 'Object identity is not exact. This object cannot be sent to AI.'; explainBtn.disabled = false; return; }
4171
+ _explainObjectSelection = panel.dataset.exactObjectId;
4150
4172
  vscode.postMessage({
4151
4173
  command: 'explainObject',
4152
- objectId: parseInt(panel.dataset.objectId),
4153
- className: panel.dataset.className,
4154
- shallowSize: parseInt(panel.dataset.shallowSize),
4155
- retainedSize: parseInt(panel.dataset.retainedSize),
4156
- fields: fields
4174
+ objectId: panel.dataset.exactObjectId,
4175
+ className: panel.dataset.className
4157
4176
  });
4158
4177
  });
4159
4178
  }
@@ -4175,6 +4194,8 @@ document.addEventListener('click',function(e){
4175
4194
  });
4176
4195
 
4177
4196
  onMessage('explainChunk', function(msg) {
4197
+ var panel = document.getElementById('inspector-panel');
4198
+ if (!_explainObjectSelection || String(msg.objectId) !== _explainObjectSelection || panel.dataset.objectId !== _explainObjectSelection) return;
4178
4199
  _explainObjectBuffer += msg.text;
4179
4200
  var area = document.getElementById('inspector-explain-area');
4180
4201
  if (area) {
@@ -4183,21 +4204,30 @@ document.addEventListener('click',function(e){
4183
4204
  }
4184
4205
  });
4185
4206
 
4186
- onMessage('explainDone', function() {
4207
+ onMessage('explainDone', function(msg) {
4208
+ var panel = document.getElementById('inspector-panel');
4209
+ if (!_explainObjectSelection || String(msg.objectId) !== _explainObjectSelection || panel.dataset.objectId !== _explainObjectSelection) return;
4187
4210
  var area = document.getElementById('inspector-explain-area');
4188
4211
  if (area) {
4189
4212
  area.classList.remove('streaming');
4190
4213
  area.classList.add('rendered');
4191
- area.innerHTML = renderMarkdown(_explainObjectBuffer);
4214
+ if (msg.safeEvidenceAnswer) {
4215
+ area.innerHTML = renderMarkdown(_explainObjectBuffer); // Escapes HTML; never executes model output.
4216
+ attachInvestigationEvidence(area, msg.evidence);
4217
+ } else { area.innerHTML = renderMarkdown(_explainObjectBuffer); }
4192
4218
  area.scrollTop = 0;
4193
4219
  }
4194
4220
  _explainObjectBuffer = '';
4221
+ _explainObjectSelection = null;
4195
4222
  var btn = document.getElementById('inspector-explain-btn');
4196
4223
  if (btn) { btn.textContent = 'Explain this object'; btn.disabled = false; }
4197
4224
  });
4198
4225
 
4199
4226
  onMessage('explainError', function(msg) {
4227
+ var panel = document.getElementById('inspector-panel');
4228
+ if (!_explainObjectSelection || String(msg.objectId) !== _explainObjectSelection || panel.dataset.objectId !== _explainObjectSelection) return;
4200
4229
  _explainObjectBuffer = '';
4230
+ _explainObjectSelection = null;
4201
4231
  var area = document.getElementById('inspector-explain-area');
4202
4232
  if (area) {
4203
4233
  area.classList.remove('streaming');
@@ -4742,7 +4772,9 @@ const onTabMessage = (tab, command, handler) => listenTab(tab, command, message
4742
4772
  row.setAttribute('tabindex', '-1');
4743
4773
  if (!leaf) row.setAttribute('aria-expanded', 'false');
4744
4774
 
4745
- var pct = _reachableHeap !== null ? ((obj.retained_size / _reachableHeap) * 100) : null;
4775
+ var pct = _reachableHeap !== null && Number.isFinite(obj.retained_size) && obj.retained_size >= 0
4776
+ ? ((obj.retained_size / _reachableHeap) * 100) : null;
4777
+ if (!Number.isFinite(pct)) pct = null;
4746
4778
  var pctStr = pct === null ? 'N/A' : pct.toFixed(1) + '%';
4747
4779
  var barWidth = pct === null ? 0 : Math.max(1, Math.min(100, pct));
4748
4780
  var displayName = obj.class_name || obj.node_type;
@@ -4763,7 +4795,7 @@ const onTabMessage = (tab, command, handler) => listenTab(tab, command, message
4763
4795
  '<span class="tree-shallow">' + fmt(obj.shallow_size) + '</span>' +
4764
4796
  '<span class="tree-size">' + fmt(obj.retained_size) + '</span>' +
4765
4797
  '<span class="tree-bar-wrap"><div class="tree-bar" style="width:' + barWidth + '%"></div></span>' +
4766
- '<span class="tree-pct">' + pctStr + '</span>' +
4798
+ '<span class="tree-pct" title="Retained size as a percentage of reachable heap. Nested entries can overlap.">' + pctStr + '</span>' +
4767
4799
  '<span class="tree-actions">' +
4768
4800
  '<span class="tree-action-slot tree-action-alive">' + (showPin ? '<button class="why-alive-btn" title="Show GC root path">Why alive?</button>' : '') + '</span>' +
4769
4801
  '<span class="tree-action-slot tree-action-icon">' + (showInspect ? '<span class="tree-inspect" role="button" aria-label="Inspect fields for ' + escapeHtml(displayName) + '" title="Inspect fields">\uD83D\uDD0D</span>' : '') + '</span>' +
@@ -4870,6 +4902,16 @@ const onTabMessage = (tab, command, handler) => listenTab(tab, command, message
4870
4902
  }
4871
4903
 
4872
4904
  // ---- Self-register ----
4905
+ function retireDominatorPercentage() {
4906
+ _reachableHeap = null;
4907
+ _treeData = [];
4908
+ document.getElementById('dominator-tree').innerHTML = '';
4909
+ document.getElementById('domtree-header').style.display = 'none';
4910
+ document.getElementById('reset-tree-btn').style.display = 'none';
4911
+ }
4912
+ ['analysisStarting', 'analysisCancelled', 'analysisFailed', 'serverCrashed'].forEach(function(command) {
4913
+ onMessage(command, retireDominatorPercentage);
4914
+ });
4873
4915
  onTabMessage('domtree', 'analysisComplete', function(msg) {
4874
4916
  renderDominatorTree(msg.topLayers || [], msg.summary && msg.summary.reachable_heap_size);
4875
4917
  });
@@ -5426,7 +5468,7 @@ const onMessage = (command, handler) => {
5426
5468
  handler(message);
5427
5469
  aiStatus.textContent = command === "chatChunk" ? "Receiving AI response…" : command === "chatDone" ? "Response complete. Verify suggestions before acting." : (message.message || "AI request failed.");
5428
5470
  if (command !== "chatChunk") finishAi();
5429
- }); else if (command === "restoreChatHistory") listen("aiHistory", handler);
5471
+ }); else if (command === "restoreChatHistory") listen("aiHistory", handler); else listen(command, handler);
5430
5472
  };
5431
5473
 
5432
5474
  // ---- Tab 9: AI Chat ----
@@ -5440,6 +5482,105 @@ const onMessage = (command, handler) => {
5440
5482
  var _isChatStreaming = false;
5441
5483
  var _chatStreamBuffer = '';
5442
5484
  var _pendingChatQuery = null; // { bubble, codeBlock } when waiting for query result
5485
+ var _chatQuerySequence = 0;
5486
+ var _chatQueryEpoch = 0;
5487
+ var _chatQueryReady = false;
5488
+ var chatQuerySegments = function chatQuerySegments(answer) {
5489
+ if (answer.length > 65536)
5490
+ return [{ text: answer }];
5491
+ const text = answer;
5492
+ const found = [];
5493
+ const fences = [];
5494
+ const candidate = (raw) => {
5495
+ const query = raw.trim();
5496
+ if (!query || query.length > 32000)
5497
+ return;
5498
+ if (/^:(?:info|path|refs|children)\s+(?:0x[0-9a-f]+|[0-9]+)\s*$/i.test(query))
5499
+ return query;
5500
+ if (!/^SELECT\b/i.test(query))
5501
+ return;
5502
+ // Ignore quoted literals when recognizing table names and statement boundaries.
5503
+ let masked = '', quote = '';
5504
+ for (let i = 0; i < query.length; i++) {
5505
+ const ch = query[i];
5506
+ if (quote) {
5507
+ if (ch === '\\') {
5508
+ i++;
5509
+ masked += ' ';
5510
+ continue;
5511
+ }
5512
+ if (ch === quote) {
5513
+ if (query[i + 1] === quote) {
5514
+ i++;
5515
+ masked += ' ';
5516
+ continue;
5517
+ }
5518
+ quote = '';
5519
+ }
5520
+ masked += ' ';
5521
+ }
5522
+ else if (ch === "'" || ch === '"') {
5523
+ quote = ch;
5524
+ masked += ' ';
5525
+ }
5526
+ else
5527
+ masked += ch;
5528
+ }
5529
+ if (quote || /--|\/\*|\*\/|\b(?:INSERT|UPDATE|DELETE|DROP|ALTER|CREATE|TRUNCATE|ATTACH|PRAGMA|EXEC|CALL|INTO)\b/i.test(masked))
5530
+ return;
5531
+ if (masked.replace(/;\s*$/, '').includes(';'))
5532
+ return;
5533
+ const tables = [...masked.matchAll(/\bFROM\s+([A-Za-z_][A-Za-z0-9_]*)/gi)].map(m => m[1].toLowerCase());
5534
+ if (!tables.length || tables.some(t => !['instances', 'class_histogram', 'dominator_tree', 'leak_suspects'].includes(t)))
5535
+ return;
5536
+ // SQL-style terminal delimiters are presentation, not supported HeapQL tokens.
5537
+ return query.endsWith(';') ? query.slice(0, -1).trimEnd() : query;
5538
+ };
5539
+ const add = (start, end, raw) => { const query = candidate(raw); if (query && found.length < 16)
5540
+ found.push({ start, end, query }); };
5541
+ // Exclude every fenced language from prose scanning, including shell/Java/JavaScript.
5542
+ const fence = /^ {0,3}(`{3,}|~{3,})([A-Za-z0-9_-]*)[ \t]*\r?\n([\s\S]*?)^ {0,3}\1[ \t]*\r?(?=\n|$)/gm;
5543
+ for (const match of text.matchAll(fence)) {
5544
+ const start = match.index, end = start + match[0].length;
5545
+ fences.push({ start, end });
5546
+ if (/^(?:heapql|sql)?$/i.test(match[2]))
5547
+ add(start, end, match[3]);
5548
+ }
5549
+ for (const match of text.matchAll(/^ {0,3}(?:`{3,}|~{3,})[^\n]*$/gm)) {
5550
+ if (!fences.some(span => match.index >= span.start && match.index < span.end)) {
5551
+ fences.push({ start: match.index, end: text.length });
5552
+ break;
5553
+ }
5554
+ }
5555
+ fences.sort((a, b) => a.start - b.start);
5556
+ const scan = (start, end) => {
5557
+ const chunk = text.slice(start, end);
5558
+ for (const match of chunk.matchAll(/`([^`\n]+)`/g))
5559
+ add(start + match.index, start + match.index + match[0].length, match[1]);
5560
+ for (const match of chunk.matchAll(/[^\n]+(?:\n(?![ \t\r]*\n)[^\n]+)*/g))
5561
+ add(start + match.index, start + match.index + match[0].length, match[0]);
5562
+ };
5563
+ let offset = 0;
5564
+ for (const span of fences) {
5565
+ scan(offset, span.start);
5566
+ offset = span.end;
5567
+ }
5568
+ scan(offset, text.length);
5569
+ found.sort((a, b) => a.start - b.start || b.end - a.end);
5570
+ const segments = [];
5571
+ offset = 0;
5572
+ for (const span of found) {
5573
+ if (span.start < offset)
5574
+ continue;
5575
+ if (span.start > offset)
5576
+ segments.push({ text: text.slice(offset, span.start) });
5577
+ segments.push({ text: text.slice(span.start, span.end), query: span.query });
5578
+ offset = span.end;
5579
+ }
5580
+ if (offset < text.length || !segments.length)
5581
+ segments.push({ text: text.slice(offset) });
5582
+ return segments;
5583
+ };
5443
5584
 
5444
5585
  function addChatBubble(role, text) {
5445
5586
  if (_chatPlaceholder) _chatPlaceholder.style.display = 'none';
@@ -5482,30 +5623,49 @@ const onMessage = (command, handler) => {
5482
5623
  }
5483
5624
  });
5484
5625
 
5485
- function attachHeapqlButtons(bubble) {
5486
- var codeBlocks = bubble.querySelectorAll('code.language-heapql');
5487
- codeBlocks.forEach(function(codeEl) {
5488
- var pre = codeEl.closest('pre');
5489
- if (!pre) return;
5490
- var query = codeEl.textContent.trim();
5491
- if (!query) return;
5626
+ function updateChatQueryButtons() {
5627
+ _chatMessages.querySelectorAll('.chat-run-query-btn').forEach(function(button) {
5628
+ button.disabled = !_chatQueryReady || !!_pendingChatQuery || button.dataset.expired === 'true';
5629
+ });
5630
+ }
5631
+ function renderChatAnswer(bubble, text) {
5632
+ bubble.textContent = '';
5633
+ chatQuerySegments(text).forEach(function(segment) {
5634
+ if (!segment.query) { var prose=document.createElement('div');prose.innerHTML=renderMarkdown(segment.text);bubble.appendChild(prose);return; }
5635
+ var query=segment.query;
5636
+ var pre=document.createElement('pre');pre.className='md-code-block';
5637
+ var code=document.createElement('code');code.className='language-heapql';code.textContent=query;pre.appendChild(code);bubble.appendChild(pre);
5492
5638
  var btn = document.createElement('button');
5493
5639
  btn.className = 'chat-run-query-btn';
5494
5640
  btn.textContent = 'Run Query';
5641
+ var epoch=_chatQueryEpoch;
5495
5642
  btn.addEventListener('click', function() {
5643
+ if (!_chatQueryReady || epoch!==_chatQueryEpoch || _pendingChatQuery || !bubble.isConnected) return;
5496
5644
  btn.disabled = true;
5497
5645
  btn.textContent = 'Running...';
5498
- _pendingChatQuery = { bubble: bubble, codeBlock: pre, button: btn };
5499
- vscode.postMessage({ command: 'executeQuery', query: query });
5646
+ var requestId='chat-query-'+(++_chatQuerySequence);
5647
+ _pendingChatQuery = { bubble: bubble, codeBlock: pre, button: btn, query:query, requestId:requestId };
5648
+ updateChatQueryButtons();
5649
+ vscode.postMessage({ command: 'executeQuery', query: query, requestId:requestId });
5500
5650
  });
5501
- pre.insertAdjacentElement('afterend', btn);
5651
+ bubble.appendChild(btn);
5502
5652
  });
5653
+ bubble.classList.add('rendered');
5654
+ updateChatQueryButtons();
5503
5655
  }
5504
5656
 
5505
- function renderChatQueryResult(pre, btn, result) {
5657
+ function retireChatQueries() {
5658
+ _chatQueryReady=false;_chatQueryEpoch++;_pendingChatQuery=null;
5659
+ _chatMessages.querySelectorAll('.chat-run-query-btn').forEach(function(button){button.dataset.expired='true';button.disabled=true;});
5660
+ }
5661
+ ['analysisStarting','analysisRetrying','serverCrashed','analysisCancelled'].forEach(function(command){onMessage(command,retireChatQueries);});
5662
+ onMessage('analysisProgress',function(msg){if(msg.stage==='loading')retireChatQueries();});
5663
+ onMessage('analysisComplete',function(){_chatQueryReady=true;updateChatQueryButtons();});
5664
+
5665
+ function renderChatQueryResult(pre, btn, result, query) {
5506
5666
  // Remove any existing result for this block
5507
- var existing = pre.parentElement.querySelector('.chat-query-result');
5508
- if (existing) existing.remove();
5667
+ var existing = btn.nextElementSibling;
5668
+ if (existing && existing.classList.contains('chat-query-result')) existing.remove();
5509
5669
 
5510
5670
  var container = document.createElement('div');
5511
5671
  container.className = 'chat-query-result';
@@ -5545,7 +5705,7 @@ const onMessage = (command, handler) => {
5545
5705
  }
5546
5706
 
5547
5707
  var timeMs = result.execution_time_ms !== undefined ? result.execution_time_ms : '?';
5548
- html += '<div class="chat-query-status">' + rows.length + ' rows (' + timeMs + 'ms)</div>';
5708
+ html += '<div class="chat-query-status">' + rows.length + ' rows (' + escapeHtml(String(timeMs)) + 'ms)</div>';
5549
5709
 
5550
5710
  container.innerHTML = html;
5551
5711
  btn.insertAdjacentElement('afterend', container);
@@ -5558,17 +5718,85 @@ const onMessage = (command, handler) => {
5558
5718
  link.addEventListener('click', function() {
5559
5719
  var queryTab = document.querySelector('.tab-btn[data-tab="query"]');
5560
5720
  if (queryTab) queryTab.click();
5721
+ if (typeof query==='string') {
5722
+ var input=document.getElementById('query-input');var run=document.getElementById('query-run-btn');
5723
+ if(input && run){input.value=query;input.dispatchEvent(new Event('input'));run.click();}
5724
+ }
5561
5725
  });
5562
5726
  }
5563
5727
 
5564
5728
  _chatMessages.scrollTop = _chatMessages.scrollHeight;
5565
5729
  }
5566
5730
 
5567
- onMessage('chatDone', function() {
5731
+ function attachInvestigationEvidence(area, evidence) {
5732
+ var answerText = area.textContent || '';
5733
+ var seen = new Set();
5734
+ var cited = (Array.isArray(evidence) ? evidence : []).slice(0, 32).filter(function(record) {
5735
+ if (!record || typeof record.id !== 'string' || !/^E[a-f0-9]{12}_[1-9][0-9]{0,3}$/.test(record.id)
5736
+ || answerText.indexOf('[' + record.id + ']') < 0 || seen.has(record.id)) return false;
5737
+ seen.add(record.id); return true;
5738
+ });
5739
+ if (cited.length) {
5740
+ var note = document.createElement('small');
5741
+ note.style.display = 'block'; note.style.marginTop = '8px';
5742
+ note.textContent = 'AI interpretation may be wrong. Evidence below is engine metadata, not proof of a cause or repair.';
5743
+ area.appendChild(note);
5744
+ }
5745
+ cited.forEach(function(record) {
5746
+ var button = document.createElement('button');
5747
+ button.className = 'chat-clear';
5748
+ button.textContent = 'View evidence: ' + record.operation;
5749
+ button.addEventListener('click', function() { vscode.postMessage({command:'openAiEvidence', id:record.id}); });
5750
+ area.appendChild(button);
5751
+ });
5752
+ }
5753
+ // Native/browser hosts scope chat in an IIFE; object views share only this safe renderer.
5754
+ window.attachInvestigationEvidence = attachInvestigationEvidence;
5755
+ function retireInvestigationViewer() {
5756
+ var viewer=document.getElementById('heaplens-ai-evidence');
5757
+ if(viewer){if(viewer.open)viewer.close();viewer.remove();}
5758
+ }
5759
+ ['analysisStarting','analysisRetrying','serverCrashed','analysisCancelled','aiReset'].forEach(function(command){onMessage(command,retireInvestigationViewer);});
5760
+ onMessage('analysisProgress',function(msg){if(msg.stage==='loading')retireInvestigationViewer();});
5761
+
5762
+ onMessage('aiEvidenceResult', function(msg) {
5763
+ var viewer = document.getElementById('heaplens-ai-evidence');
5764
+ if (!viewer) { viewer = document.createElement('dialog'); viewer.id = 'heaplens-ai-evidence'; document.body.appendChild(viewer); }
5765
+ viewer.textContent = '';
5766
+ var close = document.createElement('button'); close.textContent = 'Close evidence'; close.addEventListener('click',function(){viewer.close();});viewer.appendChild(close);
5767
+ var content = document.createElement('pre'); content.style.whiteSpace = 'pre-wrap';
5768
+ content.textContent = msg.error || (msg.record ? 'Evidence ' + msg.record.id + ' — ' + msg.record.operation + '\n' + JSON.stringify(msg.record.data, null, 2) : 'Evidence unavailable.');
5769
+ viewer.appendChild(content);if (!viewer.open) viewer.showModal();
5770
+ });
5771
+
5772
+ onMessage('aiInvestigationProgress', function(msg) {
5773
+ var areas = [];
5774
+ if (msg.channel === 'chat' && _currentBubble) areas.push(_currentBubble);
5775
+ if (msg.channel === 'explain') { var inspector = document.getElementById('inspector-explain-area'); var panel = document.getElementById('inspector-panel'); if (inspector && panel && panel.dataset.objectId === String(msg.objectId)) areas.push(inspector); }
5776
+ if (msg.channel === 'explainLeak' && typeof msg.className === 'string') {
5777
+ var target = msg.objectId ? 'explain-obj-' + leakEvidenceKey(msg.className,msg.objectId) : 'explain-' + leakEvidenceKey(msg.className);
5778
+ var targetArea = document.getElementById(target); if(targetArea)areas.push(targetArea);
5779
+ }
5780
+ areas.forEach(function(area) {
5781
+ area.textContent = msg.message || 'Investigating…';
5782
+ if (msg.channel !== 'chat') {
5783
+ var stop = document.createElement('button'); stop.textContent = 'Stop investigation';
5784
+ stop.addEventListener('click', function() { vscode.postMessage({command:'stopAi',channel:msg.channel}); }); area.appendChild(stop);
5785
+ }
5786
+ });
5787
+ });
5788
+
5789
+ var _chatStop = document.getElementById('chat-stop');
5790
+ if (_chatStop) _chatStop.addEventListener('click', function() { vscode.postMessage({command:'stopAi'}); });
5791
+
5792
+ onMessage('chatDone', function(msg) {
5568
5793
  if (_currentBubble && _chatStreamBuffer) {
5569
- _currentBubble.innerHTML = renderMarkdown(_chatStreamBuffer);
5570
- _currentBubble.classList.add('rendered');
5571
- attachHeapqlButtons(_currentBubble);
5794
+ if (msg.safeEvidenceAnswer) {
5795
+ renderChatAnswer(_currentBubble, _chatStreamBuffer);
5796
+ attachInvestigationEvidence(_currentBubble, msg.evidence);
5797
+ } else {
5798
+ renderChatAnswer(_currentBubble, _chatStreamBuffer);
5799
+ }
5572
5800
  }
5573
5801
  _isChatStreaming = false;
5574
5802
  _chatSend.disabled = false;
@@ -5577,6 +5805,10 @@ const onMessage = (command, handler) => {
5577
5805
  });
5578
5806
 
5579
5807
  onMessage('chatError', function(msg) {
5808
+ if (_currentBubble && _chatStreamBuffer) {
5809
+ _currentBubble.textContent = 'Incomplete AI answer — not a completed diagnosis.\n\n' + _chatStreamBuffer;
5810
+ _currentBubble.classList.add('error');
5811
+ }
5580
5812
  _isChatStreaming = false;
5581
5813
  _chatSend.disabled = false;
5582
5814
  _currentBubble = null;
@@ -5584,27 +5816,37 @@ const onMessage = (command, handler) => {
5584
5816
  addChatBubble('error', msg.message || 'An error occurred');
5585
5817
  });
5586
5818
 
5587
- onMessage('queryResult', function(msg) {
5588
- if (_pendingChatQuery) {
5589
- renderChatQueryResult(_pendingChatQuery.codeBlock, _pendingChatQuery.button, msg.result);
5819
+ onMessage('chatQueryResult', function(msg) {
5820
+ if (_pendingChatQuery && msg.requestId===_pendingChatQuery.requestId) {
5821
+ renderChatQueryResult(_pendingChatQuery.codeBlock, _pendingChatQuery.button, msg.result, _pendingChatQuery.query);
5590
5822
  _pendingChatQuery = null;
5823
+ updateChatQueryButtons();
5591
5824
  return true;
5592
5825
  }
5593
5826
  return false;
5594
5827
  });
5595
5828
 
5829
+ onMessage('chatQueryError', function(msg) {
5830
+ if (!_pendingChatQuery || msg.requestId!==_pendingChatQuery.requestId) return false;
5831
+ var pending=_pendingChatQuery;_pendingChatQuery=null;
5832
+ pending.button.textContent='Run Query';
5833
+ var previous=pending.button.nextElementSibling;if(previous && previous.classList.contains('chat-query-result'))previous.remove();
5834
+ var error=document.createElement('div');error.className='chat-query-result error';error.textContent=msg.error || 'Query failed. Check its syntax and retry.';
5835
+ pending.button.insertAdjacentElement('afterend',error);updateChatQueryButtons();return true;
5836
+ });
5837
+
5596
5838
  onMessage('restoreChatHistory', function(msg) {
5597
5839
  var messages = msg.messages || [];
5598
5840
  if (messages.length === 0) return;
5599
5841
  if (_chatPlaceholder) _chatPlaceholder.style.display = 'none';
5842
+ addChatBubble('system', 'Previous local conversation. Its evidence links have expired and are not reused by this analysis.');
5600
5843
  messages.forEach(function(m) {
5601
5844
  if (m.role === 'user') {
5602
5845
  addChatBubble('user', m.content);
5603
5846
  } else if (m.role === 'assistant') {
5604
5847
  var bubble = addChatBubble('assistant', '');
5605
- bubble.innerHTML = renderMarkdown(m.content);
5606
- bubble.classList.add('rendered');
5607
- attachHeapqlButtons(bubble);
5848
+ bubble.textContent = m.content;
5849
+ bubble.style.whiteSpace = 'pre-wrap';
5608
5850
  }
5609
5851
  });
5610
5852
  });
@@ -5612,6 +5854,11 @@ const onMessage = (command, handler) => {
5612
5854
  var _chatClear = document.getElementById('chat-clear');
5613
5855
  if (_chatClear) {
5614
5856
  _chatClear.addEventListener('click', function() {
5857
+ _isChatStreaming = false;
5858
+ _chatSend.disabled = false;
5859
+ _currentBubble = null;
5860
+ _chatStreamBuffer = '';
5861
+ _pendingChatQuery = null;
5615
5862
  _chatMessages.innerHTML = '';
5616
5863
  if (_chatPlaceholder) {
5617
5864
  _chatPlaceholder.style.display = 'block';
@@ -5639,6 +5886,8 @@ function aiControls() {
5639
5886
  function acceptsAi(message) {return aiReady && aiPending && message.requestId===aiPending;}
5640
5887
  function finishAi() {aiPending=null;_isChatStreaming=false;aiControls();}
5641
5888
  function requestAi(message) {
5889
+ if(message.command==='openAiEvidence') {send(message);return;}
5890
+ if(message.command==='stopAi') {send({command:message.channel && message.channel!=='chat'?'cancelAiAssistance':'aiStop'});return;}
5642
5891
  if(message.command==='executeQuery') {
5643
5892
  if(!aiReady || chatQueryId || typeof message.query!=='string' || message.query.length>32000) {
5644
5893
  if(_pendingChatQuery) {_pendingChatQuery.button.disabled=false;_pendingChatQuery.button.textContent='Run Query';_pendingChatQuery=chatQueryContext;}
@@ -5679,13 +5928,7 @@ listen('aiConfiguration',function(message) {
5679
5928
  listen('aiReset',function(message) {clearAiUi(message.message || 'Chat cleared.');});
5680
5929
  listen('aiQueryResult',function(message){
5681
5930
  if(!aiReady || message.requestId!==chatQueryId || !_pendingChatQuery)return;
5682
- renderChatQueryResult(_pendingChatQuery.codeBlock,_pendingChatQuery.button,message.result);
5683
- var link=_pendingChatQuery.codeBlock.parentElement.querySelector('.chat-query-link');
5684
- if(link)link.addEventListener('click',function(){
5685
- document.getElementById('query-input').value=message.query;
5686
- document.getElementById('query-input').dispatchEvent(new Event('input'));
5687
- document.getElementById('query-run-btn').click();
5688
- });
5931
+ renderChatQueryResult(_pendingChatQuery.codeBlock,_pendingChatQuery.button,message.result,message.query);
5689
5932
  chatQueryId=null;chatQueryContext=null;_pendingChatQuery=null;
5690
5933
  _chatMessages.querySelectorAll('.chat-run-query-btn').forEach(function(button){button.disabled=false;});
5691
5934
  });
@@ -5713,6 +5956,12 @@ const vscode={postMessage:send};const depInfoCache=Object.create(null);const isR
5713
5956
  // Self-contained: owns explain-leak streaming buffers, rendering, pagination, threshold filtering.
5714
5957
 
5715
5958
  var _explainLeakBuffers = {};
5959
+ function leakEvidenceKey(className, objectId) {
5960
+ var text = JSON.stringify([String(className), objectId === undefined ? '' : String(objectId)]);
5961
+ var key = ''; for(var i=0;i<text.length;i++)key += text.charCodeAt(i).toString(16).padStart(4,'0');
5962
+ return key;
5963
+ }
5964
+ window.leakEvidenceKey = leakEvidenceKey;
5716
5965
  var _leakPage = 0;
5717
5966
  var LEAK_PAGE_SIZE = 10;
5718
5967
  var _leakSuspectsData = [];
@@ -5800,7 +6049,7 @@ const vscode={postMessage:send};const depInfoCache=Object.create(null);const isR
5800
6049
  var inspectLink = s.object_id
5801
6050
  ? ' <button class="why-alive-btn inspect-obj-link" data-object-id="' + s.object_id + '" data-class-name="' + escapeHtml(s.class_name) + '" data-shallow="' + (s.shallow_size || 0) + '" data-retained="' + s.retained_size + '">Inspect</button>'
5802
6051
  : '';
5803
- var sanitizedId = (s.class_name + '_' + s.object_id).replace(/[^a-zA-Z0-9]/g, '_');
6052
+ var sanitizedId = leakEvidenceKey(s.class_name,s.object_id);
5804
6053
  var explainLink = ' | <a class="suspect-explain-link" data-class="' + escapeHtml(s.class_name) +
5805
6054
  '" data-object-id="' + (s.object_id || '') +
5806
6055
  '" data-retained="' + s.retained_size +
@@ -5899,7 +6148,7 @@ const vscode={postMessage:send};const depInfoCache=Object.create(null);const isR
5899
6148
  : '';
5900
6149
  var cachedDep = depInfoCache[s.class_name];
5901
6150
  var depBadge = cachedDep ? makeBadgeHtml(cachedDep.tier, cachedDep.dependency) : '';
5902
- var sanitizedId = s.class_name.replace(/[^a-zA-Z0-9]/g, '_');
6151
+ var sanitizedId = leakEvidenceKey(s.class_name);
5903
6152
  var explainLink = ' | <a class="suspect-explain-link" data-class="' + escapeHtml(s.class_name) +
5904
6153
  '" data-retained="' + s.retained_size +
5905
6154
  '" data-pct="' + s.retained_percentage +
@@ -5984,7 +6233,7 @@ const vscode={postMessage:send};const depInfoCache=Object.create(null);const isR
5984
6233
  description: link.dataset.desc
5985
6234
  };
5986
6235
  if (link.dataset.objectId) {
5987
- msgPayload.objectId = parseInt(link.dataset.objectId, 10);
6236
+ msgPayload.objectId = link.dataset.objectId;
5988
6237
  }
5989
6238
  vscode.postMessage(msgPayload);
5990
6239
  });
@@ -6024,16 +6273,16 @@ const vscode={postMessage:send};const depInfoCache=Object.create(null);const isR
6024
6273
  });
6025
6274
 
6026
6275
  onMessage('explainLeakChunk', function(msg) {
6027
- var classSanitized = msg.className.replace(/[^a-zA-Z0-9]/g, '_');
6276
+ var classSanitized = leakEvidenceKey(msg.className);
6028
6277
  // For object-level suspects, the explain area ID includes the objectId
6029
- var objSanitized = msg.objectId ? (msg.className + '_' + msg.objectId).replace(/[^a-zA-Z0-9]/g, '_') : classSanitized;
6278
+ var objSanitized = msg.objectId ? leakEvidenceKey(msg.className,msg.objectId) : classSanitized;
6030
6279
  var bufferKey = msg.objectId ? objSanitized : classSanitized;
6031
6280
  if (!_explainLeakBuffers[bufferKey]) _explainLeakBuffers[bufferKey] = '';
6032
6281
  _explainLeakBuffers[bufferKey] += msg.text;
6033
6282
  // Try object-view first when objectId is present, then class-view
6034
6283
  var area = msg.objectId
6035
- ? (document.getElementById('explain-obj-' + objSanitized) || document.getElementById('explain-' + classSanitized))
6036
- : (document.getElementById('explain-' + classSanitized) || document.getElementById('explain-obj-' + objSanitized));
6284
+ ? document.getElementById('explain-obj-' + objSanitized)
6285
+ : document.getElementById('explain-' + classSanitized);
6037
6286
  if (area) {
6038
6287
  area.textContent = _explainLeakBuffers[bufferKey];
6039
6288
  area.scrollTop = area.scrollHeight;
@@ -6059,53 +6308,61 @@ const vscode={postMessage:send};const depInfoCache=Object.create(null);const isR
6059
6308
  }
6060
6309
 
6061
6310
  onMessage('explainLeakDone', function(msg) {
6062
- var classSanitized = msg.className.replace(/[^a-zA-Z0-9]/g, '_');
6063
- var objSanitized = msg.objectId ? (msg.className + '_' + msg.objectId).replace(/[^a-zA-Z0-9]/g, '_') : classSanitized;
6311
+ var classSanitized = leakEvidenceKey(msg.className);
6312
+ var objSanitized = msg.objectId ? leakEvidenceKey(msg.className,msg.objectId) : classSanitized;
6064
6313
  var bufferKey = msg.objectId ? objSanitized : classSanitized;
6065
6314
  // Try class-view explain area
6066
- var classArea = document.getElementById('explain-' + classSanitized);
6315
+ var classArea = !msg.objectId && document.getElementById('explain-' + classSanitized);
6067
6316
  if (classArea) {
6068
6317
  classArea.classList.remove('streaming');
6069
6318
  classArea.classList.add('rendered');
6070
- classArea.innerHTML = renderMarkdown(_explainLeakBuffers[bufferKey] || '');
6319
+ if (msg.safeEvidenceAnswer) {
6320
+ classArea.innerHTML = renderMarkdown(_explainLeakBuffers[bufferKey] || '');
6321
+ attachInvestigationEvidence(classArea, msg.evidence);
6322
+ } else { classArea.innerHTML = renderMarkdown(_explainLeakBuffers[bufferKey] || ''); }
6071
6323
  classArea.scrollTop = 0;
6072
6324
  addExplainCloseBtn(classArea);
6073
6325
  }
6074
6326
  // Try object-view explain area
6075
- var objArea = document.getElementById('explain-obj-' + objSanitized);
6327
+ var objArea = msg.objectId && document.getElementById('explain-obj-' + objSanitized);
6076
6328
  if (objArea) {
6077
6329
  objArea.classList.remove('streaming');
6078
6330
  objArea.classList.add('rendered');
6079
- objArea.innerHTML = renderMarkdown(_explainLeakBuffers[bufferKey] || '');
6331
+ if (msg.safeEvidenceAnswer) {
6332
+ objArea.innerHTML = renderMarkdown(_explainLeakBuffers[bufferKey] || '');
6333
+ attachInvestigationEvidence(objArea, msg.evidence);
6334
+ } else { objArea.innerHTML = renderMarkdown(_explainLeakBuffers[bufferKey] || ''); }
6080
6335
  objArea.scrollTop = 0;
6081
6336
  addExplainCloseBtn(objArea);
6082
6337
  }
6083
6338
  delete _explainLeakBuffers[bufferKey];
6084
- document.querySelectorAll('.suspect-explain-link[data-class="' + msg.className + '"]').forEach(function(link) {
6339
+ document.querySelectorAll('.suspect-explain-link').forEach(function(link) {
6340
+ if (link.dataset.class !== msg.className) return;
6085
6341
  link.textContent = 'Explain';
6086
6342
  });
6087
6343
  });
6088
6344
 
6089
6345
  onMessage('explainLeakError', function(msg) {
6090
- var classSanitized = msg.className.replace(/[^a-zA-Z0-9]/g, '_');
6091
- var objSanitized = msg.objectId ? (msg.className + '_' + msg.objectId).replace(/[^a-zA-Z0-9]/g, '_') : classSanitized;
6346
+ var classSanitized = leakEvidenceKey(msg.className);
6347
+ var objSanitized = msg.objectId ? leakEvidenceKey(msg.className,msg.objectId) : classSanitized;
6092
6348
  var bufferKey = msg.objectId ? objSanitized : classSanitized;
6093
6349
  delete _explainLeakBuffers[bufferKey];
6094
6350
  // Try class-view explain area
6095
- var classArea = document.getElementById('explain-' + classSanitized);
6351
+ var classArea = !msg.objectId && document.getElementById('explain-' + classSanitized);
6096
6352
  if (classArea) {
6097
6353
  classArea.classList.remove('streaming');
6098
6354
  classArea.classList.add('error', 'visible');
6099
6355
  classArea.textContent = msg.message || 'An error occurred';
6100
6356
  }
6101
6357
  // Try object-view explain area
6102
- var objArea = document.getElementById('explain-obj-' + objSanitized);
6358
+ var objArea = msg.objectId && document.getElementById('explain-obj-' + objSanitized);
6103
6359
  if (objArea) {
6104
6360
  objArea.classList.remove('streaming');
6105
6361
  objArea.classList.add('error', 'visible');
6106
6362
  objArea.textContent = msg.message || 'An error occurred';
6107
6363
  }
6108
- document.querySelectorAll('.suspect-explain-link[data-class="' + msg.className + '"]').forEach(function(link) {
6364
+ document.querySelectorAll('.suspect-explain-link').forEach(function(link) {
6365
+ if(link.dataset.class !== msg.className)return;
6109
6366
  link.textContent = 'Explain';
6110
6367
  });
6111
6368
  });
@@ -6125,7 +6382,9 @@ function leakReply(message,done,error) {
6125
6382
  if(message.text)leakAnswer+=message.text;
6126
6383
  area.classList.add('visible');
6127
6384
  if(error){area.textContent=message.message || 'AI request failed.';area.classList.add('error');}
6128
- else area.innerHTML=renderMarkdown(leakAnswer);
6385
+ else if(done && !message.safeEvidenceAnswer)area.innerHTML=renderMarkdown(leakAnswer);
6386
+ else {area.textContent=leakAnswer;area.style.whiteSpace='pre-wrap';}
6387
+ if(done && message.safeEvidenceAnswer)window.attachInvestigationEvidence(area,message.evidence);
6129
6388
  if(done || error){area.classList.remove('streaming');link.textContent='Explain';addExplainCloseBtn(area);leakAnswer='';}
6130
6389
  }
6131
6390
  listen('explainLeakChunk',function(m){leakReply(m,false,false);});