pi-studio 0.9.19 → 0.9.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,13 @@ All notable changes to `pi-studio` are documented here.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.9.20] — 2026-05-27
8
+
9
+ ### Added
10
+ - Added an opt-in HTML preview comment mode for editor HTML previews: use **Comment mode** to select text or click elements inside the sandboxed preview, or **Page** for a page-level comment; comments are stored in the existing local Comments rail and can be loaded into a prompt.
11
+ - Added **Refresh** controls to Studio PDF previews and the PDF focus viewer so file-backed PDFs can be reloaded from disk without rerendering the whole Studio pane.
12
+ - Editor-only Studio views now expose the right-pane **Files** and **REPL** views; REPL mode keeps Pi run/critique disabled but allows **Send to REPL** from the editor, including the `Cmd/Ctrl+Shift+Enter` shortcut.
13
+
7
14
  ## [0.9.19] — 2026-05-27
8
15
 
9
16
  ### Changed
package/README.md CHANGED
@@ -19,7 +19,7 @@ Extension for [pi](https://pi.dev) that opens a local two-pane browser workspace
19
19
  ## What it does
20
20
 
21
21
  - Opens a two-pane browser workspace: **Editor** (left) + **Response/Working/Editor Preview** (right)
22
- - Supports one canonical full Studio view per Pi session, plus additional editor-only companion views when you just want extra editing/preview surfaces; the editor toolbar can open a detached copy of the current editor text as a companion view
22
+ - Supports one canonical full Studio view per Pi session, plus additional editor-only companion views when you want extra editing/preview surfaces; editor-only views can also browse files and use the Studio REPL send controls without taking over the full Studio session view
23
23
  - Includes a global **Zen** mode for hiding secondary Studio chrome without changing the current left/right pane layout
24
24
  - Runs editor text directly, asks for structured critique (auto/writing/code focus), offers a manual **Suggest completion** action for short cursor-aware continuations (`Option/Alt+Tab` where available or `Cmd/Ctrl+Shift+Space` from the editor, `Tab` to insert a visible suggestion) with an optional editor-plus-latest-response context mode, or opens **Quiz me** for a Studio-native active-recall loop over the current editor text, selection, current file, folder, or repo, with optional focus guidance for shaping question selection
25
25
  - Includes a live **Working** view for following current model/tool activity, with `All` / `Thinking` / `Tools` filters, image previews for image-producing tool outputs, plus **Load visible into editor** and **Copy visible** actions; when cycling response history, Working follows saved working details for the selected response when available
@@ -29,7 +29,7 @@ Extension for [pi](https://pi.dev) that opens a local two-pane browser workspace
29
29
  - Includes a docked **Outline** rail for navigating document structure in the current editor text, with clickable entries that jump in the raw editor and reveal matching preview locations when available
30
30
  - Restores the current browser-tab editor workspace after refresh and provides an explicit **Reset editor** action when you want to discard the restored draft and return the tab to a fresh blank draft without changing responses or saved files
31
31
  - Turns local preview links, including links inside sandboxed HTML previews, into Studio actions: PDFs open in the embedded viewer, images open in a zoomable focus viewer, PDF/image links can open in a new Studio preview tab, text/code/CSV/TSV document links can open in a new editor tab, DOCX/ODT links can be converted to editable Markdown, and right-click menus provide **Open here**, **Reveal in file manager**, and **Copy path** for local resources
32
- - Includes local comments anchored to selections/lines, shown in a docked **Comments** rail, with transient **Comment** / **Jump** actions from raw-editor selections plus editor-preview selections for Markdown, LaTeX, and code/text/diff previews, alongside optional inline `[an: ...]` toggles when you want comments reflected in the document text
32
+ - Includes local comments anchored to selections/lines, shown in a docked **Comments** rail, with transient **Comment** / **Jump** actions from raw-editor selections plus editor-preview selections for Markdown, LaTeX, code/text/diff previews, and an opt-in comment mode for editor HTML previews; source-anchored comments can be toggled into inline `[an: ...]` annotations when you want comments reflected in the document text
33
33
  - Browses response history (`Prev/Next/Last`) and loads either:
34
34
  - response text
35
35
  - critique notes/full critique
@@ -229,6 +229,35 @@
229
229
  let stickyStudioKind = null;
230
230
  const pendingCompanionWindows = new Map();
231
231
  let initialDocumentApplied = false;
232
+ function normalizeRightViewValue(nextView) {
233
+ const raw = String(nextView || "").trim();
234
+ const normalized = raw === "preview"
235
+ ? "preview"
236
+ : (raw === "editor-preview"
237
+ ? "editor-preview"
238
+ : (raw === "repl"
239
+ ? "repl"
240
+ : (raw === "files"
241
+ ? "files"
242
+ : ((raw === "trace" || raw === "thinking") ? "trace" : "markdown"))));
243
+ if (isEditorOnlyMode && normalized !== "editor-preview" && normalized !== "files" && normalized !== "repl") {
244
+ return "editor-preview";
245
+ }
246
+ return normalized;
247
+ }
248
+
249
+ function syncRightViewModeOptions() {
250
+ if (!rightViewSelect || !rightViewSelect.options) return;
251
+ const editorOnlyAllowed = new Set(["editor-preview", "files", "repl"]);
252
+ Array.from(rightViewSelect.options).forEach((option) => {
253
+ if (!option) return;
254
+ option.disabled = isEditorOnlyMode && !editorOnlyAllowed.has(option.value);
255
+ });
256
+ rightViewSelect.title = isEditorOnlyMode
257
+ ? "Editor-only views: editor preview, Files, or REPL. Shortcut: F7 when the right pane is active; F6 switches panes."
258
+ : "Right pane view mode. Shortcut: F7 when the right pane is active; F6 switches panes.";
259
+ }
260
+
232
261
  function getInitialRightView(source) {
233
262
  if (isEditorOnlyMode) return "editor-preview";
234
263
  return String(source || "").trim() === "file" ? "editor-preview" : "preview";
@@ -2427,11 +2456,7 @@
2427
2456
  rightTitleGroupEl.appendChild(rightFocusBtn);
2428
2457
  rightTitleGroupEl.appendChild(makeStudioUiRefreshSeparator());
2429
2458
  }
2430
- if (isEditorOnlyMode) {
2431
- rightTitleGroupEl.appendChild(makeStudioUiRefreshElement("span", "studio-refresh-static-title", "Editor (Preview)"));
2432
- } else {
2433
- rightTitleGroupEl.appendChild(rightViewSelect);
2434
- }
2459
+ rightTitleGroupEl.appendChild(rightViewSelect);
2435
2460
  rightIdentityEl.appendChild(rightTitleGroupEl);
2436
2461
  const rightToolsEl = makeStudioUiRefreshElement("div", "studio-refresh-pane-tools");
2437
2462
  if (exportPreviewControlsEl) {
@@ -2455,8 +2480,8 @@
2455
2480
  if (!isEditorOnlyMode && sendEditorBtn) actionLineTwoEl.appendChild(sendEditorBtn);
2456
2481
  const replActionLineEl = makeStudioUiRefreshElement("div", "studio-refresh-action-line repl-action-line");
2457
2482
  replActionLineEl.hidden = true;
2458
- if (!isEditorOnlyMode && sendReplBtn) replActionLineEl.appendChild(sendReplBtn);
2459
- if (!isEditorOnlyMode && replSendModeSelect) replActionLineEl.appendChild(replSendModeSelect);
2483
+ if (sendReplBtn) replActionLineEl.appendChild(sendReplBtn);
2484
+ if (replSendModeSelect) replActionLineEl.appendChild(replSendModeSelect);
2460
2485
  if (actionLineOneEl.childNodes.length > 0) actionsEl.appendChild(actionLineOneEl);
2461
2486
  actionsEl.appendChild(actionLineTwoEl);
2462
2487
  if (replActionLineEl.childNodes.length > 0) actionsEl.appendChild(replActionLineEl);
@@ -2612,7 +2637,7 @@
2612
2637
 
2613
2638
  function getIdleStatus() {
2614
2639
  if (isEditorOnlyMode) {
2615
- return "Editor-only mode: edit, load, annotate, preview, save, suggest, or refresh file-backed text.";
2640
+ return "Editor-only mode: edit, browse files, annotate, preview, save, suggest, refresh file-backed text, or send to a REPL.";
2616
2641
  }
2617
2642
  return "Edit, load, or annotate text, then run, save, send to pi editor, or critique.";
2618
2643
  }
@@ -3530,7 +3555,7 @@
3530
3555
 
3531
3556
  function cycleActivePaneView(direction) {
3532
3557
  if (activePane === "right") {
3533
- if (isEditorOnlyMode || !rightViewSelect || rightViewSelect.disabled) {
3558
+ if (!rightViewSelect || rightViewSelect.disabled) {
3534
3559
  setStatus("The right-pane view selector is unavailable.", "warning");
3535
3560
  return;
3536
3561
  }
@@ -3886,7 +3911,6 @@
3886
3911
  && !event.altKey
3887
3912
  && event.shiftKey
3888
3913
  && activePane === "left"
3889
- && !isEditorOnlyMode
3890
3914
  && rightView === "repl"
3891
3915
  ) {
3892
3916
  event.preventDefault();
@@ -4953,6 +4977,132 @@
4953
4977
  + " });\n"
4954
4978
  + " scheduleHeight();\n"
4955
4979
  + " }\n"
4980
+ + " let htmlCommentMode = false;\n"
4981
+ + " let htmlCommentHoverEl = null;\n"
4982
+ + " let htmlCommentHighlightTimer = null;\n"
4983
+ + " let htmlCommentLastPostAt = 0;\n"
4984
+ + " function htmlCommentCssEscape(value) {\n"
4985
+ + " const text = String(value || '');\n"
4986
+ + " try { if (window.CSS && typeof window.CSS.escape === 'function') return window.CSS.escape(text); } catch {}\n"
4987
+ + " return text.replace(/[^A-Za-z0-9_-]/g, function(ch) { return '\\\\' + ch; });\n"
4988
+ + " }\n"
4989
+ + " function getHtmlCommentSelector(element) {\n"
4990
+ + " if (!element || element.nodeType !== 1) return '';\n"
4991
+ + " if (element.id) return '#' + htmlCommentCssEscape(element.id);\n"
4992
+ + " const parts = [];\n"
4993
+ + " let el = element;\n"
4994
+ + " while (el && el.nodeType === 1 && el !== document.documentElement) {\n"
4995
+ + " const tag = el.tagName ? el.tagName.toLowerCase() : '';\n"
4996
+ + " if (!tag) break;\n"
4997
+ + " if (el.id) { parts.unshift(tag + '#' + htmlCommentCssEscape(el.id)); break; }\n"
4998
+ + " let index = 1;\n"
4999
+ + " let sibling = el.previousElementSibling;\n"
5000
+ + " while (sibling) { if ((sibling.tagName || '').toLowerCase() === tag) index += 1; sibling = sibling.previousElementSibling; }\n"
5001
+ + " parts.unshift(tag + ':nth-of-type(' + index + ')');\n"
5002
+ + " if (tag === 'body') break;\n"
5003
+ + " el = el.parentElement;\n"
5004
+ + " }\n"
5005
+ + " return parts.join(' > ');\n"
5006
+ + " }\n"
5007
+ + " function normalizeHtmlCommentText(value, maxLength) {\n"
5008
+ + " const text = String(value || '').replace(/\\s+/g, ' ').trim();\n"
5009
+ + " const limit = Math.max(24, Number(maxLength) || 200);\n"
5010
+ + " return text.length > limit ? text.slice(0, limit - 1).trimEnd() + '…' : text;\n"
5011
+ + " }\n"
5012
+ + " function getHtmlCommentElementLabel(element) {\n"
5013
+ + " if (!element || element.nodeType !== 1) return '';\n"
5014
+ + " const attrText = element.getAttribute('aria-label') || element.getAttribute('alt') || element.getAttribute('title') || '';\n"
5015
+ + " if (attrText) return normalizeHtmlCommentText(attrText, 220);\n"
5016
+ + " const tag = (element.tagName || '').toLowerCase();\n"
5017
+ + " if (tag === 'img') {\n"
5018
+ + " const src = String(element.getAttribute('src') || '').split(/[?#]/)[0].split('/').pop() || 'image';\n"
5019
+ + " return normalizeHtmlCommentText(src, 220);\n"
5020
+ + " }\n"
5021
+ + " return normalizeHtmlCommentText(element.innerText || element.textContent || '', 220);\n"
5022
+ + " }\n"
5023
+ + " function getHtmlCommentTarget(target) {\n"
5024
+ + " let node = target;\n"
5025
+ + " if (node && node.nodeType === 3) node = node.parentElement;\n"
5026
+ + " if (!node || node.nodeType !== 1) return document.body || document.documentElement;\n"
5027
+ + " if (typeof node.closest === 'function') {\n"
5028
+ + " return node.closest('img,figure,table,section,article,main,aside,nav,header,footer,pre,blockquote,ul,ol,li,canvas,svg,h1,h2,h3,h4,h5,h6,p,button,a,input,textarea,select,div') || node;\n"
5029
+ + " }\n"
5030
+ + " return node;\n"
5031
+ + " }\n"
5032
+ + " function getHtmlCommentSelectionText() {\n"
5033
+ + " const selection = typeof window.getSelection === 'function' ? window.getSelection() : null;\n"
5034
+ + " if (!selection || selection.rangeCount <= 0 || selection.isCollapsed) return '';\n"
5035
+ + " return normalizeHtmlCommentText(selection.toString(), 1000);\n"
5036
+ + " }\n"
5037
+ + " function getHtmlCommentSelectionElement() {\n"
5038
+ + " const selection = typeof window.getSelection === 'function' ? window.getSelection() : null;\n"
5039
+ + " if (!selection || selection.rangeCount <= 0) return null;\n"
5040
+ + " const range = selection.getRangeAt(0);\n"
5041
+ + " let node = range.commonAncestorContainer;\n"
5042
+ + " if (node && node.nodeType === 3) node = node.parentElement;\n"
5043
+ + " return node && node.nodeType === 1 ? node : null;\n"
5044
+ + " }\n"
5045
+ + " function postHtmlCommentTarget(kind, element, event, selectedText) {\n"
5046
+ + " const target = getHtmlCommentTarget(element || (event && event.target));\n"
5047
+ + " if (!target) return false;\n"
5048
+ + " htmlCommentLastPostAt = Date.now();\n"
5049
+ + " try {\n"
5050
+ + " parent.postMessage({\n"
5051
+ + " type: 'pi-studio-html-artifact-comment-target',\n"
5052
+ + " id: PREVIEW_ID,\n"
5053
+ + " kind: kind === 'selection' ? 'selection' : 'element',\n"
5054
+ + " selector: getHtmlCommentSelector(target),\n"
5055
+ + " tag: (target.tagName || '').toLowerCase(),\n"
5056
+ + " label: getHtmlCommentElementLabel(target),\n"
5057
+ + " text: normalizeHtmlCommentText(selectedText || '', 1000),\n"
5058
+ + " clientX: event && event.clientX || 0,\n"
5059
+ + " clientY: event && event.clientY || 0\n"
5060
+ + " }, '*');\n"
5061
+ + " return true;\n"
5062
+ + " } catch { return false; }\n"
5063
+ + " }\n"
5064
+ + " function clearHtmlCommentHover() {\n"
5065
+ + " if (htmlCommentHoverEl && htmlCommentHoverEl.classList) htmlCommentHoverEl.classList.remove('pi-studio-html-comment-hover');\n"
5066
+ + " htmlCommentHoverEl = null;\n"
5067
+ + " }\n"
5068
+ + " function setHtmlCommentMode(enabled) {\n"
5069
+ + " htmlCommentMode = Boolean(enabled);\n"
5070
+ + " if (document.documentElement && document.documentElement.classList) document.documentElement.classList.toggle('pi-studio-html-comment-mode', htmlCommentMode);\n"
5071
+ + " if (!htmlCommentMode) clearHtmlCommentHover();\n"
5072
+ + " }\n"
5073
+ + " function handleHtmlCommentMouseMove(event) {\n"
5074
+ + " if (!htmlCommentMode) return;\n"
5075
+ + " const target = getHtmlCommentTarget(event && event.target);\n"
5076
+ + " if (target === htmlCommentHoverEl) return;\n"
5077
+ + " clearHtmlCommentHover();\n"
5078
+ + " htmlCommentHoverEl = target;\n"
5079
+ + " if (htmlCommentHoverEl && htmlCommentHoverEl.classList) htmlCommentHoverEl.classList.add('pi-studio-html-comment-hover');\n"
5080
+ + " }\n"
5081
+ + " function handleHtmlCommentMouseUp(event) {\n"
5082
+ + " if (!htmlCommentMode) return;\n"
5083
+ + " const selectedText = getHtmlCommentSelectionText();\n"
5084
+ + " if (!selectedText) return;\n"
5085
+ + " postHtmlCommentTarget('selection', getHtmlCommentSelectionElement() || (event && event.target), event, selectedText);\n"
5086
+ + " if (event) { event.preventDefault(); event.stopPropagation(); }\n"
5087
+ + " }\n"
5088
+ + " function handleHtmlCommentClick(event) {\n"
5089
+ + " if (!htmlCommentMode) return;\n"
5090
+ + " if (Date.now() - htmlCommentLastPostAt < 450) { event.preventDefault(); event.stopPropagation(); return; }\n"
5091
+ + " postHtmlCommentTarget('element', event && event.target, event, '');\n"
5092
+ + " event.preventDefault();\n"
5093
+ + " event.stopPropagation();\n"
5094
+ + " }\n"
5095
+ + " function highlightHtmlCommentTarget(selector, anchorKind) {\n"
5096
+ + " if (htmlCommentHighlightTimer) { clearTimeout(htmlCommentHighlightTimer); htmlCommentHighlightTimer = null; }\n"
5097
+ + " Array.prototype.slice.call(document.querySelectorAll('.pi-studio-html-comment-highlight')).forEach(function(el) { el.classList.remove('pi-studio-html-comment-highlight'); });\n"
5098
+ + " if (anchorKind === 'html-page' || !selector) { try { window.scrollTo({ top: 0, behavior: 'smooth' }); } catch { window.scrollTo(0, 0); } return; }\n"
5099
+ + " let target = null;\n"
5100
+ + " try { target = document.querySelector(String(selector || '')); } catch {}\n"
5101
+ + " if (!target) return;\n"
5102
+ + " try { target.scrollIntoView({ block: 'center', inline: 'nearest', behavior: 'smooth' }); } catch { try { target.scrollIntoView(true); } catch {} }\n"
5103
+ + " if (target.classList) target.classList.add('pi-studio-html-comment-highlight');\n"
5104
+ + " htmlCommentHighlightTimer = setTimeout(function() { if (target && target.classList) target.classList.remove('pi-studio-html-comment-highlight'); }, 2400);\n"
5105
+ + " }\n"
4956
5106
  + " window.addEventListener('message', (event) => {\n"
4957
5107
  + " const data = event && event.data;\n"
4958
5108
  + " if (!data || typeof data !== 'object' || data.id !== PREVIEW_ID) return;\n"
@@ -4966,11 +5116,23 @@
4966
5116
  + " }\n"
4967
5117
  + " if (data.type === 'pi-studio-html-artifact-resources-resolved') {\n"
4968
5118
  + " applyResolvedHtmlPreviewResources(data.results);\n"
5119
+ + " return;\n"
5120
+ + " }\n"
5121
+ + " if (data.type === 'pi-studio-html-artifact-comment-mode') {\n"
5122
+ + " setHtmlCommentMode(data.enabled);\n"
5123
+ + " return;\n"
5124
+ + " }\n"
5125
+ + " if (data.type === 'pi-studio-html-artifact-highlight-comment') {\n"
5126
+ + " highlightHtmlCommentTarget(data.selector, data.anchorKind);\n"
4969
5127
  + " }\n"
4970
5128
  + " });\n"
4971
5129
  + " document.addEventListener('click', handleFragmentAnchorClick);\n"
4972
5130
  + " document.addEventListener('click', handleHtmlPreviewLocalLinkClick);\n"
4973
5131
  + " document.addEventListener('contextmenu', handleHtmlPreviewLocalLinkContextMenu);\n"
5132
+ + " document.addEventListener('mousemove', handleHtmlCommentMouseMove, true);\n"
5133
+ + " document.addEventListener('mouseleave', clearHtmlCommentHover, true);\n"
5134
+ + " document.addEventListener('mouseup', handleHtmlCommentMouseUp, true);\n"
5135
+ + " document.addEventListener('click', handleHtmlCommentClick, true);\n"
4974
5136
  + " document.addEventListener('DOMContentLoaded', () => { scheduleHtmlMathRenderScan(); scheduleHtmlPreviewResourceScan(); });\n"
4975
5137
  + " window.addEventListener('hashchange', () => {\n"
4976
5138
  + " const hash = String(window.location && window.location.hash || '');\n"
@@ -5002,6 +5164,9 @@
5002
5164
  + ".pi-studio-html-math-display{display:block;margin:0.75em 0;overflow-x:auto;text-align:center;}\n"
5003
5165
  + ".pi-studio-html-math-display>math{display:block;margin:0 auto;}\n"
5004
5166
  + ".pi-studio-html-math-inline>math{vertical-align:-0.15em;}\n"
5167
+ + "html.pi-studio-html-comment-mode,html.pi-studio-html-comment-mode body{cursor:crosshair!important;}\n"
5168
+ + ".pi-studio-html-comment-hover{outline:2px solid #0f8b8d!important;outline-offset:3px!important;}\n"
5169
+ + ".pi-studio-html-comment-highlight{outline:3px solid #d97706!important;outline-offset:4px!important;box-shadow:0 0 0 6px rgba(217,119,6,.18)!important;}\n"
5005
5170
  + "</style>\n";
5006
5171
  }
5007
5172
 
@@ -5036,6 +5201,11 @@
5036
5201
  });
5037
5202
  }
5038
5203
 
5204
+ function setHtmlArtifactDetailText(record, text) {
5205
+ if (!record || !record.detail) return;
5206
+ record.detail.textContent = record.commentMode ? "HTML preview · comment mode" : (text || "HTML preview");
5207
+ }
5208
+
5039
5209
  function handleHtmlArtifactFrameSizeMessage(event) {
5040
5210
  const data = event && event.data;
5041
5211
  if (!data || typeof data !== "object" || data.type !== "pi-studio-html-artifact-size") return;
@@ -5047,7 +5217,7 @@
5047
5217
  }
5048
5218
  if (event.source && record.iframe.contentWindow && event.source !== record.iframe.contentWindow) return;
5049
5219
  if (record.shell && record.shell.classList && record.shell.classList.contains("is-focused")) {
5050
- if (record.detail) record.detail.textContent = "HTML preview";
5220
+ setHtmlArtifactDetailText(record, "HTML preview");
5051
5221
  return;
5052
5222
  }
5053
5223
  const rawHeight = Number(data.height);
@@ -5064,9 +5234,7 @@
5064
5234
  record.shell.style.minHeight = "0";
5065
5235
  record.shell.classList.toggle("is-height-capped", capped);
5066
5236
  }
5067
- if (record.detail) {
5068
- record.detail.textContent = "HTML preview";
5069
- }
5237
+ setHtmlArtifactDetailText(record, "HTML preview");
5070
5238
  }
