mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
fix(site/src/pages/AgentsPage): fix chat image paste bugs and refactor queued message display (#22735)
handleSubmit (triggered via Enter key) didn't check isUploading, so messages could be sent while an image upload was still in progress. The send button was correctly disabled via canSend, but the keyboard shortcut bypassed that guard. QueuedMessagesList used untyped extraction helpers that fell through to JSON.stringify for attachment-only messages. Replace them with a single getQueuedMessageInfo function using typed ChatMessagePart access. Show an attachment badge (ImageIcon + count) for file parts, and use a consistent "[Queued message]" placeholder for all no-text situations. Editing a queued message with file attachments silently dropped all attachments because handleStartQueueEdit only accepted text. Thread file blocks from QueuedMessagesList through the edit callback into handleStartQueueEdit, which now calls setEditingFileBlocks. The existing useEffect in AgentDetailInput picks these up and populates the attachment UI. Also clear editingFileBlocks in handleCancelQueueEdit and handleSendFromInput.
This commit is contained in:
@@ -216,6 +216,34 @@ export const WithUploadingAttachment: Story = {
|
||||
})(),
|
||||
};
|
||||
|
||||
export const UploadingDisablesSend: Story = {
|
||||
args: (() => {
|
||||
const file = createMockFile("uploading.png", "image/png");
|
||||
return {
|
||||
attachments: [file],
|
||||
uploadStates: new Map<File, UploadState>([
|
||||
[file, { status: "uploading" }],
|
||||
]),
|
||||
previewUrls: new Map<File, string>([[file, TINY_PNG]]),
|
||||
onAttach: fn(),
|
||||
onRemoveAttachment: fn(),
|
||||
initialValue: "Message with uploading image",
|
||||
};
|
||||
})(),
|
||||
play: async ({ canvasElement, args }) => {
|
||||
const canvas = within(canvasElement);
|
||||
// Send should be disabled while an upload is still in progress,
|
||||
// even though the editor has text content.
|
||||
const sendButton = canvas.getByRole("button", { name: "Send" });
|
||||
expect(sendButton).toBeDisabled();
|
||||
// Enter key should not trigger send while uploading.
|
||||
const editor = canvas.getByRole("textbox");
|
||||
await userEvent.click(editor);
|
||||
await userEvent.keyboard("{Enter}");
|
||||
expect(args.onSend).not.toHaveBeenCalled();
|
||||
},
|
||||
};
|
||||
|
||||
export const WithAttachmentError: Story = {
|
||||
args: (() => {
|
||||
const file = createMockFile("broken.png", "image/png");
|
||||
|
||||
@@ -91,7 +91,15 @@ interface AgentChatInputProps {
|
||||
onPromoteQueuedMessage?: (id: number) => Promise<void> | void;
|
||||
// Queue editing state, owned by the parent.
|
||||
editingQueuedMessageID?: number | null;
|
||||
onStartQueueEdit?: (id: number, text: string) => void;
|
||||
onStartQueueEdit?: (
|
||||
id: number,
|
||||
text: string,
|
||||
fileBlocks: readonly {
|
||||
mediaType: string;
|
||||
data?: string;
|
||||
fileId?: string;
|
||||
}[],
|
||||
) => void;
|
||||
onCancelQueueEdit?: () => void;
|
||||
// History editing state, owned by the parent.
|
||||
isEditingHistoryMessage?: boolean;
|
||||
@@ -496,6 +504,7 @@ export const AgentChatInput = memo<AgentChatInputProps>(
|
||||
!hasFileReferences &&
|
||||
!isDisabled &&
|
||||
!isLoading &&
|
||||
!isUploading &&
|
||||
queuedMessages.length > 0 &&
|
||||
onPromoteQueuedMessage
|
||||
) {
|
||||
@@ -507,6 +516,7 @@ export const AgentChatInput = memo<AgentChatInputProps>(
|
||||
(!text && !hasUploadedAttachments && !hasFileReferences) ||
|
||||
isDisabled ||
|
||||
isLoading ||
|
||||
isUploading ||
|
||||
!hasModelOptions
|
||||
) {
|
||||
return;
|
||||
@@ -516,6 +526,7 @@ export const AgentChatInput = memo<AgentChatInputProps>(
|
||||
}, [
|
||||
isDisabled,
|
||||
isLoading,
|
||||
isUploading,
|
||||
hasModelOptions,
|
||||
hasUploadedAttachments,
|
||||
hasFileReferences,
|
||||
|
||||
@@ -143,13 +143,22 @@ export function useConversationEditingState(deps: {
|
||||
>(null);
|
||||
|
||||
const handleStartQueueEdit = useCallback(
|
||||
(id: number, text: string) => {
|
||||
(
|
||||
id: number,
|
||||
text: string,
|
||||
fileBlocks: readonly {
|
||||
mediaType: string;
|
||||
data?: string;
|
||||
fileId?: string;
|
||||
}[],
|
||||
) => {
|
||||
setDraftBeforeQueueEdit((prev) =>
|
||||
editingQueuedMessageID === null ? inputValueRef.current : prev,
|
||||
);
|
||||
setEditingQueuedMessageID(id);
|
||||
setEditorInitialValue(text);
|
||||
inputValueRef.current = text;
|
||||
setEditingFileBlocks(fileBlocks);
|
||||
},
|
||||
[editingQueuedMessageID, inputValueRef],
|
||||
);
|
||||
@@ -159,6 +168,7 @@ export function useConversationEditingState(deps: {
|
||||
inputValueRef.current = draftBeforeQueueEdit ?? "";
|
||||
setEditingQueuedMessageID(null);
|
||||
setDraftBeforeQueueEdit(null);
|
||||
setEditingFileBlocks([]);
|
||||
}, [draftBeforeQueueEdit, inputValueRef]);
|
||||
|
||||
// Wraps the parent onSend to clear local input/editing state
|
||||
@@ -185,6 +195,7 @@ export function useConversationEditingState(deps: {
|
||||
if (queueEditID !== null) {
|
||||
setEditingQueuedMessageID(null);
|
||||
setDraftBeforeQueueEdit(null);
|
||||
setEditingFileBlocks([]);
|
||||
void onDeleteQueuedMessage(queueEditID);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -166,7 +166,15 @@ interface AgentDetailInputProps {
|
||||
initialValue?: string;
|
||||
onContentChange?: (content: string) => void;
|
||||
editingQueuedMessageID: number | null;
|
||||
onStartQueueEdit: (id: number, text: string) => void;
|
||||
onStartQueueEdit: (
|
||||
id: number,
|
||||
text: string,
|
||||
fileBlocks: readonly {
|
||||
mediaType: string;
|
||||
data?: string;
|
||||
fileId?: string;
|
||||
}[],
|
||||
) => void;
|
||||
onCancelQueueEdit: () => void;
|
||||
isEditingHistoryMessage: boolean;
|
||||
onCancelHistoryEdit: () => void;
|
||||
|
||||
@@ -47,7 +47,15 @@ interface EditingState {
|
||||
) => void;
|
||||
handleCancelHistoryEdit: () => void;
|
||||
editingQueuedMessageID: number | null;
|
||||
handleStartQueueEdit: (id: number, text: string) => void;
|
||||
handleStartQueueEdit: (
|
||||
id: number,
|
||||
text: string,
|
||||
fileBlocks: readonly {
|
||||
mediaType: string;
|
||||
data?: string;
|
||||
fileId?: string;
|
||||
}[],
|
||||
) => void;
|
||||
handleCancelQueueEdit: () => void;
|
||||
handleSendFromInput: (message: string, fileIds?: string[]) => void;
|
||||
handleContentChange: (content: string) => void;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import type { ChatQueuedMessage } from "api/typesGenerated";
|
||||
import { fn } from "storybook/test";
|
||||
import { expect, fn, userEvent, within } from "storybook/test";
|
||||
import { QueuedMessagesList } from "./QueuedMessagesList";
|
||||
|
||||
// Helper to build a ChatQueuedMessage with minimal boilerplate.
|
||||
@@ -67,11 +67,10 @@ export const MixedContentTypes: Story = {
|
||||
messages: [
|
||||
// Typed text content.
|
||||
makeMessage(1, textContent("Plain text content")),
|
||||
// Legacy serialized payload in a text field.
|
||||
makeMessage(
|
||||
2,
|
||||
textContent('[{"type":"text","data":{"text":"legacy payload"}}]'),
|
||||
),
|
||||
// Attachment-only message falls back to the generic label.
|
||||
makeMessage(2, [
|
||||
{ type: "file", file_id: "img-1", media_type: "image/png" },
|
||||
] as ChatQueuedMessage["content"]),
|
||||
// Empty content falls back to the generic label.
|
||||
makeMessage(3, [] as ChatQueuedMessage["content"]),
|
||||
],
|
||||
@@ -101,3 +100,108 @@ export const LongMessageText: Story = {
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
// Multi-line text is truncated to the first line with an ellipsis appended.
|
||||
export const MultiLineTextTruncation: Story = {
|
||||
args: {
|
||||
messages: [
|
||||
makeMessage(
|
||||
1,
|
||||
textContent(
|
||||
"First line of the message\nSecond line that should be hidden",
|
||||
),
|
||||
),
|
||||
],
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
// The first line and ellipsis should be visible in the same span.
|
||||
const textSpan = canvas.getByText(/First line of the message…/);
|
||||
expect(textSpan).toBeInTheDocument();
|
||||
// The second line should not appear anywhere.
|
||||
expect(canvas.queryByText(/Second line/)).not.toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
// A message with both text and a file attachment shows the ImageIcon badge.
|
||||
export const WithAttachments: Story = {
|
||||
args: {
|
||||
messages: [
|
||||
makeMessage(1, [
|
||||
{ type: "text", text: "Check this screenshot" },
|
||||
{ type: "file", file_id: "abc-123", media_type: "image/png" },
|
||||
] as ChatQueuedMessage["content"]),
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
// A message with only file attachments and no text displays a count label.
|
||||
export const AttachmentsOnly: Story = {
|
||||
args: {
|
||||
messages: [
|
||||
makeMessage(1, [
|
||||
{ type: "file", file_id: "img-1", media_type: "image/png" },
|
||||
{ type: "file", file_id: "img-2", media_type: "image/jpeg" },
|
||||
] as ChatQueuedMessage["content"]),
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
// Clicking Edit on a message with attachments passes file blocks to onEdit.
|
||||
export const EditPassesFileBlocks: Story = {
|
||||
args: {
|
||||
onEdit: fn(),
|
||||
messages: [
|
||||
makeMessage(1, [
|
||||
{ type: "text", text: "Check this screenshot" },
|
||||
{ type: "file", file_id: "abc-123", media_type: "image/png" },
|
||||
] as ChatQueuedMessage["content"]),
|
||||
],
|
||||
},
|
||||
play: async ({ canvasElement, args }) => {
|
||||
const canvas = within(canvasElement);
|
||||
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" },
|
||||
]);
|
||||
},
|
||||
};
|
||||
|
||||
// Clicking Edit on an attachment-only message passes file blocks with empty text.
|
||||
export const EditAttachmentOnlyMessage: Story = {
|
||||
args: {
|
||||
onEdit: fn(),
|
||||
messages: [
|
||||
makeMessage(1, [
|
||||
{ type: "file", file_id: "img-1", media_type: "image/png" },
|
||||
{ type: "file", file_id: "img-2", media_type: "image/jpeg" },
|
||||
] as ChatQueuedMessage["content"]),
|
||||
],
|
||||
},
|
||||
play: async ({ canvasElement, args }) => {
|
||||
const canvas = within(canvasElement);
|
||||
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" },
|
||||
]);
|
||||
},
|
||||
};
|
||||
|
||||
// A mixed queue with text-only, text+attachment, and attachment-only messages.
|
||||
export const MixedQueueWithAttachments: Story = {
|
||||
args: {
|
||||
messages: [
|
||||
makeMessage(1, textContent("Run the linter")),
|
||||
makeMessage(2, [
|
||||
{ type: "text", text: "Fix this layout bug" },
|
||||
{ type: "file", file_id: "img-a", media_type: "image/png" },
|
||||
] as ChatQueuedMessage["content"]),
|
||||
makeMessage(3, [
|
||||
{ type: "file", file_id: "img-b", media_type: "image/png" },
|
||||
] as ChatQueuedMessage["content"]),
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import type { ChatQueuedMessage } from "api/typesGenerated";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getQueuedMessageInfo } from "./QueuedMessagesList";
|
||||
|
||||
const makeMessage = (
|
||||
content: ChatQueuedMessage["content"],
|
||||
): ChatQueuedMessage => ({
|
||||
id: 1,
|
||||
chat_id: "c",
|
||||
content,
|
||||
created_at: "",
|
||||
});
|
||||
|
||||
describe("getQueuedMessageInfo", () => {
|
||||
it("returns text for a text-only message", () => {
|
||||
const result = getQueuedMessageInfo(
|
||||
makeMessage([{ type: "text", text: "hello" }]),
|
||||
);
|
||||
expect(result).toEqual({
|
||||
displayText: "hello",
|
||||
rawText: "hello",
|
||||
attachmentCount: 0,
|
||||
fileBlocks: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves multi-line text", () => {
|
||||
const result = getQueuedMessageInfo(
|
||||
makeMessage([{ type: "text", text: "line1\nline2" }]),
|
||||
);
|
||||
expect(result).toEqual({
|
||||
displayText: "line1\nline2",
|
||||
rawText: "line1\nline2",
|
||||
attachmentCount: 0,
|
||||
fileBlocks: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("returns attachment label for a single file", () => {
|
||||
const result = getQueuedMessageInfo(
|
||||
makeMessage([{ type: "file", file_id: "a" }]),
|
||||
);
|
||||
expect(result).toEqual({
|
||||
displayText: "[Queued message]",
|
||||
rawText: "",
|
||||
attachmentCount: 1,
|
||||
fileBlocks: [{ mediaType: "application/octet-stream", fileId: "a" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("returns attachment label for multiple files", () => {
|
||||
const result = getQueuedMessageInfo(
|
||||
makeMessage([
|
||||
{ type: "file", file_id: "a" },
|
||||
{ type: "file", file_id: "b" },
|
||||
]),
|
||||
);
|
||||
expect(result).toEqual({
|
||||
displayText: "[Queued message]",
|
||||
rawText: "",
|
||||
attachmentCount: 2,
|
||||
fileBlocks: [
|
||||
{ mediaType: "application/octet-stream", fileId: "a" },
|
||||
{ mediaType: "application/octet-stream", fileId: "b" },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("returns text with attachment count for text + file", () => {
|
||||
const result = getQueuedMessageInfo(
|
||||
makeMessage([
|
||||
{ type: "text", text: "look" },
|
||||
{ type: "file", file_id: "a" },
|
||||
]),
|
||||
);
|
||||
expect(result).toEqual({
|
||||
displayText: "look",
|
||||
rawText: "look",
|
||||
attachmentCount: 1,
|
||||
fileBlocks: [{ mediaType: "application/octet-stream", fileId: "a" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("returns fallback for empty content", () => {
|
||||
const result = getQueuedMessageInfo(makeMessage([]));
|
||||
expect(result).toEqual({
|
||||
displayText: "[Queued message]",
|
||||
rawText: "",
|
||||
attachmentCount: 0,
|
||||
fileBlocks: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("returns fallback for whitespace-only text", () => {
|
||||
const result = getQueuedMessageInfo(
|
||||
makeMessage([{ type: "text", text: " " }]),
|
||||
);
|
||||
expect(result).toEqual({
|
||||
displayText: "[Queued message]",
|
||||
rawText: "",
|
||||
attachmentCount: 0,
|
||||
fileBlocks: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("returns attachment label for whitespace text + file", () => {
|
||||
const result = getQueuedMessageInfo(
|
||||
makeMessage([
|
||||
{ type: "text", text: " " },
|
||||
{ type: "file", file_id: "a" },
|
||||
]),
|
||||
);
|
||||
expect(result).toEqual({
|
||||
displayText: "[Queued message]",
|
||||
rawText: "",
|
||||
attachmentCount: 1,
|
||||
fileBlocks: [{ mediaType: "application/octet-stream", fileId: "a" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("joins multiple text parts with a space", () => {
|
||||
const result = getQueuedMessageInfo(
|
||||
makeMessage([
|
||||
{ type: "text", text: "a" },
|
||||
{ type: "text", text: "b" },
|
||||
]),
|
||||
);
|
||||
expect(result).toEqual({
|
||||
displayText: "a b",
|
||||
rawText: "a b",
|
||||
attachmentCount: 0,
|
||||
fileBlocks: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves media_type from file parts", () => {
|
||||
const result = getQueuedMessageInfo(
|
||||
makeMessage([
|
||||
{ type: "text", text: "check this" },
|
||||
{ type: "file", file_id: "img-1", media_type: "image/png" },
|
||||
{ type: "file", file_id: "doc-2", media_type: "application/pdf" },
|
||||
]),
|
||||
);
|
||||
expect(result).toEqual({
|
||||
displayText: "check this",
|
||||
rawText: "check this",
|
||||
attachmentCount: 2,
|
||||
fileBlocks: [
|
||||
{ mediaType: "image/png", fileId: "img-1" },
|
||||
{ mediaType: "application/pdf", fileId: "doc-2" },
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -9,101 +9,67 @@ import {
|
||||
import {
|
||||
ArrowUpIcon,
|
||||
CornerDownLeftIcon,
|
||||
ImageIcon,
|
||||
PencilIcon,
|
||||
Trash2Icon,
|
||||
} from "lucide-react";
|
||||
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) => void;
|
||||
onEdit?: (id: number, text: string, fileBlocks: readonly FileBlock[]) => void;
|
||||
editingMessageID?: number | null;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const asRecord = (value: unknown): Record<string, unknown> | undefined => {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return undefined;
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
};
|
||||
interface QueuedMessageInfo {
|
||||
displayText: string;
|
||||
rawText: string;
|
||||
attachmentCount: number;
|
||||
fileBlocks: readonly FileBlock[];
|
||||
}
|
||||
|
||||
const extractBlockText = (value: unknown): string | undefined => {
|
||||
if (typeof value === "string") {
|
||||
return value;
|
||||
}
|
||||
const record = asRecord(value);
|
||||
if (!record) {
|
||||
return undefined;
|
||||
}
|
||||
if (typeof record.text === "string") {
|
||||
return record.text;
|
||||
}
|
||||
const data = asRecord(record.data);
|
||||
if (data && typeof data.text === "string") {
|
||||
return data.text;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
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 rawText = content
|
||||
.filter((p) => p.type === "text")
|
||||
.map((p) => p.text)
|
||||
.filter((t): t is string => Boolean(t?.trim()))
|
||||
.join(" ")
|
||||
.trim();
|
||||
|
||||
const extractQueuedContentText = (value: unknown): string => {
|
||||
if (typeof value === "string") {
|
||||
const trimmed = value.trim();
|
||||
if (trimmed === "") {
|
||||
return "";
|
||||
}
|
||||
if (trimmed.startsWith("[") || trimmed.startsWith("{")) {
|
||||
try {
|
||||
return extractQueuedContentText(JSON.parse(trimmed));
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return value;
|
||||
if (rawText) {
|
||||
return {
|
||||
displayText: rawText,
|
||||
rawText,
|
||||
attachmentCount: fileBlocks.length,
|
||||
fileBlocks,
|
||||
};
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
const texts = value
|
||||
.map(extractBlockText)
|
||||
.filter((text): text is string => Boolean(text?.trim()));
|
||||
if (texts.length > 0) {
|
||||
return texts.join(" ");
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(value);
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
const record = asRecord(value);
|
||||
if (record) {
|
||||
const text = extractBlockText(record);
|
||||
if (text?.trim()) {
|
||||
return text;
|
||||
}
|
||||
if ("content" in record) {
|
||||
const nested = extractQueuedContentText(record.content);
|
||||
if (nested.trim()) {
|
||||
return nested;
|
||||
}
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(record);
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
return "";
|
||||
};
|
||||
|
||||
const getQueuedMessageText = (message: ChatQueuedMessage): string => {
|
||||
const text = extractQueuedContentText(message.content).trim();
|
||||
return text || "Queued message";
|
||||
return {
|
||||
displayText: "[Queued message]",
|
||||
rawText: "",
|
||||
attachmentCount: fileBlocks.length,
|
||||
fileBlocks,
|
||||
};
|
||||
};
|
||||
|
||||
export const QueuedMessagesList: FC<QueuedMessagesListProps> = ({
|
||||
@@ -116,10 +82,17 @@ export const QueuedMessagesList: FC<QueuedMessagesListProps> = ({
|
||||
}) => {
|
||||
const items = useMemo(
|
||||
() =>
|
||||
messages.map((message) => ({
|
||||
id: message.id,
|
||||
text: getQueuedMessageText(message),
|
||||
})),
|
||||
messages.map((message) => {
|
||||
const { displayText, rawText, attachmentCount, fileBlocks } =
|
||||
getQueuedMessageInfo(message);
|
||||
return {
|
||||
id: message.id,
|
||||
displayText,
|
||||
rawText,
|
||||
attachmentCount,
|
||||
fileBlocks,
|
||||
};
|
||||
}),
|
||||
[messages],
|
||||
);
|
||||
|
||||
@@ -240,9 +213,19 @@ export const QueuedMessagesList: FC<QueuedMessagesListProps> = ({
|
||||
>
|
||||
<div className="flex items-center gap-2 rounded-lg border border-solid border-border-default bg-surface-secondary px-3 py-2 font-sans text-sm leading-relaxed text-content-primary shadow-sm">
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{item.text.split("\n")[0]}
|
||||
{item.text.includes("\n") ? "…" : ""}
|
||||
{item.displayText.split("\n")[0]}
|
||||
{item.displayText.includes("\n") ? "…" : ""}
|
||||
</span>
|
||||
{item.attachmentCount > 0 && (
|
||||
<span
|
||||
role="img"
|
||||
aria-label={`${item.attachmentCount} image attachment${item.attachmentCount !== 1 ? "s" : ""}`}
|
||||
className="flex shrink-0 items-center gap-1 text-xs text-content-secondary"
|
||||
>
|
||||
<ImageIcon className="h-3 w-3" aria-hidden="true" />
|
||||
<span aria-hidden="true">{item.attachmentCount}</span>
|
||||
</span>
|
||||
)}
|
||||
{isFirst && (
|
||||
<span
|
||||
className={cn(
|
||||
@@ -268,7 +251,9 @@ export const QueuedMessagesList: FC<QueuedMessagesListProps> = ({
|
||||
size="icon"
|
||||
aria-label="Edit"
|
||||
disabled={isBusy}
|
||||
onClick={() => onEdit(item.id, item.text)}
|
||||
onClick={() =>
|
||||
onEdit(item.id, item.rawText, item.fileBlocks)
|
||||
}
|
||||
className="size-6 rounded text-content-secondary hover:bg-surface-tertiary hover:text-content-primary"
|
||||
>
|
||||
<PencilIcon className="h-3.5 w-3.5" />
|
||||
|
||||
Reference in New Issue
Block a user