Skip to content

Commit b73599b

Browse files
committed
fix(webapp): action rows render at the end of the turn
1 parent 7789d1f commit b73599b

2 files changed

Lines changed: 181 additions & 14 deletions

File tree

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
import type { UIMessage } from "@ai-sdk/react";
2+
import { createElement } from "react";
3+
import { renderToStaticMarkup } from "react-dom/server";
4+
import { describe, expect, it } from "vitest";
5+
import { OperatingSystemContextProvider } from "~/components/primitives/OperatingSystemProvider";
6+
import { ShortcutsProvider } from "~/components/primitives/ShortcutsProvider";
7+
import { DashboardAgentTurns, splitActionsBlocks } from "./DashboardAgentMessages";
8+
9+
/**
10+
* The model emits the actions block wherever it likes — the renderer pins the buttons to
11+
* the bottom of the turn. Static markup, so this proves the rendered order and nothing
12+
* about interaction.
13+
*/
14+
15+
const actionsBlock = (label: string) => ({
16+
type: "actions",
17+
id: label,
18+
revision: 0,
19+
version: 1,
20+
actions: [{ label, intent: { kind: "ask", prompt: `${label}?` } }],
21+
});
22+
23+
const card = {
24+
type: "investigation",
25+
id: "inv_1",
26+
revision: 0,
27+
version: 1,
28+
investigation: {
29+
outcome: "concluded",
30+
severity: "crit",
31+
confidence: "high",
32+
title: "A card that is not an actions row",
33+
headline: "Every attempt dies on a null order id.",
34+
remediation: "Guard the receipt builder against a missing order.",
35+
hypotheses: [],
36+
evidence: [],
37+
},
38+
};
39+
40+
function text(value: string) {
41+
return { type: "text", text: value };
42+
}
43+
44+
function view(...blocks: unknown[]) {
45+
return { type: "tool-render_view", state: "output-available", output: { blocks } };
46+
}
47+
48+
function markup(parts: unknown[]) {
49+
const message = { id: "m1", role: "assistant", parts } as unknown as UIMessage;
50+
return renderToStaticMarkup(
51+
createElement(
52+
OperatingSystemContextProvider,
53+
{ platform: "mac" },
54+
createElement(
55+
ShortcutsProvider,
56+
null,
57+
createElement(DashboardAgentTurns, {
58+
messages: [message],
59+
activity: null,
60+
onIntent: () => {},
61+
})
62+
)
63+
)
64+
);
65+
}
66+
67+
function order(html: string, ...needles: string[]) {
68+
return needles.map((needle) => html.indexOf(needle));
69+
}
70+
71+
describe("action rows render at the end of the turn", () => {
72+
it("moves an actions block below the closing text it was emitted above", () => {
73+
const html = markup([
74+
text("Here is what I found."),
75+
view(actionsBlock("Watch it")),
76+
text("Want me to watch it?"),
77+
]);
78+
79+
const [found, offer, button] = order(
80+
html,
81+
"Here is what I found.",
82+
"Want me to watch it?",
83+
"Watch it"
84+
);
85+
expect(found).toBeGreaterThan(-1);
86+
expect(offer).toBeGreaterThan(-1);
87+
expect(button).toBeGreaterThan(offer);
88+
});
89+
90+
it("keeps two actions blocks in their relative order, both after the card", () => {
91+
const html = markup([view(actionsBlock("First")), view(card), view(actionsBlock("Second"))]);
92+
93+
const [summary, first, second] = order(
94+
html,
95+
"A card that is not an actions row",
96+
"First",
97+
"Second"
98+
);
99+
expect(first).toBeGreaterThan(summary);
100+
expect(second).toBeGreaterThan(first);
101+
});
102+
103+
it("leaves a turn without actions exactly as emitted", () => {
104+
const html = markup([text("Only text."), view(card), text("Then more text.")]);
105+
106+
const [only, summary, more] = order(
107+
html,
108+
"Only text.",
109+
"A card that is not an actions row",
110+
"Then more text."
111+
);
112+
expect(summary).toBeGreaterThan(only);
113+
expect(more).toBeGreaterThan(summary);
114+
});
115+
116+
it("pulls the actions out of a block list that also carries a card", () => {
117+
const html = markup([view(actionsBlock("Watch it"), card), text("Want me to watch it?")]);
118+
119+
const [summary, offer, button] = order(
120+
html,
121+
"A card that is not an actions row",
122+
"Want me to watch it?",
123+
"Watch it"
124+
);
125+
expect(offer).toBeGreaterThan(summary);
126+
expect(button).toBeGreaterThan(offer);
127+
});
128+
});
129+
130+
describe("splitActionsBlocks", () => {
131+
it("separates actions from everything else, each keeping its order", () => {
132+
const first = actionsBlock("First");
133+
const second = actionsBlock("Second");
134+
expect(splitActionsBlocks([first, card, second])).toEqual({
135+
content: [card],
136+
actions: [first, second],
137+
});
138+
});
139+
140+
it("returns no actions when the list has none", () => {
141+
expect(splitActionsBlocks([card])).toEqual({ content: [card], actions: [] });
142+
});
143+
});

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

