Skip to content

Commit c3fe17b

Browse files
committed
Merge branch 'feat/dashboard-agent-flows-watch' into feat/agent-storybook-gallery
2 parents 4cfd0a2 + 00c210b commit c3fe17b

3 files changed

Lines changed: 205 additions & 15 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: 40 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,22 @@ function withoutSupersededInvestigations(
138138
});
139139
}
140140

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

@@ -267,25 +283,28 @@ const DashboardAgentTurn = memo(function DashboardAgentTurn({
267283
);
268284

269285
const body: React.ReactNode[] = [];
286+
const actionRows: React.ReactNode[] = [];
270287
for (let i = 0; i < parts.length; i++) {
271288
const part = parts[i]!;
272289

273290
const blocks = blocksByPart[i];
274291
if (blocks) {
275-
if (blocks.length > 0) {
276-
body.push(
277-
<ChatCardSlot key={i}>
278-
<ViewBlocks
279-
blocks={blocks as never}
280-
onIntent={onIntent}
281-
resolveUri={resolveUri}
282-
pagePaths={pagePaths}
283-
answered={answerContinuesAfter(parts as never, i)}
284-
watchOfferedInTurn={watchOfferedInTurn}
285-
/>
286-
</ChatCardSlot>
287-
);
288-
}
292+
// `answered` stays keyed on the emission index: the reorder is display only.
293+
const slot = (list: unknown[], key: string) => (
294+
<ChatCardSlot key={key}>
295+
<ViewBlocks
296+
blocks={list as never}
297+
onIntent={onIntent}
298+
resolveUri={resolveUri}
299+
pagePaths={pagePaths}
300+
answered={answerContinuesAfter(parts as never, i)}
301+
watchOfferedInTurn={watchOfferedInTurn}
302+
/>
303+
</ChatCardSlot>
304+
);
305+
const { content, actions } = splitActionsBlocks(blocks);
306+
if (content.length > 0) body.push(slot(content, `${i}`));
307+
if (actions.length > 0) actionRows.push(slot(actions, `actions-${i}`));
289308
continue;
290309
}
291310

@@ -316,12 +335,18 @@ const DashboardAgentTurn = memo(function DashboardAgentTurn({
316335
}
317336
>
318337
{body}
338+
{actionRows}
319339
</ChatWakeSlot>
320340
</ChatTurn>
321341
);
322342
}
323343

324-
return <ChatTurn>{body}</ChatTurn>;
344+
return (
345+
<ChatTurn>
346+
{body}
347+
{actionRows}
348+
</ChatTurn>
349+
);
325350
});
326351

327352
export function DashboardAgentTurns({

apps/webapp/test/dashboardAgentWatches.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,8 @@ const ctx = vi.hoisted(() => ({
5151
agentDb: undefined as unknown as DashboardAgentDb,
5252
canAccess: true,
5353
actor: undefined as undefined | { userId: string; client?: string; environmentId?: string },
54+
/** Every task id the suite would have triggered for real. */
55+
triggered: [] as string[],
5456
}));
5557

5658
vi.mock("~/services/uatRoutePreamble.server", () => ({
@@ -84,6 +86,23 @@ vi.mock("~/v3/canAccessDashboardAgent.server", () => ({
8486
canAccessDashboardAgent: async () => ctx.canAccess,
8587
}));
8688

89+
// The routes drive the real service, which builds a TriggerClient from .env — so an unmocked
90+
// suite triggers actual runs against whatever origin .env names.
91+
vi.mock("@trigger.dev/sdk", async (importOriginal) => {
92+
const actual = await importOriginal<typeof import("@trigger.dev/sdk")>();
93+
return {
94+
...actual,
95+
TriggerClient: class {
96+
tasks = {
97+
trigger: async (taskId: string) => {
98+
ctx.triggered.push(taskId);
99+
return { id: "run_test" };
100+
},
101+
};
102+
},
103+
};
104+
});
105+
87106
const SESSION_SECRET = "test-session-secret-for-watch-tokens";
88107
process.env.SESSION_SECRET = SESSION_SECRET;
89108
// The agent's subscribe endpoint refuses without an email transport configured.
@@ -1594,6 +1613,9 @@ describe("the check endpoint", () => {
15941613
const body = await response.json();
15951614
expect(body.result).toBe("terminal_unsatisfied");
15961615

1616+
// Arming the chain goes through the stubbed client, never a real trigger.
1617+
expect(ctx.triggered).toContain("dashboard-agent-watch-batch");
1618+
15971619
const row = await getWatch(ctx.agentDb, { id: watch.watchId });
15981620
expect(row?.lastCheckedAt).not.toBeNull();
15991621
expect(row?.tickCount).toBe(0);

0 commit comments

Comments
 (0)