Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 78 additions & 0 deletions bun_client/frontend/src/components/PluginAnnotations.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { Badge } from '../design';
import { annotationSeverityVariant, type PluginAnnotation } from '../plugin_utils';

interface PluginAnnotationsProps {
annotations: PluginAnnotation[];
/** Called when an annotation is clicked, e.g. to jump to the line in the diff. */
onSelect?: (annotation: PluginAnnotation) => void;
}

/**
* Lists a plugin's line-level annotations beneath its body. Rendering these
* inline in the diff is separate follow-up work; this is the flat view on the
* plugin output surfaces.
*/
export default function PluginAnnotations({ annotations, onSelect }: PluginAnnotationsProps) {
if (annotations.length === 0) return null;

return (
<div
style={{
borderTop: '1px solid var(--border)',
padding: '12px 16px',
display: 'flex',
flexDirection: 'column',
gap: '8px',
}}
>
<div
style={{
fontSize: '11px',
fontWeight: 600,
letterSpacing: '0.04em',
textTransform: 'uppercase',
color: 'var(--text-secondary)',
}}
>
{annotations.length} annotation{annotations.length === 1 ? '' : 's'}
</div>
{annotations.map((annotation, i) => (
<div
key={`${annotation.filename}:${annotation.line}:${i}`}
onClick={onSelect ? () => onSelect(annotation) : undefined}
style={{
display: 'flex',
gap: '10px',
alignItems: 'baseline',
fontSize: '13px',
lineHeight: '1.5',
cursor: onSelect ? 'pointer' : 'default',
}}
>
{annotation.severity && (
<Badge
variant={annotationSeverityVariant(annotation.severity)}
size="sm"
pill
>
{annotation.severity}
</Badge>
)}
<span
style={{
fontFamily: 'var(--font-mono)',
fontSize: '12px',
color: 'var(--accent)',
whiteSpace: 'nowrap',
}}
>
{annotation.filename}:{annotation.line}
</span>
<span style={{ color: 'var(--text-primary)', wordBreak: 'break-word' }}>
{annotation.content}
</span>
</div>
))}
</div>
);
}
156 changes: 156 additions & 0 deletions bun_client/frontend/src/components/PluginBodyView.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import Markdown from 'react-markdown';
import type { PluginBody } from '../plugin_utils';

interface PluginBodyViewProps {
body: PluginBody;
/** Rendered when the body has no content. */
emptyLabel?: string;
}

// Theme variables the sandboxed HTML frame inherits from the app, so an HTML
// body doesn't render as a white box on a dark theme.
const FRAME_VARS = [
'--bg-primary',
'--bg-tertiary',
'--text-primary',
'--text-secondary',
'--accent',
'--border',
'--font-mono',
];

const MIN_FRAME_HEIGHT = 32;
const MAX_FRAME_HEIGHT = 600;

function frameCssVars(): string {
const styles = getComputedStyle(document.documentElement);
return FRAME_VARS.map(name => `${name}:${styles.getPropertyValue(name).trim()};`).join('');
}

function buildFrameDocument(html: string): string {
return `<!doctype html><html><head><meta charset="utf-8"><style>
:root{${frameCssVars()}}
html,body{margin:0;padding:0;background:transparent;color:var(--text-primary);
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;font-size:14px;line-height:1.6;
overflow-wrap:break-word;}
h1,h2,h3,h4{margin:1em 0 .5em;color:var(--text-primary);}
h1{font-size:1.4em;}h2{font-size:1.2em;}h3{font-size:1.1em;}
p{margin:.5em 0;}
ul,ol{margin:.5em 0;padding-left:1.5em;}
li{margin:.25em 0;}
a{color:var(--accent);}
code{font-family:var(--font-mono);background:var(--bg-tertiary);padding:.15em .4em;border-radius:4px;font-size:.9em;}
pre{font-family:var(--font-mono);background:var(--bg-tertiary);padding:12px;border-radius:6px;overflow-x:auto;margin:.5em 0;}
pre code{background:none;padding:0;}
blockquote{border-left:3px solid var(--accent);margin:.5em 0;padding-left:1em;color:var(--text-secondary);}
hr{border:none;border-top:1px solid var(--border);margin:1em 0;}
table{border-collapse:collapse;width:100%;margin:.5em 0;}
th,td{border:1px solid var(--border);padding:8px;text-align:left;}
th{background:var(--bg-tertiary);}
img{max-width:100%;}
</style></head><body>${html}</body></html>`;
}