5071
5239
 
5072
5240
  function handleHtmlArtifactFrameFragmentMessage(event) {
@@ -5190,7 +5358,7 @@
5190
5358
  error: error && error.message ? error.message : String(error || "HTML preview math render failed."),
5191
5359
  })));
5192
5360
  } finally {
5193
- if (record.detail) record.detail.textContent = "HTML preview";
5361
+ setHtmlArtifactDetailText(record, "HTML preview");
5194
5362
  }
5195
5363
  }
5196
5364
 
@@ -5280,7 +5448,7 @@
5280
5448
  if (record.detail) record.detail.textContent = "HTML preview · loading local images";
5281
5449
  const results = await Promise.all(items.map((item) => fetchHtmlArtifactResource(record, item)));
5282
5450
  postHtmlArtifactResourceResults(record, results);
5283
- if (record.detail) record.detail.textContent = "HTML preview";
5451
+ setHtmlArtifactDetailText(record, "HTML preview");
5284
5452
  }
5285
5453
 
5286
5454
  function handleHtmlArtifactFrameResourceMessage(event) {
@@ -5363,11 +5531,36 @@
5363
5531
  setStatus("Right-click this local HTML preview link for file actions.", "warning");
5364
5532
  }
5365
5533
 
5534
+ function handleHtmlArtifactFrameCommentTargetMessage(event) {
5535
+ const data = event && event.data;
5536
+ if (!data || typeof data !== "object" || data.type !== "pi-studio-html-artifact-comment-target") return;
5537
+ const id = typeof data.id === "string" ? data.id : "";
5538
+ const record = id ? htmlArtifactFramesById.get(id) : null;
5539
+ if (!record || !record.iframe || !record.iframe.isConnected) {
5540
+ if (id) htmlArtifactFramesById.delete(id);
5541
+ return;
5542
+ }
5543
+ if (!record.commentable) return;
5544
+ if (event.source && record.iframe.contentWindow && event.source !== record.iframe.contentWindow) return;
5545
+ const note = addReviewNoteFromHtmlArtifactTarget(record, data);
5546
+ if (note && record.iframe && record.iframe.contentWindow) {
5547
+ try {
5548
+ record.iframe.contentWindow.postMessage({
5549
+ type: "pi-studio-html-artifact-highlight-comment",
5550
+ id: record.id || "",
5551
+ selector: note.htmlSelector || "",
5552
+ anchorKind: note.anchorKind || "html-element",
5553
+ }, "*");
5554
+ } catch {}
5555
+ }
5556
+ }
5557
+
5366
5558
  window.addEventListener("message", handleHtmlArtifactFrameSizeMessage);
