From 1c463914790163d056e484b4acd7b7495e9e2f2f Mon Sep 17 00:00:00 2001 From: Cory Thomas Date: Fri, 3 Jul 2026 14:11:56 -0400 Subject: [PATCH] Add human-readable Findings view to Hackbot UI The run-detail Findings panel only showed a raw JSON dump. Findings have a variable schema per agent, so add a generic, recursive "Friendly" renderer selectable via a segmented toggle in the panel header. Raw JSON remains the default view; Friendly is opt-in. The renderer maps arbitrary keys to Title Case labels and dispatches by value type: booleans and confidence as colored badges, bug_id as a Bugzilla link, string arrays as chips, arrays of objects as cards, nested objects recursively. Long text fields preserve newlines and pretty-print embedded ```json``` blocks via a lightweight helper (no markdown dependency, no dangerouslySetInnerHTML). --- services/hackbot-ui/app/globals.css | 124 +++++++++++ .../hackbot-ui/components/FindingsView.tsx | 199 ++++++++++++++++++ services/hackbot-ui/components/RunDetail.tsx | 8 +- services/hackbot-ui/lib/findings-format.tsx | 88 ++++++++ 4 files changed, 413 insertions(+), 6 deletions(-) create mode 100644 services/hackbot-ui/components/FindingsView.tsx create mode 100644 services/hackbot-ui/lib/findings-format.tsx diff --git a/services/hackbot-ui/app/globals.css b/services/hackbot-ui/app/globals.css index 1dcd6d5fc6..2f939c8eb9 100644 --- a/services/hackbot-ui/app/globals.css +++ b/services/hackbot-ui/app/globals.css @@ -283,3 +283,127 @@ dl.kv dd { opacity: 1; } } + +/* Findings panel: header with an inline view toggle */ +.panel-head { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 16px; +} +.panel-head h2 { + margin: 0; +} + +/* Segmented control (Friendly / Raw JSON) */ +.segmented { + display: inline-flex; + border: 1px solid var(--border); + border-radius: 6px; + overflow: hidden; +} +.segmented button { + background: transparent; + color: var(--muted); + border: none; + border-radius: 0; + padding: 5px 12px; + font-size: 12px; + font-weight: 600; + cursor: pointer; +} +.segmented button:hover { + background: var(--panel-2); + color: var(--text); +} +.segmented button + button { + border-left: 1px solid var(--border); +} +.segmented button.active { + background: var(--accent); + color: #fff; +} +.segmented button.active:hover { + background: var(--accent-hover); +} + +/* Friendly findings: reuse dl.kv, top-align tall values; narrow nested labels */ +dl.kv.findings-kv > dt { + padding-top: 2px; +} +dl.kv dd > dl.kv { + grid-template-columns: 140px 1fr; +} + +/* Long / multi-line string values */ +.finding-text { + font-family: var(--mono); + font-size: 13px; + line-height: 1.55; + white-space: pre-wrap; + word-break: break-word; + background: #0a0c12; + border: 1px solid var(--border); + border-radius: 8px; + padding: 12px 14px; + max-height: 420px; + overflow: auto; + margin: 0 0 8px; +} +.finding-text:last-child { + margin-bottom: 0; +} + +/* String / scalar arrays rendered as chips (e.g. target_files) */ +ul.chips { + list-style: none; + display: flex; + flex-wrap: wrap; + gap: 6px; + padding: 0; + margin: 0; +} +ul.chips li.chip { + font-family: var(--mono); + font-size: 12px; + background: var(--panel-2); + border: 1px solid var(--border); + border-radius: 6px; + padding: 2px 8px; + word-break: break-all; +} + +/* Nested object / array-of-objects cards */ +.finding-card { + border: 1px solid var(--border); + border-radius: 8px; + padding: 12px 14px; + margin-bottom: 8px; + background: var(--panel-2); +} +.finding-card:last-child { + margin-bottom: 0; +} +.finding-card > .finding-index { + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--muted); + margin-bottom: 8px; +} + +/* Boolean + confidence badge variants (reuse .badge pill) */ +.badge.bool-true, +.badge.conf-high { + background: rgba(46, 160, 67, 0.18); + color: var(--green); +} +.badge.bool-false, +.badge.conf-low { + background: rgba(229, 83, 75, 0.18); + color: var(--red); +} +.badge.conf-medium { + background: rgba(210, 153, 42, 0.16); + color: var(--amber); +} diff --git a/services/hackbot-ui/components/FindingsView.tsx b/services/hackbot-ui/components/FindingsView.tsx new file mode 100644 index 0000000000..3e4305e3b4 --- /dev/null +++ b/services/hackbot-ui/components/FindingsView.tsx @@ -0,0 +1,199 @@ +"use client"; + +import { useState } from "react"; + +import { + isPlainObject, + isScalar, + isStringArray, + renderMarkdownish, + titleize, +} from "@/lib/findings-format"; + +type ViewMode = "friendly" | "raw"; + +const BUGZILLA_URL = "https://bugzilla.mozilla.org/show_bug.cgi?id="; +const MAX_DEPTH = 6; +// Strings shorter than this and without newlines render inline; longer ones get +// their own text block. +const INLINE_STRING_MAX = 80; + +function BoolBadge({ value }: { value: boolean }) { + return ( + + {value ? "yes" : "no"} + + ); +} + +function ConfidenceBadge({ value }: { value: string }) { + const level = value.toLowerCase(); + const cls = + level === "high" || level === "medium" || level === "low" + ? `conf-${level}` + : ""; + return {value}; +} + +function StringValue({ value }: { value: string }) { + const trimmed = value.trim(); + if (!trimmed) return ; + if (!trimmed.includes("\n") && trimmed.length <= INLINE_STRING_MAX) { + return {trimmed}; + } + return <>{renderMarkdownish(value)}; +} + +function StringChips({ items }: { items: string[] }) { + return ( + + ); +} + +// Recursive dispatcher: narrows `unknown` at runtime and renders each value +// type in a friendly way. Handles arbitrary/unknown keys generically; a few +// keys (bug_id, confidence) get extra polish. +function FindingValue({ + value, + fieldKey, + depth, +}: { + value: unknown; + fieldKey?: string; + depth: number; +}) { + if (value === null || value === undefined) { + return ; + } + if (typeof value === "boolean") { + return ; + } + if (typeof value === "number") { + if (fieldKey === "bug_id") { + return ( + + {value} + + ); + } + return {value}; + } + if (typeof value === "string") { + if (fieldKey === "confidence" && value.trim()) { + return ; + } + return ; + } + if (Array.isArray(value)) { + if (value.length === 0) return ; + if (isStringArray(value)) return ; + if (value.every(isScalar)) { + return String(v))} />; + } + return ( +
+ {value.map((item, i) => ( +
+
#{i + 1}
+ +
+ ))} +
+ ); + } + if (isPlainObject(value)) { + const entries = Object.entries(value); + if (entries.length === 0) return ; + if (depth >= MAX_DEPTH) { + return
{JSON.stringify(value, null, 2)}
; + } + return ( +
+ {entries.map(([k, v]) => ( + + ))} +
+ ); + } + return {String(value)}; +} + +function FindingRow({ + fieldKey, + value, + depth, +}: { + fieldKey: string; + value: unknown; + depth: number; +}) { + return ( + <> +
{titleize(fieldKey)}
+
+ +
+ + ); +} + +function FriendlyFindings({ findings }: { findings: Record }) { + const entries = Object.entries(findings); + if (entries.length === 0) { + return

No findings.

; + } + return ( +
+ {entries.map(([k, v]) => ( + + ))} +
+ ); +} + +export function FindingsView({ + findings, +}: { + findings: Record; +}) { + // Default to raw JSON so the existing view is unchanged; Friendly is opt-in. + const [mode, setMode] = useState("raw"); + return ( +
+
+

Findings

+
+ + +
+
+ {mode === "friendly" ? ( + + ) : ( +
{JSON.stringify(findings, null, 2)}
+ )} +
+ ); +} diff --git a/services/hackbot-ui/components/RunDetail.tsx b/services/hackbot-ui/components/RunDetail.tsx index be0bdbf3ca..e85828e2ed 100644 --- a/services/hackbot-ui/components/RunDetail.tsx +++ b/services/hackbot-ui/components/RunDetail.tsx @@ -5,6 +5,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { updateRunStatus } from "@/lib/store"; import { isTerminal, type RunDoc } from "@/lib/types"; +import { FindingsView } from "./FindingsView"; import { StatusBadge } from "./StatusBadge"; const POLL_MS = 4000; @@ -142,12 +143,7 @@ export function RunDetail({ runId }: { runId: string }) { )} - {hasFindings && ( -
-

Findings

-
{JSON.stringify(findings, null, 2)}
-
- )} + {hasFindings && }

