Skip to content

Commit fd5006e

Browse files
committed
feat(agent): ranking charts name the winner in prose and carry action buttons
A superlative question is answered in words with the top item's name and number; the chart illustrates. The chart block gains up to three actions (ask — the user's own follow-up, or navigate with a canonical trigger:// target), rendered as buttons under the chart and wired through the panel's existing intent handling. A navigate target is a plain string at the input boundary and a non-parsing one costs the button, never the tool call.
1 parent 2de5845 commit fd5006e

10 files changed

Lines changed: 205 additions & 5 deletions

File tree

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

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,16 @@
11
import type { OutputColumnMetadata } from "@internal/clickhouse";
22
import type { ChartBlock } from "@internal/dashboard-agent";
3+
import { isTriggerUri, type AgentIntent, type ChartAction } from "@internal/dashboard-agent-contracts";
34
import { useEffect, useState } from "react";
45
import { QueryResultsChart } from "~/components/code/QueryResultsChart";
56
import type { ChartConfiguration } from "~/components/metrics/QueryWidget";
7+
import { Button } from "~/components/primitives/Buttons";
68
import { Spinner } from "~/components/primitives/Spinner";
79
import { useOptionalEnvironment } from "~/hooks/useEnvironment";
810
import { useOptionalOrganization } from "~/hooks/useOrganizations";
911
import { useOptionalProject } from "~/hooks/useProject";
1012
import { cn } from "~/utils/cn";
13+
import { ChatActionsRow } from "./chat-layout";
1114

1215
// Render an agent "chart" block by running its TRQL query through the dashboard's
1316
// own /resources/metric endpoint (session-authed, returns rows + real column
@@ -51,7 +54,53 @@ type ChartState =
5154
timeRange?: { from: string; to: string };
5255
};
5356

54-
export function AgentChart({ block }: { block: ChartBlock }) {
57+
/**
58+
* The buttons under a ranking chart: act on the item the chart put on top.
59+
*
60+
* A card never navigates or asks on its own — it hands the block's intent to the
61+
* host, which submits an `ask` as the user's next message and resolves a
62+
* `navigate` before moving. Without an `onIntent` there is nothing to hand it to,
63+
* so the row isn't rendered rather than showing dead buttons.
64+
*/
65+
export function ChartActions({
66+
actions,
67+
onIntent,
68+
}: {
69+
actions: ChartAction[];
70+
onIntent?: (intent: AgentIntent) => void;
71+
}) {
72+
// A chart action's navigate target is a plain string at the contract boundary
73+
// (the model may hold no canonical URI) — only targets that really parse
74+
// become buttons, so a hallucinated URI costs a button, never a dead click.
75+
const renderable = actions.filter(
76+
(action) => action.intent.kind !== "navigate" || isTriggerUri(action.intent.target)
77+
);
78+
if (!onIntent || renderable.length === 0) return null;
79+
return (
80+
<div className="border-t border-grid-bright px-2 pb-2 pt-2">
81+
<ChatActionsRow>
82+
{renderable.map((action, i) => (
83+
<Button
84+
key={i}
85+
// The first action is the one to take; the rest are alternatives.
86+
variant={i === 0 ? "primary/small" : "secondary/small"}
87+
onClick={() => onIntent(action.intent as AgentIntent)}
88+
>
89+
{action.label}
90+
</Button>
91+
))}
92+
</ChatActionsRow>
93+
</div>
94+
);
95+
}
96+
97+
export function AgentChart({
98+
block,
99+
onIntent,
100+
}: {
101+
block: ChartBlock;
102+
onIntent?: (intent: AgentIntent) => void;
103+
}) {
55104
const organization = useOptionalOrganization();
56105
const project = useOptionalProject();
57106
const environment = useOptionalEnvironment();
@@ -147,6 +196,7 @@ export function AgentChart({ block }: { block: ChartBlock }) {
147196
/>
148197
)}
149198
</div>
199+
<ChartActions actions={block.actions ?? []} onIntent={onIntent} />
150200
</div>
151201
);
152202
}

