mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
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:
Binary file not shown.
|
After Width: | Height: | Size: 69 B |
+51
-4
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user