5367
5559
  window.addEventListener("message", handleHtmlArtifactFrameFragmentMessage);
5368
5560
  window.addEventListener("message", handleHtmlArtifactFrameMathRenderMessage);
5369
5561
  window.addEventListener("message", handleHtmlArtifactFrameResourceMessage);
5370
5562
  window.addEventListener("message", handleHtmlArtifactFrameLocalLinkMessage);
5563
+ window.addEventListener("message", handleHtmlArtifactFrameCommentTargetMessage);
5371
5564
 
5372
5565
  function isStudioHtmlFocusOpen() {
5373
5566
  return Boolean(studioHtmlFocusOverlayEl && studioHtmlFocusOverlayEl.hidden === false && studioHtmlFocusShellEl);
@@ -5595,6 +5788,36 @@
5595
5788
 
5596
5789
  const tools = document.createElement("span");
5597
5790
  tools.className = "studio-html-artifact-tools";
5791
+ const commentable = Boolean(options && options.commentable);
5792
+
5793
+ let commentBtn = null;
5794
+ let pageCommentBtn = null;
5795
+ const makeCommentButton = (text, title, onClick) => {
5796
+ const button = document.createElement("button");
5797
+ button.type = "button";
5798
+ button.className = "studio-html-artifact-comment-btn";
5799
+ button.textContent = text;
5800
+ button.title = title;
5801
+ button.addEventListener("pointerdown", (event) => { event.stopPropagation(); });
5802
+ button.addEventListener("mousedown", (event) => { event.stopPropagation(); });
5803
+ button.addEventListener("click", (event) => {
5804
+ event.preventDefault();
5805
+ event.stopPropagation();
5806
+ onClick();
5807
+ });
5808
+ return button;
5809
+ };
5810
+ if (commentable) {
5811
+ commentBtn = makeCommentButton("Comment mode", "Turn on HTML preview comment mode. Select text or click an element in the preview to add a local comment.", () => {
5812
+ const record = htmlArtifactFramesById.get(previewId);
5813
+ setHtmlArtifactRecordCommentMode(record, !(record && record.commentMode));
5814
+ });
5815
+ commentBtn.setAttribute("aria-pressed", "false");
5816
+ pageCommentBtn = makeCommentButton("Page", "Add a page-level local comment for this HTML preview.", () => {
5817
+ const record = htmlArtifactFramesById.get(previewId);
5818
+ addReviewNoteFromHtmlArtifactPage(record || null);
5819
+ });
5820
+ }
5598
5821
 
5599
5822
  const zoomControls = document.createElement("span");
5600
5823
  zoomControls.className = "studio-html-artifact-zoom-controls";
@@ -5658,6 +5881,8 @@
5658
5881
  fullscreenBtn.appendChild(makeStudioUiRefreshIcon("fullscreen"));
5659
5882
  updateZoomUi();
5660
5883
  tools.appendChild(detail);
5884
+ if (commentBtn) tools.appendChild(commentBtn);
5885
+ if (pageCommentBtn) tools.appendChild(pageCommentBtn);
5661
5886
  tools.appendChild(zoomControls);
5662
5887
  tools.appendChild(fullscreenBtn);
5663
5888
 
@@ -5672,7 +5897,7 @@
5672
5897
  iframe.referrerPolicy = "no-referrer";
5673
5898
  iframe.setAttribute("sandbox", "allow-scripts allow-modals");
5674
5899
  iframe.setAttribute("allow", "clipboard-write");
5675
- iframe.addEventListener("load", () => { postArtifactZoom(); });
5900
+ iframe.addEventListener("load", () => { postArtifactZoom(); postHtmlArtifactCommentMode(htmlArtifactFramesById.get(previewId)); });
5676
5901
  iframe.srcdoc = buildHtmlArtifactSrcdoc(html, previewId);
5677
5902
  shell.appendChild(iframe);
5678
5903
  htmlArtifactFramesById.set(previewId, {
@@ -5681,6 +5906,11 @@
5681
5906
  shell,
5682
5907
  detail,
5683
5908
  zoomControls,
5909
+ commentBtn,
5910
+ pageCommentBtn,
5911
+ commentMode: false,
5912
+ commentable,
5913
+ title,
5684
5914
  sourcePath: options && options.sourcePath ? String(options.sourcePath) : "",
5685
5915
  resourceDir: options && options.resourceDir ? String(options.resourceDir) : "",
5686
5916
  mathRenderBatchCount: 0,
@@ -5697,6 +5927,33 @@
5697
5927
  }
5698
5928
  }
5699
5929
 
5930
+ function postHtmlArtifactCommentMode(record) {
5931
+ if (!record || !record.iframe || !record.iframe.contentWindow) return;
5932
+ try {
5933
+ record.iframe.contentWindow.postMessage({
5934
+ type: "pi-studio-html-artifact-comment-mode",
5935
+ id: record.id || "",
5936
+ enabled: Boolean(record.commentMode),
5937
+ }, "*");
5938
+ } catch {}
5939
+ }
5940
+
5941
+ function setHtmlArtifactRecordCommentMode(record, enabled) {
5942
+ if (!record) return;
5943
+ record.commentMode = Boolean(enabled);
5944
+ if (record.shell && record.shell.classList) record.shell.classList.toggle("is-comment-mode", record.commentMode);
5945
+ if (record.commentBtn) {
5946
+ record.commentBtn.classList.toggle("is-active", record.commentMode);
5947
+ record.commentBtn.setAttribute("aria-pressed", record.commentMode ? "true" : "false");
5948
+ record.commentBtn.textContent = "Comment mode";
5949
+ record.commentBtn.title = record.commentMode
5950
+ ? "HTML comment mode is on. Select text or click an element in the preview to add a local comment."
5951
+ : "Turn on HTML preview comment mode. Select text or click an element in the preview to add a local comment.";
5952
+ }
5953
+ if (record.detail) record.detail.textContent = record.commentMode ? "HTML preview · comment mode" : "HTML preview";
5954
+ postHtmlArtifactCommentMode(record);
5955
+ }
5956
+
5700
5957
  function getRightPaneHtmlArtifactSource() {
5701
5958
  if (rightView === "editor-preview") {
5702
5959
  const editorText = prepareEditorTextForPreview(sourceTextEl.value || "");
@@ -5814,6 +6071,15 @@
5814
6071
  openLink.textContent = "Open PDF";
5815
6072
  actions.appendChild(openLink);
5816
6073
 
6074
+ const refreshBtn = document.createElement("button");
6075
+ refreshBtn.type = "button";
6076
+ refreshBtn.className = "studio-pdf-focus-btn studio-pdf-focus-refresh";
6077
+ refreshBtn.textContent = "Refresh";
6078
+ refreshBtn.title = "Reload this PDF preview from disk.";
6079
+ refreshBtn.setAttribute("aria-label", "Refresh PDF preview from disk");
6080
+ refreshBtn.addEventListener("click", () => refreshStudioPdfFocusViewer());
6081
+ actions.appendChild(refreshBtn);
6082
+
5817
6083
  const fullscreenBtn = document.createElement("button");
5818
6084
  fullscreenBtn.type = "button";
5819
6085
  fullscreenBtn.className = "studio-pdf-focus-btn studio-pdf-focus-fullscreen";
@@ -5935,6 +6201,70 @@
5935
6201
  return "/pdf-resource?" + params.toString();
5936
6202
  }
5937
6203
 
6204
+ function buildRefreshedStudioPdfViewerUrl(value) {
6205
+ const raw = String(value || "").trim();
6206
+ if (!raw) return "";
6207
+ const hashIndex = raw.indexOf("#");
6208
+ const base = hashIndex >= 0 ? raw.slice(0, hashIndex) : raw;
6209
+ const hash = hashIndex >= 0 ? raw.slice(hashIndex) : "";
6210
+ const nonce = Date.now().toString(36);
6211
+ try {
6212
+ const url = new URL(base || window.location.href, window.location.href);
6213
+ url.searchParams.set("_studioPdfRefresh", nonce);
6214
+ return url.href + hash;
6215
+ } catch {
6216
+ const separator = base.indexOf("?") >= 0 ? "&" : "?";
6217
+ return base + separator + "_studioPdfRefresh=" + encodeURIComponent(nonce) + hash;
6218
+ }
6219
+ }
6220
+
6221
+ function syncStudioPdfCardViewerUrl(card, viewerUrl) {
6222
+ if (!card) return;
6223
+ const nextUrl = String(viewerUrl || "").trim();
6224
+ if (!nextUrl) return;
6225
+ if (card.dataset) card.dataset.studioPdfViewerUrl = nextUrl;
6226
+ const frame = typeof card.querySelector === "function" ? card.querySelector("iframe.studio-pdf-frame") : null;
6227
+ if (frame) frame.src = nextUrl;
6228
+ const openLink = typeof card.querySelector === "function" ? card.querySelector("a.studio-pdf-card-link") : null;
6229
+ if (openLink) openLink.href = nextUrl;
6230
+ const focusBtn = typeof card.querySelector === "function" ? card.querySelector("button.studio-pdf-card-focus") : null;
6231
+ if (focusBtn && focusBtn.dataset) focusBtn.dataset.studioPdfViewerUrl = nextUrl;
6232
+ }
6233
+
6234
+ function refreshStudioPdfCard(card) {
6235
+ if (!card) return false;
6236
+ const frame = typeof card.querySelector === "function" ? card.querySelector("iframe.studio-pdf-frame") : null;
6237
+ const currentUrl = String(card.dataset && card.dataset.studioPdfViewerUrl ? card.dataset.studioPdfViewerUrl : "").trim()
6238
+ || String(frame && frame.src ? frame.src : "").trim();
6239
+ const nextUrl = buildRefreshedStudioPdfViewerUrl(currentUrl);
6240
+ if (!nextUrl) return false;
6241
+ syncStudioPdfCardViewerUrl(card, nextUrl);
6242
+ setStatus("Refreshed PDF preview from disk.", "success");
6243
+ return true;
6244
+ }
6245
+
6246
+ function getStudioPdfFocusActiveFrame() {
6247
+ if (studioPdfFocusMovedFrameState && studioPdfFocusMovedFrameState.frame && studioPdfFocusMovedFrameState.frame.isConnected) {
6248
+ return studioPdfFocusMovedFrameState.frame;
6249
+ }
6250
+ return studioPdfFocusFrameEl;
6251
+ }
6252
+
6253
+ function refreshStudioPdfFocusViewer() {
6254
+ const frame = getStudioPdfFocusActiveFrame();
6255
+ const currentUrl = String(frame && frame.src ? frame.src : "").trim()
6256
+ || String(studioPdfFocusOpenLinkEl && studioPdfFocusOpenLinkEl.href ? studioPdfFocusOpenLinkEl.href : "").trim();
6257
+ const nextUrl = buildRefreshedStudioPdfViewerUrl(currentUrl);
6258
+ if (!nextUrl) {
6259
+ setStatus("Could not refresh this PDF preview.", "warning");
6260
+ return false;
6261
+ }
6262
+ if (frame) frame.src = nextUrl;
6263
+ if (studioPdfFocusOpenLinkEl) studioPdfFocusOpenLinkEl.href = nextUrl;
6264
+ setStatus("Refreshed PDF preview from disk.", "success");
6265
+ return true;
6266
+ }
6267
+
5938
6268
  function syncStudioPdfFocusFullscreenButton() {
5939
6269
  if (!studioPdfFocusFullscreenBtn) return;
5940
6270
  const isFullscreen = Boolean(document.fullscreenElement && studioPdfFocusDialogEl && document.fullscreenElement === studioPdfFocusDialogEl);
@@ -6465,6 +6795,18 @@
6465
6795
  openLink.textContent = "Open PDF";
6466
6796
  actions.appendChild(openLink);
6467
6797
 
6798
+ const refreshBtn = document.createElement("button");
6799
+ refreshBtn.type = "button";
6800
+ refreshBtn.className = "studio-pdf-card-action studio-pdf-card-refresh";
6801
+ refreshBtn.textContent = "Refresh";
6802
+ refreshBtn.title = "Reload this PDF preview from disk.";
6803
+ refreshBtn.addEventListener("click", (event) => {
6804
+ event.preventDefault();
6805
+ event.stopPropagation();
6806
+ if (!refreshStudioPdfCard(card)) setStatus("Could not refresh this PDF preview.", "warning");
6807
+ });
6808
+ actions.appendChild(refreshBtn);
6809
+
6468
6810
  header.appendChild(actions);
6469
6811
  }
6470
6812
  card.appendChild(header);
@@ -8245,7 +8587,7 @@
8245
8587
  const text = prepareEditorTextForPreview(sourceTextEl.value || "");
8246
8588
  const previewLanguage = getEditorLanguageForPreview();
8247
8589
  if (isHtmlArtifactPreviewText(text, previewLanguage)) {
8248
- renderHtmlArtifactPreview(sourcePreviewEl, text, "source", { title: "Editor HTML preview", ...getHtmlPreviewResourceContextOptions() });
8590
+ renderHtmlArtifactPreview(sourcePreviewEl, text, "source", { title: "Editor HTML preview", commentable: true, ...getHtmlPreviewResourceContextOptions() });
8249
8591
  return;
8250
8592
  }
8251
8593
  if (renderDelimitedTextPreview(sourcePreviewEl, text, "source", previewLanguage)) {
@@ -9149,7 +9491,7 @@
9149
9491
  }
9150
9492
  const previewLanguage = getEditorLanguageForPreview();
9151
9493
  if (isHtmlArtifactPreviewText(editorText, previewLanguage)) {
9152
- renderHtmlArtifactPreview(critiqueViewEl, editorText, "response", { title: "Editor HTML preview", ...getHtmlPreviewResourceContextOptions() });
9494
+ renderHtmlArtifactPreview(critiqueViewEl, editorText, "response", { title: "Editor HTML preview", commentable: true, ...getHtmlPreviewResourceContextOptions() });
9153
9495
  return;
9154
9496
  }
9155
9497
  if (renderDelimitedTextPreview(critiqueViewEl, editorText, "response", previewLanguage)) {
@@ -9439,7 +9781,8 @@
9439
9781
  if (stripAnnotationsBtn) stripAnnotationsBtn.disabled = uiBusy || !hasAnnotationMarkers(sourceTextEl.value);
9440
9782
  if (compactBtn) compactBtn.disabled = isEditorOnlyMode || uiBusy || compactInProgress || wsState === "Disconnected";
9441
9783
  editorViewSelect.disabled = isEditorOnlyMode;
9442
- rightViewSelect.disabled = isEditorOnlyMode;
9784
+ syncRightViewModeOptions();
9785
+ rightViewSelect.disabled = false;
9443
9786
  followSelect.disabled = isEditorOnlyMode || uiBusy;
9444
9787
  if (responseHighlightSelect) responseHighlightSelect.disabled = isEditorOnlyMode || rightView !== "markdown";
9445
9788
  insertHeaderBtn.disabled = uiBusy;
@@ -9550,7 +9893,7 @@
9550
9893
  sourceState: normalizeWorkspaceSourceState(sourceState),
9551
9894
  resourceDir: getCurrentResourceDirValue(),
9552
9895
  editorView,
9553
- rightView: isEditorOnlyMode ? "editor-preview" : rightView,
9896
+ rightView: normalizeRightViewValue(rightView),
9554
9897
  editorLanguage,
9555
9898
  followLatest,
9556
9899
  responseHistoryIndex,
@@ -9625,15 +9968,7 @@
9625
9968
  setEditorLanguage(state.editorLanguage.trim());
9626
9969
  }
9627
9970
  editorView = state.editorView === "preview" ? "preview" : "markdown";
9628
- rightView = isEditorOnlyMode
9629
- ? "editor-preview"
9630
- : (state.rightView === "preview"
9631
- ? "preview"
9632
- : (state.rightView === "editor-preview"
9633
- ? "editor-preview"
9634
- : (state.rightView === "repl"
9635
- ? "repl"
9636
- : (state.rightView === "files" ? "files" : ((state.rightView === "trace" || state.rightView === "thinking") ? "trace" : "markdown")))));
9971
+ rightView = normalizeRightViewValue(state.rightView);
9637
9972
  if (typeof state.followLatest === "boolean") {
9638
9973
  followLatest = state.followLatest;
9639
9974
  }
@@ -10176,15 +10511,8 @@
10176
10511
 
10177
10512
  function setRightView(nextView) {
10178
10513
  const previousView = rightView;
10179
- rightView = nextView === "preview"
10180
- ? "preview"
10181
- : (nextView === "editor-preview"
10182
- ? "editor-preview"
10183
- : (nextView === "repl"
10184
- ? "repl"
10185
- : (nextView === "files"
10186
- ? "files"
10187
- : ((nextView === "trace" || nextView === "thinking") ? "trace" : "markdown"))));
10514
+ rightView = normalizeRightViewValue(nextView);
10515
+ syncRightViewModeOptions();
10188
10516
  rightViewSelect.value = rightView;
10189
10517
  if (rightView === "trace" && previousView !== "trace") {
10190
10518
  traceAutoScroll = true;
@@ -12589,10 +12917,21 @@
12589
12917
  scheduleScratchpadPersistence(value, descriptor.key);
12590
12918
  }
12591
12919
 
12920
+ function normalizeReviewNoteAnchorKind(value) {
12921
+ const raw = typeof value === "string" ? value.trim().toLowerCase() : "";
12922
+ if (raw === "html-selection" || raw === "html-element" || raw === "html-page") return raw;
12923
+ return "source";
12924
+ }
12925
+
12926
+ function isReviewNoteDomAnchor(note) {
12927
+ return Boolean(note && normalizeReviewNoteAnchorKind(note.anchorKind) !== "source");
12928
+ }
12929
+
12592
12930
  function normalizeReviewNote(note) {
12593
12931
  if (!note || typeof note !== "object") return null;
12594
12932
  const id = typeof note.id === "string" && note.id.trim() ? note.id : makeRequestId();
12595
12933
  const text = typeof note.text === "string" ? note.text : "";
12934
+ const anchorKind = normalizeReviewNoteAnchorKind(note.anchorKind);
12596
12935
  const createdAt = typeof note.createdAt === "number" && Number.isFinite(note.createdAt)
12597
12936
  ? note.createdAt
12598
12937
  : Date.now();
@@ -12622,6 +12961,11 @@
12622
12961
  lineEnd,
12623
12962
  selectedText: typeof note.selectedText === "string" ? note.selectedText : "",
12624
12963
  selectedDisplayText: typeof note.selectedDisplayText === "string" ? note.selectedDisplayText : "",
12964
+ anchorKind,
12965
+ htmlSelector: typeof note.htmlSelector === "string" ? note.htmlSelector : "",
12966
+ htmlTag: typeof note.htmlTag === "string" ? note.htmlTag : "",
12967
+ htmlLabel: typeof note.htmlLabel === "string" ? note.htmlLabel : "",
12968
+ htmlPreviewTitle: typeof note.htmlPreviewTitle === "string" ? note.htmlPreviewTitle : "",
12625
12969
  };
12626
12970
  }
12627
12971
 
@@ -13120,17 +13464,26 @@
13120
13464
  }
13121
13465
  }