apps/webapp/app/components/dashboard-agent/demo/components/DemoChartCard.tsx

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,21 @@
66
* the fixture rows directly. The frame (border, title strip, height) mirrors
77
* `AgentChart` so the card reads identically in the panel.
88
*/
9+
import type { AgentIntent, ChartAction } from "@internal/dashboard-agent-contracts";
910
import { QueryResultsChart } from "~/components/code/QueryResultsChart";
11+
import { ChartActions } from "../../AgentChart";
1012
import { demoChart } from "../fixtures/chart";
1113

12-
export function DemoChartCard({ title = demoChart.title }: { title?: string }) {
14+
export function DemoChartCard({
15+
title = demoChart.title,
16+
actions,
17+
onIntent,
18+
}: {
19+
title?: string;
20+
/** The row under the chart, when this chart ranks something. */
21+
actions?: ChartAction[];
22+
onIntent?: (intent: AgentIntent) => void;
23+
}) {
1324
return (
1425
<div className="overflow-hidden rounded-lg border border-border-bright bg-background-dimmed">
1526
{title ? (
@@ -25,6 +36,7 @@ export function DemoChartCard({ title = demoChart.title }: { title?: string }) {
2536
timeRange={demoChart.timeRange}
2637
/>
2738
</div>
39+
<ChartActions actions={actions ?? []} onIntent={onIntent} />
2840
</div>
2941
);
3042
}

apps/webapp/app/components/dashboard-agent/demo/fixtures/blocks.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import {
1313
type EnvelopedDiagnosisBlock,
1414
type ViewBlock,
1515
} from "@internal/dashboard-agent-contracts";
16-
import { demoId, DEMO_WORLD } from "../ids";
16+
import { demoId, demoRunsUri, DEMO_WORLD } from "../ids";
1717

1818
const envelope = (id: string, revision = 0) => ({
1919
id: demoId(id),
@@ -105,6 +105,25 @@ export const demoChartBlock: EnvelopedChartBlock = {
105105
groupByColumn: "task_identifier",
106106
stacked: false,
107107
aggregation: "sum",
108+
// The chart ranks the tasks; the buttons act on the one it put on top. The ask
109+
// is phrased as the user's own follow-up, because that's what it becomes.
110+
actions: [
111+
{
112+
label: `Investigate ${DEMO_WORLD.taskId}`,
113+
intent: {
114+
kind: "ask",
115+
prompt: `Investigate the ${DEMO_WORLD.taskId} failures — why are they failing?`,
116+
},
117+
},
118+
{
119+
label: "See its failed runs",
120+
intent: {
121+
kind: "navigate",
122+
target: demoRunsUri(),
123+
filters: { tasks: [DEMO_WORLD.taskId], statuses: ["COMPLETED_WITH_ERROR"], period: "1d" },
124+
},
125+
},
126+
],
108127
};
109128

110129
/**

apps/webapp/app/components/dashboard-agent/demo/ids.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,10 @@ const scope = { projectRef: DEMO_PROJECT_REF, environmentId: DEMO_ENVIRONMENT_ID
4949
// fixture URI is grammar-valid by construction — the contracts package, not the
5050
// fixture author, decides what a legal URI looks like.
5151

52+
export function demoRunsUri(): TriggerUri {
53+
return formatTriggerUri({ kind: "runs", ...scope });
54+
}
55+
5256
export function demoRunUri(runId: string): TriggerUri {
5357
return formatTriggerUri({ kind: "run", ...scope, runId });
5458
}

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ export function ViewBlocks({
5252
case "diagnosis":
5353
return <RunDiagnosisCard key={key} block={block} />;
5454
case "chart":
55-
return <AgentChart key={key} block={block} />;
55+
return <AgentChart key={key} block={block} onIntent={onIntent} />;
5656
// The one progressive block: revisions share the investigationId, so
5757
// latest-wins above keeps a single live card.
5858
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
@@ -197,6 +197,11 @@ export const MANIFEST: GallerySection[] = [
197197

198198
// --- Chart card ----------------------------------------------------------
199199
{ sectionId: "chart-with-data", title: "With canned rows", group: "chart" },
200+
{
201+
sectionId: "chart-with-actions",
202+
title: "Ranking chart with actions on the top item",
203+
group: "chart",
204+
},
200205
{ sectionId: "chart-empty", title: "Empty — no data to display", group: "chart" },
201206

202207
// --- Watch chips ---------------------------------------------------------

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -647,6 +647,9 @@ const STATES: Record<string, React.ReactNode> = {
647647

648648
// --- Chart card ---------------------------------------------------------
649649
"chart-with-data": <DemoChartCard />,
650+
"chart-with-actions": (
651+
<DemoChartCard actions={demoFixtures.demoChartBlock.actions ?? []} onIntent={noop} />
652+
),
650653
"chart-empty": <EmptyChartCard />,
651654

652655
// --- Watch chips --------------------------------------------------------

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

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,61 @@ describe("model-facing input schema", () => {
185185
});
186186
});
187187

188+
describe("chart actions", () => {
189+
// A ranking chart answers "which task fails most" — the actions are what to do
190+
// about the winner. Two kinds, both from the intent union.
191+
const actions = [
192+
{
193+
label: "Investigate send-order-receipt",
194+
intent: {
195+
kind: "ask",
196+
prompt: "Investigate the send-order-receipt failures — why are they failing?",
197+
},
198+
},
199+
{
200+
label: "See its failed runs",
201+
intent: {
202+
kind: "navigate",
203+
target: "trigger://proj_abc/env_abc/runs",
204+
filters: { tasks: ["send-order-receipt"], period: "1d" },
205+
},
206+
},
207+
];
208+
209+
it("parses a chart with actions", () => {
210+
const parsed = viewBlockInputSchema.parse({ ...legacyChart, actions });
211+
expect(parsed.type).toBe("chart");
212+
expect(parsed.type === "chart" && parsed.actions).toHaveLength(2);
213+
expect(viewBlockSchema.safeParse({ ...legacyChart, ...envelope, actions }).success).toBe(true);
214+
expect(parseStoredViewBlock({ ...legacyChart, actions }).type).toBe("chart");
215+
});
216+
217+
it("still parses a chart with no actions", () => {
218+
expect(viewBlockInputSchema.safeParse(legacyChart).success).toBe(true);
219+
expect(legacyViewBlockSchema.safeParse(legacyChart).success).toBe(true);
220+
});
221+
222+
it("caps the row at three buttons", () => {
223+
const fourth = { label: "One too many", intent: { kind: "ask", prompt: "And this?" } };
224+
expect(
225+
viewBlockInputSchema.safeParse({ ...legacyChart, actions: [...actions, fourth, fourth] })
226+
.success
227+
).toBe(false);
228+
});
229+
230+
it("accepts a non-canonical navigate target — the renderer drops it, the call survives", () => {
231+
// The model can't always build a canonical URI, and a malformed target must
232+
// cost one button rather than fail the whole render_view call. ChartActions
233+
// filters non-parsing targets out at render time.
234+
expect(
235+
viewBlockInputSchema.safeParse({
236+
...legacyChart,
237+
actions: [{ label: "Runs", intent: { kind: "navigate", target: "/runs?status=FAILED" } }],
238+
}).success
239+
).toBe(true);
240+
});
241+
});
242+
188243
describe("report block", () => {
189244
it("round-trips a whole view model", () => {
190245
const parsed = reportBlockSchema.parse(reportBlock);

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

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
* and is rendered in transcript order.
2828
*/
2929
import { evidenceRefSchema, evidenceSchema } from "./evidence.js";
30+
import { runFiltersSchema } from "./run-filters.js";
3031
import { triggerUriSchema } from "./trigger-uri.js";
3132
import { z } from "zod";
3233

@@ -150,6 +151,42 @@ export const diagnosisBlockBodySchema = z.object({
150151
// chart
151152
// ---------------------------------------------------------------------------
152153

154+
/**
155+
* A button under a chart. The chart answers "which task failed most"; the
156+
* actions are what to do about the winner — investigate it, or go look at its
157+
* runs. The card only *emits* the intent; the host decides whether to honour it,
158+
* the same rule every other intent follows.
159+
*/
160+
/**
161+
* A chart action's intent. Mirrors `agentIntentSchema`, but the navigate
162+
* `target` is a plain string at this boundary — the model can't always build a
163+
* canonical URI (the grammar embeds ids it doesn't hold), and a malformed
164+
* target must cost one button, not the whole tool call. The `render_view`
165+
* executor drops navigate actions whose target isn't a valid trigger:// URI.
166+
*/
167+
const chartActionIntentSchema = z.union([
168+
z.object({
169+
kind: z.literal("ask"),
170+
prompt: z.string().min(1),
171+
}),
172+
z.object({
173+
kind: z.literal("navigate"),
174+
target: z.string().min(1),
175+
filters: runFiltersSchema.optional(),
176+
}),
177+
]);
178+
179+
export const chartActionSchema = z.object({
180+
label: z
181+
.string()
182+
.describe("The button text, naming the thing, e.g. 'Investigate send-order-receipt'."),
183+
intent: chartActionIntentSchema.describe(
184+
"What the button does. `ask` is the default and always works: phrase the user's own follow-up in their voice ('Investigate the send-order-receipt failures — why are they failing?'), and the click sends it as their next message. `navigate` takes them to the matching page — ONLY when you already hold a canonical `trigger://` URI for it (e.g. one a tool returned); an invalid target is silently dropped, so when in doubt use `ask`."
185+
),
186+
});
187+
188+
export type ChartAction = z.infer<typeof chartActionSchema>;
189+
153190
// The chart block carries the TRQL query (not the rows): the panel runs it
154191
// through the dashboard's own query execution + QueryResultsChart, so the chart
155192
// is live and matches the Query page exactly. The agent describes the chart with
@@ -197,6 +234,18 @@ export const chartBlockBodySchema = z.object({
197234
.enum(["sum", "avg", "count", "min", "max"])
198235
.optional()
199236
.describe("How to combine values that share an x point. Default sum."),
237+
/**
238+
* Optional and capped at three: a chart that ranks things gets a way to act on
239+
* the winner. Older stored charts have no `actions` at all and must keep
240+
* parsing, so this is additive and never required.
241+
*/
242+
actions: z
243+
.array(chartActionSchema)
244+
.max(3)
245+
.optional()
246+
.describe(
247+
"Optional buttons under the chart, at most 2-3. After a ranking or failures chart, give the top item an 'Investigate <name>' ask action, and a navigate action to the page that shows it (its filtered runs list, its error, its queue) when you have the target."
248+
),
200249
});
201250

202251
// ---------------------------------------------------------------------------

internal-packages/dashboard-agent/src/tool-schemas.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -634,7 +634,10 @@ Investigations:
634634
Answering with data and charts:
635635
- For questions about metrics, trends, counts, rates, costs, or "over time" / "by task" style aggregations, query the analytics data. First call get_query_schema (no table to list the tables, then a table name for its columns), then write a TRQL query. TRQL is SQL-style over ClickHouse: bucket time with toStartOfHour/toStartOfDay on the table's time column, produce one numeric column per series with countIf/sumIf, always include a time filter, and keep the result aggregated to a few dozen points.
636636
- To chart the answer, call render_view with a "chart" block containing the TRQL query itself plus chartType (line for trends over time, bar for categories), xAxisColumn, yAxisColumns, and groupByColumn when you split a single value column into series. The panel runs the query and renders it, so you don't have to run_query first just to chart.
637-
- Use run_query when you want to state specific numbers in prose, or to sanity-check a query before charting. If it returns an error, read the message and fix the query.`;
637+
- Use run_query when you want to state specific numbers in prose, or to sanity-check a query before charting. If it returns an error, read the message and fix the query.
638+
- A chart never answers alone. A superlative or ranking question — "which tasks fail most", "what's slowest", "which queue is busiest" — is answered IN PROSE, naming the winner and its number ("send-order-receipt — 3 of the 4 failures"); the chart illustrates that answer, it is not the answer. Run the query with run_query when you need the number to say it.
639+
- On a ranking or failures chart, give the top item buttons through the chart block's "actions": an ask action phrasing the user's own follow-up ("Investigate the send-order-receipt failures — why are they failing?"), plus a navigate action to the page that shows it (its filtered runs list, its error, its queue) when you hold a canonical trigger:// target for it. Two or three, never more.
640+
- Those buttons are not an offer to do the work: they sit next to a finished answer, and they never license "want me to drill into the top offender?" — asking to look is still banned.`;
638641

639642
// Used when the current project has a connected GitHub repo: the base prompt
640643
// plus the source-reading tools and how to use them.

0 commit comments

Comments
 (0)