From 3da97a51c98b4cf5548a247316a91712c654c8f7 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 18 Aug 2026 13:50:37 +0000 Subject: [PATCH 1/3] fix(site/src/pages/AgentsPage): disallow queued message edits --- .../AgentsPage/AgentChatPage.stories.tsx | 75 -------------- .../pages/AgentsPage/AgentChatPage.test.ts | 99 ------------------- site/src/pages/AgentsPage/AgentChatPage.tsx | 85 ++-------------- .../AgentsPage/AgentChatPageView.stories.tsx | 3 - .../pages/AgentsPage/AgentChatPageView.tsx | 14 +-- .../AgentsPage/components/AgentChatInput.tsx | 65 ++---------- .../components/ChatPageContent.stories.tsx | 3 - .../AgentsPage/components/ChatPageContent.tsx | 13 --- .../components/QueuedMessagesList.stories.tsx | 48 +++------ .../components/QueuedMessagesList.test.ts | 28 ------ .../components/QueuedMessagesList.tsx | 62 ++---------- 11 files changed, 36 insertions(+), 459 deletions(-) diff --git a/site/src/pages/AgentsPage/AgentChatPage.stories.tsx b/site/src/pages/AgentsPage/AgentChatPage.stories.tsx index ae2f1b829b5..0ebf3dfefaf 100644 --- a/site/src/pages/AgentsPage/AgentChatPage.stories.tsx +++ b/site/src/pages/AgentsPage/AgentChatPage.stories.tsx @@ -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 = { diff --git a/site/src/pages/AgentsPage/AgentChatPage.test.ts b/site/src/pages/AgentsPage/AgentChatPage.test.ts index eea803a8436..ca2222ccc17 100644 --- a/site/src/pages/AgentsPage/AgentChatPage.test.ts +++ b/site/src/pages/AgentsPage/AgentChatPage.test.ts @@ -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(); 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"); diff --git a/site/src/pages/AgentsPage/AgentChatPage.tsx b/site/src/pages/AgentsPage/AgentChatPage.tsx index eb9bfe1656f..ac007b8ef91 100644 --- a/site/src/pages/AgentsPage/AgentChatPage.tsx +++ b/site/src/pages/AgentsPage/AgentChatPage.tsx @@ -509,12 +509,10 @@ export function useConversationEditingState(deps: { attachments?: readonly PendingAttachment[], editedMessageID?: number, ) => Promise; - onDeleteQueuedMessage: (id: number) => Promise; chatInputRef: React.RefObject; inputValueRef: React.RefObject; }) { - 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(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() === diff --git a/site/src/pages/AgentsPage/AgentChatPageView.stories.tsx b/site/src/pages/AgentsPage/AgentChatPageView.stories.tsx index 932ab83f12e..40f398ffe0a 100644 --- a/site/src/pages/AgentsPage/AgentChatPageView.stories.tsx +++ b/site/src/pages/AgentsPage/AgentChatPageView.stories.tsx @@ -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, diff --git a/site/src/pages/AgentsPage/AgentChatPageView.tsx b/site/src/pages/AgentsPage/AgentChatPageView.tsx index 10ab490e35b..66a4492cb37 100644 --- a/site/src/pages/AgentsPage/AgentChatPageView.tsx +++ b/site/src/pages/AgentsPage/AgentChatPageView.tsx @@ -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 = ({ }; }); - const isEditing = - editing.editingMessageId !== null || - editing.editingQueuedMessageID !== null; + const isEditing = editing.editingMessageId !== null; const chatOwnerUsername = chatOwner?.username?.trim(); const chatOwnerLabel = @@ -1011,9 +1002,6 @@ export const AgentChatPageView: FC = ({ 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} diff --git a/site/src/pages/AgentsPage/components/AgentChatInput.tsx b/site/src/pages/AgentsPage/components/AgentChatInput.tsx index a6b36853eb1..7f09ae510f5 100644 --- a/site/src/pages/AgentsPage/components/AgentChatInput.tsx +++ b/site/src/pages/AgentsPage/components/AgentChatInput.tsx @@ -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; onPromoteQueuedMessage?: (id: number) => Promise | 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 = ({ queuedMessages = [], onDeleteQueuedMessage, onPromoteQueuedMessage, - editingQueuedMessageID = null, - onStartQueueEdit, - onCancelQueueEdit, isEditingHistoryMessage = false, onCancelHistoryEdit, userPromptHistory = [], @@ -988,10 +976,7 @@ export const AgentChatInput: FC = ({ 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 = ({ // 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 = ({ 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 = ({ {queuedMessages.length > 0 && ( { - if (id === editingQueuedMessageID) { - onCancelQueueEdit?.(); - } - void onDeleteQueuedMessage?.(id); - }} - onPromote={(id) => { - if (id === editingQueuedMessageID) { - onCancelQueueEdit?.(); - } - void onPromoteQueuedMessage?.(id); - }} - onEdit={onStartQueueEdit} - editingMessageID={editingQueuedMessageID} + onDelete={(id) => void onDeleteQueuedMessage?.(id)} + onPromote={(id) => void onPromoteQueuedMessage?.(id)} className="mb-2" /> )} @@ -1167,23 +1132,7 @@ export const AgentChatInput: FC = ({ onDragLeave={onAttach ? handleDragLeave : undefined} onDrop={onAttach ? handleDrop : undefined} > - {editingQueuedMessageID !== null && ( -
- - Editing queued message - - -
- )} - {isEditingHistoryMessage && editingQueuedMessageID === null && ( + {isEditingHistoryMessage && (
@@ -1670,7 +1619,7 @@ export const AgentChatInput: FC = ({ Interrupting. Waiting for the agent to stop. )} - {!(isStreaming && editingQueuedMessageID === null) && ( + {!isStreaming && ( - - Edit - - )}