Skip to content

Commit 97a8e16

Browse files
committed
feat(dashboard-agent): the watch offer becomes a button
New "actions" view block: a row of 1-3 buttons the model may emit. A watch action opens the watch configuration card pre-filled; ask sends the labelled question as the user's next message; a navigate target that doesn't parse is dropped at render time, as on chart actions.
1 parent 2030c33 commit 97a8e16

10 files changed

Lines changed: 315 additions & 15 deletions

File tree

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
/**
2+
* The agent's offer, as buttons.
3+
*
4+
* When an answer ends in "want me to set up a watch?", this block is what makes
5+
* that a click instead of a typed reply: the `watch` intent opens the watch
6+
* configuration card pre-filled, `ask` sends the labelled question as the user's
7+
* next message. The block only emits an intent — the host decides whether to
8+
* honour it, so without `onIntent` there is nothing to render.
9+
*
10+
* PURE COMPONENT: props in, markup out.
11+
*/
12+
import type {
13+
ActionsBlock as ActionsBlockPayload,
14+
AgentIntent,
15+
} from "@internal/dashboard-agent-contracts";
16+
import { Button } from "~/components/primitives/Buttons";
17+
import { ChatActionsRow } from "./chat-layout";
18+
import { renderableActions } from "./view-actions";
19+
20+
export function ActionsBlock({
21+
block,
22+
onIntent,
23+
}: {
24+
block: ActionsBlockPayload;
25+
onIntent?: (intent: AgentIntent) => void;
26+
}) {
27+
const renderable = renderableActions(block.actions);
28+
if (!onIntent || renderable.length === 0) return null;
29+
return (
30+
<ChatActionsRow>
31+
{renderable.map((action, i) => (
32+
<Button
33+
key={i}
34+
// The first action is the one to take; the rest are alternatives.
35+
variant={i === 0 ? "primary/small" : "secondary/small"}
36+
onClick={() => onIntent(action.intent as AgentIntent)}
37+
>
38+
{action.label}
39+
</Button>
40+
))}
41+
</ChatActionsRow>
42+
);
43+
}

apps/webapp/app/components/dashboard-agent/AgentChart.tsx

Lines changed: 5 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,6 @@
11
import type { OutputColumnMetadata } from "@internal/clickhouse";
22
import type { ChartBlock } from "@internal/dashboard-agent";
3-
import {
4-
isTriggerUri,
5-
type AgentIntent,
6-
type ChartAction,
7-
} from "@internal/dashboard-agent-contracts";
3+
import type { AgentIntent, ChartAction } from "@internal/dashboard-agent-contracts";
84
import { useEffect, useState } from "react";
95
import { QueryResultsChart } from "~/components/code/QueryResultsChart";
106
import type { ChartConfiguration } from "~/components/metrics/QueryWidget";
@@ -15,6 +11,7 @@ import { useOptionalOrganization } from "~/hooks/useOrganizations";
1511
import { useOptionalProject } from "~/hooks/useProject";
1612
import { cn } from "~/utils/cn";
1713
import { ChatActionsRow } from "./chat-layout";
14+
import { renderableActions } from "./view-actions";
1815

