Skip to content

Commit a27118c

Browse files
committed
fix(webapp): drop the watch action button when the turn already proposed a watch
1 parent 1df17cf commit a27118c

3 files changed

Lines changed: 55 additions & 6 deletions

File tree

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

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ import { stripModelImages } from "./model-markdown";
2323
import { reportBlockFromToolPart } from "./report-block-adapter";
2424
import { shouldShowLiveTurnError } from "./turn-error";
2525
import type { ResolvedUri } from "./ReportView";
26-
import { answerContinuesAfter, turnAlreadyOffersWatch } from "./view-actions";
26+
import { answerContinuesAfter, turnAlreadyOffersWatch, turnProposesWatch } from "./view-actions";
2727
import { latestRevisionBlocks } from "./view-blocks";
2828
import { ViewBlocks } from "./view-catalog";
2929
import { findWakeWatch, WakeBanner, wakeRefFromMessageId, type WakeWatch } from "./WakeBanner";
@@ -258,11 +258,13 @@ const DashboardAgentTurn = memo(function DashboardAgentTurn({
258258
});
259259
// One answer for the whole turn: two `render_view` parts each deciding for themselves
260260
// would show the watch button twice. `ViewBlocks` collapses revisions the same way.
261-
const watchOfferedInTurn = turnAlreadyOffersWatch(
262-
blocksByPart
263-
.filter((blocks): blocks is unknown[] => blocks !== null)
264-
.map((blocks) => latestRevisionBlocks(blocks as never))
265-
);
261+
const watchOfferedInTurn =
262+
turnProposesWatch(parts as never) ||
263+
turnAlreadyOffersWatch(
264+
blocksByPart
265+
.filter((blocks): blocks is unknown[] => blocks !== null)
266+
.map((blocks) => latestRevisionBlocks(blocks as never))
267+
);
266268

267269
const body: React.ReactNode[] = [];
268270
for (let i = 0; i < parts.length; i++) {

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

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
cardAlreadyOffersWatch,
77
renderableActions,
88
turnAlreadyOffersWatch,
9+
turnProposesWatch,
910
withoutWatchActions,
1011
} from "./view-actions";
1112

@@ -117,6 +118,32 @@ describe("one watch button per answer", () => {
117118
});
118119
});
119120

121+
describe("a turn that proposed a watch through the tool", () => {
122+
const text = { type: "text", text: "here is what I found" };
123+
const scheduled = (output: unknown, state = "output-available") => ({
124+
type: "tool-schedule_watch",
125+
state,
126+
output,
127+
});
128+
const intent = { intent: watchAction.intent };
129+
130+
it("sees the proposal that opened the card", () => {
131+
expect(turnProposesWatch([text, scheduled(intent)])).toBe(true);
132+
});
133+
134+
it("leaves the button alone when the spec was rejected — no card opened", () => {
135+
expect(turnProposesWatch([text, scheduled({ error: "Couldn't build that watch: bad" })])).toBe(
136+
false
137+
);
138+
});
139+
140+
it("waits for the output: a call still running proposes nothing", () => {
141+
expect(turnProposesWatch([scheduled(intent, "input-available")])).toBe(false);
142+
expect(turnProposesWatch([{ type: "tool-schedule_watch", output: intent }])).toBe(false);
143+
expect(turnProposesWatch([text])).toBe(false);
144+
});
145+
});
146+
120147
describe("ActionsBlock", () => {
121148
const source = readFileSync(new URL("./ActionsBlock.tsx", import.meta.url), "utf8");
122149

@@ -154,6 +181,10 @@ describe("the one-watch-button flag is decided per turn, not per render_view cal
154181
expect(turn).toContain("watchOfferedInTurn={watchOfferedInTurn}");
155182
});
156183

184+
it("counts the turn's own schedule_watch proposal as an offer", () => {
185+
expect(turn).toMatch(/turnProposesWatch\(parts as never\) \|\|/);
186+
});
187+
157188
it("lets a card add its own offer but never drop the turn's", () => {
158189
expect(catalog).toMatch(/watchOfferedInTurn \|\|\s*cardAlreadyOffersWatch\(/);
159190
});

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

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// A navigate target is a plain string at the contract boundary, so only targets
22
// that parse become buttons: a hallucinated URI costs a button, never a dead click.
33
import {
4+
agentIntentSchema,
45
isTriggerUri,
56
type ActionsBlockAction,
67
type ChartAction,
@@ -37,6 +38,21 @@ export function turnAlreadyOffersWatch(blockGroups: ViewBlock[][]): boolean {
3738
return blockGroups.some(cardAlreadyOffersWatch);
3839
}
3940

41+
/**
42+
* `schedule_watch` opens the pre-filled card itself, so a button repeating it is dead.
43+
* A rejected spec returns an error instead of an intent: no card opens, so the button stays.
44+
*/
45+
export function turnProposesWatch(
46+
parts: ReadonlyArray<{ type?: string; state?: string; output?: unknown }>
47+
): boolean {
48+
return parts.some(
49+
(part) =>
50+
part.type === "tool-schedule_watch" &&
51+
part.state === "output-available" &&
52+
agentIntentSchema.safeParse((part.output as { intent?: unknown } | undefined)?.intent).success
53+
);
54+
}
55+
4056
export function withoutWatchActions<T extends CardAction>(actions: T[]): T[] {
4157
return actions.filter((action) => action.intent.kind !== "watch");
4258
}

0 commit comments

Comments
 (0)