feat(site): agents desktop recording thumbnail frontend (#24023)

Frontend for https://github.com/coder/coder/pull/24022.

From that PR's description: 

> The agents chat interface displays thumbnails for videos recorded by
the computer use agent. Currently, to display a thumbnail, the frontend
downloads the entire video and shows the first frame.

#24022 adds a thumbnail file id to `wait_agent` tool results, and this
PR displays it instead of fetching the entire video.
This commit is contained in:
Hugo Dutka
2026-04-09 13:55:40 +02:00
committed by GitHub
parent efb19eb748
commit 128a7c23e6
5 changed files with 77 additions and 13 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 69 B

@@ -2,8 +2,9 @@ import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, fireEvent, userEvent, waitFor, within } from "storybook/test";
import { RecordingPreview } from "./RecordingPreview";
// The file is stored in site/.storybook/static/tiny-recording.mp4.
// Static assets stored in site/.storybook/static/.
const TINY_MP4 = "/tiny-recording.mp4";
const TINY_THUMBNAIL = "/tiny-thumbnail.png";
const meta: Meta<typeof RecordingPreview> = {
title: "pages/AgentsPage/ChatElements/tools/RecordingPreview",
@@ -23,10 +24,13 @@ type Story = StoryObj<typeof RecordingPreview>;
export const Default: Story = {
args: {
recordingFileId: "dummy-recording-id",
thumbnailFileId: "dummy-thumb-id",
thumbnailSrc: TINY_THUMBNAIL,
src: TINY_MP4,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.getByRole("img")).toBeInTheDocument();
expect(
canvas.getByRole("button", { name: "View recording" }),
).toBeInTheDocument();
@@ -36,6 +40,8 @@ export const Default: Story = {
export const LightboxOpen: Story = {
args: {
recordingFileId: "dummy-recording-id",
thumbnailFileId: "dummy-thumb-id",
thumbnailSrc: TINY_THUMBNAIL,
src: TINY_MP4,
},
play: async ({ canvasElement }) => {
@@ -55,13 +61,14 @@ export const LightboxOpen: Story = {
export const ThumbnailError: Story = {
args: {
recordingFileId: "dummy-recording-id",
thumbnailFileId: "bad-thumb-id",
src: TINY_MP4,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const video = canvasElement.querySelector("video");
expect(video).not.toBeNull();
fireEvent.error(video!);
const img = canvasElement.querySelector("img");
expect(img).not.toBeNull();
fireEvent.error(img!);
await waitFor(() => {
expect(canvas.getByText("Thumbnail unavailable")).toBeInTheDocument();
// The play button should still be available so the user can
@@ -72,3 +79,43 @@ export const ThumbnailError: Story = {
});
},
};
export const WithThumbnail: Story = {
args: {
recordingFileId: "rec-id",
thumbnailFileId: "thumb-id",
thumbnailSrc: TINY_THUMBNAIL,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const img = canvas.getByRole("img");
expect(img).toBeInTheDocument();
expect(img).toHaveAttribute("src", TINY_THUMBNAIL);
// No <video> element should be in the DOM.
expect(canvasElement.querySelector("video")).toBeNull();
expect(
canvas.getByRole("button", { name: "View recording" }),
).toBeInTheDocument();
},
};
export const WithoutThumbnail: Story = {
args: {
recordingFileId: "rec-id",
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
// No <img> or <video> element should be in the DOM.
expect(canvasElement.querySelector("img")).toBeNull();
expect(canvasElement.querySelector("video")).toBeNull();
// Play button is still present.
expect(
canvas.getByRole("button", { name: "View recording" }),
).toBeInTheDocument();
// Gray placeholder div is visible.
const placeholder = canvasElement.querySelector(
".bg-surface-secondary:not(.flex)",
);
expect(placeholder).not.toBeNull();
},
};
@@ -7,9 +7,14 @@ import { DEFAULT_ASPECT, PREVIEW_HEIGHT } from "./previewConstants";
interface RecordingPreviewProps {
/** The chat file ID for the MP4 recording. */
recordingFileId: string;
/** File ID for the JPEG thumbnail of a completed recording. */
thumbnailFileId?: string;
/** Optional video URL override. When provided, this is used
* directly instead of deriving the URL from recordingFileId. */
src?: string;
/** Optional thumbnail URL override. When provided, this is used
* directly instead of deriving the URL from thumbnailFileId. */
thumbnailSrc?: string;
}
/**
@@ -20,7 +25,9 @@ interface RecordingPreviewProps {
*/
export const RecordingPreview: React.FC<RecordingPreviewProps> = ({
recordingFileId,
thumbnailFileId,
src: srcOverride,
thumbnailSrc: thumbnailSrcOverride,
}) => {
const [showLightbox, setShowLightbox] = useState(false);
const [thumbnailError, setThumbnailError] = useState(false);
@@ -28,9 +35,6 @@ export const RecordingPreview: React.FC<RecordingPreviewProps> = ({
// component remounts and resets its internal error state.
const [lightboxKey, setLightboxKey] = useState(0);
const thumbnailSrc =
// Seek to first frame so the browser renders a thumbnail preview.
srcOverride ?? `/api/experimental/chats/files/${recordingFileId}#t=0.001`;
const videoSrc =
srcOverride ?? `/api/experimental/chats/files/${recordingFileId}`;
@@ -44,14 +48,19 @@ export const RecordingPreview: React.FC<RecordingPreviewProps> = ({
<ImageOffIcon className="h-3 w-3" />
Thumbnail unavailable
</div>
) : (
<video
src={thumbnailSrc}
preload="metadata"
muted
) : thumbnailFileId ? (
<img
src={
thumbnailSrcOverride ??
`/api/experimental/chats/files/${thumbnailFileId}`
}
alt="Recording thumbnail"
className="h-full w-full pointer-events-none object-cover"
onError={() => setThumbnailError(true)}
/>
) : (
// No thumbnail available — neutral gray placeholder.
<div className="h-full w-full bg-surface-secondary" />
)}
<button
type="button"
@@ -172,6 +172,8 @@ export const SubagentTool: React.FC<{
variant?: "default" | "computer-use";
/** File ID for a completed recording (shown after tool completes). */
recordingFileId?: string;
/** File ID for the JPEG thumbnail of a completed recording. */
thumbnailFileId?: string;
}> = ({
toolName,
title,
@@ -187,6 +189,7 @@ export const SubagentTool: React.FC<{
showDesktopPreview,
variant = "default",
recordingFileId,
thumbnailFileId,
}) => {
const [expanded, setExpanded] = useState(false);
const { desktopChatId, onOpenDesktop } = useDesktopPanel();
@@ -262,7 +265,10 @@ export const SubagentTool: React.FC<{
{recordingFileId && toolStatus === "completed" && (
<div className="mt-1.5 w-fit">
<RecordingPreview recordingFileId={recordingFileId} />
<RecordingPreview
recordingFileId={recordingFileId}
thumbnailFileId={thumbnailFileId}
/>
</div>
)}
{expanded && hasPrompt && (
@@ -356,6 +356,7 @@ const SubagentRenderer: FC<ToolRendererProps> = ({
: undefined;
const report = rec ? asString(rec.report) : "";
const recordingFileId = rec ? asString(rec.recording_file_id) : "";
const thumbnailFileId = rec ? asString(rec.thumbnail_file_id) : "";
const prompt = parsedArgs ? asString(parsedArgs.prompt) : "";
const subagentMessage = parsedArgs ? asString(parsedArgs.message) : "";
const title =
@@ -418,6 +419,7 @@ const SubagentRenderer: FC<ToolRendererProps> = ({
}
variant={variant}
recordingFileId={recordingFileId || undefined}
thumbnailFileId={thumbnailFileId || undefined}
/>
);
};