Skip to content

Commit c109845

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 1ed9ff8 commit c109845

4 files changed

Lines changed: 76 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

@@ -237,17 +238,28 @@ const DashboardAgentTurn = memo(function DashboardAgentTurn({
237238
const parts = message.parts ?? [];
238239
if (parts.length === 0) return null;
239240

241+
// Null for a part that renders no view at all; an empty array for one whose blocks were
242+
// all superseded. Both skip the part, only the first falls through to the other renderers.
243+
const blocksByPart = parts.map((part, i) => {
244+
const raw = blocksFor(part);
245+
return raw
246+
? withoutSupersededInvestigations(raw, `${message.id}:${i}`, investigationWinners)
247+
: null;
248+
});
249+
// One answer for the whole turn: two `render_view` parts each deciding for themselves
250+
// would show the watch button twice. `ViewBlocks` collapses revisions the same way.
251+
const watchOfferedInTurn = turnAlreadyOffersWatch(
252+
blocksByPart
253+
.filter((blocks): blocks is unknown[] => blocks !== null)
254+
.map((blocks) => latestRevisionBlocks(blocks as never))
255+
);
256+
240257
const body: React.ReactNode[] = [];
241258
for (let i = 0; i < parts.length; i++) {
242259
const part = parts[i]!;
243260

244-
const rawBlocks = blocksFor(part);
245-
if (rawBlocks) {
246-
const blocks = withoutSupersededInvestigations(
247-
rawBlocks,
248-
`${message.id}:${i}`,
249-
investigationWinners
250-
);
261+
const blocks = blocksByPart[i];
262+
if (blocks) {
251263
if (blocks.length > 0) {
252264
body.push(
253265
<ChatCardSlot key={i}>
@@ -257,6 +269,7 @@ const DashboardAgentTurn = memo(function DashboardAgentTurn({
257269
resolveUri={resolveUri}
258270
pagePaths={pagePaths}
259271
answered={answerContinuesAfter(parts as never, i)}
272+
watchOfferedInTurn={watchOfferedInTurn}
260273
/>
261274
</ChatCardSlot>
262275
);

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: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,17 +16,21 @@ 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 entries = latestRevisionEntries(blocks);
29-
const watchOfferedOnCard = cardAlreadyOffersWatch(entries.map((entry) => entry.block));
32+
const watchOfferedOnCard =
33+
watchOfferedInTurn || cardAlreadyOffersWatch(entries.map((entry) => entry.block));
3034
return (
3135
<div className="space-y-2">
3236
{entries.map(({ block, index }) => {

0 commit comments

Comments
 (0)