/**
* Renders a plugin's HTML body inside a sandboxed frame.
*
* Plugin bodies are frequently LLM-generated text derived from a PR's diff and
* description, so they are not trusted markup. The frame withholds
* `allow-scripts`, which blocks script execution and inline event handlers; it
* keeps `allow-same-origin` only so the parent can measure the content to size
* the frame (harmless with scripting off, since nothing inside can run).
*/
function HtmlFrame({ html }: { html: string }) {
const frameRef = useRef<HTMLIFrameElement>(null);
const [height, setHeight] = useState(MIN_FRAME_HEIGHT);
// Re-read the theme's colors whenever the app's theme changes.
const [theme, setTheme] = useState(() => document.documentElement.dataset.theme ?? '');

const srcDoc = useMemo(() => {
// `theme` is not read directly: it re-derives the document so the frame
// picks up the new theme's variables.
void theme;
return buildFrameDocument(html);
}, [html, theme]);

useEffect(() => {
const observer = new MutationObserver(() =>
setTheme(document.documentElement.dataset.theme ?? '')
);
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ['data-theme'],
});
return () => observer.disconnect();
}, []);

const measure = useCallback(() => {
const frame = frameRef.current;
const doc = frame?.contentDocument;
if (!frame || !doc?.documentElement) return;
// A document can't report a height below the frame it lives in, so
// collapse the frame before reading: measuring in place would ratchet,
// leaving blank space when a re-run produces shorter output.
frame.style.height = '0px';
const measured = doc.documentElement.scrollHeight;
const next = Math.min(Math.max(measured, MIN_FRAME_HEIGHT), MAX_FRAME_HEIGHT);
frame.style.height = `${next}px`;
setHeight(next);
}, []);

// The frame's content can't resize itself (no scripting), but it does reflow
// when the panel around it changes width.
useEffect(() => {
const frame = frameRef.current;
if (!frame || typeof ResizeObserver === 'undefined') return;
const observer = new ResizeObserver(() => measure());
observer.observe(frame);
return () => observer.disconnect();
}, [measure]);

return (
<iframe
ref={frameRef}
title="Plugin HTML output"
sandbox="allow-same-origin"
srcDoc={srcDoc}
onLoad={measure}
style={{
display: 'block',
width: '100%',
height: `${height}px`,
border: 'none',
background: 'transparent',
colorScheme: 'normal',
}}
/>
);
}

/**
* Renders the body of a plugin response according to its declared body_type.
* Markdown is rendered inline (react-markdown ignores embedded raw HTML); an
* `html` body goes through the sandboxed frame above.
*/
export default function PluginBodyView({ body, emptyLabel = 'No output' }: PluginBodyViewProps) {
if (!body.body_content) {
return (
<span
style={{
color: 'var(--text-secondary)',
fontStyle: 'italic',
fontSize: '14px',
}}
>
{emptyLabel}
</span>
);
}

if (body.body_type === 'html') {
return <HtmlFrame html={body.body_content} />;
}

return <Markdown>{body.body_content}</Markdown>;
}
47 changes: 17 additions & 30 deletions bun_client/frontend/src/components/PluginOutput.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { useState, useEffect } from 'react';
import { rpcCall } from '../api';
import { Button, Badge, Card, mapStatusToVariant, Theme } from '../design';
import { resolvePluginBody, sortedAnnotations, type PluginResult } from '../plugin_utils';
import PluginAnnotations from './PluginAnnotations';
import PluginBodyView from './PluginBodyView';

