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 (
+
+ {items.map((item, i) => (
+ -
+ {item}
+
+ ))}
+
+ );
+}
+
+// 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) => (
+
+ ))}
+
+ );
+ }
+ 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;
+}