Skip to content

Commit ad2698b

Browse files
committed
feat(webapp): render the cards the flows already emit
The prompt requires render_view and the schema union allows actions, investigation and report blocks, but ViewBlocks only had cases for diagnosis and chart, so those three rendered as an empty div. Adds the three renderers, the block-envelope latest-wins resolution the switch keys on, and a contract test that reads the block types off viewBlockSchema, so a new union member fails until it has a renderer.
1 parent 566de27 commit ad2698b

17 files changed

Lines changed: 2286 additions & 13 deletions
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import type {
2+
ActionsBlock as ActionsBlockPayload,
3+
AgentIntent,
4+
} from "@internal/dashboard-agent-contracts";
5+
import { Button } from "~/components/primitives/Buttons";
6+
import { ChatActionsRow } from "./chat-layout";
7+
import { renderableActions } from "./view-actions";
8+
9+
export function ActionsBlock({
10+
block,
11+
onIntent,
12+
}: {
13+
block: ActionsBlockPayload;
14+
onIntent?: (intent: AgentIntent) => void;
15+
}) {
16+
const renderable = renderableActions(block.actions);
17+
if (!onIntent || renderable.length === 0) return null;
18+
return (
19+
<ChatActionsRow>
20+
{renderable.map((action, i) => (
21+
<Button
22+
key={i}
23+
variant={i === 0 ? "primary/small" : "secondary/small"}
24+
onClick={() => onIntent(action.intent as AgentIntent)}
25+
>
26+
{action.label}
27+
</Button>
28+
))}
29+
</ChatActionsRow>
30+
);
31+
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { readFileSync } from "node:fs";
2+
import { describe, expect, it } from "vitest";
3+
4+
const source = readFileSync(new URL("./InvestigationCard.tsx", import.meta.url), "utf8");
5+
6+
describe("InvestigationCard purity", () => {
7+
it("imports nothing from Remix", () => {
8+
expect(source).not.toMatch(/from\s+"@remix-run\//);
9+
});
10+
11+
it("imports no app hooks and no server module", () => {
12+
expect(source).not.toMatch(/from\s+"~\/hooks\//);
13+
expect(source).not.toMatch(/\.server"/);
14+
});
15+
16+
it("calls no hook other than useState", () => {
17+
const hooks = [...source.matchAll(/\buse([A-Z]\w*)\(/g)].map((match) => `use${match[1]}`);
18+
expect([...new Set(hooks)]).toEqual(["useState"]);
19+
});
20+
21+
it("resolves evidence URIs through the host, never a route of its own", () => {
22+
expect(source).toMatch(/resolveUri/);
23+
expect(source).not.toMatch(/\/orgs\//);
24+
});
25+
26+
it("hands its actions to the host as intents, and never composes its own", () => {
27+
expect(source).toMatch(/capabilities\?\.actions/);
28+
expect(source).toMatch(/onIntent\(action\.intent\)/);
29+
expect(source).not.toMatch(/kind:\s*"(ask|navigate)"/);
30+
expect(source).toMatch(/ChatActionsRow/);
31+
});
32+
33+
it("renders no spinner — the transcript owns the one live progress element", () => {
34+
// A spinner in the card would restart on every revision.
35+
expect(source).not.toMatch(/AgentSpinner|ChatProgress|ChatPendingTool/);
36+
});
37+
38+
it("renders nothing action-shaped without a host to hand intents to", () => {
39+
expect(source).toMatch(/if \(!onIntent \|\| actions\.length === 0\) return null;/);
40+
});
41+
});
Lines changed: 252 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,252 @@
1+
// `id` is the investigationId and `revision` climbs: re-emitting replaces, never stacks.
2+
import { ChevronDownIcon, ChevronRightIcon } from "@heroicons/react/20/solid";
3+
import type {
4+
AgentIntent,
5+
Evidence,
6+
HypothesisVerdict,
7+
InvestigationAction,
8+
InvestigationBlock,
9+
InvestigationHypothesis,
10+
InvestigationSeverity,
11+
} from "@internal/dashboard-agent-contracts";
12+
import { useState } from "react";
13+
import { Button } from "~/components/primitives/Buttons";
14+
import { Callout } from "~/components/primitives/Callout";
15+
import {
16+
CategoryBadge,
17+
ConfidenceBadge,
18+
EVIDENCE_ROW_CLASS,
19+
SeverityBadge,
20+
VerdictBadge,
21+
} from "./agent-badges";
22+
import { AgentCard, AgentCardBody, AgentCardHeader } from "./agent-card";
23+
import { ChatActionsRow } from "./chat-layout";
24+
import type { ResolvedUri } from "./ReportView";
25+
26+
const SEVERITY_LABELS: Record<InvestigationSeverity, string> = {
27+
info: "Info",
28+
warn: "Degraded",
29+
crit: "Critical",
30+
};
31+
32+
const VERDICT_LABELS: Record<HypothesisVerdict, string> = {
33+
testing: "Testing",
34+
validated: "Validated",
35+
invalidated: "Ruled out",
36+
};
37+
38+
type ResolveUri = (uri: string) => ResolvedUri | null;
39+
40+
function Section({ title, children }: { title: string; children: React.ReactNode }) {
41+
return (
42+
<div className="space-y-2">
43+
<h4 className="text-xs font-medium uppercase tracking-wide text-text-dimmed">{title}</h4>
44+
{children}
45+
</div>
46+
);
47+
}
48+
49+
function EvidenceItem({
50+
evidence,
51+
stacked,
52+
resolveUri,
53+
}: {
54+
evidence: Evidence;
55+
stacked?: boolean;
56+
resolveUri?: ResolveUri;
57+
}) {
58+
const resolved = resolveUri?.(evidence.uri) ?? null;
59+
return (
60+
<li className={stacked ? "space-y-1.5" : EVIDENCE_ROW_CLASS}>
61+
{/* The Badge primitive is a grid, so `w-fit` is needed to stop it stretching. */}
62+
<CategoryBadge className="w-fit justify-self-start">{evidence.kind}</CategoryBadge>
63+
<div className="min-w-0 space-y-1.5">
64+
<p className="text-xs text-text-bright">{evidence.label}</p>
65+
{resolved ? (
66+
<a
67+
href={resolved.url}
68+
className="block break-all font-mono text-[10px] text-text-link transition hover:underline"
69+
>
70+
{resolved.label}
71+
</a>
72+
) : (
73+
<div className="break-all font-mono text-[10px] text-text-dimmed">{evidence.uri}</div>
74+
)}
75+
{evidence.excerpt ? (
76+
<pre className="overflow-x-auto rounded-sm border border-grid-bright bg-background-bright px-2 py-1.5 font-mono text-[11px] leading-relaxed text-text-bright scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control">
77+
{evidence.excerpt}
78+
</pre>
79+
) : null}
80+
</div>
81+
</li>
82+
);
83+
}
84+
85+
function HypothesisRow({
86+
hypothesis,
87+
resolveUri,
88+
}: {
89+
hypothesis: InvestigationHypothesis;
90+
resolveUri?: ResolveUri;
91+
}) {
92+
return (
93+
<li className="space-y-3 border-l-2 border-grid-bright pl-4">
94+
<div className="flex flex-wrap items-center gap-2">
95+
<VerdictBadge verdict={hypothesis.verdict}>
96+
{VERDICT_LABELS[hypothesis.verdict]}
97+
</VerdictBadge>
98+
</div>
99+
<p className="text-sm text-text-bright">{hypothesis.statement}</p>
100+
{hypothesis.finding ? <p className="text-xs text-text-dimmed">{hypothesis.finding}</p> : null}
101+
{hypothesis.evidence.length > 0 ? (
102+
<ul className="space-y-5 pt-1">
103+
{hypothesis.evidence.map((evidence, i) => (
104+
<EvidenceItem key={i} evidence={evidence} stacked resolveUri={resolveUri} />
105+
))}
106+
</ul>
107+
) : null}
108+
</li>
109+
);
110+
}
111+
112+
function InvestigationActions({
113+
actions,
114+
onIntent,
115+
}: {
116+
actions: InvestigationAction[];
117+
onIntent?: (intent: AgentIntent) => void;
118+
}) {
119+
if (!onIntent || actions.length === 0) return null;
120+
return (
121+
<div className="border-t border-grid-bright pt-4">
122+
<ChatActionsRow>
123+
{actions.map((action, i) => (
124+
<Button
125+
key={action.kind}
126+
variant={i === 0 ? "primary/small" : "secondary/small"}
127+
onClick={() => onIntent(action.intent)}
128+
>
129+
{action.label}
130+
</Button>
131+
))}
132+
</ChatActionsRow>
133+
</div>
134+
);
135+
}
136+
137+
export function InvestigationCard({
138+
block,
139+
defaultExpanded = false,
140+
resolveUri,
141+
onIntent,
142+
answered = false,
143+
}: {
144+
block: InvestigationBlock;
145+
defaultExpanded?: boolean;
146+
resolveUri?: ResolveUri;
147+
onIntent?: (intent: AgentIntent) => void;
148+
/** The turn kept answering after this card, so "keep digging" has nothing to ask for. */
149+
answered?: boolean;
150+
}) {
151+
const [expanded, setExpanded] = useState(defaultExpanded);
152+
const investigation = block.investigation;
153+
const concluded = investigation.outcome === "concluded";
154+
155+
return (
156+
<AgentCard>
157+
<AgentCardHeader className="space-y-1.5">
158+
<div className="flex flex-wrap items-center gap-2">
159+
<span className="text-xs font-medium text-text-dimmed">Investigation</span>
160+
<SeverityBadge severity={investigation.severity}>
161+
{SEVERITY_LABELS[investigation.severity]}
162+
</SeverityBadge>
163+
<ConfidenceBadge confidence={investigation.confidence} />
164+
</div>
165+
{investigation.runId ? (
166+
<div className="truncate font-mono text-xs text-text-dimmed">{investigation.runId}</div>
167+
) : null}
168+
</AgentCardHeader>
169+
170+
<AgentCardBody density="roomy">
171+
<p className="text-sm font-medium text-text-bright">{investigation.title}</p>
172+
173+
<Section title={concluded ? "What happened" : "What we know"}>
174+
<p className="text-sm text-text-dimmed">{investigation.headline}</p>
175+
</Section>
176+
177+
{/* The schema makes `remediation` and `checkNext` mutually exclusive. */}
178+
{concluded && investigation.remediation ? (
179+
<Section title="How to fix">
180+
<p className="text-sm text-text-dimmed">{investigation.remediation}</p>
181+
</Section>
182+
) : null}
183+
184+
{investigation.checkNext && investigation.checkNext.length > 0 ? (
185+
<Section title="What to check next">
186+
<ol className="list-decimal space-y-2 pl-5">
187+
{investigation.checkNext.map((item, i) => (
188+
<li key={i} className="text-sm text-text-dimmed">
189+
{item}
190+
</li>
191+
))}
192+
</ol>
193+
</Section>
194+
) : null}
195+
196+
{investigation.caveat ? (
197+
<Callout variant="warning">{investigation.caveat.message}</Callout>
198+
) : null}
199+
200+
<div className="space-y-4 border-t border-grid-bright pt-4">
201+
<Button
202+
variant="minimal/small"
203+
onClick={() => setExpanded((v) => !v)}
204+
LeadingIcon={expanded ? ChevronDownIcon : ChevronRightIcon}
205+
aria-expanded={expanded}
206+
>
207+
<span className="flex items-center gap-1.5 text-xs text-text-dimmed">
208+
{expanded ? "Hide how I worked this out" : "How I worked this out"}
209+
<span className="text-text-faint">
210+
({investigation.hypotheses.length} hypothes
211+
{investigation.hypotheses.length === 1 ? "is" : "es"})
212+
</span>
213+
</span>
214+
</Button>
215+
216+
{expanded ? (
217+
<div className="space-y-5 pt-1">
218+
<Section title="Hypotheses">
219+
<ul className="space-y-5">
220+
{investigation.hypotheses.map((hypothesis) => (
221+
<HypothesisRow
222+
key={hypothesis.id}
223+
hypothesis={hypothesis}
224+
resolveUri={resolveUri}
225+
/>
226+
))}
227+
</ul>
228+
</Section>
229+
230+
{investigation.evidence.length > 0 ? (
231+
<Section title="Evidence">
232+
<ul className="space-y-3">
233+
{investigation.evidence.map((evidence, i) => (
234+
<EvidenceItem key={i} evidence={evidence} resolveUri={resolveUri} />
235+
))}
236+
</ul>
237+
</Section>
238+
) : null}
239+
</div>
240+
) : null}
241+
</div>
242+
243+
<InvestigationActions
244+
actions={(block.capabilities?.actions ?? []).filter(
245+
(action) => !answered || action.kind !== "ask_follow_up"
246+
)}
247+
onIntent={onIntent}
248+
/>
249+
</AgentCardBody>
250+
</AgentCard>
251+
);
252+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { readFileSync } from "node:fs";
2+
import { describe, expect, it } from "vitest";
3+
4+
const source = readFileSync(new URL("./ReportView.tsx", import.meta.url), "utf8");
5+
6+
describe("ReportView purity", () => {
7+
it("imports nothing from Remix", () => {
8+
expect(source).not.toMatch(/from\s+"@remix-run\//);
9+
});
10+
11+
it("imports no hooks and no server module", () => {
12+
expect(source).not.toMatch(/from\s+"~\/hooks\//);
13+
expect(source).not.toMatch(/\.server"/);
14+
});
15+
16+
it("calls no React hook of its own", () => {
17+
expect(source).not.toMatch(/\buse[A-Z]\w*\(/);
18+
});
19+
});

0 commit comments

Comments
 (0)