1916
// Render an agent "chart" block by running its TRQL query through the dashboard's
2017
// own /resources/metric endpoint (session-authed, returns rows + real column
@@ -82,12 +79,9 @@ export function ChartActions({
8279
actions: ChartAction[];
8380
onIntent?: (intent: AgentIntent) => void;
8481
}) {
85-
// A chart action's navigate target is a plain string at the contract boundary
86-
// (the model may hold no canonical URI) — only targets that really parse
87-
// become buttons, so a hallucinated URI costs a button, never a dead click.
88-
const renderable = actions.filter(
89-
(action) => action.intent.kind !== "navigate" || isTriggerUri(action.intent.target)
90-
);
82+
// Only navigate targets that really parse become buttons — see
83+
// `renderableActions`, shared with the standalone `actions` block.
84+
const renderable = renderableActions(actions);
9185
if (!onIntent || renderable.length === 0) return null;
9286
return (
9387
<div className="border-t border-grid-bright px-2 pb-2 pt-2">
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
/**
2+
* The `actions` block turns the agent's offer into buttons, so what matters is
3+
* which actions become buttons and what each click hands back. The filter is
4+
* asserted directly; `ActionsBlock` itself is checked at source level, the same
5+
* way the other cards are (`InvestigationCard.test.ts`), since the panel has no
6+
* DOM test host.
7+
*/
8+
import type { ActionsBlockAction } from "@internal/dashboard-agent-contracts";
9+
import { readFileSync } from "node:fs";
10+
import { describe, expect, it } from "vitest";
11+
import { renderableActions } from "./view-actions";
12+
13+
const watchAction: ActionsBlockAction = {
14+
label: "Set up a watch",
15+
intent: {
16+
kind: "watch",
17+
spec: {
18+
kind: "error_recurrence",
19+
fingerprint: "a1b2c3",
20+
checkEveryMinutes: 15,
21+
maxHours: 6,
22+
note: "the TypeError in send-order-receipt",
23+
},
24+
},
25+
};
26+
27+
const askAction: ActionsBlockAction = {
28+
label: "Investigate it",
29+
intent: { kind: "ask", prompt: "Investigate the send-order-receipt failures." },
30+
};
31+
32+
describe("renderableActions", () => {
33+
it("drops a navigate action whose target isn't a trigger:// URI", () => {
34+
const actions: ActionsBlockAction[] = [
35+
askAction,
36+
{ label: "Runs", intent: { kind: "navigate", target: "/runs?status=FAILED" } },
37+
];
38+
expect(renderableActions(actions)).toEqual([askAction]);
39+
});
40+
41+
it("keeps a navigate action with a canonical target", () => {
42+
const navigate: ActionsBlockAction = {
43+
label: "See its failed runs",
44+
intent: { kind: "navigate", target: "trigger://proj_abc/env_abc/runs" },
45+
};
46+
expect(renderableActions([navigate])).toEqual([navigate]);
47+
});
48+
49+
it("keeps a watch action, spec intact — that spec is what pre-fills the card", () => {
50+
expect(renderableActions([watchAction, askAction])).toEqual([watchAction, askAction]);
51+
});
52+
53+
it("can filter every action out, leaving nothing to render", () => {
54+
expect(
55+
renderableActions([{ label: "Nowhere", intent: { kind: "navigate", target: "nope" } }])
56+
).toEqual([]);
57+
});
58+
});
59+
60+
describe("ActionsBlock", () => {
61+
const source = readFileSync(new URL("./ActionsBlock.tsx", import.meta.url), "utf8");
62+
63+
it("hands the action's own intent to the host, and renders nothing without one", () => {
64+
expect(source).toContain("onIntent(action.intent");
65+
expect(source).toContain("if (!onIntent || renderable.length === 0) return null;");
66+
});
67+
68+
it("filters through the shared filter rather than rendering every action", () => {
69+
expect(source).toContain("renderableActions(block.actions)");
70+
});
71+
72+
it("is a pure component: no app hooks, no server module, no Remix", () => {
73+
expect(source).not.toMatch(/from\s+"~\/hooks\//);
74+
expect(source).not.toMatch(/from\s+"@remix-run\//);
75+
expect(source).not.toMatch(/\.server"/);
76+
});
77+
});
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
/**
2+
* Which of a card's actions become buttons.
3+
*
4+
* A navigate target is a plain string at the contract boundary (the model may
5+
* hold no canonical URI), so only targets that really parse become buttons — a
6+
* hallucinated URI costs a button, never a dead click. Shared by the `actions`
7+
* block and the chart's own action row so the two can't drift.
8+
*/
9+
import {
10+
isTriggerUri,
11+
type ActionsBlockAction,
12+
type ChartAction,
13+
} from "@internal/dashboard-agent-contracts";
14+
15+
type CardAction = ChartAction | ActionsBlockAction;
16+
17+
export function renderableActions<T extends CardAction>(actions: T[]): T[] {
18+
return actions.filter((action) => {
19+
const intent: CardAction["intent"] = action.intent;
20+
return intent.kind !== "navigate" || isTriggerUri(intent.target);
21+
});
22+
}

apps/webapp/app/components/dashboard-agent/view-catalog.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { AgentIntent, ViewBlock } from "@internal/dashboard-agent-contracts";
2+
import { ActionsBlock } from "./ActionsBlock";
23
import { AgentChart } from "./AgentChart";
34
import { InvestigationCard } from "./InvestigationCard";
45
import { ReportView, type ResolvedUri } from "./ReportView";
@@ -57,6 +58,10 @@ export function ViewBlocks({
5758
return <RunDiagnosisCard key={key} block={block} />;
5859
case "chart":
5960
return <AgentChart key={key} block={block} onIntent={onIntent} />;
61+
// The agent's offer as buttons — a watch action opens the watch card
62+
// pre-filled. Renders nothing without a host to honour the intent.
63+
case "actions":
64+
return <ActionsBlock key={key} block={block} onIntent={onIntent} />;
6065
// The one progressive block: revisions share the investigationId, so
6166
// latest-wins above keeps a single live card.
6267
case "investigation":

apps/webapp/app/routes/storybook.agent-ui/manifest.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,11 @@ export const MANIFEST: GallerySection[] = [
112112
title: "Enveloped revisions plus a legacy block",
113113
group: "view-blocks",
114114
},
115+
{
116+
sectionId: "view-blocks-actions-offer",
117+
title: "Actions block — the watch offer as buttons",
118+
group: "view-blocks",
119+
},
115120

116121
// --- Investigation card ---------------------------------------------------
117122
// The shipped `InvestigationCard` (fed the real block) first, then the demo

apps/webapp/app/routes/storybook.agent-ui/route.tsx

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,34 @@ const revisedDiagnosis: ViewBlock[] = [
224224
// render, in order — the pre-envelope behaviour.
225225
const legacyBlocks: ViewBlock[] = [externalServiceDiagnosis, lowConfidenceDiagnosis];
226226

227+
// The watch offer as buttons: the answer ends with one line of prose, this block
228+
// makes the offer a click. The first action is the one to take.
229+
const offerActionsBlock: ViewBlock = {
230+
type: "actions",
231+
id: "actions-offer",
232+
revision: 0,
233+
version: VIEW_BLOCK_VERSION,
234+
actions: [
235+
{
236+
label: "Set up a watch",
237+
intent: {
238+
kind: "watch",
239+
spec: {
240+
kind: "error_recurrence",
241+
fingerprint: "a1b2c3",
242+
checkEveryMinutes: 15,
243+
maxHours: 6,
244+
note: "the TypeError in send-order-receipt",
245+
},
246+
},
247+
},
248+
{
249+
label: "See its failed runs",
250+
intent: { kind: "navigate", target: "trigger://proj_abc/env_abc/runs" },
251+
},
252+
],
253+
};
254+
227255
/**
228256
* The badge matrix: one card per diagnosis category, cycling through the three
229257
* confidence levels, so every badge colour pair on the card is on screen at
@@ -939,6 +967,7 @@ const STATES: Record<string, React.ReactNode> = {
939967
]}
940968
/>
941969
),
970+
"view-blocks-actions-offer": <ViewBlocks blocks={[offerActionsBlock]} onIntent={noop} />,
942971

943972
// --- Investigation card, the shipped one ---------------------------------
944973
// The card only, so the unfinished states show no progress line here: progress

internal-packages/dashboard-agent-contracts/src/blocks.test.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,66 @@ describe("chart actions", () => {
240240
});
241241
});
242242

243+
describe("actions block", () => {
244+
// The agent's offer, clickable: "want me to watch this?" as a button that
245+
// opens the watch card pre-filled.
246+
const watchAction = {
247+
label: "Set up a watch",
248+
intent: {
249+
kind: "watch",
250+
spec: {
251+
kind: "error_recurrence",
252+
fingerprint: "a1b2c3",
253+
checkEveryMinutes: 15,
254+
maxHours: 6,
255+
note: "the TypeError in send-order-receipt",
256+
},
257+
},
258+
};
259+
260+
const askAction = {
261+
label: "Investigate it",
262+
intent: { kind: "ask", prompt: "Investigate the send-order-receipt failures." },
263+
};
264+
265+
it("round-trips through both schemas", () => {
266+
const body = { type: "actions", actions: [watchAction, askAction] };
267+
const input = viewBlockInputSchema.parse(body);
268+
expect(input.type === "actions" && input.actions).toHaveLength(2);
269+
const strict = viewBlockSchema.parse({ ...body, ...envelope });
270+
expect(strict.type === "actions" && strict.actions[0].intent.kind).toBe("watch");
271+
expect(parseStoredViewBlock(body).type).toBe("actions");
272+
});
273+
274+
it("needs at least one action and caps the row at three", () => {
275+
expect(viewBlockInputSchema.safeParse({ type: "actions", actions: [] }).success).toBe(false);
276+
expect(
277+
viewBlockInputSchema.safeParse({
278+
type: "actions",
279+
actions: [askAction, askAction, askAction, askAction],
280+
}).success
281+
).toBe(false);
282+
});
283+
284+
it("rejects a propose_fix intent — it is reserved and not executable", () => {
285+
expect(
286+
viewBlockInputSchema.safeParse({
287+
type: "actions",
288+
actions: [{ label: "Fix it", intent: { kind: "propose_fix", investigationId: "inv_1" } }],
289+
}).success
290+
).toBe(false);
291+
});
292+
293+
it("accepts a non-canonical navigate target — the renderer drops it", () => {
294+
expect(
295+
viewBlockInputSchema.safeParse({
296+
type: "actions",
297+
actions: [{ label: "Runs", intent: { kind: "navigate", target: "/runs?status=FAILED" } }],
298+
}).success
299+
).toBe(true);
300+
});
301+
});
302+
243303
describe("report block", () => {
244304
it("round-trips a whole view model", () => {
245305
const parsed = reportBlockSchema.parse(reportBlock);

0 commit comments

Comments
 (0)