Skip to content

Commit 4697e90

Browse files
committed
fix(webapp): one watch button per answer, not two
An investigation card carries its own watch button, and the prompt asked for a watch offer on top of it. The card wins — it is the one with the pre-filled spec — and the prompt now says so too.
1 parent e08a8de commit 4697e90

5 files changed

Lines changed: 67 additions & 7 deletions

File tree

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

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,20 @@ import type {
44
} from "@internal/dashboard-agent-contracts";
55
import { Button } from "~/components/primitives/Buttons";
66
import { ChatActionsRow } from "./chat-layout";
7-
import { renderableActions } from "./view-actions";
7+
import { renderableActions, withoutWatchActions } from "./view-actions";
88

99
export function ActionsBlock({
1010
block,
1111
onIntent,
12+
dropWatch = false,
1213
}: {
1314
block: ActionsBlockPayload;
1415
onIntent?: (intent: AgentIntent) => void;
16+
/** Set when an investigation card in the same answer already offers the watch. */
17+
dropWatch?: boolean;
1518
}) {
16-
const renderable = renderableActions(block.actions);
19+
const actions = dropWatch ? withoutWatchActions(block.actions) : block.actions;
20+
const renderable = renderableActions(actions);
1721
if (!onIntent || renderable.length === 0) return null;
1822
return (
1923
<ChatActionsRow>

apps/webapp/app/components/dashboard-agent/view-actions.test.ts

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { ActionsBlockAction } from "@internal/dashboard-agent-contracts";
22
import { readFileSync } from "node:fs";
33
import { describe, expect, it } from "vitest";
4-
import { renderableActions } from "./view-actions";
4+
import { cardAlreadyOffersWatch, renderableActions, withoutWatchActions } from "./view-actions";
55

66
const watchAction: ActionsBlockAction = {
77
label: "Set up a watch",
@@ -50,6 +50,34 @@ describe("renderableActions", () => {
5050
});
5151
});
5252

53+
describe("one watch button per answer", () => {
54+
const watchAction = { label: "Watch for a repeat", intent: { kind: "watch" as const, spec: {} } };
55+
const card = (actions: unknown[]) =>
56+
({ type: "investigation", investigation: {}, capabilities: { actions } }) as never;
57+
58+
it("sees the card's own watch offer", () => {
59+
expect(cardAlreadyOffersWatch([card([watchAction])])).toBe(true);
60+
});
61+
62+
it("leaves an answer whose card offers no watch alone", () => {
63+
expect(
64+
cardAlreadyOffersWatch([
65+
card([{ label: "Keep digging", intent: { kind: "ask", prompt: "" } }]),
66+
])
67+
).toBe(false);
68+
expect(cardAlreadyOffersWatch([])).toBe(false);
69+
});
70+
71+
it("drops the model's duplicate offer, keeping everything else", () => {
72+
expect(
73+
withoutWatchActions([
74+
{ label: "Set up a watch", intent: { kind: "watch", spec: {} } },
75+
{ label: "View similar", intent: { kind: "navigate", target: "trigger://x" } },
76+
] as never)
77+
).toEqual([{ label: "View similar", intent: { kind: "navigate", target: "trigger://x" } }]);
78+
});
79+
});
80+
5381
describe("ActionsBlock", () => {
5482
const source = readFileSync(new URL("./ActionsBlock.tsx", import.meta.url), "utf8");
5583

@@ -59,7 +87,7 @@ describe("ActionsBlock", () => {
5987
});
6088

6189
it("filters through the shared filter rather than rendering every action", () => {
62-
expect(source).toContain("renderableActions(block.actions)");
90+
expect(source).toContain("renderableActions(actions)");
6391
});
6492

6593
it("is a pure component: no app hooks, no server module, no Remix", () => {

apps/webapp/app/components/dashboard-agent/view-actions.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
isTriggerUri,
55
type ActionsBlockAction,
66
type ChartAction,
7+
type ViewBlock,
78
} from "@internal/dashboard-agent-contracts";
89

910
type CardAction = ChartAction | ActionsBlockAction;
@@ -14,3 +15,20 @@ export function renderableActions<T extends CardAction>(actions: T[]): T[] {
1415
return intent.kind !== "navigate" || isTriggerUri(intent.target);
1516
});
1617
}
18+
19+
/**
20+
* An investigation card carries its own "watch for a repeat" button, and the model is
21+
* asked to end an unresolved answer with a watch offer — so an answer that does both
22+
* shows the same button twice. The card wins: it is the one with the pre-filled spec.
23+
*/
24+
export function cardAlreadyOffersWatch(blocks: ViewBlock[]): boolean {
25+
return blocks.some(
26+
(block) =>
27+
block.type === "investigation" &&
28+
(block.capabilities?.actions ?? []).some((action) => action.intent.kind === "watch")
29+
);
30+
}
31+
32+
export function withoutWatchActions<T extends CardAction>(actions: T[]): T[] {
33+
return actions.filter((action) => action.intent.kind !== "watch");
34+
}

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

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { InvestigationCard } from "./InvestigationCard";
55
import { ReportView, type ResolvedUri } from "./ReportView";
66
import { RunDiagnosisCard } from "./RunDiagnosisCard";
77
import { blockKey, latestRevisionBlocks } from "./view-blocks";
8+
import { cardAlreadyOffersWatch } from "./view-actions";
89
import { WatchResultBlock } from "./WatchResultBlock";
910

1011
// Unknown block types are skipped, so an older or newer agent cannot render
@@ -21,9 +22,11 @@ export function ViewBlocks({
2122
pagePaths?: Record<string, string>;
2223
}) {
2324
if (!Array.isArray(blocks)) return null;
25+
const rendered = latestRevisionBlocks(blocks);
26+
const watchOfferedOnCard = cardAlreadyOffersWatch(rendered);
2427
return (
2528
<div className="space-y-2">
26-
{latestRevisionBlocks(blocks).map((block) => {
29+
{rendered.map((block) => {
2730
// Index into the original array, so collapsing a revision above an
2831
// envelope-less block can't shift its key.
2932
const key = blockKey(block, blocks.indexOf(block));
@@ -33,7 +36,14 @@ export function ViewBlocks({
3336
case "chart":
3437
return <AgentChart key={key} block={block} onIntent={onIntent} />;
3538
case "actions":
36-
return <ActionsBlock key={key} block={block} onIntent={onIntent} />;
39+
return (
40+
<ActionsBlock
41+
key={key}
42+
block={block}
43+
onIntent={onIntent}
44+
dropWatch={watchOfferedOnCard}
45+
/>
46+
);
3747
// Revisions share the investigationId, so latest-wins keeps one card.
3848
case "investigation":
3949
return (

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -517,7 +517,7 @@ Is anything wrong?:
517517
518518
Watches — telling the user later:
519519
- When the user wants to be told when something happens ("tell me when this run finishes", "let me know when the backlog drains", "tell me when it's back under 100", "tell me if that queue stops moving", "ping me if runs start waiting more than 5 minutes", "ping me if that error comes back", "tell me when prod is healthy again"), call schedule_watch. Never poll: repeating a read tool until the thing happens is not a watch, and you cannot wait inside a turn.
520-
- Offer a watch whenever your answer points at something worth monitoring that you can't resolve now: a recurring or unresolved error, a queue trending toward trouble, a condition the user would want to hear about the moment it changes. The offer is two things together: one short line ("Want me to set up a watch so you're told if it hits again?") AND a render_view "actions" block with one button — label it like "Set up a watch", intent {"kind":"watch","spec":{…}} carrying the same spec schedule_watch would compose. Clicking it opens the configuration card pre-filled, so the user answers with a click instead of typing "yeah". One offer per answer at most; skip it when the news is good or the user is clearly just browsing. schedule_watch is still how you answer a user who asks for a watch in their own words.
520+
- Offer a watch whenever your answer points at something worth monitoring that you can't resolve now: a recurring or unresolved error, a queue trending toward trouble, a condition the user would want to hear about the moment it changes. The offer is two things together: one short line ("Want me to set up a watch so you're told if it hits again?") AND a render_view "actions" block with one button — label it like "Set up a watch", intent {"kind":"watch","spec":{…}} carrying the same spec schedule_watch would compose. Clicking it opens the configuration card pre-filled, so the user answers with a click instead of typing "yeah". One offer per answer at most; skip it when the news is good, when the user is clearly just browsing, or when this answer's investigation card already carries a watch button — the card is the offer, and repeating it puts two watch buttons on one answer. schedule_watch is still how you answer a user who asks for a watch in their own words.
521521
- schedule_watch does not start anything. It opens a configuration card pre-filled with what you composed, and the user confirming that card is what starts the watch. So say what you filled in — what is being watched, how often it checks, and when it gives up (the maxHours you set) — and that confirming starts it. Never say it's running, scheduled, or that you'll tell them later: "I've filled in a watch for you to review — confirm to start it", never "I'll let you know when it finishes". Pick the longest cadence that still answers in time — 1 minute only for a run's state, 5 minutes or more for backlog, error recurrence, and health.
522522
- The card settles everything after the user confirms: whether this chat can hold another watch, whether the same thing is already watched, and whether the condition is already true (in which case they get the answer instead of a watch). Never promise, predict, or pre-explain any of those.
523523
- A watch wake is a message you send unprompted, and it is narrated ONCE, briefly: what the outcome was, the numbers from the facts you were given, and one suggested next step. Nothing else — no new investigation, no fresh reads, no recap of the conversation.

0 commit comments

Comments
 (0)