Skip to content
Open
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
124 changes: 124 additions & 0 deletions services/hackbot-ui/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
199 changes: 199 additions & 0 deletions services/hackbot-ui/components/FindingsView.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<span className={`badge ${value ? "bool-true" : "bool-false"}`}>
{value ? "yes" : "no"}
</span>
);
}

function ConfidenceBadge({ value }: { value: string }) {
const level = value.toLowerCase();
const cls =
level === "high" || level === "medium" || level === "low"
? `conf-${level}`
: "";
return <span className={`badge ${cls}`.trim()}>{value}</span>;
}

function StringValue({ value }: { value: string }) {
const trimmed = value.trim();
if (!trimmed) return <span className="muted">—</span>;
if (!trimmed.includes("\n") && trimmed.length <= INLINE_STRING_MAX) {
return <span>{trimmed}</span>;
}
return <>{renderMarkdownish(value)}</>;
}

function StringChips({ items }: { items: string[] }) {
return (
<ul className="chips">
{items.map((item, i) => (
<li className="chip" key={i}>
{item}
</li>
))}
</ul>
);
}

// 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 <span className="muted">—</span>;
}
if (typeof value === "boolean") {
return <BoolBadge value={value} />;
}
if (typeof value === "number") {
if (fieldKey === "bug_id") {
return (
<a href={`${BUGZILLA_URL}${value}`} target="_blank" rel="noreferrer">
{value}
</a>
);
}
return <span>{value}</span>;
}
if (typeof value === "string") {
if (fieldKey === "confidence" && value.trim()) {
return <ConfidenceBadge value={value} />;
}
return <StringValue value={value} />;
}
if (Array.isArray(value)) {
if (value.length === 0) return <span className="muted">—</span>;
if (isStringArray(value)) return <StringChips items={value} />;
if (value.every(isScalar)) {
return <StringChips items={value.map((v) => String(v))} />;
}
return (
<div>
{value.map((item, i) => (
<div className="finding-card" key={i}>
<div className="finding-index">#{i + 1}</div>
<FindingValue value={item} depth={depth + 1} />
</div>
))}
</div>
);
}
if (isPlainObject(value)) {
const entries = Object.entries(value);
if (entries.length === 0) return <span className="muted">—</span>;
if (depth >= MAX_DEPTH) {
return <pre className="log">{JSON.stringify(value, null, 2)}</pre>;
}
return (
<dl className="kv">
{entries.map(([k, v]) => (
<FindingRow key={k} fieldKey={k} value={v} depth={depth + 1} />
))}
</dl>
);
}
return <span>{String(value)}</span>;
}

function FindingRow({
fieldKey,
value,
depth,
}: {
fieldKey: string;
value: unknown;
depth: number;
}) {
return (
<>
<dt>{titleize(fieldKey)}</dt>
<dd>
<FindingValue value={value} fieldKey={fieldKey} depth={depth} />
</dd>
</>
);
}

function FriendlyFindings({ findings }: { findings: Record<string, unknown> }) {
const entries = Object.entries(findings);
if (entries.length === 0) {
return <p className="muted">No findings.</p>;
}
return (
<dl className="kv findings-kv">
{entries.map(([k, v]) => (
<FindingRow key={k} fieldKey={k} value={v} depth={0} />
))}
</dl>
);
}

export function FindingsView({
findings,
}: {
findings: Record<string, unknown>;
}) {
// Default to raw JSON so the existing view is unchanged; Friendly is opt-in.
const [mode, setMode] = useState<ViewMode>("raw");
return (
<div className="panel">
<div className="panel-head">
<h2>Findings</h2>
<div className="segmented" role="tablist" aria-label="Findings view">
<button
type="button"
role="tab"
aria-selected={mode === "raw"}
className={mode === "raw" ? "active" : ""}
onClick={() => setMode("raw")}
>
JSON
</button>
<button
type="button"
role="tab"
aria-selected={mode === "friendly"}
className={mode === "friendly" ? "active" : ""}
onClick={() => setMode("friendly")}
>
Friendly
</button>
</div>
</div>
{mode === "friendly" ? (
<FriendlyFindings findings={findings} />
) : (
<pre className="log">{JSON.stringify(findings, null, 2)}</pre>
)}
</div>
);
}
8 changes: 2 additions & 6 deletions services/hackbot-ui/components/RunDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -142,12 +143,7 @@ export function RunDetail({ runId }: { runId: string }) {
</div>
)}

{hasFindings && (
<div className="panel">
<h2>Findings</h2>
<pre className="log">{JSON.stringify(findings, null, 2)}</pre>
</div>
)}
{hasFindings && <FindingsView findings={findings} />}

<div className="panel">
<h2>Artifacts ({run.artifacts.length})</h2>
Expand Down
Loading