refactor(site/src/pages/AgentsPage): use ChatMessagePart for editingFileBlocks (#23151)

Replace the ad-hoc camelCase file block shape ({ mediaType, fileId, data })
with snake_case fields matching ChatMessagePart from the API types.

The RenderBlock file variant now uses media_type/file_id instead of
mediaType/fileId. The parsers in messageParsing.ts and streamState.ts
pass validated ChatMessagePart objects through directly instead of
destructuring and reassembling with renamed fields. This eliminates
the needless API → camelCase → snake_case roundtrip that the edit
flow previously required.

Refs #22735
This commit is contained in:
Mathias Fredriksson
2026-03-17 04:10:08 -08:00
committed by GitHub
parent 524bca4c87
commit 4e2d7ffaa7
13 changed files with 74 additions and 141 deletions
+2 -6
View File
@@ -1,4 +1,4 @@
import type { ChatQueuedMessage } from "api/typesGenerated";
import type { ChatMessagePart, ChatQueuedMessage } from "api/typesGenerated";
import {
ModelSelector,
type ModelSelectorOption,
@@ -94,11 +94,7 @@ interface AgentChatInputProps {
onStartQueueEdit?: (
id: number,
text: string,
fileBlocks: readonly {
mediaType: string;
data?: string;
fileId?: string;
}[],
fileBlocks: readonly ChatMessagePart[],
) => void;
onCancelQueueEdit?: () => void;
// History editing state, owned by the parent.
+4 -15
View File
@@ -15,6 +15,7 @@ import {
import { deploymentSSHConfig } from "api/queries/deployment";
import { workspaceById, workspaceByIdKey } from "api/queries/workspaces";
import type * as TypesGen from "api/typesGenerated";
import type { ChatMessagePart } from "api/typesGenerated";
import { useProxy } from "contexts/ProxyContext";
import {
getTerminalHref,
@@ -102,18 +103,14 @@ export function useConversationEditingState(deps: {
string | null
>(null);
const [editingFileBlocks, setEditingFileBlocks] = useState<
readonly { mediaType: string; data?: string; fileId?: string }[]
readonly ChatMessagePart[]
>([]);
const handleEditUserMessage = useCallback(
(
messageId: number,
text: string,
fileBlocks?: readonly {
mediaType: string;
data?: string;
fileId?: string;
}[],
fileBlocks?: readonly ChatMessagePart[],
) => {
setDraftBeforeHistoryEdit((prev) =>
editingMessageId !== null ? prev : inputValueRef.current,
@@ -143,15 +140,7 @@ export function useConversationEditingState(deps: {
>(null);
const handleStartQueueEdit = useCallback(
(
id: number,
text: string,
fileBlocks: readonly {
mediaType: string;
data?: string;
fileId?: string;
}[],
) => {
(id: number, text: string, fileBlocks: readonly ChatMessagePart[]) => {
setDraftBeforeQueueEdit((prev) =>
editingQueuedMessageID === null ? inputValueRef.current : prev,
);
@@ -246,10 +246,10 @@ function renderBlockList({
);
}
case "file":
if (block.mediaType.startsWith("image/")) {
const src = block.fileId
? `/api/experimental/chats/files/${block.fileId}`
: `data:${block.mediaType};base64,${block.data}`;
if (block.media_type.startsWith("image/")) {
const src = block.file_id
? `/api/experimental/chats/files/${block.file_id}`
: `data:${block.media_type};base64,${block.data}`;
return (
<button
key={`${keyPrefix}-file-${index}`}
@@ -291,7 +291,7 @@ const ChatMessageItem = memo<{
onEditUserMessage?: (
messageId: number,
text: string,
fileBlocks?: Array<{ mediaType: string; data?: string; fileId?: string }>,
fileBlocks?: readonly TypesGen.ChatMessagePart[],
) => void;
editingMessageId?: number | null;
savingMessageId?: number | null;
@@ -381,7 +381,7 @@ const ChatMessageItem = memo<{
const fileBlocks = parsed.blocks.filter(
(b): b is Extract<RenderBlock, { type: "file" }> =>
b.type === "file" &&
b.mediaType.startsWith("image/"),
b.media_type.startsWith("image/"),
);
onEditUserMessage(
message.id,
@@ -408,15 +408,15 @@ const ChatMessageItem = memo<{
{(() => {
const imageBlocks = parsed.blocks.filter(
(b): b is Extract<RenderBlock, { type: "file" }> =>
b.type === "file" && b.mediaType.startsWith("image/"),
b.type === "file" && b.media_type.startsWith("image/"),
);
if (imageBlocks.length === 0) return null;
return (
<div className="mt-2 flex flex-wrap gap-2">
{imageBlocks.map((block, i) => {
const src = block.fileId
? `/api/experimental/chats/files/${block.fileId}`
: `data:${block.mediaType};base64,${block.data}`;
const src = block.file_id
? `/api/experimental/chats/files/${block.file_id}`
: `data:${block.media_type};base64,${block.data}`;
return (
<button
key={`user-file-${i}`}
@@ -612,7 +612,7 @@ const StickyUserMessage: FC<{
onEditUserMessage?: (
messageId: number,
text: string,
fileBlocks?: Array<{ mediaType: string; data?: string; fileId?: string }>,
fileBlocks?: readonly TypesGen.ChatMessagePart[],
) => void;
editingMessageId?: number | null;
savingMessageId?: number | null;
@@ -751,11 +751,7 @@ const StickyUserMessage: FC<{
? (
messageId: number,
text: string,
fileBlocks?: Array<{
mediaType: string;
data?: string;
fileId?: string;
}>,
fileBlocks?: readonly TypesGen.ChatMessagePart[],
) => {
onEditUserMessage(messageId, text, fileBlocks);
requestAnimationFrame(() => {
@@ -873,7 +869,7 @@ interface ConversationTimelineProps {
onEditUserMessage?: (
messageId: number,
text: string,
fileBlocks?: Array<{ mediaType: string; data?: string; fileId?: string }>,
fileBlocks?: readonly TypesGen.ChatMessagePart[],
) => void;
editingMessageId?: number | null;
savingMessageId?: number | null;
@@ -258,9 +258,9 @@ describe("parseMessageContent", () => {
expect(result.blocks).toHaveLength(1);
expect(result.blocks[0]).toEqual({
type: "file",
mediaType: "image/png",
media_type: "image/png",
data: undefined,
fileId: "abc-123-def",
file_id: "abc-123-def",
});
});
@@ -275,9 +275,9 @@ describe("parseMessageContent", () => {
expect(result.blocks).toHaveLength(1);
expect(result.blocks[0]).toEqual({
type: "file",
mediaType: "image/png",
media_type: "image/png",
data: "iVBORw0KGgo=",
fileId: undefined,
file_id: undefined,
});
});
@@ -244,23 +244,17 @@ export const parseMessageContent = (content: unknown): ParsedMessageContent => {
parsed.blocks = ensureToolBlock(parsed.blocks, id);
break;
}
case "file": {
const mediaType = asString(typedBlock.media_type);
const data = asString(typedBlock.data);
const fileId = asString(typedBlock.file_id);
if (mediaType && (data || fileId)) {
case "file":
if (
typedBlock.media_type &&
(typedBlock.data || typedBlock.file_id)
) {
parsed.blocks = [
...parsed.blocks,
{
type: "file",
mediaType,
data: data || undefined,
fileId: fileId || undefined,
},
typedBlock as Extract<RenderBlock, { type: "file" }>,
];
}
break;
}
case "source": {
const url = asString(typedBlock.url);
const title = asString(typedBlock.title);
@@ -141,26 +141,17 @@ export const applyMessagePartToStreamState = (
},
};
}
case "file": {
const mediaType = asString(part.media_type);
const data = asString(part.data);
const fileId = asString(part.file_id);
if (!mediaType || (!data && !fileId)) {
case "file":
if (!part.media_type || (!part.data && !part.file_id)) {
return prev;
}
return {
...nextState,
blocks: [
...nextState.blocks,
{
type: "file",
mediaType,
data: data || undefined,
fileId: fileId || undefined,
},
part as Extract<RenderBlock, { type: "file" }>,
],
};
}
case "source": {
const url = asString(part.url);
const title = asString(part.title);
@@ -38,9 +38,9 @@ export type RenderBlock =
}
| {
type: "file";
mediaType: string;
media_type: string;
data?: string; // base64, absent when file_id is available
fileId?: string;
file_id?: string;
}
| {
type: "file-reference";
@@ -48,7 +48,7 @@ interface AgentDetailTimelineProps {
onEditUserMessage?: (
messageId: number,
text: string,
fileBlocks?: readonly { mediaType: string; data?: string }[],
fileBlocks?: readonly TypesGen.ChatMessagePart[],
) => void;
editingMessageId?: number | null;
savingMessageId?: number | null;
@@ -169,22 +169,14 @@ interface AgentDetailInputProps {
onStartQueueEdit: (
id: number,
text: string,
fileBlocks: readonly {
mediaType: string;
data?: string;
fileId?: string;
}[],
fileBlocks: readonly TypesGen.ChatMessagePart[],
) => void;
onCancelQueueEdit: () => void;
isEditingHistoryMessage: boolean;
onCancelHistoryEdit: () => void;
// File blocks from the message being edited, converted to
// File parts from the message being edited, converted to
// File objects and pre-populated into attachments.
editingFileBlocks?: readonly {
mediaType: string;
data?: string;
fileId?: string;
}[];
editingFileBlocks?: readonly TypesGen.ChatMessagePart[];
}
export const AgentDetailInput: FC<AgentDetailInputProps> = ({
@@ -258,29 +250,28 @@ export const AgentDetailInput: FC<AgentDetailInputProps> = ({
return;
}
const files = editingFileBlocks.map((block, i) => {
const ext = block.mediaType.split("/")[1] ?? "png";
const mt = block.media_type ?? "application/octet-stream";
const ext = mt.split("/")[1] ?? "png";
// Empty File used as a Map key only, its content is never
// read because the existing fileId is reused at send time.
return new File([], `attachment-${i}.${ext}`, {
type: block.mediaType,
});
// read because the existing file_id is reused at send time.
return new File([], `attachment-${i}.${ext}`, { type: mt });
});
setAttachments(files);
setPreviewUrls(
new Map(
files.map((f, i) => [
f,
`/api/experimental/chats/files/${editingFileBlocks[i].fileId}`,
`/api/experimental/chats/files/${editingFileBlocks[i].file_id}`,
]),
),
);
const newUploadStates = new Map<File, UploadState>();
for (const [i, file] of files.entries()) {
const block = editingFileBlocks[i];
if (block.fileId) {
if (block.file_id) {
newUploadStates.set(file, {
status: "uploaded",
fileId: block.fileId,
fileId: block.file_id,
});
}
}
@@ -3,7 +3,7 @@ import { withAuthProvider, withDashboardProvider } from "testHelpers/storybook";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { API } from "api/api";
import type * as TypesGen from "api/typesGenerated";
import type { ChatDiffStatus } from "api/typesGenerated";
import type { ChatDiffStatus, ChatMessagePart } from "api/typesGenerated";
import type { ModelSelectorOption } from "components/ai-elements";
import { fn, spyOn } from "storybook/test";
import { reactRouterParameters } from "storybook-addon-remix-react-router";
@@ -47,11 +47,7 @@ const defaultEditing = {
chatInputRef: { current: null },
editorInitialValue: "",
editingMessageId: null,
editingFileBlocks: [] as readonly {
mediaType: string;
data?: string;
fileId?: string;
}[],
editingFileBlocks: [] as readonly ChatMessagePart[],
handleEditUserMessage: fn(),
handleCancelHistoryEdit: fn(),
editingQueuedMessageID: null,
+4 -16
View File
@@ -1,5 +1,5 @@
import type * as TypesGen from "api/typesGenerated";
import type { ChatDiffStatus } from "api/typesGenerated";
import type { ChatDiffStatus, ChatMessagePart } from "api/typesGenerated";
import type { ModelSelectorOption } from "components/ai-elements";
import { ArchiveIcon } from "lucide-react";
import { type FC, type RefObject, useEffect, useRef, useState } from "react";
@@ -31,30 +31,18 @@ interface EditingState {
chatInputRef: RefObject<ChatMessageInputRef | null>;
editorInitialValue: string;
editingMessageId: number | null;
editingFileBlocks: readonly {
mediaType: string;
data?: string;
fileId?: string;
}[];
editingFileBlocks: readonly ChatMessagePart[];
handleEditUserMessage: (
messageId: number,
text: string,
fileBlocks?: readonly {
mediaType: string;
data?: string;
fileId?: string;
}[],
fileBlocks?: readonly ChatMessagePart[],
) => void;
handleCancelHistoryEdit: () => void;
editingQueuedMessageID: number | null;
handleStartQueueEdit: (
id: number,
text: string,
fileBlocks: readonly {
mediaType: string;
data?: string;
fileId?: string;
}[],
fileBlocks: readonly ChatMessagePart[],
) => void;
handleCancelQueueEdit: () => void;
handleSendFromInput: (message: string, fileIds?: string[]) => void;
@@ -163,7 +163,7 @@ export const EditPassesFileBlocks: Story = {
const editButton = canvas.getByRole("button", { name: "Edit" });
await userEvent.click(editButton);
expect(args.onEdit).toHaveBeenCalledWith(1, "Check this screenshot", [
{ mediaType: "image/png", fileId: "abc-123" },
{ type: "file", file_id: "abc-123", media_type: "image/png" },
]);
},
};
@@ -184,8 +184,8 @@ export const EditAttachmentOnlyMessage: Story = {
const editButton = canvas.getByRole("button", { name: "Edit" });
await userEvent.click(editButton);
expect(args.onEdit).toHaveBeenCalledWith(1, "", [
{ mediaType: "image/png", fileId: "img-1" },
{ mediaType: "image/jpeg", fileId: "img-2" },
{ type: "file", file_id: "img-1", media_type: "image/png" },
{ type: "file", file_id: "img-2", media_type: "image/jpeg" },
]);
},
};
@@ -38,21 +38,21 @@ describe("getQueuedMessageInfo", () => {
it("returns attachment label for a single file", () => {
const result = getQueuedMessageInfo(
makeMessage([{ type: "file", file_id: "a" }]),
makeMessage([{ type: "file", file_id: "a", media_type: "image/png" }]),
);
expect(result).toEqual({
displayText: "[Queued message]",
rawText: "",
attachmentCount: 1,
fileBlocks: [{ mediaType: "application/octet-stream", fileId: "a" }],
fileBlocks: [{ type: "file", file_id: "a", media_type: "image/png" }],
});
});
it("returns attachment label for multiple files", () => {
const result = getQueuedMessageInfo(
makeMessage([
{ type: "file", file_id: "a" },
{ type: "file", file_id: "b" },
{ type: "file", file_id: "a", media_type: "image/png" },
{ type: "file", file_id: "b", media_type: "image/png" },
]),
);
expect(result).toEqual({
@@ -60,8 +60,8 @@ describe("getQueuedMessageInfo", () => {
rawText: "",
attachmentCount: 2,
fileBlocks: [
{ mediaType: "application/octet-stream", fileId: "a" },
{ mediaType: "application/octet-stream", fileId: "b" },
{ type: "file", file_id: "a", media_type: "image/png" },
{ type: "file", file_id: "b", media_type: "image/png" },
],
});
});
@@ -70,14 +70,14 @@ describe("getQueuedMessageInfo", () => {
const result = getQueuedMessageInfo(
makeMessage([
{ type: "text", text: "look" },
{ type: "file", file_id: "a" },
{ type: "file", file_id: "a", media_type: "image/png" },
]),
);
expect(result).toEqual({
displayText: "look",
rawText: "look",
attachmentCount: 1,
fileBlocks: [{ mediaType: "application/octet-stream", fileId: "a" }],
fileBlocks: [{ type: "file", file_id: "a", media_type: "image/png" }],
});
});
@@ -107,14 +107,14 @@ describe("getQueuedMessageInfo", () => {
const result = getQueuedMessageInfo(
makeMessage([
{ type: "text", text: " " },
{ type: "file", file_id: "a" },
{ type: "file", file_id: "a", media_type: "image/png" },
]),
);
expect(result).toEqual({
displayText: "[Queued message]",
rawText: "",
attachmentCount: 1,
fileBlocks: [{ mediaType: "application/octet-stream", fileId: "a" }],
fileBlocks: [{ type: "file", file_id: "a", media_type: "image/png" }],
});
});
@@ -146,8 +146,8 @@ describe("getQueuedMessageInfo", () => {
rawText: "check this",
attachmentCount: 2,
fileBlocks: [
{ mediaType: "image/png", fileId: "img-1" },
{ mediaType: "application/pdf", fileId: "doc-2" },
{ type: "file", file_id: "img-1", media_type: "image/png" },
{ type: "file", file_id: "doc-2", media_type: "application/pdf" },
],
});
});
@@ -1,4 +1,4 @@
import type { ChatQueuedMessage } from "api/typesGenerated";
import type { ChatMessagePart, ChatQueuedMessage } from "api/typesGenerated";
import { Button } from "components/Button/Button";
import { Spinner } from "components/Spinner/Spinner";
import {
@@ -16,17 +16,15 @@ import {
import { type FC, useCallback, useEffect, useMemo, useState } from "react";
import { cn } from "utils/cn";
interface FileBlock {
mediaType: string;
data?: string;
fileId?: string;
}
interface QueuedMessagesListProps {
messages: readonly ChatQueuedMessage[];
onDelete: (id: number) => Promise<void> | void;
onPromote: (id: number) => Promise<void> | void;
onEdit?: (id: number, text: string, fileBlocks: readonly FileBlock[]) => void;
onEdit?: (
id: number,
text: string,
fileBlocks: readonly ChatMessagePart[],
) => void;
editingMessageID?: number | null;
className?: string;
}
@@ -35,20 +33,14 @@ interface QueuedMessageInfo {
displayText: string;
rawText: string;
attachmentCount: number;
fileBlocks: readonly FileBlock[];
fileBlocks: readonly ChatMessagePart[];
}
export const getQueuedMessageInfo = (
message: ChatQueuedMessage,
): QueuedMessageInfo => {
const { content } = message;
const fileBlocks: FileBlock[] = content
.filter((p) => p.type === "file")
.map((p) => ({
mediaType: p.media_type ?? "application/octet-stream",
fileId: p.file_id,
data: p.data,
}));
const fileBlocks = content.filter((p) => p.type === "file");
const rawText = content
.filter((p) => p.type === "text")
.map((p) => p.text)