13122
13466
 
13467
+ function formatHtmlReviewNoteAnchorLabel(note) {
13468
+ const kind = normalizeReviewNoteAnchorKind(note && note.anchorKind);
13469
+ const tag = String(note && note.htmlTag ? note.htmlTag : "").trim().toLowerCase();
13470
+ if (kind === "html-selection") return "HTML selection";
13471
+ if (kind === "html-page") return "HTML page";
13472
+ return tag ? ("HTML <" + tag + ">") : "HTML element";
13473
+ }
13474
+
13123
13475
  function summarizeReviewNoteAnchor(note) {
13476
+ if (isReviewNoteDomAnchor(note)) return formatHtmlReviewNoteAnchorLabel(note);
13124
13477
  const start = Math.max(1, Number(note && note.lineStart) || 1);
13125
13478
  const end = Math.max(start, Number(note && note.lineEnd) || start);
13126
13479
  return start === end ? "Line " + start : ("Lines " + start + "–" + end);
13127
13480
  }
13128
13481
 
13129
13482
  function summarizeReviewNoteQuote(note) {
13130
- const normalized = String(note && (note.selectedDisplayText || note.selectedText) ? (note.selectedDisplayText || note.selectedText) : "")
13483
+ const normalized = String(note && (note.selectedDisplayText || note.selectedText || note.htmlLabel || note.htmlSelector) ? (note.selectedDisplayText || note.selectedText || note.htmlLabel || note.htmlSelector) : "")
13131
13484
  .replace(/\s+/g, " ")
13132
13485
  .trim();
13133
- if (!normalized) return "Anchor: current line / empty selection";
13486
+ if (!normalized) return isReviewNoteDomAnchor(note) ? "Anchor: HTML preview" : "Anchor: current line / empty selection";
13134
13487
  return normalized.length > 140 ? normalized.slice(0, 137) + "…" : normalized;
13135
13488
  }
