Skip to content

Commit 38ed62f

Browse files
committed
fix(webapp): decide the one-watch-button rule per turn, not per render_view
The flag was computed inside `ViewBlocks`, which sees the blocks of a single `render_view` part. A turn that renders the investigation card in one call and the actions block in another gave each call its own answer, and the duplicate Watch button came back. It is now computed where every part of the message is in scope; a card can still add its own offer, never drop the turn's.
1 parent 653e2cd commit 38ed62f

4 files changed

Lines changed: 75 additions & 9 deletions

File tree

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

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,8 @@ 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 } from "./view-actions";
26+
import { answerContinuesAfter, turnAlreadyOffersWatch } from "./view-actions";
27+
import { latestRevisionBlocks } from "./view-blocks";
2728
import { ViewBlocks } from "./view-catalog";
2829
import { findWakeWatch, WakeBanner, wakeRefFromMessageId, type WakeWatch } from "./WakeBanner";
2930

@@ -228,17 +229,28 @@ const DashboardAgentTurn = memo(function DashboardAgentTurn({
228229
const parts = message.parts ?? [];
229230
if (parts.length === 0) return null;
230231

232+
// Null for a part that renders no view at all; an empty array for one whose blocks were
233+
// all superseded. Both skip the part, only the first falls through to the other renderers.
234+
const blocksByPart = parts.map((part, i) => {
235+
const raw = blocksFor(part);
236+
return raw
237+
? withoutSupersededInvestigations(raw, `${message.id}:${i}`, investigationWinners)
238+
: null;
239+
});
240+
// One answer for the whole turn: two `render_view` parts each deciding for themselves
241+
// would show the watch button twice. `ViewBlocks` collapses revisions the same way.
242+
const watchOfferedInTurn = turnAlreadyOffersWatch(
243+
blocksByPart
244+
.filter((blocks): blocks is unknown[] => blocks !== null)
245+
.map((blocks) => latestRevisionBlocks(blocks as never))
246+
);
247+
231248
const body: React.ReactNode[] = [];
232249
for (let i = 0; i < parts.length; i++) {
233250
const part = parts[i]!;
234251

235-
const rawBlocks = blocksFor(part);
236-
if (rawBlocks) {
237-
const blocks = withoutSupersededInvestigations(
238-
rawBlocks,
239-
`${message.id}:${i}`,
240-
investigationWinners
241-
);
252+
const blocks = blocksByPart[i];
253+
if (blocks) {
242254
if (blocks.length > 0) {
243255
body.push(
244256
<ChatCardSlot key={i}>
@@ -248,6 +260,7 @@ const DashboardAgentTurn = memo(function DashboardAgentTurn({
248260
resolveUri={resolveUri}
249261
pagePaths={pagePaths}
250262
answered={answerContinuesAfter(parts as never, i)}
263+
watchOfferedInTurn={watchOfferedInTurn}
251264
/>
252265
</ChatCardSlot>
253266
);

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

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
answerContinuesAfter,
66
cardAlreadyOffersWatch,
77
renderableActions,
8+
turnAlreadyOffersWatch,
89
withoutWatchActions,
910
} from "./view-actions";
1011

@@ -88,6 +89,24 @@ describe("one watch button per answer", () => {
8889
expect(cardAlreadyOffersWatch([])).toBe(false);
8990
});
9091

92+
// The bug this closes: one `render_view` call carries the investigation card and a second
93+
// carries the actions block, so each call asked only about its own blocks and said no.
94+
it("sees a watch offered by another of the same turn's render_view calls", () => {
95+
const investigationCall = [card([watchAction])];
96+
const actionsCall = [{ type: "actions", actions: [watchAction] }] as never[];
97+
98+
expect(cardAlreadyOffersWatch(actionsCall)).toBe(false);
99+
// Either order: the card can be rendered before or after the block that repeats it.
100+
expect(turnAlreadyOffersWatch([investigationCall, actionsCall])).toBe(true);
101+
expect(turnAlreadyOffersWatch([actionsCall, investigationCall])).toBe(true);
102+
});
103+
104+
it("says no when no call in the turn has a card offering one", () => {
105+
const plain = [card([{ label: "Keep digging", intent: { kind: "ask", prompt: "" } }])];
106+
expect(turnAlreadyOffersWatch([plain, []])).toBe(false);
107+
expect(turnAlreadyOffersWatch([])).toBe(false);
108+
});
109+
91110
it("drops the model's duplicate offer, keeping everything else", () => {
92111
expect(
93112
withoutWatchActions([
@@ -116,3 +135,26 @@ describe("ActionsBlock", () => {
116135
expect(source).not.toMatch(/\.server"/);
117136
});
118137
});
138+
139+
/**
140+
* There is no rendering harness here, so this pins the wiring rather than the pixels: the
141+
* turn-wide answer is computed where every part is in scope and reaches every card, and
142+
* `ViewBlocks` can only add to it. What it does not prove is that the button disappears.
143+
*/
144+
describe("the one-watch-button flag is decided per turn, not per render_view call", () => {
145+
const turn = readFileSync(new URL("./DashboardAgentMessages.tsx", import.meta.url), "utf8");
146+
const catalog = readFileSync(new URL("./view-catalog.tsx", import.meta.url), "utf8");
147+
148+
it("computes it over every part's blocks, above the per-part loop", () => {
149+
expect(turn).toContain("turnAlreadyOffersWatch(");
150+
// Above the loop: computed from the whole `parts` map, not from one part.
151+
expect(turn.indexOf("const watchOfferedInTurn")).toBeLessThan(
152+
turn.indexOf("for (let i = 0; i < parts.length; i++)")
153+
);
154+
expect(turn).toContain("watchOfferedInTurn={watchOfferedInTurn}");
155+
});
156+
157+
it("lets a card add its own offer but never drop the turn's", () => {
158+
expect(catalog).toContain("watchOfferedInTurn || cardAlreadyOffersWatch(rendered)");
159+
});
160+
});

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,14 @@ export function cardAlreadyOffersWatch(blocks: ViewBlock[]): boolean {
2929
);
3030
}
3131

32+
/**
33+
* The same question across every card a turn renders. One `render_view` call can carry the
34+
* investigation card and another the actions block, so a per-call answer misses the pair.
35+
*/
36+
export function turnAlreadyOffersWatch(blockGroups: ViewBlock[][]): boolean {
37+
return blockGroups.some(cardAlreadyOffersWatch);
38+
}
39+
3240
export function withoutWatchActions<T extends CardAction>(actions: T[]): T[] {
3341
return actions.filter((action) => action.intent.kind !== "watch");
3442
}

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,17 +16,20 @@ export function ViewBlocks({
1616
resolveUri,
1717
pagePaths,
1818
answered = false,
19+
watchOfferedInTurn = false,
1920
}: {
2021
blocks: ViewBlock[];
2122
onIntent?: (intent: AgentIntent) => void;
2223
resolveUri?: (uri: string) => ResolvedUri | null;
2324
pagePaths?: Record<string, string>;
2425
/** The turn kept answering after this card, so "keep digging" has nothing to ask for. */
2526
answered?: boolean;
27+
/** A card in another of this turn's parts already offers the watch; see `view-actions`. */
28+
watchOfferedInTurn?: boolean;
2629
}) {
2730
if (!Array.isArray(blocks)) return null;
2831
const rendered = latestRevisionBlocks(blocks);
29-
const watchOfferedOnCard = cardAlreadyOffersWatch(rendered);
32+
const watchOfferedOnCard = watchOfferedInTurn || cardAlreadyOffersWatch(rendered);
3033
return (
3134
<div className="space-y-2">
3235
{rendered.map((block) => {

0 commit comments

Comments
 (0)