Artifacts ({run.artifacts.length})

diff --git a/services/hackbot-ui/lib/findings-format.tsx b/services/hackbot-ui/lib/findings-format.tsx new file mode 100644 index 0000000000..04802c27b8 --- /dev/null +++ b/services/hackbot-ui/lib/findings-format.tsx @@ -0,0 +1,88 @@ +// Pure helpers for the friendly Findings renderer (components/FindingsView.tsx). +// Findings have a variable schema, so these stay generic: label formatting, +// runtime type guards, and a lightweight text renderer that pretty-prints +// embedded ```json fenced blocks without any markdown dependency. + +import type { ReactNode } from "react"; + +// Polished labels for known/acronym keys; everything else falls back to titleize. +const LABEL_OVERRIDES: Record = { + bug_id: "Bug ID", + total_cost_usd: "Total Cost (USD)", + num_turns: "Turns", + regressor_node: "Regressor Node", +}; + +export function titleize(key: string): string { + if (key in LABEL_OVERRIDES) return LABEL_OVERRIDES[key]; + return key + .replace(/[_-]+/g, " ") + .trim() + .replace(/\b\w/g, (c) => c.toUpperCase()) + .replace(/\bId\b/g, "ID") + .replace(/\bUrl\b/g, "URL") + .replace(/\bUsd\b/g, "USD"); +} + +export function isPlainObject(v: unknown): v is Record { + return typeof v === "object" && v !== null && !Array.isArray(v); +} + +export function isStringArray(a: unknown[]): a is string[] { + return a.every((x) => typeof x === "string"); +} + +export function isScalar(v: unknown): v is string | number | boolean { + return ( + typeof v === "string" || typeof v === "number" || typeof v === "boolean" + ); +} + +// Pretty-print a fenced code block body when it is (or claims to be) JSON; +// otherwise return it verbatim. +function formatFence(lang: string, body: string): string { + const trimmed = body.trim(); + if (lang === "json" || trimmed.startsWith("{") || trimmed.startsWith("[")) { + try { + return JSON.stringify(JSON.parse(trimmed), null, 2); + } catch { + return trimmed; + } + } + return trimmed; +} + +// Render a possibly-long string: prose segments keep their newlines (pre-wrap +// via .finding-text) and ```fenced``` blocks become code blocks, with JSON +// pretty-printed. Inline markdown (e.g. **bold**) is shown literally — safe by +// construction (all React text nodes, no dangerouslySetInnerHTML). +export function renderMarkdownish(text: string): ReactNode[] { + const nodes: ReactNode[] = []; + const fence = /```(\w*)\n?([\s\S]*?)```/g; + let lastIndex = 0; + let key = 0; + let match: RegExpExecArray | null; + + const pushProse = (chunk: string) => { + if (chunk.trim()) { + nodes.push( +
+ {chunk.trim()} +
, + ); + } + }; + + while ((match = fence.exec(text)) !== null) { + pushProse(text.slice(lastIndex, match.index)); + nodes.push( +
+        {formatFence(match[1], match[2])}
+      
, + ); + lastIndex = match.index + match[0].length; + } + pushProse(text.slice(lastIndex)); + + return nodes; +}