From b4753422e8d85e5c4128801919db284a6fa835c5 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 21:58:42 +0000 Subject: [PATCH 1/2] Define a plugin response contract with diff annotations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plugins can now emit JSON with a body object ({body_type: markdown|html, body_content}) and a list of annotations anchoring remarks to a filename and line in the PR diff. Output that doesn't match the contract — plain text, invalid JSON, a missing body, or an unknown body_type — is wrapped verbatim as a markdown body with no annotations, so existing plugins keep working unchanged. Parsing happens when results are served, not when they're stored: the raw stdout stays in the DB and is still returned in the `result` field, while GetPluginOutput/RerunPlugins replies gain parsed `body` and `annotations` fields. Annotations from every successfully executed plugin are also aggregated into a new `annotations` field on the PR reply payload (GetPR and friends), each tagged with its source plugin, ready for follow-up work rendering them into the diff. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01CM8QkxAUerhjRFyJ2NT3wY --- docs/plugins.md | 48 +++++++++ docs/protocol.md | 41 ++++++-- server/plugin_contract.go | 157 +++++++++++++++++++++++++++++ server/plugin_contract_test.go | 177 +++++++++++++++++++++++++++++++++ server/server.go | 41 +++++--- 5 files changed, 441 insertions(+), 23 deletions(-) create mode 100644 server/plugin_contract.go create mode 100644 server/plugin_contract_test.go diff --git a/docs/plugins.md b/docs/plugins.md index 195a9c7..773f720 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -70,6 +70,54 @@ The `example_plugin` included in this repository demonstrates the interface and When your plugin runs, its standard output (stdout) is captured and stored in the database. Clients can then retrieve and display this output when you are reviewing a PR. For example, in the web client, plugin outputs appear in a dedicated "Plugins" section for each PR. +## Plugin Response Contract + +A plugin's stdout can be plain text, but a plugin may instead emit a JSON document matching the response contract. This lets it declare how its body should be rendered and attach line-level annotations to the PR diff: + +```json +{ + "body": { + "body_type": "markdown", + "body_content": "This is the response." + }, + "annotations": [ + {"filename": "test.py", "line": 75, "severity": "warning", "content": "this line looks wrong"} + ] +} +``` + +### `body` Object + +| Field | Type | Description | +|----------------|--------|----------------------------------------------| +| `body_type` | string | Either `markdown` or `html` (case-insensitive) | +| `body_content` | string | The renderable output of the plugin | + +### `annotations` List (optional) + +Each annotation anchors a remark to a line of a file in the PR: + +| Field | Type | Description | +|------------|--------|--------------------------------------------------------------| +| `filename` | string | Path of the file within the repo (required) | +| `line` | int | 1-based line number the annotation applies to (required) | +| `severity` | string | Free-form severity, e.g. `info`, `warning`, `error` | +| `content` | string | The annotation text | + +Annotations without a `filename` or a positive `line` are dropped, since they can't be anchored to the diff. + +### Backwards Compatibility + +Output that doesn't match the contract — plain text, invalid JSON, JSON without a `body` object, or an unknown `body_type` — is treated as legacy output and wrapped as: + +```json +{"body": {"body_type": "markdown", "body_content": ""}, "annotations": []} +``` + +so existing plugins keep working unchanged. The raw stdout is always stored and returned as-is in the `result` field of `GetPluginOutput`; parsing happens when results are served to clients. + +Annotations from every successfully executed plugin for a PR are also aggregated into the `annotations` field of the `GetPR` response (each tagged with the plugin's name), so clients can render them into the diff. + ## On-Demand Plugins By default, all configured plugins automatically run when a PR is fetched or when its commit changes (once per SHA). However, some plugins can be expensive to run (e.g., those making API calls to third-party services like Gemini or Claude). diff --git a/docs/protocol.md b/docs/protocol.md index 918afd2..6a54810 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -97,6 +97,17 @@ Fetches a pull request from GitHub and returns it as rendered content (including | `comments` | []CommentJSON| List of structured PR active comments | | `outdated_comments` | []CommentJSON| List of structured PR outdated comments | | `reviews` | []ReviewJSON | List of submitted reviews | +| `annotations` | []PRAnnotation | Diff annotations aggregated from every plugin that has already executed successfully for this PR (see [Plugins](plugins.md#plugin-response-contract)) | + +#### PRAnnotation Object + +| Field | Type | Description | +|------------|--------|----------------------------------------------------------| +| `plugin` | string | Name of the plugin that produced the annotation | +| `filename` | string | Path of the file within the repo | +| `line` | int | 1-based line number the annotation applies to | +| `severity` | string | Free-form severity, e.g. `info`, `warning`, `error` | +| `content` | string | The annotation text | #### PRMetadata Object @@ -498,13 +509,29 @@ Retrieves the output and status of all plugins for a specific pull request. **Reply** (`GetPluginOutputReply`): | Field | Type | Description | |----------|-----------------------------|--------------------------------------------------------| -| `output` | map[string]PluginResult | Map of plugin names to their respective results/status | - -#### `PluginResult` Object -| Field | Type | Description | -|----------|--------|----------------------------------------------------------------------| -| `result` | string | The captured output (stdout/stderr) of the plugin | -| `status` | string | Execution status: `pending`, `success`, or `error` | +| `output` | map[string]PluginOutput | Map of plugin names to their respective results/status | + +#### `PluginOutput` Object +| Field | Type | Description | +|---------------|--------------|-----------------------------------------------------------------------------------| +| `result` | string | The raw captured stdout of the plugin, unchanged (kept for older clients) | +| `status` | string | Execution status: `pending`, `success`, `error`, or `deferred` | +| `body` | PluginBody | The plugin's output parsed against the [response contract](plugins.md#plugin-response-contract); non-conforming output is wrapped as a `markdown` body holding the raw output | +| `annotations` | []Annotation | Line-level diff annotations declared by the plugin (empty for legacy output) | + +#### `PluginBody` Object +| Field | Type | Description | +|----------------|--------|----------------------------------------| +| `body_type` | string | Either `markdown` or `html` | +| `body_content` | string | The renderable output of the plugin | + +#### `Annotation` Object +| Field | Type | Description | +|------------|--------|------------------------------------------------------| +| `filename` | string | Path of the file within the repo | +| `line` | int | 1-based line number the annotation applies to | +| `severity` | string | Free-form severity, e.g. `info`, `warning`, `error` | +| `content` | string | The annotation text | --- diff --git a/server/plugin_contract.go b/server/plugin_contract.go new file mode 100644 index 0000000..ff3572a --- /dev/null +++ b/server/plugin_contract.go @@ -0,0 +1,157 @@ +package server + +import ( + "crs/config" + "crs/database" + "encoding/json" + "sort" + "strings" +) + +// Plugin response contract +// +// Plugins historically wrote free-form text to stdout and clients rendered it +// verbatim. A plugin may now instead emit a JSON document matching the +// contract below, which lets it declare how its body should be rendered and +// attach line-level annotations to the PR diff: +// +// { +// "body": { +// "body_type": "markdown", +// "body_content": "This is the response." +// }, +// "annotations": [ +// {"filename": "test.py", "line": 75, "severity": "warning", "content": "this line looks wrong"} +// ] +// } +// +// Output that does not match the contract — non-JSON output, JSON without a +// body object, or an unknown body_type — is treated as legacy output and +// wrapped as {"body_type": "markdown", "body_content": } with no +// annotations, so existing plugins keep working unchanged. + +const ( + BodyTypeMarkdown = "markdown" + BodyTypeHTML = "html" +) + +// PluginBody is the renderable portion of a plugin response. +type PluginBody struct { + // BodyType is either "markdown" or "html". + BodyType string `json:"body_type"` + BodyContent string `json:"body_content"` +} + +// PluginAnnotation anchors a plugin remark to a line of a file in the PR. +// Filename and Line are required for an annotation to be kept; Severity and +// Content are free-form. +type PluginAnnotation struct { + Filename string `json:"filename"` + Line int `json:"line"` + Severity string `json:"severity"` + Content string `json:"content"` +} + +// PluginResponse is the parsed form of a plugin's stdout. +type PluginResponse struct { + Body PluginBody `json:"body"` + Annotations []PluginAnnotation `json:"annotations"` +} + +// rawPluginResponse mirrors PluginResponse with a pointer body so parsing can +// tell "no body key" apart from an empty one. +type rawPluginResponse struct { + Body *PluginBody `json:"body"` + Annotations []PluginAnnotation `json:"annotations"` +} + +// ParsePluginOutput parses raw plugin stdout against the response contract. +// Output that doesn't conform is returned whole as a markdown body with no +// annotations. Annotations missing a filename or a positive line number are +// dropped, since they can't be anchored to the diff. +func ParsePluginOutput(raw string) PluginResponse { + fallback := PluginResponse{ + Body: PluginBody{BodyType: BodyTypeMarkdown, BodyContent: raw}, + Annotations: []PluginAnnotation{}, + } + + var parsed rawPluginResponse + if err := json.Unmarshal([]byte(raw), &parsed); err != nil || parsed.Body == nil { + return fallback + } + bodyType := strings.ToLower(strings.TrimSpace(parsed.Body.BodyType)) + if bodyType != BodyTypeMarkdown && bodyType != BodyTypeHTML { + return fallback + } + + response := PluginResponse{ + Body: PluginBody{BodyType: bodyType, BodyContent: parsed.Body.BodyContent}, + Annotations: []PluginAnnotation{}, + } + for _, a := range parsed.Annotations { + if a.Filename == "" || a.Line < 1 { + continue + } + response.Annotations = append(response.Annotations, a) + } + return response +} + +// PluginOutput is the client-facing view of a stored plugin result: the raw +// captured stdout (kept so existing clients keep working) plus the parsed +// response contract. +type PluginOutput struct { + Result string `json:"result"` + Status string `json:"status"` + Body PluginBody `json:"body"` + Annotations []PluginAnnotation `json:"annotations"` +} + +// parsePluginOutputs converts stored plugin results to their client-facing +// view, parsing each stored result against the response contract. +func parsePluginOutputs(results map[string]database.PluginResult) map[string]PluginOutput { + outputs := make(map[string]PluginOutput, len(results)) + for name, res := range results { + parsed := ParsePluginOutput(res.Result) + outputs[name] = PluginOutput{ + Result: res.Result, + Status: res.Status, + Body: parsed.Body, + Annotations: parsed.Annotations, + } + } + return outputs +} + +// PRAnnotation is a plugin annotation aggregated into a PR reply, tagged with +// the plugin that produced it. +type PRAnnotation struct { + PluginAnnotation + Plugin string `json:"plugin"` +} + +// collectPluginAnnotations gathers the annotations from every plugin that has +// already run successfully for the PR, grouped by plugin in name order so the +// result is deterministic. +func collectPluginAnnotations(owner, repo string, number int) ([]PRAnnotation, error) { + results, err := config.C().DB.GetPluginResults(owner, repo, number) + if err != nil { + return nil, err + } + + names := make([]string, 0, len(results)) + for name, res := range results { + if res.Status == "success" { + names = append(names, name) + } + } + sort.Strings(names) + + annotations := []PRAnnotation{} + for _, name := range names { + for _, a := range ParsePluginOutput(results[name].Result).Annotations { + annotations = append(annotations, PRAnnotation{PluginAnnotation: a, Plugin: name}) + } + } + return annotations, nil +} diff --git a/server/plugin_contract_test.go b/server/plugin_contract_test.go new file mode 100644 index 0000000..69776d0 --- /dev/null +++ b/server/plugin_contract_test.go @@ -0,0 +1,177 @@ +package server + +import ( + "crs/config" + "testing" +) + +func TestParsePluginOutput_ConformingMarkdownResponse(t *testing.T) { + raw := `{ + "body": {"body_type": "markdown", "body_content": "This is the response."}, + "annotations": [ + {"filename": "test.py", "line": 75, "severity": "warning", "content": "this line looks wrong"}, + {"filename": "main.go", "line": 3, "severity": "error", "content": "nil dereference"} + ] + }` + resp := ParsePluginOutput(raw) + + if resp.Body.BodyType != BodyTypeMarkdown { + t.Errorf("expected body_type %q, got %q", BodyTypeMarkdown, resp.Body.BodyType) + } + if resp.Body.BodyContent != "This is the response." { + t.Errorf("unexpected body_content: %q", resp.Body.BodyContent) + } + if len(resp.Annotations) != 2 { + t.Fatalf("expected 2 annotations, got %d", len(resp.Annotations)) + } + first := resp.Annotations[0] + if first.Filename != "test.py" || first.Line != 75 || first.Severity != "warning" || first.Content != "this line looks wrong" { + t.Errorf("unexpected first annotation: %+v", first) + } +} + +func TestParsePluginOutput_ConformingHTMLResponse(t *testing.T) { + raw := `{"body": {"body_type": "HTML", "body_content": "