Lines changed: 38 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,22 @@ function withoutSupersededInvestigations(
133133
});
134134
}
135135

136+
function isActionsBlock(block: unknown): boolean {
137+
return (block as { type?: unknown } | null)?.type === "actions";
138+
}
139+
140+
/**
141+
* Buttons belong at the bottom of a turn, wherever the model emitted them: the actions
142+
* blocks are rendered after everything else, keeping their order among themselves.
143+
* Display only — the parts the turn walks are untouched.
144+
*/
145+
export function splitActionsBlocks<T>(blocks: T[]): { content: T[]; actions: T[] } {
146+
return {
147+
content: blocks.filter((block) => !isActionsBlock(block)),
148+
actions: blocks.filter(isActionsBlock),
149+
};
150+
}
151+
136152
// #region chat-layout transcript
137153
// `chat-layout.test.ts` fails if a spacing utility class appears in this region.
138154

@@ -242,6 +258,7 @@ const DashboardAgentTurn = memo(function DashboardAgentTurn({
242258
if (parts.length === 0) return null;
243259

244260
const body: React.ReactNode[] = [];
261+
const actionRows: React.ReactNode[] = [];
245262
for (let i = 0; i < parts.length; i++) {
246263
const part = parts[i]!;
247264

@@ -252,19 +269,21 @@ const DashboardAgentTurn = memo(function DashboardAgentTurn({
252269
`${message.id}:${i}`,
253270
investigationWinners
254271
);
255-
if (blocks.length > 0) {
256-
body.push(
257-
<ChatCardSlot key={i}>
258-
<ViewBlocks
259-
blocks={blocks as never}
260-
onIntent={onIntent}
261-
resolveUri={resolveUri}
262-
pagePaths={pagePaths}
263-
answered={answerContinuesAfter(parts as never, i)}
264-
/>
265-
</ChatCardSlot>
266-
);
267-
}
272+
// `answered` stays keyed on the emission index: the reorder is display only.
273+
const slot = (list: unknown[], key: string) => (
274+
<ChatCardSlot key={key}>
275+
<ViewBlocks
276+
blocks={list as never}
277+
onIntent={onIntent}
278+
resolveUri={resolveUri}
279+
pagePaths={pagePaths}
280+
answered={answerContinuesAfter(parts as never, i)}
281+
/>
282+
</ChatCardSlot>
283+
);
284+
const { content, actions } = splitActionsBlocks(blocks);
285+
if (content.length > 0) body.push(slot(content, `${i}`));
286+
if (actions.length > 0) actionRows.push(slot(actions, `actions-${i}`));
268287
continue;
269288
}
270289

@@ -285,7 +304,12 @@ const DashboardAgentTurn = memo(function DashboardAgentTurn({
285304
body.push(renderDashboardPart(part, i, resolveUri));
286305
}
287306

288-
return <ChatTurn>{body}</ChatTurn>;
307+
return (
308+
<ChatTurn>
309+
{body}
310+
{actionRows}
311+
</ChatTurn>
312+
);
289313
});
290314

291315
export function DashboardAgentTurns({

0 commit comments

Comments
 (0)