From ea15d8b4ef3fb8f6a9e206eaa2670cf07138455f Mon Sep 17 00:00:00 2001 From: Brendan Whitney Date: Fri, 10 Jul 2026 09:26:54 -0600 Subject: [PATCH 1/2] feat: render HTML documents natively in the review UI with scoped doc styles and granular commentable blocks --- .../src/components/document/DocumentView.tsx | 172 ++++++++++++++++-- frontend/src/style.css | 27 +++ 2 files changed, 188 insertions(+), 11 deletions(-) diff --git a/frontend/src/components/document/DocumentView.tsx b/frontend/src/components/document/DocumentView.tsx index b830a75..609c8eb 100644 --- a/frontend/src/components/document/DocumentView.tsx +++ b/frontend/src/components/document/DocumentView.tsx @@ -5,18 +5,151 @@ import { createComment as apiCreateComment } from '../../comment-api'; import CommentRow from '../comments/CommentRow'; import CommentTextarea from '../comments/CommentTextarea'; +// HTML container elements we recurse THROUGH (emitting their block-level +// descendants) so a document splits into the same granular, ordered blocks a +// markdown doc does — instead of one giant root-wrapper block. +const HTML_CONTAINER_TAGS = new Set(['html', 'body', 'main', 'article', 'section', 'header', 'footer', 'nav']); + +function collectHtmlBlocks(root: Element | null, out: Element[], carryId?: string): void { + if (!root) return; + let pending = carryId; + for (const child of Array.from(root.children)) { + if (HTML_CONTAINER_TAGS.has(child.tagName.toLowerCase())) { + // A container's own id (e.g.
that a TOC link targets) is + // carried onto the first block emitted from within it, so anchor links + // still resolve after the container itself is unwrapped. + collectHtmlBlocks(child, out, child.id || pending); + } else if (pending && !child.id) { + const clone = child.cloneNode(true) as Element; + clone.id = pending; + out.push(clone); + } else { + out.push(child); + } + pending = undefined; // only the first block inside a container inherits the id + } +} + +// Confine an HTML doc's own ` + : el.outerHTML, // external stylesheet — left as-is (rare in a self-contained spec) + ) + .join('\n'); + }); + + // Ordered list of commentable blocks. Markdown is rendered then split by + // top-level element (lists split so each
  • is its own block, preserving + // ordered-list numbering). HTML is split by walking block-level descendants + // of , so it yields the same granular blocks rather than one wrapper. const blocks = createMemo(() => { - const rawHtml = renderMd(content()); - const temp = document.createElement('div'); - temp.innerHTML = rawHtml; const result: { html: string; idx: number }[] = []; let idx = 0; + const doc = parsedHtml(); + if (doc) { + const els: Element[] = []; + collectHtmlBlocks(doc.body, els); + for (const el of els) result.push({ html: el.outerHTML, idx: idx++ }); + return result; + } + const temp = document.createElement('div'); + temp.innerHTML = renderMd(content()); for (const child of Array.from(temp.children)) { const tag = child.tagName.toLowerCase(); if (tag === 'ul' || tag === 'ol') { @@ -41,7 +174,7 @@ export default function DocumentView() { ); return ( -
    +
    {mdMeta().filename || 'Document'} 0}> @@ -49,12 +182,17 @@ export default function DocumentView() { · {totalComments()} comment{totalComments() !== 1 ? 's' : ''}
    - {(block) => } + +
    + + + {(block) => } +
    ); } -function DocumentBlock(props: { html: string; blockIdx: number }) { +function DocumentBlock(props: { html: string; blockIdx: number; isHtml: boolean }) { const [showNewComment, setShowNewComment] = createSignal(false); const blockComments = createMemo(() => @@ -64,8 +202,20 @@ function DocumentBlock(props: { html: string; blockIdx: number }) { ); function handleBlockClick(e: MouseEvent) { - if ((e.target as HTMLElement).closest('.comment-box') || (e.target as HTMLElement).closest('.reply-textarea-wrap')) - return; + const target = e.target as HTMLElement; + if (target.closest('.comment-box') || target.closest('.reply-textarea-wrap')) return; + + // HTML docs: plain-click-to-comment like markdown, but a plain click that + // lands on a genuinely interactive target (a link, a
    summary) + // does its native thing instead — commenting on those is rare. Shift+click + // force-comments even there (and we suppress the native action then). + if (props.isHtml) { + if (e.shiftKey) { + e.preventDefault(); + } else if (target.closest('a[href], summary, button')) { + return; + } + } // If user already has a comment, don't open a new one const existingUser = blockComments().find((c) => c.author === 'user' && c.mode === 'review'); diff --git a/frontend/src/style.css b/frontend/src/style.css index 6ad2abe..d69d860 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -1393,6 +1393,33 @@ tr.expand-row:hover td { padding-left: 8px; } +/* HTML documents (a .html file added via add_document) are authored assuming a + light page. Render them on a white page with dark default text so unstyled + content stays legible against the dark app chrome. No !important is used, so + the document's own inline styles and `:l.outerHTML).join(` +`):""}),a=X(()=>{const o=[];let s=0;const l=n();if(l){const _=[];lm(l.body,_);for(const u of _)o.push({html:u.outerHTML,idx:s++});return o}const c=document.createElement("div");c.innerHTML=wr(t());for(const _ of Array.from(c.children)){const u=_.tagName.toLowerCase();if(u==="ul"||u==="ol"){const d=Array.from(_.children).filter(p=>p.tagName.toLowerCase()==="li"),E=u==="ol"?parseInt(_.getAttribute("start")||"1",10):1;for(let p=0;pet.list.filter(o=>o.item===He()&&!o.parentId&&o.status!=="dismissed").length);return(()=>{var o=gC(),s=o.firstChild;return b(s,()=>ta().filename||"Document",null),b(s,O(F,{get when(){return i()>0},get children(){return[" ","· ",ae(()=>i())," comment",ae(()=>i()!==1?"s":"")]}}),null),b(o,O(F,{get when(){return ae(()=>!!e())()&&r()},get children(){var l=EC();return K(()=>l.innerHTML=r()),l}}),null),b(o,O(Re,{get each(){return a()},children:l=>O(vC,{get html(){return l.html},get blockIdx(){return l.idx},get isHtml(){return e()}})}),null),K(()=>o.classList.toggle("html-doc",!!e())),o})()}function vC(t){const[e,n]=H(!1),r=X(()=>et.list.filter(o=>o.item===He()&&o.block===t.blockIdx&&!o.parentId&&o.status!=="dismissed"));function a(o){const s=o.target;if(s.closest(".comment-box")||s.closest(".reply-textarea-wrap"))return;if(t.isHtml){if(o.shiftKey)o.preventDefault();else if(s.closest("a[href], summary, button"))return}r().find(c=>c.author==="user"&&c.mode==="review")||n(!0)}async function i(o){const s=await Ap({author:"user",text:o,item:He(),block:t.blockIdx,mode:"review"});ml(s),n(!1)}return[(()=>{var o=SC();return o.$$click=a,K(s=>{var l=r().length>0,c=`md-block-${He()}-${t.blockIdx}`,_=t.blockIdx,u=t.html;return l!==s.e&&o.classList.toggle("has-comment",s.e=l),c!==s.t&&ce(o,"id",s.t=c),_!==s.a&&ce(o,"data-block",s.a=_),u!==s.o&&(o.innerHTML=s.o=u),s},{e:void 0,t:void 0,a:void 0,o:void 0}),o})(),O(Re,{get each(){return r()},children:o=>(()=>{var s=TC(),l=s.firstChild;return b(l,O(sm,{comment:o})),s})()}),O(F,{get when(){return e()},get children(){var o=fC();return b(o,O(xr,{onSave:i,onCancel:()=>n(!1)})),o}})]}ge(["click"]);var IC=A("
    "),AC=A("
    "),yC=A("
    Click line to comment · Cmd+Enter save · f search (!test *.py) · w whole file · e reviewed · c commits · n/p next/prev comment · Shift Shift symbol search"),DC=A("
    Click any block to comment · Cmd+Enter save · Esc cancel"),LC=A("
    No changes to review");function wC(){const[t,e]=H(!1),n=`lgtm-active-item:${_n()}`,r=new Map;function a(){var x;const N=He(),I=document.getElementById("diff-container");r.set(N,{filePath:((x=jt())==null?void 0:x.path)??null,scrollTop:(I==null?void 0:I.scrollTop)??0})}function i(N){const I=r.get(N);I&&requestAnimationFrame(()=>{const x=document.getElementById("diff-container");x&&(x.scrollTop=I.scrollTop)})}async function o(){try{const N=await lp();Xm(N)}catch{}}async function s(){try{const N=await Wm();jm(N)}catch{}}async function l(){try{const N=await SE();ic(N.walkthrough),oc(N.stale),mE()}catch{ic(null),oc(!1)}}async function c(){if(!(ye().length>0))try{const N=await Er("diff");N.mode==="diff"&&(ea(ia(N.diff)),wn().repoPath||Jl(N.meta||{}))}catch{}}async function _(N){a(),Km(N),localStorage.setItem(n,N);const I=await Er(N);if(I.mode==="diff"){Jl(I.meta||{}),Zl("diff"),ea(ia(I.diff));const x=r.get(N),B=x==null?void 0:x.filePath,P=B?ye().find(M=>M.path===B):void 0;P?_r(P.path):ye().length>0?_r(ye()[0].path):kt(null),$n(!1),await s();try{const M=await $s();if(jl(M),M.length>0&&!(wn().branch===wn().baseBranch))for(const U of M)Sr(U.sha,!0)}catch{}}else I.mode==="file"&&(Zl("file"),e(!1),Qm({content:I.content,filename:I.filename,filepath:I.filepath,markdown:I.markdown,title:I.title}),await s());i(N)}async function u(){try{await o(),await _(He()),qe("Refreshed")}catch(N){qe("Failed to refresh: "+N.message)}}async function d(){var N;try{const I=He(),x={};document.querySelectorAll(".md-block[data-block]").forEach(w=>{var Y,Z;const U=w.dataset.block;if(U!=null){const ne=`${I}-${U}`;x[ne]=((Z=(Y=w.textContent)==null?void 0:Y.trim())==null?void 0:Z.slice(0,80))||`Block ${U}`}});const B=hE(et.list,ye(),Mt(),x,I),P=await up(B,{},I),M=I==="diff"?"Code Changes":((N=Mt().find(w=>w.id===I))==null?void 0:N.title)??I;qe(`Review round ${P.round} submitted for ${M}!`,3e3),on("list",w=>w.filter(U=>U.item!==I)),lc()}catch(I){qe("Failed to submit: "+I.message)}}async function E(N){try{const I=await pp(N);qe("Review submitted to GitHub!",3e3),I.reviewUrl&&window.open(I.reviewUrl,"_blank"),on("list",x=>x.filter(B=>B.item!=="diff")),lc()}catch(I){qe("GitHub submit failed: "+I.message)}}async function p(N){try{await dp(N),await o(),He()===N&&await _("diff")}catch(I){qe("Failed to remove: "+I.message)}}async function m(){e(!1);const N=Jt().filter(x=>Fp[x.sha]).map(x=>x.sha),I=N.length>0&&N.length0&&_r(ye()[0].path),qe(`Showing ${N.length} commit${N.length!==1?"s":""}`)}catch(x){qe("Failed to apply: "+x.message)}}function g(N){const I=document.getElementById("diff-container"),x=Array.from(I.querySelectorAll("tr.comment-row, tr.claude-comment-row"));if(x.length===0)return;const B=I.getBoundingClientRect();if(N==="next"){const P=x.find(M=>M.getBoundingClientRect().top>B.top+10);P?P.scrollIntoView({block:"center",behavior:"smooth"}):x[0].scrollIntoView({block:"center",behavior:"smooth"})}else{const P=x.reverse().find(M=>M.getBoundingClientRect().tope(!t()),onJumpComment:g,onSymbolSearch:()=>Mp(!wp()),onOpenPalette:()=>pl(!Js()),onOpenHelp:()=>Pp(!xp())}),Ve(()=>{const N=Ye(),I=tt();N&&N.stops[I]&&$p(N.stops[I].id)}),Ve(()=>{Ye()&&c()}),Ve(()=>{var I;if(!document.body.classList.contains("embedded"))return;const N=Ye()!==null;(I=window.parent)==null||I.postMessage({type:"lgtm-walkthrough-availability",has:N},"*")});function f(){const N=new Set;for(const I of ye()){const x=I.path.toLowerCase();x.endsWith(".py")?N.add("python"):x.endsWith(".ts")||x.endsWith(".tsx")||x.endsWith(".js")||x.endsWith(".jsx")?N.add("typescript"):x.endsWith(".rs")&&N.add("rust")}return[...N]}function R(N){for(const I of["python","typescript","rust"]){const x=N[I];ul(I,x==="partial"?"ok":x)}}let T=null,C="";async function v(){try{const N=await sl();R(N)}catch{}}Ve(()=>{const N=f(),I=N.slice().sort().join(",");I===C||N.length===0||(C=I,Tp(N).then(R).catch(()=>{}))});async function S(){try{na(await aa())}catch{}}async function L(){const[N,I]=await Promise.all([qs().catch(()=>null),aa().catch(()=>null)]);N&&ec(N),na(I)}function D(){const N=new EventSource(`${_e()}/events`);N.addEventListener("comments_changed",async()=>{const I=et.list.filter(B=>(B==null?void 0:B.author)==="claude"&&!B.parentId).length;await s(),et.list.filter(B=>(B==null?void 0:B.author)==="claude"&&!B.parentId).length>I&&qe("New comments from Claude",2e3)}),N.addEventListener("items_changed",()=>{o().then(()=>qe("Review items updated",2e3))}),N.addEventListener("walkthrough_changed",()=>{l().then(()=>qe("Walkthrough updated",2e3))}),N.addEventListener("analysis_changed",()=>{L()}),N.addEventListener("git_changed",async()=>{u(),l();try{const I=await $s();jl(I)}catch{}await S()}),N.onerror=()=>{N.close(),setTimeout(D,5e3)}}It(async()=>{await vE(),yE(),oE(),await o();const N=await qs();N&&ec(N),na(await aa().catch(()=>null)),Zm(await EE().catch(()=>({claimed:!1,alive:!1,claimedAt:null}))),await l();const I=new URLSearchParams(window.location.search).get("item"),x=I&&Mt().some(Y=>Y.id===I),B=localStorage.getItem(n),P=B&&Mt().some(Y=>Y.id===B);await _(x?I:P?B:"diff");const M=wn();M.repoName&&(document.title=`${M.repoName} — ${M.branch||""}`),y(),D(),v(),T=window.setInterval(v,5e3);const w=document.getElementById("resize-handle"),U=document.querySelector(".sidebar");if(w&&U){let Y=!1;w.addEventListener("mousedown",Z=>{Y=!0,w.classList.add("dragging"),document.body.style.cursor="col-resize",document.body.style.userSelect="none",Z.preventDefault()}),document.addEventListener("mousemove",Z=>{Y&&(U.style.width=Math.min(Math.max(Z.clientX,150),600)+"px")}),document.addEventListener("mouseup",()=>{Y&&(Y=!1,w.classList.remove("dragging"),document.body.style.cursor="",document.body.style.userSelect="")})}}),ft(()=>{T!=null&&(clearInterval(T),T=null)});function y(){var P;const N=window.location.hash.match(/#file=(.+)/);if(!N)return;const I=decodeURIComponent(N[1]);if(!ye().find(M=>M.path===I))return;const B=I.split("/");for(let M=1;Me(!t()),get showCommitToggle(){return ae(()=>Wt()==="diff")()&&Jt().length>0}}),O(Dh,{onSwitchItem:_,onCloseTab:p}),O(xh,{get visible(){return t()},onApply:m}),(()=>{var N=AC(),I=N.firstChild;return b(N,O(F,{get when(){return Wt()==="diff"},get children(){return[O(eC,{}),IC()]}}),I),b(I,O(F,{get when(){return Pn()},get fallback(){return O(F,{get when(){return Wt()==="diff"},get fallback(){return O(OC,{})},get children(){return O(F,{get when(){return ye().length>0},get fallback(){return LC()},get children(){return O(pC,{})}})}})},get children(){return O(KR,{})}})),N})(),O(xE,{}),O(F,{get when(){return Wt()==="diff"},get children(){return yC()}}),O(F,{get when(){return Wt()==="file"},get children(){return DC()}}),O(Eb,{}),O(Ab,{}),O(Pb,{}),O(Wb,{})]}var MC=A("

    LGTM

    Registered projects"),xC=A("
    Loading…"),PC=A("
    "),kC=A("
    No projects registered yet. Run the MCP start tool from a project to register it."),UC=A("(repo missing)"),FC=A("
    "),BC=A("Remove?"),GC=A("