interface PluginOutputProps {
owner: string;
Expand All @@ -11,11 +14,6 @@ interface PluginOutputProps {
onClose?: () => void;
}

interface PluginResult {
result: string;
status: string;
}

interface GetPluginOutputResponse {
output: Record<string, PluginResult>;
}
Expand Down Expand Up @@ -142,6 +140,8 @@ export default function PluginOutput({
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
{pluginNames.map(pluginName => {
const plugin = pluginOutput[pluginName];
const body = resolvePluginBody(plugin);
const annotations = sortedAnnotations(plugin);
return (
<Card
key={pluginName}
Expand Down Expand Up @@ -172,6 +172,12 @@ export default function PluginOutput({
>
{pluginName}
</span>
{annotations.length > 0 && (
<Badge variant="info" size="sm" pill>
{annotations.length} annotation
{annotations.length === 1 ? '' : 's'}
</Badge>
)}
{plugin.status.toLowerCase() === 'deferred' && (
<Button
onClick={() => executePlugin(pluginName)}
Expand All @@ -184,38 +190,19 @@ export default function PluginOutput({
)}
</div>
<div
className="markdown-content"
style={{
padding: '16px',
maxHeight: '400px',
overflowY: 'auto',
fontSize: '14px',
lineHeight: '1.6',
color: 'var(--text-primary)',
}}
>
{plugin.result ? (
<pre
style={{
margin: 0,
fontSize: '13px',
lineHeight: '1.5',
fontFamily: 'var(--font-mono)',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
color: 'var(--text-primary)',
}}
>
{plugin.result}
</pre>
) : (
<div
style={{
color: 'var(--text-secondary)',
fontStyle: 'italic',
fontSize: '14px',
}}
>
No output
</div>
)}
<PluginBodyView body={body} />
</div>
<PluginAnnotations annotations={annotations} />
</Card>
);
})}
Expand Down
21 changes: 8 additions & 13 deletions bun_client/frontend/src/components/review/PluginsPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import Markdown from 'react-markdown';
import { Button, colors, shadows } from '../../design';
import { resolvePluginBody, sortedAnnotations } from '../../plugin_utils';
import PluginAnnotations from '../PluginAnnotations';
import PluginBodyView from '../PluginBodyView';
import type { PluginResult } from './types';

interface PluginsPanelProps {
Expand Down Expand Up @@ -157,19 +159,12 @@ export default function PluginsPanel({
background: 'var(--bg-primary)',
}}
>
{data.result ? (
<Markdown>{data.result}</Markdown>
) : (
<span
style={{
color: 'var(--text-secondary)',
fontStyle: 'italic',
}}
>
No output produced.
</span>
)}
<PluginBodyView
body={resolvePluginBody(data)}
emptyLabel="No output produced."
/>
</div>
<PluginAnnotations annotations={sortedAnnotations(data)} />
</div>
))}
</div>
Expand Down
18 changes: 14 additions & 4 deletions bun_client/frontend/src/components/review/types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { colors } from '../../design';
import type { PRAnnotation } from '../../plugin_utils';

// Above this many changed lines in a single file, the diff renders that file
// without per-line syntax highlighting to avoid thousands of Prism instances.
Expand Down Expand Up @@ -34,10 +35,15 @@ export interface ReviewData {
html_url: string;
}

export interface PluginResult {
result: string;
status: string;
}
// The plugin response contract lives in plugin_utils, alongside the helpers
// that interpret it; re-exported here so review components have one import.
export type {
PluginAnnotation,
PluginBody,
PluginBodyType,
PluginResult,
PRAnnotation,
} from '../../plugin_utils';

export interface PRMetadata {
number: number;
Expand Down Expand Up @@ -71,6 +77,10 @@ export interface PRResponse {
reviews: ReviewData[];
metadata: PRMetadata;
feedback: string;
// Annotations from every plugin that has already run for this PR, each
// tagged with its source plugin. Rendering these into the diff is follow-up
// work; the plugin surfaces read annotations from GetPluginOutput today.
annotations?: PRAnnotation[];
// Only set by SyncPR: true when the sync pulled in a new head SHA or new comments.
updated?: boolean;
}
Expand Down
Loading
Loading