Skip to content

Commit 472435a

Browse files
authored
feat: add HeartbeatToolCall transcript card (#3631)
## Summary Render the `heartbeat` tool — the agent's recurring, idle-gated self check-in — with a dedicated transcript card (`HeartbeatToolCall`) instead of the generic fallback. The header is a glanceable status pill (live cadence / paused / cleared); expanding reveals a scanning ECG pulse trace, the State/Cadence/Context/Trigger stats, and the check-in prompt. ## Background The `heartbeat` tool already exists end-to-end (`HeartbeatToolArgsSchema` / `HeartbeatToolResultSchema`, the config modal, the slash command), but agent `heartbeat` tool calls in the transcript fell back to `GenericToolCall` — just raw args/result JSON. This implements the proposed `Heartbeat Tool Call.html` design as a real card so the schedule reads at a glance. ## Implementation - `HeartbeatToolCall.tsx` mirrors `GetGoalToolCall`: shared `ToolPrimitives`, `useToolExpansion`, and `StatusIndicator`/`getStatusDisplay`. The result is validated against `HeartbeatToolResultSchema` before use, so a malformed persisted transcript degrades to "no settings" rather than throwing (self-healing). - Registered `heartbeat` in `getToolComponent`, and added the `Activity` (ECG) icon to `TOOL_NAME_TO_ICON` to match the card's pulse-trace motif (the config menu keeps `HeartPulse`). - Added reduced-motion-gated `heartbeat-dot-pulse` / `heartbeat-trace-scan` keyframes to `globals.css`, following the existing keyframe + utility-class convention. - Design-fidelity note: the mockup's `text-foreground-secondary` is an undefined token in the app (renders nothing), so muted text maps to the real `text-secondary` / `text-muted`. ## Validation - Registry wiring test: valid args → `HeartbeatToolCall`; out-of-range `intervalMs` → `GenericToolCall`. - Interval formatter unit tests (hour/minute boundaries, singular/plural). - 8 Storybook stories cover the design states. Chromatic snapshots are **disabled** on the file because the repo-wide snapshot budget (`tests/ui/storybook/budget.test.ts`) is already at its ceiling; the stories still render under local Storybook and the CI Storybook test-runner. Flip to a single mode once the budget is raised. ## Risks Low. Additive — a new entry in the tool-component registry and an icon-map key; no existing tool rendering changes. Worst case for the new card is a degraded/empty detail section on a malformed result, which is handled defensively. --------- Signed-off-by: Thomas Kosiewski <tk@coder.com>
1 parent 899a637 commit 472435a

9 files changed

Lines changed: 703 additions & 24 deletions

File tree

Lines changed: 258 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,258 @@
1+
import type { Meta, StoryObj } from "@storybook/react-vite";
2+
import { HeartbeatToolCall } from "@/browser/features/Tools/HeartbeatToolCall";
3+
import { CHROMATIC_DISABLED, lightweightMeta, StoryUiShell } from "@/browser/stories/meta.js";
4+
import { HEARTBEAT_DEFAULT_MESSAGE_BODY } from "@/constants/heartbeat";
5+
6+
const meta = {
7+
...lightweightMeta,
8+
title: "App/Chat/Tools/Heartbeat",
9+
component: HeartbeatToolCall,
10+
parameters: {
11+
...lightweightMeta.parameters,
12+
// The repo-wide Chromatic snapshot budget (tests/ui/storybook/budget.test.ts) is
13+
// already at its ceiling, so these states stay out of paid visual snapshots. They
14+
// still render under local Storybook and the CI Storybook test-runner smoke pass.
15+
// Flip to CHROMATIC_SINGLE_MODE once the budget is raised to add regression coverage.
16+
chromatic: CHROMATIC_DISABLED,
17+
},
18+
decorators: [
19+
(Story) => (
20+
<StoryUiShell>
21+
<div className="bg-background p-6">
22+
<div className="w-full max-w-2xl">
23+
<Story />
24+
</div>
25+
</div>
26+
</StoryUiShell>
27+
),
28+
],
29+
} satisfies Meta<typeof HeartbeatToolCall>;
30+
31+
export default meta;
32+
33+
type Story = StoryObj<typeof meta>;
34+
35+
const TASK_PROMPT =
36+
"Check the CI run for the auth refactor. If it's green, open the PR; if it's red, " +
37+
"summarize the first failure and stop.";
38+
39+
/** set · enabled with a custom task prompt (expanded to show the full schedule). */
40+
export const ScheduledEnabled: Story = {
41+
args: {
42+
args: {
43+
action: "set",
44+
enabled: true,
45+
intervalMs: 30 * 60_000,
46+
contextMode: "normal",
47+
message: TASK_PROMPT,
48+
},
49+
status: "completed",
50+
defaultExpanded: true,
51+
result: {
52+
success: true,
53+
action: "set",
54+
configured: true,
55+
settings: {
56+
enabled: true,
57+
intervalMs: 30 * 60_000,
58+
contextMode: "normal",
59+
message: TASK_PROMPT,
60+
},
61+
summary: "Heartbeat is enabled for this workspace at 30 minutes.",
62+
},
63+
},
64+
};
65+
66+
/**
67+
* set · multiline custom message with a long unbroken token. Pinned to a fixed ~360px
68+
* container (the Storybook test-runner renders at desktop width and ignores viewport /
69+
* Chromatic modes, so the narrow case must be forced with a wrapper) and a play that
70+
* fails if the prompt body's long URL/path overflows instead of wrapping.
71+
*/
72+
export const CustomMessageWrapping: Story = {
73+
args: {
74+
args: { action: "set", enabled: true, intervalMs: 1_800_000 },
75+
status: "completed",
76+
defaultExpanded: true,
77+
result: {
78+
success: true,
79+
action: "set",
80+
configured: true,
81+
settings: {
82+
enabled: true,
83+
intervalMs: 1_800_000,
84+
contextMode: "normal",
85+
message:
86+
"Poll the deploy and report status.\n" +
87+
"Logs: https://ci.example.com/runs/0123456789abcdef0123456789abcdef/jobs/deploy-prod-us-east-1/raw?download=true\n" +
88+
"If it failed, summarize the first error and stop.",
89+
},
90+
summary: "Heartbeat is enabled for this workspace at 30 minutes.",
91+
},
92+
},
93+
decorators: [
94+
(Story) => (
95+
<div data-testid="heartbeat-card-container" className="w-[375px]">
96+
<Story />
97+
</div>
98+
),
99+
],
100+
play: async ({ canvasElement }) => {
101+
// defaultExpanded renders the prompt synchronously; confirm the long token is present.
102+
if (!canvasElement.textContent?.includes("ci.example.com")) {
103+
throw new Error("Heartbeat check-in prompt did not render");
104+
}
105+
const container = canvasElement.querySelector('[data-testid="heartbeat-card-container"]');
106+
if (!(container instanceof HTMLElement)) {
107+
throw new Error("Heartbeat story container not found");
108+
}
109+
// Let layout settle before measuring.
110+
await new Promise<void>((resolve) =>
111+
requestAnimationFrame(() => requestAnimationFrame(() => resolve()))
112+
);
113+
if (container.scrollWidth > container.clientWidth + 1) {
114+
throw new Error(
115+
`Heartbeat tool card overflowed its ${container.clientWidth}px container by ` +
116+
`${container.scrollWidth - container.clientWidth}px`
117+
);
118+
}
119+
},
120+
};
121+
122+
/**
123+
* set · long cadence that compacts context first, with no custom message — exercises
124+
* the default-prompt fallback (the common case, since `message` is only stored when set).
125+
*/
126+
export const LongCadenceCompact: Story = {
127+
args: {
128+
args: { action: "set", enabled: true, intervalMs: 2 * 3_600_000, contextMode: "compact" },
129+
status: "completed",
130+
defaultExpanded: true,
131+
result: {
132+
success: true,
133+
action: "set",
134+
configured: true,
135+
settings: { enabled: true, intervalMs: 2 * 3_600_000, contextMode: "compact" },
136+
summary: "Heartbeat is enabled for this workspace at 2 hours.",
137+
},
138+
},
139+
};
140+
141+
/** get · reads current settings (reset context mode). */
142+
export const ReadReset: Story = {
143+
args: {
144+
args: { action: "get" },
145+
status: "completed",
146+
defaultExpanded: true,
147+
result: {
148+
success: true,
149+
action: "get",
150+
configured: true,
151+
settings: {
152+
enabled: true,
153+
intervalMs: 3_600_000,
154+
contextMode: "reset",
155+
message: HEARTBEAT_DEFAULT_MESSAGE_BODY,
156+
},
157+
summary: "Heartbeat is enabled for this workspace at 1 hour.",
158+
},
159+
},
160+
};
161+
162+
/** set · kept but paused (amber). */
163+
export const Paused: Story = {
164+
args: {
165+
args: { action: "set", enabled: false },
166+
status: "completed",
167+
defaultExpanded: true,
168+
result: {
169+
success: true,
170+
action: "set",
171+
configured: true,
172+
settings: {
173+
enabled: false,
174+
intervalMs: 30 * 60_000,
175+
contextMode: "normal",
176+
message: HEARTBEAT_DEFAULT_MESSAGE_BODY,
177+
},
178+
summary: "Heartbeat is disabled for this workspace at 30 minutes.",
179+
},
180+
},
181+
};
182+
183+
/** get · nothing configured for this workspace. */
184+
export const ReadNotConfigured: Story = {
185+
args: {
186+
args: { action: "get" },
187+
status: "completed",
188+
defaultExpanded: true,
189+
result: {
190+
success: true,
191+
action: "get",
192+
configured: false,
193+
settings: null,
194+
summary: "No heartbeat settings are configured for this workspace.",
195+
},
196+
},
197+
};
198+
199+
/** unset · schedule removed. */
200+
export const Cleared: Story = {
201+
args: {
202+
args: { action: "unset" },
203+
status: "completed",
204+
defaultExpanded: true,
205+
result: {
206+
success: true,
207+
action: "unset",
208+
configured: false,
209+
settings: null,
210+
summary: "Heartbeat settings removed for this workspace.",
211+
},
212+
},
213+
};
214+
215+
/** Mid-flight, before the result arrives. */
216+
export const Executing: Story = {
217+
args: {
218+
args: { action: "set", enabled: true, intervalMs: 30 * 60_000 },
219+
status: "executing",
220+
defaultExpanded: true,
221+
},
222+
};
223+
224+
/**
225+
* Error · a reachable failure result. Out-of-range intervals are rejected by the tool
226+
* schema before this card renders (they route to GenericToolCall), so the card's ErrorBox
227+
* is exercised here with valid args and a server-side failure.
228+
*/
229+
export const ErrorResult: Story = {
230+
args: {
231+
args: { action: "set", enabled: true, intervalMs: 30 * 60_000 },
232+
status: "failed",
233+
defaultExpanded: true,
234+
result: {
235+
success: false,
236+
error: "Failed to update heartbeat settings: workspace configuration is unavailable.",
237+
},
238+
},
239+
};
240+
241+
/**
242+
* Interrupted before the result arrived (no settings/error/executing state). The expanded
243+
* body falls back to the requested args instead of going blank — the generic renderer used
244+
* to surface the args here.
245+
*/
246+
export const Interrupted: Story = {
247+
args: {
248+
args: {
249+
action: "set",
250+
enabled: true,
251+
intervalMs: 2 * 3_600_000,
252+
contextMode: "compact",
253+
message: "Watch the long-running migration and report when it finishes.",
254+
},
255+
status: "interrupted",
256+
defaultExpanded: true,
257+
},
258+
};

0 commit comments

Comments
 (0)