fix(site/src/pages/AgentsPage): disallow queued message edits (#28265)

Closes CODAGT-230.

Selecting "Edit queued message" in Agent Chat did not stop the queued
message from sending: edit mode was purely client-side state, so at turn
end the backend promoted the queued message and sent it anyway, leaving
the UI stuck in a stale "Editing queued message" state with the send
button showing "Save".

This removes queued-message editing entirely instead of adding backend
locking. Queued messages can still be promoted ("Send now") and deleted
("Remove from queue"); editing already-sent history messages is
unchanged.

## Changes

- Remove the Edit pencil from queued message rows
(`QueuedMessagesList`), the "Editing queued message" banner and "Save"
button label (`AgentChatInput`), and the queue-edit state, handlers, and
prop plumbing (`AgentChatPage`, `AgentChatPageView`, `ChatPageContent`).
- Remove the queued-edit save flow that deleted the original queued row
and re-queued a new message, plus the now-unused `rawText`/`fileBlocks`
fields of `getQueuedMessageInfo`.
- Drop the redundant `isEditingHistoryMessage` prop on `ChatPageInput`,
which had become identical to `isEditing`, and stop discarding the
delete/promote promises so `QueuedMessagesList` busy state works again.
- Replace the queued-edit stories and tests with an `ActionsExcludeEdit`
regression story asserting queued rows expose "Send now" and "Remove
from queue" but no "Edit" button.

## Testing

- `pnpm -C site check`, `pnpm -C site lint:types`.
- Unit: `AgentChatPage.test.ts`, `QueuedMessagesList.test.ts` (92
tests). Storybook: all four touched story files (102 tests). A
pre-existing unhandled xterm error in `AgentChatPageView.stories.tsx`
reproduces identically at the merge base.
- Remote dogfood UAT passed on a dev.coder.com workspace with a real
model: no edit affordance on queued rows, queued delete and turn-end
promotion still work, history-message editing and Escape behavior
unaffected.

> Mux acted on behalf of Mike for this pull request.
This commit is contained in:
Michael Suchacz
2026-08-18 19:34:40 +02:00
committed by GitHub
parent affeeaf9c8
commit ba5717dc67
11 changed files with 73 additions and 463 deletions
@@ -2944,26 +2944,6 @@ const compactCommandMessages: TypesGen.ChatMessagesResponse = {
has_more: false,
};
const compactQueuedEditChat: TypesGen.Chat = {
id: CHAT_ID,
...baseChatFields,
title: "Compact queued edit",
status: "running",
};
const compactQueuedEditMessages: TypesGen.ChatMessagesResponse = {
messages: compactCommandMessages.messages,
queued_messages: [
{
...MockChatQueuedMessage,
id: 3,
chat_id: CHAT_ID,
content: [{ type: "text", text: "Queued follow-up" }],
},
],
has_more: false,
};
/** Submitting "/compact" alone requests a manual compaction instead of
* sending a chat message. */
export const SlashCompactCommandSubmits: Story = {
@@ -3013,61 +2993,6 @@ export const SlashCompactCommandSubmits: Story = {
},
};
export const SlashCompactQueuedEditSaves: Story = {
parameters: {
queries: buildQueries(compactQueuedEditChat, compactQueuedEditMessages, {
diffUrl: undefined,
}),
},
beforeEach: () => {
spyOn(API.experimental, "getUserSkills").mockResolvedValue([]);
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const compactSpy = spyOn(API.experimental, "compactChat");
const sendSpy = spyOn(
API.experimental,
"createChatMessage",
).mockResolvedValue({
queued: true,
queued_message: {
...MockChatQueuedMessage,
id: 4,
chat_id: CHAT_ID,
content: [{ type: "text", text: "/compact" }],
},
});
const deleteSpy = spyOn(
API.experimental,
"deleteChatQueuedMessage",
).mockResolvedValue();
spyOn(API.experimental, "getChat").mockResolvedValue(compactQueuedEditChat);
spyOn(API.experimental, "getChatMessages").mockResolvedValue({
...compactQueuedEditMessages,
queued_messages: [],
});
await userEvent.click(await canvas.findByRole("button", { name: "Edit" }));
const editor = await canvas.findByTestId("chat-message-input");
await userEvent.clear(editor);
await userEvent.type(editor, "/compact");
await userEvent.click(canvas.getByRole("button", { name: "Save" }));
await waitFor(() => {
expect(sendSpy).toHaveBeenCalledTimes(1);
expect(deleteSpy).toHaveBeenCalledTimes(1);
});
expect(sendSpy).toHaveBeenCalledWith(
CHAT_ID,
expect.objectContaining({
content: [{ type: "text", text: "/compact" }],
}),
);
expect(deleteSpy).toHaveBeenCalledWith(CHAT_ID, 3);
expect(compactSpy).not.toHaveBeenCalled();
},
};
/** A personal skill named "compact" takes precedence: "/compact" is sent
* as a normal message (skill trigger) and no compaction is requested. */
export const SlashCompactYieldsToPersonalSkill: Story = {
@@ -585,7 +585,6 @@ describe("useConversationEditingState", () => {
const renderEditing = (...args: [] | [string | undefined]) => {
const onSend = vi.fn().mockResolvedValue(undefined);
const onDeleteQueuedMessage = vi.fn().mockResolvedValue(undefined);
const chatInputRef = createRef<ChatMessageInputRef>();
const inputValueRef = { current: "" };
// createRef returns { current: null }, but we need it initialized
@@ -598,7 +597,6 @@ describe("useConversationEditingState", () => {
useConversationEditingState({
chatID: resolvedChatID,
onSend,
onDeleteQueuedMessage,
chatInputRef,
inputValueRef,
}),
@@ -688,40 +686,6 @@ describe("useConversationEditingState", () => {
unmount();
});
it("loads queue edit text into the composer and restores the prior draft on cancel without refocusing", () => {
const { result, unmount } = renderEditing();
// Simulate the user typing a draft via handleContentChange.
act(() => {
result.current.handleContentChange(
"work in progress",
"work in progress",
false,
);
});
const remountKeyBefore = result.current.remountKey;
act(() => {
result.current.handleStartQueueEdit(9, "queued message", []);
});
expect(result.current.editingQueuedMessageID).toBe(9);
expect(result.current.editorInitialValue).toBe("queued message");
expect(result.current.remountKey).toBe(remountKeyBefore + 1);
const remountKeyAfterEdit = result.current.remountKey;
act(() => {
result.current.handleCancelQueueEdit();
});
expect(result.current.editingQueuedMessageID).toBeNull();
expect(result.current.editorInitialValue).toBe("work in progress");
expect(result.current.remountKey).toBe(remountKeyAfterEdit + 1);
unmount();
});
it("does not force focus when replacing input values on mobile", () => {
setMobileViewport(true);
const { result, unmount } = renderEditing();
@@ -741,16 +705,6 @@ describe("useConversationEditingState", () => {
result.current.handleCancelHistoryEdit();
});
expect(mockInput.focus).not.toHaveBeenCalled();
act(() => {
result.current.handleStartQueueEdit(9, "queued message", []);
});
expect(mockInput.focus).not.toHaveBeenCalled();
act(() => {
result.current.handleCancelQueueEdit();
});
expect(mockInput.focus).not.toHaveBeenCalled();
unmount();
});
@@ -772,22 +726,6 @@ describe("useConversationEditingState", () => {
unmount();
});
it("falls back to the persisted draft when queue edit starts before hydration", () => {
localStorage.setItem(expectedKey, "persisted draft");
const { result, unmount } = renderEditing();
act(() => {
result.current.handleStartQueueEdit(9, "queued message", []);
});
act(() => {
result.current.handleCancelQueueEdit();
});
expect(result.current.editorInitialValue).toBe("persisted draft");
unmount();
});
it("prefers the live editor value over stale persisted draft state", () => {
localStorage.setItem(expectedKey, "stale persisted draft");
const { result, unmount } = renderEditing();
@@ -1181,43 +1119,6 @@ describe("useConversationEditingState", () => {
unmount();
});
it("preserves serialized editor state across queue edit then cancel", () => {
const editorState = JSON.stringify({
root: {
children: [
{
children: [{ text: "queued draft", type: "text" }],
type: "paragraph",
},
],
type: "root",
},
});
localStorage.setItem(expectedKey, editorState);
const { result, unmount } = renderEditing();
act(() => {
result.current.handleContentChange("queued draft", editorState, false);
});
act(() => {
result.current.handleStartQueueEdit(99, "queued msg", []);
});
expect(result.current.editingQueuedMessageID).toBe(99);
expect(result.current.initialEditorState).toBeUndefined();
act(() => {
result.current.handleCancelQueueEdit();
});
expect(result.current.editingQueuedMessageID).toBeNull();
expect(result.current.initialEditorState).toBe(editorState);
expect(result.current.editorInitialValue).toBe("queued draft");
unmount();
});
it("returns undefined initialEditorState after edit then cancel with plain-text draft", () => {
localStorage.setItem(expectedKey, "plain text draft");
+9 -76
View File
@@ -509,12 +509,10 @@ export function useConversationEditingState(deps: {
attachments?: readonly PendingAttachment[],
editedMessageID?: number,
) => Promise<void>;
onDeleteQueuedMessage: (id: number) => Promise<void>;
chatInputRef: React.RefObject<ChatMessageInputRef | null>;
inputValueRef: React.RefObject<string>;
}) {
const { chatID, onSend, onDeleteQueuedMessage, chatInputRef, inputValueRef } =
deps;
const { chatID, onSend, chatInputRef, inputValueRef } = deps;
const draftStorageKey = chatID
? `${draftInputStorageKeyPrefix}${chatID}`
: null;
@@ -600,53 +598,6 @@ export function useConversationEditingState(deps: {
setEditingFileBlocks([]);
};
// -- Queue editing state --
const [editingQueuedMessageID, setEditingQueuedMessageID] = useState<
number | null
>(null);
const [draftBeforeQueueEdit, setDraftBeforeQueueEdit] =
useState<ParsedDraft | null>(null);
const handleStartQueueEdit = (
id: number,
text: string,
fileBlocks: readonly ChatMessagePart[],
) => {
if (editingQueuedMessageID === null) {
const currentEditorState = draftStorageKey
? parseStoredDraft(localStorage.getItem(draftStorageKey)).editorState
: undefined;
setDraftBeforeQueueEdit({
text: inputValueRef.current,
editorState: currentEditorState,
});
}
setEditingQueuedMessageID(id);
setDraftState({
editorInitialValue: text,
initialEditorState: undefined,
});
serializedEditorStateRef.current = undefined;
setRemountKey((k) => k + 1);
inputValueRef.current = text;
setEditingFileBlocks(fileBlocks);
};
const handleCancelQueueEdit = () => {
const savedText = draftBeforeQueueEdit?.text ?? "";
const savedState = draftBeforeQueueEdit?.editorState;
setDraftState({
editorInitialValue: savedText,
initialEditorState: savedState,
});
serializedEditorStateRef.current = savedState;
setRemountKey((k) => k + 1);
inputValueRef.current = savedText;
setEditingQueuedMessageID(null);
setDraftBeforeQueueEdit(null);
setEditingFileBlocks([]);
};
// Clears the composer for an in-flight history edit and
// returns a rollback function that restores the editing draft
// if the send fails.
@@ -675,10 +626,7 @@ export function useConversationEditingState(deps: {
};
// Clears all input and editing state after a successful send.
const finalizeSuccessfulSend = (
editedMessageID: number | undefined,
queueEditID: number | null,
) => {
const finalizeSuccessfulSend = (editedMessageID: number | undefined) => {
chatInputRef.current?.clear();
if (!isMobileViewport()) {
chatInputRef.current?.focus();
@@ -692,23 +640,15 @@ export function useConversationEditingState(deps: {
setDraftBeforeHistoryEdit(null);
setEditingFileBlocks([]);
}
if (queueEditID !== null) {
setEditingQueuedMessageID(null);
setDraftBeforeQueueEdit(null);
setEditingFileBlocks([]);
void onDeleteQueuedMessage(queueEditID);
}
};
// Wraps the parent onSend to clear local input/editing state
// and handle queue-edit deletion.
// Wraps the parent onSend to clear local input/editing state.
const handleSendFromInput = async (
message: string,
attachments?: readonly PendingAttachment[],
) => {
const editedMessageID =
editingMessageId !== null ? editingMessageId : undefined;
const queueEditID = editingQueuedMessageID;
const sendPromise = onSend(message, attachments, editedMessageID);
// For history edits, clear input immediately and prepare
@@ -728,7 +668,7 @@ export function useConversationEditingState(deps: {
throw error;
}
finalizeSuccessfulSend(editedMessageID, queueEditID);
finalizeSuccessfulSend(editedMessageID);
};
const handleContentChange = (
@@ -739,11 +679,9 @@ export function useConversationEditingState(deps: {
inputValueRef.current = content;
serializedEditorStateRef.current = serializedEditorState;
// Don't overwrite the persisted draft while editing a
// history or queued message, the original draft (possibly
// containing file-reference chips) is saved in React state
// and should survive a cancel.
if (editingMessageId !== null || editingQueuedMessageID !== null) {
// Don't overwrite the persisted draft while editing a history message.
// The original draft is saved in React state and should survive a cancel.
if (editingMessageId !== null) {
return;
}
@@ -786,9 +724,6 @@ export function useConversationEditingState(deps: {
editingFileBlocks,
handleEditUserMessage,
handleCancelHistoryEdit,
editingQueuedMessageID,
handleStartQueueEdit,
handleCancelQueueEdit,
handleSendFromInput,
handleContentChange,
handleLoadingDraftChange,
@@ -1501,7 +1436,6 @@ const AgentChatPage: FC = () => {
const editing = useConversationEditingState({
chatID: agentId,
onSend: handleSend,
onDeleteQueuedMessage: handleDeleteQueuedMessage,
chatInputRef,
inputValueRef,
});
@@ -1690,12 +1624,11 @@ const AgentChatPage: FC = () => {
// "/compact" on its own (no attachments or file references)
// requests a manual context compaction instead of sending a
// message. Only new sends are intercepted; history and queued
// edits keep their original meaning, and a personal or workspace
// message. Only new sends are intercepted; history edits keep their
// original meaning, and a personal or workspace
// skill named "compact" takes precedence so the command cannot shadow it.
const isExactCompactSubmission =
editedMessageID === undefined &&
editing.editingQueuedMessageID === null &&
content.length === 1 &&
content[0].type === "text" &&
content[0].text?.trim() ===
@@ -86,9 +86,6 @@ const buildEditing = (
editingFileBlocks: [] as readonly ChatMessagePart[],
handleEditUserMessage: fn(),
handleCancelHistoryEdit: fn(),
editingQueuedMessageID: null,
handleStartQueueEdit: fn(),
handleCancelQueueEdit: fn(),
handleSendFromInput: fn(),
handleContentChange: fn(),
...overrides,
@@ -96,13 +96,6 @@ interface EditingState {
fileBlocks?: readonly ChatMessagePart[],
) => void;
handleCancelHistoryEdit: () => void;
editingQueuedMessageID: number | null;
handleStartQueueEdit: (
id: number,
text: string,
fileBlocks: readonly ChatMessagePart[],
) => void;
handleCancelQueueEdit: () => void;
handleSendFromInput: (
message: string,
attachments?: readonly PendingAttachment[],
@@ -824,9 +817,7 @@ export const AgentChatPageView: FC<AgentChatPageViewProps> = ({
};
});
const isEditing =
editing.editingMessageId !== null ||
editing.editingQueuedMessageID !== null;
const isEditing = editing.editingMessageId !== null;
const chatOwnerUsername = chatOwner?.username?.trim();
const chatOwnerLabel =
@@ -1011,10 +1002,6 @@ export const AgentChatPageView: FC<AgentChatPageViewProps> = ({
remountKey={editing.remountKey}
onContentChange={editing.handleContentChange}
isEditing={isEditing}
editingQueuedMessageID={editing.editingQueuedMessageID}
onStartQueueEdit={editing.handleStartQueueEdit}
onCancelQueueEdit={editing.handleCancelQueueEdit}
isEditingHistoryMessage={editing.editingMessageId !== null}
onCancelHistoryEdit={editing.handleCancelHistoryEdit}
editingFileBlocks={editing.editingFileBlocks}
mcpServers={mcpServers}
@@ -29,7 +29,6 @@ import { disconnectMCPServerOAuth2 } from "#/api/queries/chats";
import type * as TypesGen from "#/api/typesGenerated";
import type {
AgentChatSendShortcut,
ChatMessagePart,
ChatQueuedMessage,
} from "#/api/typesGenerated";
import { Alert, AlertDescription } from "#/components/Alert/Alert";
@@ -153,14 +152,6 @@ interface AgentChatInputProps {
queuedMessages?: readonly ChatQueuedMessage[];
onDeleteQueuedMessage?: (id: number) => Promise<void> | void;
onPromoteQueuedMessage?: (id: number) => Promise<void> | void;
// Queue editing state, owned by the parent.
editingQueuedMessageID?: number | null;
onStartQueueEdit?: (
id: number,
text: string,
fileBlocks: readonly ChatMessagePart[],
) => void;
onCancelQueueEdit?: () => void;
// History editing state, owned by the parent.
isEditingHistoryMessage?: boolean;
onCancelHistoryEdit?: () => void;
@@ -384,9 +375,6 @@ export const AgentChatInput: FC<AgentChatInputProps> = ({
queuedMessages = [],
onDeleteQueuedMessage,
onPromoteQueuedMessage,
editingQueuedMessageID = null,
onStartQueueEdit,
onCancelQueueEdit,
isEditingHistoryMessage = false,
onCancelHistoryEdit,
userPromptHistory = [],
@@ -988,10 +976,7 @@ export const AgentChatInput: FC<AgentChatInputProps> = ({
const handleComposerKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Escape") {
if (editingQueuedMessageID !== null) {
e.preventDefault();
onCancelQueueEdit?.();
} else if (isEditingHistoryMessage) {
if (isEditingHistoryMessage) {
e.preventDefault();
onCancelHistoryEdit?.();
} else if (isStreaming && onInterrupt && !isInterruptPending) {
@@ -1020,10 +1005,7 @@ export const AgentChatInput: FC<AgentChatInputProps> = ({
// streaming so the user can prepare the next prompt. Escape is
// cycle-aware so it does not accidentally interrupt streaming.
const isPromptCyclingSuppressed =
editingQueuedMessageID !== null ||
isEditingHistoryMessage ||
isDisabled ||
isLoading;
isEditingHistoryMessage || isDisabled || isLoading;
if (isPromptCyclingSuppressed) {
return;
}
@@ -1086,12 +1068,7 @@ export const AgentChatInput: FC<AgentChatInputProps> = ({
applyCycleValue(nextPrompt);
};
const sendButtonLabel =
editingQueuedMessageID !== null
? "Save"
: isEditingHistoryMessage
? "Save Edit"
: "Send";
const sendButtonLabel = isEditingHistoryMessage ? "Save Edit" : "Send";
const sendShortcutLabel =
sendShortcut === MODIFIER_AGENT_CHAT_SEND_SHORTCUT
? "Cmd/Ctrl+Enter"
@@ -1112,20 +1089,8 @@ export const AgentChatInput: FC<AgentChatInputProps> = ({
{queuedMessages.length > 0 && (
<QueuedMessagesList
messages={queuedMessages}
onDelete={(id) => {
if (id === editingQueuedMessageID) {
onCancelQueueEdit?.();
}
void onDeleteQueuedMessage?.(id);
}}
onPromote={(id) => {
if (id === editingQueuedMessageID) {
onCancelQueueEdit?.();
}
void onPromoteQueuedMessage?.(id);
}}
onEdit={onStartQueueEdit}
editingMessageID={editingQueuedMessageID}
onDelete={(id) => onDeleteQueuedMessage?.(id)}
onPromote={(id) => onPromoteQueuedMessage?.(id)}
className="mb-2"
/>
)}
@@ -1167,23 +1132,7 @@ export const AgentChatInput: FC<AgentChatInputProps> = ({
onDragLeave={onAttach ? handleDragLeave : undefined}
onDrop={onAttach ? handleDrop : undefined}
>
{editingQueuedMessageID !== null && (
<div className="flex items-center justify-between border-b border-border-default/70 bg-surface-primary/25 px-3 py-1.5">
<span className="text-sm text-content-secondary">
Editing queued message
</span>
<Button
type="button"
variant="subtle"
size="sm"
onClick={onCancelQueueEdit}
className="h-7 px-2 text-content-secondary hover:text-content-primary"
>
Cancel
</Button>
</div>
)}
{isEditingHistoryMessage && editingQueuedMessageID === null && (
{isEditingHistoryMessage && (
<div className="flex items-center justify-between border-b border-border-default/70 px-3 py-1.5">
<span className="flex items-center gap-1.5 text-xs font-medium text-content-warning">
<PencilIcon className="size-3.5" />
@@ -1670,7 +1619,7 @@ export const AgentChatInput: FC<AgentChatInputProps> = ({
Interrupting. Waiting for the agent to stop.
</span>
)}
{!(isStreaming && editingQueuedMessageID === null) && (
{!isStreaming && (
<Tooltip>
<TooltipTrigger asChild>
<Button
@@ -69,10 +69,6 @@ const StoryChatPageInput: FC<{
modelSelectorPlaceholder="Select model"
canConfigureAgentSetup={false}
isEditing={false}
editingQueuedMessageID={null}
onStartQueueEdit={fn()}
onCancelQueueEdit={fn()}
isEditingHistoryMessage={false}
onCancelHistoryEdit={fn()}
workspaceOptions={[]}
selectedWorkspaceId={null}
@@ -278,14 +278,6 @@ interface ChatPageInputProps {
hasFileReferences: boolean,
) => void;
isEditing: boolean;
editingQueuedMessageID: number | null;
onStartQueueEdit: (
id: number,
text: string,
fileBlocks: readonly TypesGen.ChatMessagePart[],
) => void;
onCancelQueueEdit: () => void;
isEditingHistoryMessage: boolean;
onCancelHistoryEdit: () => void;
// File parts from the message being edited, converted to
// File objects and pre-populated into attachments.
@@ -348,10 +340,6 @@ export const ChatPageInput: FC<ChatPageInputProps> = ({
remountKey,
onContentChange,
isEditing,
editingQueuedMessageID,
onStartQueueEdit,
onCancelQueueEdit,
isEditingHistoryMessage,
onCancelHistoryEdit,
editingFileBlocks,
mcpServers,
@@ -576,10 +564,7 @@ export const ChatPageInput: FC<ChatPageInputProps> = ({
queuedMessages={queuedMessages}
onDeleteQueuedMessage={onDeleteQueuedMessage}
onPromoteQueuedMessage={onPromoteQueuedMessage}
editingQueuedMessageID={editingQueuedMessageID}
onStartQueueEdit={onStartQueueEdit}
onCancelQueueEdit={onCancelQueueEdit}
isEditingHistoryMessage={isEditingHistoryMessage}
isEditingHistoryMessage={isEditing}
onCancelHistoryEdit={onCancelHistoryEdit}
userPromptHistory={userPromptHistory}
isDisabled={isInputDisabled}
@@ -622,11 +607,8 @@ export const ChatPageInput: FC<ChatPageInputProps> = ({
unsupportedProviderNames={unsupportedProviderNames}
aiGatewayDisabled={aiGatewayDisabled}
// Commands act on the whole chat, so they only make sense
// for new sends: hide them while editing a history or
// queued message.
slashCommands={
isEditing || isEditingHistoryMessage ? undefined : CHAT_SLASH_COMMANDS
}
// for new sends: hide them while editing a history message.
slashCommands={isEditing ? undefined : CHAT_SLASH_COMMANDS}
/>
);
@@ -1,5 +1,5 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, fn, userEvent, within } from "storybook/test";
import { expect, fn, userEvent, waitFor, within } from "storybook/test";
import type { ChatQueuedMessage } from "#/api/typesGenerated";
import { MockChatQueuedMessage } from "#/testHelpers/chatEntities";
import { QueuedMessagesList } from "./QueuedMessagesList";
@@ -143,46 +143,60 @@ export const AttachmentsOnly: Story = {
},
};
// Clicking Edit on a message with attachments passes file blocks to onEdit.
export const EditPassesFileBlocks: Story = {
// Queued messages retain send and delete actions without exposing edit.
export const ActionsExcludeEdit: Story = {
args: {
onEdit: fn(),
messages: [
buildMessage(1, [
{ type: "text", text: "Check this screenshot" },
{ type: "file", file_id: "abc-123", media_type: "image/png" },
] as ChatQueuedMessage["content"]),
],
messages: [buildMessage(1, textContent("Run the linter"))],
},
play: async ({ canvasElement, args }) => {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const editButton = canvas.getByRole("button", { name: "Edit" });
await userEvent.click(editButton);
expect(args.onEdit).toHaveBeenCalledWith(1, "Check this screenshot", [
{ type: "file", file_id: "abc-123", media_type: "image/png" },
]);
expect(canvas.getByRole("button", { name: "Send now" })).toBeVisible();
expect(
canvas.getByRole("button", { name: "Remove from queue" }),
).toBeVisible();
expect(
canvas.queryByRole("button", { name: "Edit" }),
).not.toBeInTheDocument();
},
};
// Clicking Edit on an attachment-only message passes file blocks with empty text.
export const EditAttachmentOnlyMessage: Story = {
let rejectQueuedDelete: ((error: Error) => void) | undefined;
// Deleting hides the row optimistically and disables sibling actions while
// pending; a rejected delete restores the row and re-enables actions.
export const DeleteRejectionRestoresRow: Story = {
args: {
onEdit: fn(),
messages: [
buildMessage(1, [
{ type: "file", file_id: "img-1", media_type: "image/png" },
{ type: "file", file_id: "img-2", media_type: "image/jpeg" },
] as ChatQueuedMessage["content"]),
buildMessage(1, textContent("First queued")),
buildMessage(2, textContent("Second queued")),
],
onDelete: () =>
new Promise<void>((_, reject) => {
rejectQueuedDelete = reject;
}),
},
play: async ({ canvasElement, args }) => {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const editButton = canvas.getByRole("button", { name: "Edit" });
await userEvent.click(editButton);
expect(args.onEdit).toHaveBeenCalledWith(1, "", [
{ type: "file", file_id: "img-1", media_type: "image/png" },
{ type: "file", file_id: "img-2", media_type: "image/jpeg" },
]);
const removeButtons = canvas.getAllByRole("button", {
name: "Remove from queue",
});
await userEvent.click(removeButtons[0]);
expect(canvas.queryByText("First queued")).not.toBeInTheDocument();
expect(canvas.getByText("Second queued")).toBeVisible();
expect(canvas.getByRole("button", { name: "Send now" })).toBeDisabled();
if (!rejectQueuedDelete) {
throw new Error("onDelete was not invoked");
}
rejectQueuedDelete(new Error("delete failed"));
await waitFor(() => expect(canvas.getByText("First queued")).toBeVisible());
for (const button of canvas.getAllByRole("button", {
name: "Send now",
})) {
expect(button).toBeEnabled();
}
},
};
@@ -14,10 +14,8 @@ describe("getQueuedMessageInfo", () => {
);
expect(result).toEqual({
displayText: "hello",
rawText: "hello",
attachmentCount: 0,
hookNotices: [],
fileBlocks: [],
});
});
@@ -30,10 +28,8 @@ describe("getQueuedMessageInfo", () => {
);
expect(result).toEqual({
displayText: "hello",
rawText: "hello",
attachmentCount: 0,
hookNotices: ["policy notice"],
fileBlocks: [],
});
});
@@ -43,10 +39,8 @@ describe("getQueuedMessageInfo", () => {
);
expect(result).toEqual({
displayText: "line1\nline2",
rawText: "line1\nline2",
attachmentCount: 0,
hookNotices: [],
fileBlocks: [],
});
});
@@ -56,10 +50,8 @@ describe("getQueuedMessageInfo", () => {
);
expect(result).toEqual({
displayText: "[Queued message]",
rawText: "",
attachmentCount: 1,
hookNotices: [],
fileBlocks: [{ type: "file", file_id: "a", media_type: "image/png" }],
});
});
@@ -72,13 +64,8 @@ describe("getQueuedMessageInfo", () => {
);
expect(result).toEqual({
displayText: "[Queued message]",
rawText: "",
attachmentCount: 2,
hookNotices: [],
fileBlocks: [
{ type: "file", file_id: "a", media_type: "image/png" },
{ type: "file", file_id: "b", media_type: "image/png" },
],
});
});
@@ -91,10 +78,8 @@ describe("getQueuedMessageInfo", () => {
);
expect(result).toEqual({
displayText: "look",
rawText: "look",
attachmentCount: 1,
hookNotices: [],
fileBlocks: [{ type: "file", file_id: "a", media_type: "image/png" }],
});
});
@@ -102,10 +87,8 @@ describe("getQueuedMessageInfo", () => {
const result = getQueuedMessageInfo(buildMessage([]));
expect(result).toEqual({
displayText: "[Queued message]",
rawText: "",
attachmentCount: 0,
hookNotices: [],
fileBlocks: [],
});
});
@@ -115,10 +98,8 @@ describe("getQueuedMessageInfo", () => {
);
expect(result).toEqual({
displayText: "[Queued message]",
rawText: "",
attachmentCount: 0,
hookNotices: [],
fileBlocks: [],
});
});
@@ -131,10 +112,8 @@ describe("getQueuedMessageInfo", () => {
);
expect(result).toEqual({
displayText: "[Queued message]",
rawText: "",
attachmentCount: 1,
hookNotices: [],
fileBlocks: [{ type: "file", file_id: "a", media_type: "image/png" }],
});
});
@@ -147,14 +126,12 @@ describe("getQueuedMessageInfo", () => {
);
expect(result).toEqual({
displayText: "a b",
rawText: "a b",
attachmentCount: 0,
hookNotices: [],
fileBlocks: [],
});
});
it("preserves media_type from file parts", () => {
it("counts multiple file attachments alongside text", () => {
const result = getQueuedMessageInfo(
buildMessage([
{ type: "text", text: "check this" },
@@ -164,13 +141,8 @@ describe("getQueuedMessageInfo", () => {
);
expect(result).toEqual({
displayText: "check this",
rawText: "check this",
attachmentCount: 2,
hookNotices: [],
fileBlocks: [
{ type: "file", file_id: "img-1", media_type: "image/png" },
{ type: "file", file_id: "doc-2", media_type: "application/pdf" },
],
});
});
});
@@ -3,11 +3,10 @@ import {
CornerDownLeftIcon,
ImageIcon,
InfoIcon,
PencilIcon,
Trash2Icon,
} from "lucide-react";
import { type FC, useEffect, useState } from "react";
import type { ChatMessagePart, ChatQueuedMessage } from "#/api/typesGenerated";
import type { ChatQueuedMessage } from "#/api/typesGenerated";
import { Button } from "#/components/Button/Button";
import { Spinner } from "#/components/Spinner/Spinner";
import {
@@ -21,32 +20,24 @@ interface QueuedMessagesListProps {
messages: readonly ChatQueuedMessage[];
onDelete: (id: number) => Promise<void> | void;
onPromote: (id: number) => Promise<void> | void;
onEdit?: (
id: number,
text: string,
fileBlocks: readonly ChatMessagePart[],
) => void;
editingMessageID?: number | null;
className?: string;
}
interface QueuedMessageInfo {
displayText: string;
rawText: string;
attachmentCount: number;
fileBlocks: readonly ChatMessagePart[];
hookNotices: string[];
}
export const getQueuedMessageInfo = (
message: ChatQueuedMessage,
): QueuedMessageInfo => {
const fileBlocks: ChatMessagePart[] = [];
let attachmentCount = 0;
const textParts: string[] = [];
const hookNotices: string[] = [];
for (const part of message.content) {
if (part.type === "file") {
fileBlocks.push(part);
attachmentCount++;
} else if (part.type === "text" && part.text?.trim()) {
textParts.push(part.text);
} else if (part.type === "hook-notice" && part.text?.trim()) {
@@ -57,9 +48,7 @@ export const getQueuedMessageInfo = (
return {
displayText: rawText || "[Queued message]",
rawText,
attachmentCount: fileBlocks.length,
fileBlocks,
attachmentCount,
hookNotices,
};
};
@@ -68,21 +57,12 @@ export const QueuedMessagesList: FC<QueuedMessagesListProps> = ({
messages,
onDelete,
onPromote,
onEdit,
editingMessageID = null,
className,
}) => {
const items = messages.map((message) => {
const { displayText, rawText, attachmentCount, fileBlocks, hookNotices } =
const { displayText, attachmentCount, hookNotices } =
getQueuedMessageInfo(message);
return {
id: message.id,
displayText,
rawText,
attachmentCount,
fileBlocks,
hookNotices,
};
return { id: message.id, displayText, attachmentCount, hookNotices };
});
const [hoveredID, setHoveredID] = useState<number | null>(null);
@@ -178,22 +158,15 @@ export const QueuedMessagesList: FC<QueuedMessagesListProps> = ({
)}
>
{visibleItems.map((item, index) => {
const isEditing = item.id === editingMessageID;
const isFirst = index === 0;
const isItemBusy = busyItem !== null && busyItem.id === item.id;
const isHovered = hoveredID === item.id;
// Show actions when: first and nothing else hovered,
// or this item is hovered, or being edited.
const showActions =
isEditing || isHovered || (isFirst && hoveredID === null);
const showActions = isHovered || (isFirst && hoveredID === null);
return (
<div
key={item.id}
className={cn(
"my-1 opacity-40 hover:opacity-80 transition-opacity",
isEditing && "rounded-lg opacity-100 ring-2 ring-content-link/40",
)}
className="my-1 opacity-40 transition-opacity hover:opacity-80"
onMouseEnter={() => setHoveredID(item.id)}
onMouseLeave={() =>
setHoveredID((current) => (current === item.id ? null : current))
@@ -247,25 +220,6 @@ export const QueuedMessagesList: FC<QueuedMessagesListProps> = ({
showActions ? "opacity-100" : "opacity-0",
)}
>
{onEdit && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="subtle"
size="icon"
aria-label="Edit"
disabled={isBusy}
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="size-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">Edit</TooltipContent>
</Tooltip>
)}
<Tooltip>
<TooltipTrigger asChild>
<Button