13136
13489
 
@@ -13218,6 +13571,7 @@
13218
13571
  }
13219
13572
 
13220
13573
  function resolveReviewNoteRange(note, text) {
13574
+ if (isReviewNoteDomAnchor(note)) return null;
13221
13575
  const source = String(text || "");
13222
13576
  const safeStart = Math.max(0, Math.min(Number(note && note.selectionStart) || 0, source.length));
13223
13577
  const safeEnd = Math.max(safeStart, Math.min(Number(note && note.selectionEnd) || safeStart, source.length));
@@ -13281,6 +13635,7 @@
13281
13635
  }
13282
13636
 
13283
13637
  function formatReviewNotePromptLineRange(bounds, note) {
13638
+ if (isReviewNoteDomAnchor(note)) return summarizeReviewNoteAnchor(note);
13284
13639
  const start = bounds ? bounds.lineStart : Math.max(1, Number(note && note.lineStart) || 1);
13285
13640
  const end = bounds ? bounds.lineEnd : Math.max(start, Number(note && note.lineEnd) || start);
13286
13641
  return start === end ? "L" + start : ("L" + start + "-L" + end);
@@ -13294,7 +13649,7 @@
13294
13649
  const descriptor = getCurrentStudioDocumentDescriptor();
13295
13650
  const documentLabel = descriptor && descriptor.label ? descriptor.label : (sourceState && sourceState.label ? sourceState.label : "Studio document");
13296
13651
  const parts = [
13297
- "Please address the following Studio comments. Use the file names and line numbers as anchors. The full document is not included here, only the comments and their anchors.",
13652
+ "Please address the following Studio comments. Use file names, line numbers, and preview anchors to locate each comment. The full document is not included here, only the comments and their anchors.",
13298
13653
  "Document: " + documentLabel,
13299
13654
  "",
13300
13655
  "## Comments",
@@ -13316,6 +13671,9 @@
13316
13671
  if (anchor) {
13317
13672
  parts.push("", "> " + anchor.replace(/\n/g, "\n> "));
13318
13673
  }
13674
+ if (isReviewNoteDomAnchor(note) && note.htmlSelector) {
13675
+ parts.push("", "Preview selector: `" + String(note.htmlSelector).replace(/`/g, "\\`") + "`");
13676
+ }
13319
13677
  parts.push("");
13320
13678
  });
13321
13679
 
@@ -13337,6 +13695,7 @@
13337
13695
  const source = String(text || "");
13338
13696
  const lineMap = new Map();
13339
13697
  for (const note of reviewNotes) {
13698
+ if (isReviewNoteDomAnchor(note)) continue;
13340
13699
  const bounds = getResolvedReviewNoteLineBounds(note, source);
13341
13700
  if (!bounds) continue;
13342
13701
  for (let line = bounds.lineStart; line <= bounds.lineEnd; line += 1) {
@@ -15875,8 +16234,8 @@
15875
16234
  return reviewNotes.slice().sort((left, right) => {
15876
16235
  const leftBounds = getResolvedReviewNoteLineBounds(left, source);
15877
16236
  const rightBounds = getResolvedReviewNoteLineBounds(right, source);
15878
- const leftLine = leftBounds ? leftBounds.lineStart : Math.max(1, Number(left && left.lineStart) || 1);
15879
- const rightLine = rightBounds ? rightBounds.lineStart : Math.max(1, Number(right && right.lineStart) || 1);
16237
+ const leftLine = leftBounds ? leftBounds.lineStart : (isReviewNoteDomAnchor(left) ? Number.MAX_SAFE_INTEGER : Math.max(1, Number(left && left.lineStart) || 1));
16238
+ const rightLine = rightBounds ? rightBounds.lineStart : (isReviewNoteDomAnchor(right) ? Number.MAX_SAFE_INTEGER : Math.max(1, Number(right && right.lineStart) || 1));
15880
16239
  if (leftLine !== rightLine) return leftLine - rightLine;
15881
16240
 
15882
16241
  const leftStart = leftBounds ? leftBounds.start : Math.max(0, Number(left && left.selectionStart) || 0);
@@ -15908,6 +16267,15 @@
15908
16267
  function getReviewNoteInlineState(note, text) {
15909
16268
  const source = String(text || "");
15910
16269
  const annotationBody = escapeReviewNoteAnnotationText(note && note.text);
16270
+ if (isReviewNoteDomAnchor(note)) {
16271
+ return {
16272
+ annotationBody,
16273
+ range: null,
16274
+ markerText: "",
16275
+ exists: false,
16276
+ canToggle: false,
16277
+ };
16278
+ }
15911
16279
  if (!annotationBody) {
15912
16280
  return {
15913
16281
  annotationBody: "",
@@ -16233,7 +16601,9 @@
16233
16601
  const jumpBtn = document.createElement("button");
16234
16602
  jumpBtn.type = "button";
16235
16603
  jumpBtn.textContent = "Jump";
16236
- jumpBtn.title = "Jump to this comment's anchored location in the editor.";
16604
+ jumpBtn.title = isReviewNoteDomAnchor(note)
16605
+ ? "Jump to this comment's HTML preview anchor."
16606
+ : "Jump to this comment's anchored location in the editor.";
16237
16607
  jumpBtn.addEventListener("click", () => {
16238
16608
  jumpToReviewNote(note.id);
16239
16609
  });
@@ -16246,9 +16616,11 @@
16246
16616
  convertBtn.textContent = inlineState.exists ? "Inline: On" : "Inline: Off";
16247
16617
  convertBtn.setAttribute("aria-pressed", inlineState.exists ? "true" : "false");
16248
16618
  convertBtn.disabled = !inlineState.canToggle || uiBusy;
16249
- convertBtn.title = inlineState.exists
16250
- ? "This comment currently has an inline [an: ...] annotation in the editor. Click to remove it."
16251
- : "This comment is currently not inline in the editor. Click to add it as an inline [an: ...] annotation.";
16619
+ convertBtn.title = isReviewNoteDomAnchor(note)
16620
+ ? "Inline annotations are only available for comments anchored to source text."
16621
+ : (inlineState.exists
16622
+ ? "This comment currently has an inline [an: ...] annotation in the editor. Click to remove it."
16623
+ : "This comment is currently not inline in the editor. Click to add it as an inline [an: ...] annotation.");
16252
16624
  convertBtn.addEventListener("click", () => {
16253
16625
  convertReviewNoteToAnnotation(note.id);
16254
16626
  });
@@ -16276,9 +16648,11 @@
16276
16648
  convertBtn.disabled = !nextInlineState.canToggle || uiBusy;
16277
16649
  convertBtn.textContent = nextInlineState.exists ? "Inline: On" : "Inline: Off";
16278
16650
  convertBtn.setAttribute("aria-pressed", nextInlineState.exists ? "true" : "false");
16279
- convertBtn.title = nextInlineState.exists
16280
- ? "This comment currently has an inline [an: ...] annotation in the editor. Click to remove it."
16281
- : "This comment is currently not inline in the editor. Click to add it as an inline [an: ...] annotation.";
16651
+ convertBtn.title = isReviewNoteDomAnchor(note)
16652
+ ? "Inline annotations are only available for comments anchored to source text."
16653
+ : (nextInlineState.exists
16654
+ ? "This comment currently has an inline [an: ...] annotation in the editor. Click to remove it."
16655
+ : "This comment is currently not inline in the editor. Click to add it as an inline [an: ...] annotation.");
16282
16656
  scheduleReviewNotesPersistence();
16283
16657
  updateReviewNotesUi();
16284
16658
  });
@@ -16350,6 +16724,55 @@
16350
16724
  return note;
16351
16725
  }
16352
16726
 
16727
+ function addReviewNoteFromHtmlArtifactTarget(record, data) {
16728
+ if (!record || !record.commentable) return null;
16729
+ const kind = data && data.kind === "selection" ? "html-selection" : "html-element";
16730
+ const selector = typeof data.selector === "string" ? data.selector : "";
16731
+ const tag = typeof data.tag === "string" ? data.tag : "";
16732
+ const text = typeof data.text === "string" ? data.text : "";
16733
+ const label = typeof data.label === "string" ? data.label : "";
16734
+ const display = text || label || selector || (tag ? ("<" + tag + ">") : "HTML element");
16735
+ return addReviewNoteFromAnchor({
16736
+ selectionStart: 0,
16737
+ selectionEnd: 0,
16738
+ lineStart: 1,
16739
+ lineEnd: 1,
16740
+ selectedText: "",
16741
+ selectedDisplayText: display,
16742
+ anchorKind: kind,
16743
+ htmlSelector: selector,
16744
+ htmlTag: tag,
16745
+ htmlLabel: label,
16746
+ htmlPreviewTitle: record.title || "HTML preview",
16747
+ }, {
16748
+ statusMessage: kind === "html-selection"
16749
+ ? "Added local comment from HTML preview selection."
16750
+ : "Added local comment from HTML preview element.",
16751
+ });
16752
+ }
16753
+
16754
+ function addReviewNoteFromHtmlArtifactPage(record) {
16755
+ if (!record || !record.commentable) {
16756
+ setStatus("HTML preview comments are only available for editor previews.", "warning");
16757
+ return null;
16758
+ }
16759
+ return addReviewNoteFromAnchor({
16760
+ selectionStart: 0,
16761
+ selectionEnd: 0,
16762
+ lineStart: 1,
16763
+ lineEnd: 1,
16764
+ selectedText: "",
16765
+ selectedDisplayText: record.title || "HTML preview",
16766
+ anchorKind: "html-page",
16767
+ htmlSelector: "",
16768
+ htmlTag: "",
16769
+ htmlLabel: record.title || "HTML preview",
16770
+ htmlPreviewTitle: record.title || "HTML preview",
16771
+ }, {
16772
+ statusMessage: "Added page-level local comment for HTML preview.",
16773
+ });
16774
+ }
16775
+
16353
16776
  function addReviewNoteFromAnchor(anchor, options) {
16354
16777
  if (!anchor || typeof anchor !== "object") return null;
16355
16778
  const note = normalizeReviewNote({
@@ -16363,6 +16786,11 @@
16363
16786
  lineEnd: anchor.lineEnd,
16364
16787
  selectedText: anchor.selectedText,
16365
16788
  selectedDisplayText: typeof anchor.selectedDisplayText === "string" ? anchor.selectedDisplayText : (typeof anchor.selectedText === "string" ? anchor.selectedText : ""),
16789
+ anchorKind: anchor.anchorKind,
16790
+ htmlSelector: anchor.htmlSelector,
16791
+ htmlTag: anchor.htmlTag,
16792
+ htmlLabel: anchor.htmlLabel,
16793
+ htmlPreviewTitle: anchor.htmlPreviewTitle,
16366
16794
  });
16367
16795
  if (!note) return null;
16368
16796
  if (editorSelectionCommentBtn) {
@@ -16510,9 +16938,55 @@
16510
16938
  return jumped;
16511
16939
  }
16512
16940
 
16941
+ function getConnectedHtmlArtifactRecords() {
16942
+ const records = [];
16943
+ htmlArtifactFramesById.forEach((record, id) => {
16944
+ if (!record || !record.iframe || !record.iframe.isConnected || !record.iframe.contentWindow) {
16945
+ if (id) htmlArtifactFramesById.delete(id);
16946
+ return;
16947
+ }
16948
+ records.push(record);
16949
+ });
16950
+ return records;
16951
+ }
16952
+
16953
+ function jumpToHtmlReviewNote(note) {
16954
+ if (!isReviewNoteDomAnchor(note)) return false;
16955
+ const records = getConnectedHtmlArtifactRecords().filter((record) => record && record.commentable);
16956
+ if (records.length === 0) {
16957
+ setStatus("Open the HTML preview before jumping to this comment.", "warning");
16958
+ return false;
16959
+ }
16960
+ const record = records[0];
16961
+ if (record.shell && typeof record.shell.scrollIntoView === "function") {
16962
+ try {
16963
+ record.shell.scrollIntoView({ block: "nearest", inline: "nearest", behavior: "smooth" });
16964
+ } catch {
16965
+ try { record.shell.scrollIntoView(false); } catch {}
16966
+ }
16967
+ }
16968
+ try {
16969
+ record.iframe.contentWindow.postMessage({
16970
+ type: "pi-studio-html-artifact-highlight-comment",
16971
+ id: record.id || "",
16972
+ selector: note.htmlSelector || "",
16973
+ anchorKind: normalizeReviewNoteAnchorKind(note.anchorKind),
16974
+ }, "*");
16975
+ setStatus("Jumped to HTML preview comment anchor.", "success");
16976
+ return true;
16977
+ } catch {
16978
+ setStatus("Could not jump to this HTML preview comment.", "warning");
16979
+ return false;
16980
+ }
16981
+ }
16982
+
16513
16983
  function jumpToReviewNote(noteId) {
16514
16984
  const note = reviewNotes.find((entry) => entry && entry.id === noteId);
16515
16985
  if (!note) return;
16986
+ if (isReviewNoteDomAnchor(note)) {
16987
+ jumpToHtmlReviewNote(note);
16988
+ return;
16989
+ }
16516
16990
  jumpToReviewAnchor(note, {
16517
16991
  status: false,
16518
16992
  notFoundStatusMessage: "Could not find the anchored location for this comment.",
@@ -16927,6 +17401,8 @@
16927
17401
  const directIsStop = activeKind === "direct";
16928
17402
  const critiqueIsStop = activeKind === "critique";
16929
17403
  const canQueueSteering = studioRunChainActive && !critiqueIsStop;
17404
+ const hasReplSession = Boolean(getActiveReplSessionForCurrentRuntime());
17405
+ const showReplSend = rightView === "repl";
16930
17406
 
16931
17407
  if (isEditorOnlyMode) {
16932
17408
  if (sendRunBtn) {
@@ -16942,15 +17418,25 @@
16942
17418
  queueSteerBtn.title = "Queue steering is unavailable in editor-only mode.";
16943
17419
  }
16944
17420
  if (sendReplBtn) {
16945
- sendReplBtn.hidden = true;
16946
- sendReplBtn.disabled = true;
16947
- sendReplBtn.classList.remove("repl-primary-action");
17421
+ sendReplBtn.hidden = !showReplSend;
17422
+ sendReplBtn.disabled = !showReplSend || wsState === "Disconnected" || uiBusy || replBusy || !hasReplSession;
17423
+ sendReplBtn.classList.toggle("repl-primary-action", showReplSend);
17424
+ sendReplBtn.textContent = showReplSend ? withStudioShortcutLabel(replSendMode === "literate" ? "Send selection/chunks" : "Send to REPL", "repl-send") : "Send to REPL";
17425
+ sendReplBtn.title = hasReplSession
17426
+ ? (replSendMode === "literate"
17427
+ ? "Literate send: selection, current fenced code chunk, or all matching chunks if the cursor is outside a chunk. Shortcut: Cmd/Ctrl+Shift+Enter."
17428
+ : "Raw send: selection, or full editor if no selection. Shortcut: Cmd/Ctrl+Shift+Enter.")
17429
+ : "Start or select a REPL session in the right pane first.";
16948
17430
  const replActionLine = sendReplBtn.closest(".repl-action-line");
16949
- if (replActionLine instanceof HTMLElement) replActionLine.hidden = true;
17431
+ if (replActionLine instanceof HTMLElement) replActionLine.hidden = !showReplSend;
16950
17432
  }
16951
17433
  if (replSendModeSelect) {
16952
- replSendModeSelect.hidden = true;
16953
- replSendModeSelect.disabled = true;
17434
+ replSendModeSelect.hidden = !showReplSend;
17435
+ replSendModeSelect.disabled = !showReplSend || wsState === "Disconnected" || uiBusy || replBusy;
17436
+ replSendModeSelect.value = replSendMode;
17437
+ replSendModeSelect.title = replSendMode === "literate"
17438
+ ? "Literate send: Send to REPL uses the selection, current fenced code chunk, or all matching chunks if the cursor is outside a chunk."
17439
+ : "Raw send: Send to REPL uses the selection, or full editor if no selection.";
16954
17440
  }
16955
17441
  if (critiqueBtn) {
16956
17442
  critiqueBtn.textContent = "Critique text";
@@ -16992,9 +17478,7 @@
16992
17478
  : "Queue steering is available while Run editor text is active.";
16993
17479
  }
16994
17480
 
16995
- const hasReplSession = Boolean(getActiveReplSessionForCurrentRuntime());
16996
17481
  if (sendReplBtn) {
16997
- const showReplSend = rightView === "repl";
16998
17482
  sendReplBtn.hidden = !showReplSend;
16999
17483
  sendReplBtn.disabled = !showReplSend || wsState === "Disconnected" || uiBusy || replBusy || !hasReplSession;
17000
17484
  sendReplBtn.classList.toggle("repl-primary-action", showReplSend);
package/client/studio.css CHANGED
@@ -392,7 +392,6 @@
392
392
  }
393
393
 
394
394
  body[data-studio-mode="editor-only"] #editorViewSelect,
395
- body[data-studio-mode="editor-only"] #rightViewSelect,
396
395
  body[data-studio-mode="editor-only"] #sendRunBtn,
397
396
  body[data-studio-mode="editor-only"] #queueSteerBtn,
398
397
  body[data-studio-mode="editor-only"] #sendEditorBtn,
@@ -424,7 +423,7 @@
424
423
  }
425
424
 
426
425
  body[data-studio-mode="editor-only"] #rightSectionHeader .section-header-main::before {
427
- content: "Preview";
426
+ content: "View";
428
427
  font-weight: 600;
429
428
  font-size: 14px;
430
429
  }
@@ -2506,6 +2505,35 @@
2506
2505
  white-space: nowrap;
2507
2506
  }
2508
2507
 
2508
+ .rendered-markdown .studio-html-artifact-comment-btn {
2509
+ flex: 0 0 auto;
2510
+ min-height: 24px;
2511
+ padding: 0 9px;
2512
+ border: 1px solid var(--control-border);
2513
+ border-radius: 999px;
2514
+ background: var(--panel);
2515
+ color: var(--text);
2516
+ font: inherit;
2517
+ font-size: 11px;
2518
+ line-height: 1;
2519
+ cursor: pointer;
2520
+ white-space: nowrap;
2521
+ }
2522
+
2523
+ .rendered-markdown .studio-html-artifact-comment-btn:not(:disabled):hover,
2524
+ .rendered-markdown .studio-html-artifact-comment-btn:focus-visible {
2525
+ background: var(--control-hover-bg, var(--inline-code-bg));
2526
+ border-color: var(--control-border-hover, var(--accent));
2527
+ outline: none;
2528
+ }
2529
+
2530
+ .rendered-markdown .studio-html-artifact-comment-btn.is-active,
2531
+ .rendered-markdown .studio-html-artifact-shell.is-comment-mode .studio-html-artifact-comment-btn.is-active {
2532
+ background: var(--accent-soft);
2533
+ border-color: var(--accent);
2534
+ color: var(--accent);
2535
+ }
2536
+
2509
2537
  .rendered-markdown .studio-html-artifact-zoom-controls {
2510
2538
  flex: 0 0 auto;
2511
2539
  display: inline-flex;
@@ -4036,6 +4064,10 @@
4036
4064
  line-height: 1.35;
4037
4065
  }
4038
4066
 
4067
+ body[data-studio-mode="editor-only"] .shortcuts-full-only {
4068
+ display: none !important;
4069
+ }
4070
+
4039
4071
  .scratchpad-textarea {
4040
4072
  width: 100%;
4041
4073
  min-height: 280px;
package/index.ts CHANGED
@@ -209,6 +209,8 @@ interface InitialStudioDocument {
209
209
  resourceDir?: string;
210
210
  }
211
211
 
212
+ type PersistedStudioReviewNoteAnchorKind = "source" | "html-selection" | "html-element" | "html-page";
213
+
212
214
  interface PersistedStudioReviewNote {
213
215
  id: string;
214
216
  text: string;
@@ -220,6 +222,11 @@ interface PersistedStudioReviewNote {
220
222
  lineEnd: number;
221
223
  selectedText: string;
222
224
  selectedDisplayText?: string;
225
+ anchorKind?: PersistedStudioReviewNoteAnchorKind;
226
+ htmlSelector?: string;
227
+ htmlTag?: string;
228
+ htmlLabel?: string;
229
+ htmlPreviewTitle?: string;
223
230
  }
224
231
 
225
232
  interface StudioPersistentState {
@@ -725,6 +732,10 @@ function createEmptyStudioPersistentState(): StudioPersistentState {
725
732
  };
726
733
  }
727
734
 
735
+ function normalizePersistedStudioReviewNoteAnchorKind(value: unknown): PersistedStudioReviewNoteAnchorKind {
736
+ return value === "html-selection" || value === "html-element" || value === "html-page" ? value : "source";
737
+ }
738
+
728
739
  function normalizePersistedStudioReviewNote(value: unknown): PersistedStudioReviewNote | null {
729
740
  if (!value || typeof value !== "object") return null;
730
741
  const candidate = value as Partial<PersistedStudioReviewNote>;
@@ -759,6 +770,11 @@ function normalizePersistedStudioReviewNote(value: unknown): PersistedStudioRevi
759
770
  lineEnd,
760
771
  selectedText: typeof candidate.selectedText === "string" ? candidate.selectedText : "",
761
772
  selectedDisplayText: typeof candidate.selectedDisplayText === "string" ? candidate.selectedDisplayText : "",
773
+ anchorKind: normalizePersistedStudioReviewNoteAnchorKind(candidate.anchorKind),
774
+ htmlSelector: typeof candidate.htmlSelector === "string" ? candidate.htmlSelector : "",
775
+ htmlTag: typeof candidate.htmlTag === "string" ? candidate.htmlTag : "",
776
+ htmlLabel: typeof candidate.htmlLabel === "string" ? candidate.htmlLabel : "",
777
+ htmlPreviewTitle: typeof candidate.htmlPreviewTitle === "string" ? candidate.htmlPreviewTitle : "",
762
778
  };
763
779
  }
764
780
 
@@ -10042,14 +10058,14 @@ ${cssVarsBlock}
10042
10058
  <div class="scratchpad-header">
10043
10059
  <div>
10044
10060
  <h2 id="reviewNotesTitle">Comments</h2>
10045
- <p class="scratchpad-description">Local comments for editor text. Stay out of the text, anchored to selections or lines, and can be converted into inline <span class="review-notes-inline-token">[an: ...]</span> annotations.</p>
10061
+ <p class="scratchpad-description">Local comments for editor text and editor previews. They stay out of the text; source-anchored comments can be converted into inline <span class="review-notes-inline-token">[an: ...]</span> annotations.</p>
10046
10062
  </div>
10047
10063
  <button id="reviewNotesCloseBtn" type="button" class="scratchpad-close-btn" aria-label="Hide comments" title="Hide comments">✕</button>
10048
10064
  </div>
10049
10065
  <div class="review-notes-toolbar">
10050
10066
  <span id="reviewNotesMeta" class="scratchpad-meta">No comments</span>
10051
10067
  </div>
10052
- <div id="reviewNotesEmptyState" class="review-notes-empty">No comments yet for this document. Select text in <strong>Editor (Raw)</strong> or <strong>Editor (Preview)</strong> and use <em>Comment</em>, or use <em>Line comment</em> in <strong>Editor (Raw)</strong>.</div>
10068
+ <div id="reviewNotesEmptyState" class="review-notes-empty">No comments yet for this document. Select text in <strong>Editor (Raw)</strong> or <strong>Editor (Preview)</strong> and use <em>Comment</em>, use <em>Line comment</em> in <strong>Editor (Raw)</strong>, or use <em>Comment mode</em> in an editor HTML preview.</div>
10053
10069
  <div id="reviewNotesList" class="review-notes-list" aria-live="polite"></div>
10054
10070
  <div class="review-notes-dock-footer">
10055
10071
  <div class="scratchpad-actions">
@@ -10181,14 +10197,14 @@ ${cssVarsBlock}
10181
10197
  <h3>Editor</h3>
10182
10198
  <dl>
10183
10199
  <div><dt>Cmd/Ctrl+S</dt><dd>Save editor</dd></div>
10184
- <div><dt>Cmd/Ctrl+Enter</dt><dd>Run editor text, or queue steering during an active run</dd></div>
10200
+ <div class="shortcuts-full-only"><dt>Cmd/Ctrl+Enter</dt><dd>Run editor text, or queue steering during an active run</dd></div>
10185
10201
  <div><dt>Option/Alt+Tab or Cmd/Ctrl+Shift+Space</dt><dd>Suggest a completion at the editor cursor</dd></div>
10186
10202
  <div><dt>Tab</dt><dd>Insert a visible completion suggestion; otherwise indent selected editor text</dd></div>
10187
10203
  <div><dt>Esc</dt><dd>Dismiss a visible completion suggestion, close overlays, exit pane focus, or stop an active request</dd></div>
10188
10204
  <div><dt>Shift+Tab</dt><dd>Unindent selected editor text</dd></div>
10189
10205
  </dl>
10190
10206
  </section>
10191
- <section class="shortcuts-group">
10207
+ <section class="shortcuts-group shortcuts-full-only">
10192
10208
  <h3>Response</h3>
10193
10209
  <dl>
10194
10210
  <div><dt>Alt/Option+←</dt><dd>Previous response when not editing text</dd></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-studio",
3
- "version": "0.9.19",
3
+ "version": "0.9.20",
4
4
  "description": "Two-pane browser workspace for pi with prompt/response editing, annotations, critiques, active quiz, prompt/response history, live previews, and tmux-backed REPL/literate REPL workflows",
5
5
  "type": "module",
6
6
  "license": "MIT",