hi

"}}` + resp := ParsePluginOutput(raw) + + if resp.Body.BodyType != BodyTypeHTML { + t.Errorf("expected body_type %q (normalized), got %q", BodyTypeHTML, resp.Body.BodyType) + } + if resp.Body.BodyContent != "

hi

" { + t.Errorf("unexpected body_content: %q", resp.Body.BodyContent) + } + if len(resp.Annotations) != 0 { + t.Errorf("expected no annotations, got %d", len(resp.Annotations)) + } +} + +func TestParsePluginOutput_LegacyPlainTextFallsBackToMarkdown(t *testing.T) { + raw := "Just some plain text summary.\nWith multiple lines." + resp := ParsePluginOutput(raw) + + if resp.Body.BodyType != BodyTypeMarkdown { + t.Errorf("expected fallback body_type markdown, got %q", resp.Body.BodyType) + } + if resp.Body.BodyContent != raw { + t.Errorf("expected raw output preserved verbatim, got %q", resp.Body.BodyContent) + } + if len(resp.Annotations) != 0 { + t.Errorf("expected no annotations on fallback, got %d", len(resp.Annotations)) + } +} + +func TestParsePluginOutput_JSONWithoutBodyFallsBack(t *testing.T) { + raw := `{"summary": "valid JSON but not the contract"}` + resp := ParsePluginOutput(raw) + + if resp.Body.BodyType != BodyTypeMarkdown || resp.Body.BodyContent != raw { + t.Errorf("expected whole output wrapped as markdown, got %+v", resp.Body) + } +} + +func TestParsePluginOutput_UnknownBodyTypeFallsBack(t *testing.T) { + raw := `{"body": {"body_type": "plaintext", "body_content": "hello"}}` + resp := ParsePluginOutput(raw) + + if resp.Body.BodyType != BodyTypeMarkdown || resp.Body.BodyContent != raw { + t.Errorf("expected whole output wrapped as markdown, got %+v", resp.Body) + } +} + +func TestParsePluginOutput_DropsUnanchoredAnnotations(t *testing.T) { + raw := `{ + "body": {"body_type": "markdown", "body_content": "ok"}, + "annotations": [ + {"filename": "", "line": 10, "content": "no filename"}, + {"filename": "a.go", "line": 0, "content": "no line"}, + {"filename": "a.go", "line": -3, "content": "negative line"}, + {"filename": "keep.go", "line": 1, "content": "kept"} + ] + }` + resp := ParsePluginOutput(raw) + + if len(resp.Annotations) != 1 { + t.Fatalf("expected 1 annotation to survive, got %d: %+v", len(resp.Annotations), resp.Annotations) + } + if resp.Annotations[0].Filename != "keep.go" { + t.Errorf("unexpected surviving annotation: %+v", resp.Annotations[0]) + } +} + +func TestCollectPluginAnnotations_OnlySuccessfulPluginsTagged(t *testing.T) { + db := setupTestDB(t) + config.SetC(config.Config{DB: db}) + + conforming := `{ + "body": {"body_type": "markdown", "body_content": "found issues"}, + "annotations": [ + {"filename": "test.py", "line": 75, "severity": "warning", "content": "this line looks wrong"} + ] + }` + if err := db.UpsertPluginResult("owner", "repo", 1, "b-linter", conforming, "success", "sha1"); err != nil { + t.Fatalf("upsert failed: %v", err) + } + // Legacy plain-text plugin: contributes no annotations. + if err := db.UpsertPluginResult("owner", "repo", 1, "a-summarizer", "plain summary", "success", "sha1"); err != nil { + t.Fatalf("upsert failed: %v", err) + } + // Failed plugin with annotation-shaped output must be excluded. + if err := db.UpsertPluginResult("owner", "repo", 1, "c-broken", conforming, "error", "sha1"); err != nil { + t.Fatalf("upsert failed: %v", err) + } + + annotations, err := collectPluginAnnotations("owner", "repo", 1) + if err != nil { + t.Fatalf("collectPluginAnnotations failed: %v", err) + } + if len(annotations) != 1 { + t.Fatalf("expected 1 annotation, got %d: %+v", len(annotations), annotations) + } + a := annotations[0] + if a.Plugin != "b-linter" { + t.Errorf("expected annotation tagged with plugin name, got %q", a.Plugin) + } + if a.Filename != "test.py" || a.Line != 75 || a.Severity != "warning" { + t.Errorf("unexpected annotation: %+v", a) + } +} + +func TestCollectPluginAnnotations_NoResults(t *testing.T) { + db := setupTestDB(t) + config.SetC(config.Config{DB: db}) + + annotations, err := collectPluginAnnotations("owner", "repo", 99) + if err != nil { + t.Fatalf("collectPluginAnnotations failed: %v", err) + } + if len(annotations) != 0 { + t.Errorf("expected no annotations, got %+v", annotations) + } +} + +func TestParsePluginOutputs_KeepsRawResultAlongsideParsed(t *testing.T) { + db := setupTestDB(t) + raw := `{"body": {"body_type": "html", "body_content": "done"}}` + if err := db.UpsertPluginResult("owner", "repo", 2, "renderer", raw, "success", "sha2"); err != nil { + t.Fatalf("upsert failed: %v", err) + } + results, err := db.GetPluginResults("owner", "repo", 2) + if err != nil { + t.Fatalf("get results failed: %v", err) + } + + outputs := parsePluginOutputs(results) + out, ok := outputs["renderer"] + if !ok { + t.Fatal("expected output for plugin 'renderer'") + } + if out.Result != raw { + t.Errorf("expected raw result preserved for legacy clients, got %q", out.Result) + } + if out.Status != "success" { + t.Errorf("expected status success, got %q", out.Status) + } + if out.Body.BodyType != BodyTypeHTML || out.Body.BodyContent != "done" { + t.Errorf("unexpected parsed body: %+v", out.Body) + } +} diff --git a/server/server.go b/server/server.go index 1ae9cc0..c55eee0 100644 --- a/server/server.go +++ b/server/server.go @@ -3,7 +3,6 @@ package server import ( "context" "crs/config" - "crs/database" "crs/git_tools" "crs/llm" "crs/utils" @@ -148,15 +147,16 @@ type GetPRstructArgs struct { // state. Reply structs embed it so all methods expose the same fields, and // populate() is the single place they get filled in and normalized. type PRPayload struct { - Okay bool `json:"okay"` - Content string `json:"content"` - Metadata *PRMetadata `json:"metadata"` - Diff string `json:"diff"` - Comments []CommentJSON `json:"comments"` - OutdatedComments []CommentJSON `json:"outdated_comments"` - Reviews []ReviewJSON `json:"reviews"` - Commits []CommitJSON `json:"commits"` - Feedback string `json:"feedback"` + Okay bool `json:"okay"` + Content string `json:"content"` + Metadata *PRMetadata `json:"metadata"` + Diff string `json:"diff"` + Comments []CommentJSON `json:"comments"` + OutdatedComments []CommentJSON `json:"outdated_comments"` + Reviews []ReviewJSON `json:"reviews"` + Commits []CommitJSON `json:"commits"` + Feedback string `json:"feedback"` + Annotations []PRAnnotation `json:"annotations"` } // populate fills the payload from fetched PR details, normalizing nil slices @@ -188,6 +188,15 @@ func (p *PRPayload) populate(details *PRDetails, content string, owner, repo str slog.Warn("Error loading feedback for PR reply", "repo", repo, "pr", number, "error", err) } p.Feedback = feedback + + annotations, err := collectPluginAnnotations(owner, repo, number) + if err != nil { + slog.Warn("Error loading plugin annotations for PR reply", "repo", repo, "pr", number, "error", err) + } + if annotations == nil { + annotations = []PRAnnotation{} + } + p.Annotations = annotations } type GetPRReply struct { @@ -1140,7 +1149,7 @@ type GetPluginOutputArgs struct { } type GetPluginOutputReply struct { - Output map[string]database.PluginResult `json:"output"` + Output map[string]PluginOutput `json:"output"` } // GetPluginOutput returns all stored plugin outputs for the given PR @@ -1165,7 +1174,7 @@ func (h *RPCHandler) GetPluginOutput(args *GetPluginOutputArgs, reply *GetPlugin }() } - reply.Output = results + reply.Output = parsePluginOutputs(results) return nil } @@ -1177,9 +1186,9 @@ type RerunPluginsArgs struct { } type RerunPluginsReply struct { - Okay bool `json:"okay"` - Message string `json:"message"` - Output map[string]database.PluginResult `json:"output"` + Okay bool `json:"okay"` + Message string `json:"message"` + Output map[string]PluginOutput `json:"output"` } // RerunPlugins forces reexecution of plugins for a given PR, bypassing SHA cache checks. @@ -1234,7 +1243,7 @@ func (h *RPCHandler) RerunPlugins(args *RerunPluginsArgs, reply *RerunPluginsRep } // Return empty output for now (plugins running async) - reply.Output = make(map[string]database.PluginResult) + reply.Output = make(map[string]PluginOutput) return nil } From 7ea5132e864b78d5274c74263d50029d2992790e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 04:55:07 +0000 Subject: [PATCH 2/2] Render the plugin response contract in the web client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The plugin output page rendered whatever the plugin wrote to stdout as preformatted text, so a plugin speaking the response contract showed its raw JSON. Both plugin surfaces — the standalone output page and the review view's drawer — now render the parsed body according to its declared body_type and list the plugin's annotations beneath it. plugin_utils owns the client's reading of the contract: resolvePluginBody trusts a body the server sends (an empty body with annotations attached is a legitimate annotations-only response) and falls back to the raw stdout as markdown when the body is missing or carries an unknown type, so an older server keeps working. An html body renders in a sandboxed frame rather than via innerHTML. Plugin bodies are frequently LLM-generated text derived from a PR's diff and description, so they aren't trusted markup; the frame withholds allow-scripts, which blocks scripts and inline handlers, and keeps allow-same-origin only to measure content for sizing. It inherits the active theme's variables and refits when the theme changes, collapsing before measuring so a shorter re-run doesn't leave blank space. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01CM8QkxAUerhjRFyJ2NT3wY --- .../src/components/PluginAnnotations.tsx | 78 +++++++++ .../src/components/PluginBodyView.tsx | 156 ++++++++++++++++++ .../frontend/src/components/PluginOutput.tsx | 47 ++---- .../src/components/review/PluginsPanel.tsx | 21 +-- .../frontend/src/components/review/types.ts | 18 +- bun_client/frontend/src/plugin_utils.test.ts | 151 +++++++++++++++++ bun_client/frontend/src/plugin_utils.ts | 96 +++++++++++ docs/plugins.md | 6 + 8 files changed, 526 insertions(+), 47 deletions(-) create mode 100644 bun_client/frontend/src/components/PluginAnnotations.tsx create mode 100644 bun_client/frontend/src/components/PluginBodyView.tsx create mode 100644 bun_client/frontend/src/plugin_utils.test.ts create mode 100644 bun_client/frontend/src/plugin_utils.ts diff --git a/bun_client/frontend/src/components/PluginAnnotations.tsx b/bun_client/frontend/src/components/PluginAnnotations.tsx new file mode 100644 index 0000000..1eda6c6 --- /dev/null +++ b/bun_client/frontend/src/components/PluginAnnotations.tsx @@ -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 ( +
+
+ {annotations.length} annotation{annotations.length === 1 ? '' : 's'} +
+ {annotations.map((annotation, i) => ( +
onSelect(annotation) : undefined} + style={{ + display: 'flex', + gap: '10px', + alignItems: 'baseline', + fontSize: '13px', + lineHeight: '1.5', + cursor: onSelect ? 'pointer' : 'default', + }} + > + {annotation.severity && ( + + {annotation.severity} + + )} + + {annotation.filename}:{annotation.line} + + + {annotation.content} + +
+ ))} +
+ ); +} diff --git a/bun_client/frontend/src/components/PluginBodyView.tsx b/bun_client/frontend/src/components/PluginBodyView.tsx new file mode 100644 index 0000000..128f562 --- /dev/null +++ b/bun_client/frontend/src/components/PluginBodyView.tsx @@ -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 `${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(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 ( +