mirror of
https://github.com/coder/coder.git
synced 2026-09-21 12:44:32 +08:00
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:
@@ -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");
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user