diff --git a/frontend/README.md b/frontend/README.md index 38038a9cf3..90a0076423 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -34,8 +34,12 @@ For single-turn targets or targets that cannot replay the first prompt as histor without copying the response. After a successful single send, **Undo last send** also restores the prompt in a new conversation, if the target can replay the preceding history. Both actions restore the saved prompt and converter results -for text, image, audio, video, and mixed responses. They do not overwrite new -prompt edits or changed converter pipelines. +for text, image, audio, video, and mixed responses when the converter pipeline +has not changed. If you change the pipeline, both actions keep your +current converters and temporary settings, restore the original prompt, and +discard the old conversion results. You can then convert the prompt again. +Neither action overwrites new prompt or working-input edits, sends a prompt, +or runs a converter. When you save an edited conversation, its final user turn moves to the prompt box instead of saved history. Text and attachments retain their metadata, including text-only video remix IDs. You can enter and edit prompts without a target. diff --git a/frontend/e2e/chat-recovery.spec.ts b/frontend/e2e/chat-recovery.spec.ts index 30fc286c76..a0b781dfee 100644 --- a/frontend/e2e/chat-recovery.spec.ts +++ b/frontend/e2e/chat-recovery.spec.ts @@ -708,7 +708,7 @@ test.describe("Chat processing recovery @seeded", () => { }); } - test("warns about lost converter choices when recovering a saved failure to a new attack", async ({ + test("restores original media when recovering a saved failure to a new attack", async ({ page, request, localTarget, imageConverterId, }) => { await page.getByTestId("file-input").setInputFiles({ @@ -747,7 +747,6 @@ test.describe("Chat processing recovery @seeded", () => { await expect(page).toHaveURL((url: URL) => url.pathname.includes(created.attack_result_id)); await expect(page.getByTestId("chat-input")).toHaveValue("Restore the original image"); await expect(page.getByRole("button", { name: `Remove ${originalImage.original_filename}`, exact: true })).toBeVisible(); - await expect(page.getByText(/Converter choices could not be restored/)).toBeVisible(); await expect(page.getByTestId("clear-media-conversion-image")).toHaveCount(0); await expect(page.getByTestId(/^message-bubble-/)).toHaveCount(0); expect(localTarget.requestBodies).toHaveLength(1); @@ -761,7 +760,6 @@ test.describe("Chat processing recovery @seeded", () => { expect(payload.pieces.every((piece) => !piece.applied_converter_ids?.length)).toBe(true); expect(retried.messages.messages[0].message_pieces[1].original_value) .toBe(originalImage.original_value); - await expect(page.getByText(/Converter choices could not be restored/)).toHaveCount(0); const historyResponse = await request.get( `/api/attacks/${retried.attack.attack_result_id}/messages?conversation_id=${retried.messages.conversation_id}`, { headers: compatibilityHeaders() }, diff --git a/frontend/src/components/Chat/ChatWindow.test.tsx b/frontend/src/components/Chat/ChatWindow.test.tsx index 23dd85e56d..f402804c81 100644 --- a/frontend/src/components/Chat/ChatWindow.test.tsx +++ b/frontend/src/components/Chat/ChatWindow.test.tsx @@ -15,6 +15,8 @@ import { BackendMessage, AttackTargetResolutionStatus, ConverterInstance, + ConversationMessagesResponse, + CreateConversationResponse, Message, MessageAttachment, MessageSendConversation, @@ -77,11 +79,12 @@ jest.mock("@/services/api", () => ({ listConverters: jest.fn(), listConverterTypes: jest.fn(), getConverter: jest.fn(), + buildConverter: jest.fn(), createConverter: jest.fn(), deleteConverter: jest.fn(), previewConversion: jest.fn(), }, - targetsApi: { buildTarget: jest.fn() }, + targetsApi: { buildTarget: jest.fn(), listTargets: jest.fn() }, labelsApi: { getLabels: jest.fn().mockImplementation(() => new Promise(() => {})), }, @@ -100,6 +103,7 @@ const mockedAttacksApi = attacksApi as jest.Mocked; // Existing transcript fixtures are reused across the submission, status and read endpoints. const mockSendResult = jest.fn, [string, AddMessageRequest]>(); const mockedConvertersApi = convertersApi as jest.Mocked; +const mockedTargetsApi = targetsApi as jest.Mocked; const mockedScoresApi = scoresApi as jest.Mocked; const mockedMapper = messageMapper as jest.Mocked; const actualMessageMapper = jest.requireActual( @@ -1461,7 +1465,7 @@ describe("ChatWindow Integration", () => { async function prepareUndoChat(options: { immutable?: boolean; emptyHistory?: boolean; assistantHistory?: boolean; restorable?: boolean; - responseDataTypes?: string[]; + responseDataTypes?: string[]; configurable?: boolean; } = {}) { const user = userEvent.setup(); const prefix: BackendMessage[] = [{ @@ -1501,6 +1505,8 @@ describe("ChatWindow Integration", () => { mockedAttacksApi.getMessages.mockImplementation(async (_attackId, conversationId) => ( conversationId === "undo-branch" ? { conversation_id: "undo-branch", messages: prefix, target_response_status: null } + : conversationId === "undo-other" + ? { conversation_id: "undo-other", messages: [], target_response_status: null } : source )); mockedAttacksApi.getAttack.mockResolvedValue(attack); @@ -1508,7 +1514,10 @@ describe("ChatWindow Integration", () => { mockedMapper.buildMessagePieces.mockImplementation(actualMessageMapper.buildMessagePieces); mockedMapper.backendMessagesToFrontend.mockImplementation(actualMessageMapper.backendMessagesToFrontend); mockedConvertersApi.listConverters.mockResolvedValue({ - items: [makeConverterInstance("first", "Base64Converter"), makeConverterInstance("second", "Base64Converter")], + items: [{ + ...makeConverterInstance("first", options.configurable ? "CaesarConverter" : "Base64Converter"), + reconstructable: options.configurable, + }, makeConverterInstance("second", "Base64Converter")], }); mockedConvertersApi.previewConversion.mockImplementation(async (request) => { let value = request.original_value; @@ -1554,12 +1563,16 @@ describe("ChatWindow Integration", () => { const selected = jest.fn(); function UndoHarness() { const [conversation, setConversation] = useState("undo-source"); - return + + + { selected(id); setConversation(id); }} />; + onSelectConversation={(id: string) => { selected(id); setConversation(id); }} /> + ; } - render(); + const rendered = render(); await user.type(await screen.findByPlaceholderText("Type prompt here"), "Original prompt"); await user.click(screen.getByRole("button", { name: "Toggle converter panel" })); await user.click(await screen.findByRole("combobox", { name: "Add converter" })); @@ -1580,6 +1593,7 @@ describe("ChatWindow Integration", () => { } return { user, selected, + refreshView: () => rendered.rerender(), changeSource: () => { source = { ...source, messages: [...source.messages, { role: "user", turn_number: 3, created_at: "", @@ -1680,46 +1694,140 @@ describe("ChatWindow Integration", () => { })); }); - it.each([ - { edit: "pipeline", responseDataTypes: ["text"] }, - { edit: "working input", responseDataTypes: ["text"] }, - { edit: "pipeline", responseDataTypes: ["image_path"] }, - { edit: "working input", responseDataTypes: ["audio_path"] }, - ])("protects $edit edits before first-prompt restore after a $responseDataTypes response", async ({ - edit, responseDataTypes, - }: { edit: string; responseDataTypes: string[] }) => { - const { user, selected } = await prepareUndoChat({ immutable: true, emptyHistory: true, responseDataTypes }); - if (edit === "pipeline") { - await user.click(screen.getByRole("button", { name: "Remove converter second" })); - } else { - await user.type(screen.getByRole("textbox", { name: "Working input - Text" }), "Keep my working input"); - } + it.each(["text", "audio_path"])("protects working-input edits before first-prompt restore after a %s response", async ( + responseDataType: string, + ) => { + const { user, selected } = await prepareUndoChat({ + immutable: true, emptyHistory: true, responseDataTypes: [responseDataType], + }); + await user.click(screen.getByRole("button", { name: "Remove converter second" })); + await user.type(screen.getByRole("textbox", { name: "Working input - Text" }), "Keep my working input"); expect(screen.getByRole("button", { name: "Undo last send" })).toBeDisabled(); await user.click(screen.getAllByRole("button", { name: "Copy or Branch" })[0]); const branch = screen.getByRole("menuitem", { name: "Branch conversation", exact: true }); expect(branch).toHaveAttribute("aria-disabled", "true"); expect(branch).toHaveAccessibleDescription(expect.stringMatching( - edit === "pipeline" ? /converter pipeline changed/i : /clear the current prompt and conversion edits/i, + /clear the current prompt and conversion edits/i, )); await user.click(branch); expect(mockedAttacksApi.saveConversation).not.toHaveBeenCalled(); expect(mockedAttacksApi.createConversation).not.toHaveBeenCalled(); expect(selected).not.toHaveBeenCalled(); - if (edit === "pipeline") { - expect(screen.queryByRole("button", { name: "Remove converter second" })).not.toBeInTheDocument(); - expect(screen.getByRole("button", { name: "Remove converter first" })).toBeInTheDocument(); + expect(screen.getByRole("textbox", { name: "Working input - Text" })).toHaveValue("Keep my working input"); + }); + + it.each(["text", "image_path"])("branches with the current pipeline after a %s response without stale conversions", async ( + responseDataType: string, + ) => { + const { user, selected } = await prepareUndoChat({ + immutable: true, emptyHistory: true, responseDataTypes: [responseDataType], + }); + const attack = await mockedAttacksApi.getAttack("undo-attack"); + mockedAttacksApi.saveConversation.mockResolvedValue({ + attack, messages: { conversation_id: "undo-branch", messages: [], target_response_status: null }, + }); + await user.click(screen.getByRole("button", { name: "Remove converter second" })); + expect(screen.getByRole("button", { name: "Undo last send" })).toBeEnabled(); + await user.click(screen.getAllByRole("button", { name: "Copy or Branch" })[0]); + await user.click(screen.getByRole("menuitem", { name: "Branch conversation" })); + await waitFor(() => expect(selected).toHaveBeenCalledWith("undo-branch")); + expect(screen.getByPlaceholderText("Type prompt here")).toHaveValue("Original prompt"); + expect(screen.getByRole("button", { name: "Remove converter first" })).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Remove converter second" })).not.toBeInTheDocument(); + expect(screen.queryByDisplayValue("Custom middle text")).not.toBeInTheDocument(); + expect(screen.queryByRole("textbox", { name: "Converted prompt" })).not.toBeInTheDocument(); + expect(screen.getByRole("textbox", { name: "Working input - Text" })).toHaveValue("Original prompt"); + expect(mockedConvertersApi.previewConversion).toHaveBeenCalledTimes(2); + expect(mockedAttacksApi.submitMessageSend).toHaveBeenCalledTimes(1); + await user.click(screen.getByRole("button", { name: "Convert" })); + await waitFor(() => expect(screen.getByRole("textbox", { name: "Converted prompt" })) + .toHaveValue("Original prompt|first")); + expect(mockedConvertersApi.previewConversion).toHaveBeenLastCalledWith({ + original_value: "Original prompt", original_value_data_type: "text", converter_ids: ["first"], + }); + }); + + it.each(["cleared", "replaced"])("branches with a %s pipeline without old applied provenance", async (edit: string) => { + const { user, selected } = await prepareUndoChat({ immutable: true, emptyHistory: true }); + const attack = await mockedAttacksApi.getAttack("undo-attack"); + mockedAttacksApi.saveConversation.mockResolvedValue({ + attack, messages: { conversation_id: "undo-branch", messages: [], target_response_status: null }, + }); + await user.click(screen.getByRole("button", { name: "Remove converter second" })); + await user.click(screen.getByRole("button", { name: "Remove converter first" })); + if (edit === "replaced") { + await user.click(screen.getByRole("combobox", { name: "Add converter" })); + await user.click(await screen.findByRole("option", { name: /^second/ })); + } + await user.click(screen.getAllByRole("button", { name: "Copy or Branch" })[0]); + await user.click(screen.getByRole("menuitem", { name: "Branch conversation" })); + await waitFor(() => expect(selected).toHaveBeenCalledWith("undo-branch")); + expect(screen.getByPlaceholderText("Type prompt here")).toHaveValue("Original prompt"); + expect(screen.queryByRole("textbox", { name: "Converted prompt" })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Remove converter first" })).not.toBeInTheDocument(); + if (edit === "replaced") expect(screen.getByRole("button", { name: "Remove converter second" })).toBeInTheDocument(); + expect(mockedConvertersApi.previewConversion).toHaveBeenCalledTimes(2); + expect(mockedAttacksApi.submitMessageSend).toHaveBeenCalledTimes(1); + await user.click(screen.getByRole("button", { name: "Send message" })); + await waitFor(() => expect(mockedAttacksApi.submitMessageSend).toHaveBeenCalledTimes(2)); + expect(mockedAttacksApi.submitMessageSend).toHaveBeenLastCalledWith("undo-attack", expect.objectContaining({ + target_conversation_id: "undo-branch", pieces: [{ data_type: "text", original_value: "Original prompt" }], + })); + }); + + it.each(["branch", "undo"])("keeps temporary converter settings changed after sending during %s", async (action: string) => { + const { user, selected } = await prepareUndoChat({ immutable: true, emptyHistory: true, configurable: true }); + const attack = await mockedAttacksApi.getAttack("undo-attack"); + mockedAttacksApi.saveConversation.mockResolvedValue({ + attack, messages: { conversation_id: "undo-branch", messages: [], target_response_status: null }, + }); + mockedConvertersApi.listConverterTypes.mockResolvedValue({ items: [{ + converter_type: "CaesarConverter", supported_input_types: ["text"], supported_output_types: ["text"], + is_llm_based: false, + parameters: [{ name: "caesar_offset", type_name: "int", required: true, default: "1" }], + }] }); + mockedTargetsApi.listTargets.mockResolvedValue({ items: [], pagination: { limit: 200, has_more: false } }); + const identifier = { ...makeConverterInstance("first", "CaesarConverter").identifier, caesar_offset: 3 }; + mockedConvertersApi.buildConverter.mockResolvedValue({ identifier }); + await user.click(screen.getByRole("button", { name: "Settings for converter first" })); + await user.click(screen.getByRole("menuitem", { name: "Settings" })); + const offset = await screen.findByLabelText(/caesar_offset/i); + await user.clear(offset); + await user.type(offset, "3"); + await user.click(screen.getByRole("button", { name: "Apply Settings" })); + await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument()); + expect(await screen.findByRole("button", { name: "Undo last send" })).toBeEnabled(); + if (action === "undo") { + await user.click(screen.getByRole("button", { name: "Undo last send" })); } else { - expect(screen.getByRole("textbox", { name: "Working input - Text" })).toHaveValue("Keep my working input"); + await user.click(screen.getAllByRole("button", { name: "Copy or Branch" })[0]); + await user.click(screen.getByRole("menuitem", { name: "Branch conversation" })); } + await waitFor(() => expect(selected).toHaveBeenCalledWith("undo-branch")); + expect(screen.getByPlaceholderText("Type prompt here")).toHaveValue("Original prompt"); + expect(screen.getByText("Temporary")).toBeInTheDocument(); + expect(screen.queryByRole("textbox", { name: "Converted prompt" })).not.toBeInTheDocument(); + expect(mockedConvertersApi.previewConversion).toHaveBeenCalledTimes(2); + expect(mockedAttacksApi.submitMessageSend).toHaveBeenCalledTimes(1); + await user.click(screen.getByRole("button", { name: "Convert" })); + await waitFor(() => expect(mockedConvertersApi.previewConversion).toHaveBeenCalledTimes(3)); + expect(mockedConvertersApi.previewConversion).toHaveBeenLastCalledWith(expect.objectContaining({ + original_value: "Original prompt", converter_ids: ["first", "second"], + converter_specs: [{ source_name: "first", source_hash: "first-hash", params: { caesar_offset: 3 } }, null], + })); + expect(mockedConvertersApi.createConverter).not.toHaveBeenCalled(); }); - it("disables undo rather than overwriting a new prompt", async () => { + it.each(["composer", "working input"])("disables undo rather than overwriting new %s edits", async (input: string) => { const { user } = await prepareUndoChat(); - await user.type(screen.getByPlaceholderText("Type prompt here"), "Keep my new draft"); + await user.click(screen.getByRole("button", { name: "Remove converter second" })); + const draftInput = input === "composer" ? screen.getByPlaceholderText("Type prompt here") + : screen.getByRole("textbox", { name: "Working input - Text" }); + await user.type(draftInput, "Keep my new draft"); expect(screen.getByRole("button", { name: "Undo last send" })).toBeDisabled(); await user.click(screen.getByRole("button", { name: "Undo last send" })); expect(mockedAttacksApi.createConversation).not.toHaveBeenCalled(); - expect(screen.getByPlaceholderText("Type prompt here")).toHaveValue("Keep my new draft"); + expect(draftInput).toHaveValue("Keep my new draft"); }); it("detects newer source messages before creating an undo branch", async () => { @@ -1732,11 +1840,101 @@ describe("ChatWindow Integration", () => { expect(screen.getByRole("button", { name: "Undo last send" })).toBeDisabled(); }); - it("disables undo if the converter pipeline has changed", async () => { - const { user } = await prepareUndoChat(); + it.each(["removed", "cleared", "replaced"])("undoes with a %s pipeline without stale conversion state", async (edit: string) => { + const { user, selected } = await prepareUndoChat(); await user.click(screen.getByRole("button", { name: "Remove converter second" })); - expect(screen.getByRole("button", { name: "Undo last send" })).toBeDisabled(); - expect(mockedAttacksApi.createConversation).not.toHaveBeenCalled(); + if (edit !== "removed") await user.click(screen.getByRole("button", { name: "Remove converter first" })); + if (edit === "replaced") { + await user.click(screen.getByRole("combobox", { name: "Add converter" })); + await user.click(await screen.findByRole("option", { name: /^second/ })); + } + expect(screen.getByRole("button", { name: "Undo last send" })).toBeEnabled(); + await user.click(screen.getByRole("button", { name: "Undo last send" })); + await waitFor(() => expect(selected).toHaveBeenCalledWith("undo-branch")); + expect(mockedAttacksApi.createConversation).toHaveBeenCalledWith("undo-attack", { + source_conversation_id: "undo-source", cutoff_index: 0, + }); + expect(screen.getByText("Context before send")).toBeInTheDocument(); + expect(screen.getByPlaceholderText("Type prompt here")).toHaveValue("Original prompt"); + expect(screen.queryByRole("textbox", { name: "Converted prompt" })).not.toBeInTheDocument(); + expect(screen.queryByDisplayValue("Custom middle text")).not.toBeInTheDocument(); + expect(screen.queryByDisplayValue("Custom middle text|second")).not.toBeInTheDocument(); + expect(screen.getByRole("textbox", { name: "Working input - Text" })).toHaveValue("Original prompt"); + if (edit === "removed") expect(screen.getByRole("button", { name: "Remove converter first" })).toBeInTheDocument(); + else expect(screen.queryByRole("button", { name: "Remove converter first" })).not.toBeInTheDocument(); + if (edit === "replaced") expect(screen.getByRole("button", { name: "Remove converter second" })).toBeInTheDocument(); + else expect(screen.queryByRole("button", { name: "Remove converter second" })).not.toBeInTheDocument(); + expect(mockedConvertersApi.previewConversion).toHaveBeenCalledTimes(2); + expect(mockedAttacksApi.submitMessageSend).toHaveBeenCalledTimes(1); + await user.click(screen.getByRole("button", { name: "Send message" })); + await waitFor(() => expect(mockedAttacksApi.submitMessageSend).toHaveBeenCalledTimes(2)); + expect(mockedAttacksApi.submitMessageSend).toHaveBeenLastCalledWith("undo-attack", expect.objectContaining({ + target_conversation_id: "undo-branch", pieces: [{ data_type: "text", original_value: "Original prompt" }], + })); + }); + + it.each([ + { phase: "load", edit: "pipeline" }, { phase: "create", edit: "pipeline" }, + { phase: "history", edit: "pipeline" }, { phase: "load", edit: "working input" }, + ])("preserves $edit changes during the Undo $phase request", async ({ phase, edit }: { phase: string; edit: string }) => { + const { user, selected } = await prepareUndoChat(); + let finishRead: ((value: ConversationMessagesResponse) => void) | undefined; + let finishCreate: ((value: CreateConversationResponse) => void) | undefined; + const source = await mockedAttacksApi.getMessages("undo-attack", "undo-source"); + if (phase === "create") { + mockedAttacksApi.createConversation.mockImplementationOnce(() => new Promise((resolve) => { finishCreate = resolve; })); + } else if (phase === "load") { + mockedAttacksApi.getMessages.mockImplementationOnce(() => new Promise((resolve) => { finishRead = resolve; })); + } else { + mockedAttacksApi.getMessages.mockImplementationOnce(async () => source) + .mockImplementationOnce(() => new Promise((resolve) => { finishRead = resolve; })); + } + await user.click(screen.getByRole("button", { name: "Undo last send" })); + if (phase !== "load") await waitFor(() => expect(mockedAttacksApi.createConversation).toHaveBeenCalledTimes(1)); + if (edit === "pipeline") await user.click(screen.getByRole("button", { name: "Remove converter second" })); + else await user.type(screen.getByRole("textbox", { name: "Working input - Text" }), "Keep working edits"); + await act(async () => { + if (finishRead) finishRead(phase === "load" ? source + : { conversation_id: "undo-branch", messages: source.messages.slice(0, 1), target_response_status: null }); + if (finishCreate) finishCreate({ conversation_id: "undo-branch", created_at: "" }); + }); + expect(await screen.findByText(phase === "load" ? /view, prompt, or runtime changed/ : /without replacing your current draft/)) + .toBeInTheDocument(); + expect(selected).not.toHaveBeenCalled(); + expect(screen.getByPlaceholderText("Type prompt here")).toHaveValue(""); + expect(screen.getByRole("button", { name: "Remove converter first" })).toBeInTheDocument(); + if (edit === "pipeline") expect(screen.queryByRole("button", { name: "Remove converter second" })).not.toBeInTheDocument(); + else expect(screen.getByRole("textbox", { name: "Working input - Text" })).toHaveValue("Keep working edits"); + expect(mockedConvertersApi.previewConversion).toHaveBeenCalledTimes(2); + if (phase === "load") expect(mockedAttacksApi.createConversation).not.toHaveBeenCalled(); + }); + + it.each(["generation", "readiness", "navigation"])("does not restore Undo after a pending branch changes %s", async (change: string) => { + const runtime = jest.spyOn(runtimeHooks, "useRuntime").mockReturnValue({ + ready: true, state: "ready", generation: "undo-generation", + }); + const { user, selected, refreshView } = await prepareUndoChat(); + let finishCreate: ((value: CreateConversationResponse) => void) | undefined; + mockedAttacksApi.createConversation.mockImplementationOnce(() => new Promise((resolve) => { finishCreate = resolve; })); + await user.click(screen.getByRole("button", { name: "Undo last send" })); + await waitFor(() => expect(mockedAttacksApi.createConversation).toHaveBeenCalledTimes(1)); + if (change === "navigation") { + await user.click(screen.getByRole("button", { name: "Switch undo conversation" })); + await user.click(screen.getByRole("button", { name: "Return to sent conversation" })); + } else { + runtime.mockReturnValue({ + ready: change !== "readiness", state: change === "readiness" ? "unavailable" : "ready", + generation: change === "generation" ? "new-generation" : "undo-generation", + }); + refreshView(); + } + if (!finishCreate) throw new Error("Missing pending branch"); + const resolveCreate = finishCreate; + await act(async () => { resolveCreate({ conversation_id: "undo-branch", created_at: "" }); }); + expect(await screen.findByText(/without replacing your current draft/)).toBeInTheDocument(); + expect(selected).not.toHaveBeenCalled(); + expect(screen.getByPlaceholderText("Type prompt here")).toHaveValue(""); + expect(mockedConvertersApi.previewConversion).toHaveBeenCalledTimes(2); }); it("keeps the sent turn and disables undo when branch creation has an uncertain outcome", async () => { @@ -2414,7 +2612,6 @@ describe("ChatWindow Integration", () => { expect(await screen.findByRole("textbox")).toHaveValue("Retry this prompt"); await waitFor(() => expect(screen.getByRole("textbox")).toBeEnabled()); expect(screen.getByRole("button", { name: "Remove evidence.png" })).toBeInTheDocument(); - expect(screen.getByText(/Converter choices could not be restored/)).toBeInTheDocument(); expect(screen.queryByText("Saved error")).not.toBeInTheDocument(); expect(mockedAttacksApi.submitMessageSend).not.toHaveBeenCalled(); }); @@ -4706,10 +4903,8 @@ describe("ChatWindow Integration", () => { expect(screen.getByTestId("chat-input")).toHaveValue("live draft"); if (reinitialized) { expect(screen.queryByTestId("converted-file-chip")).not.toBeInTheDocument(); - expect(screen.getByText(/Converter choices could not be restored/)).toBeInTheDocument(); } else { expect(await screen.findByTestId("converted-file-chip")).toHaveTextContent("live.pdf"); - expect(screen.queryByText(/Converter choices could not be restored/)).not.toBeInTheDocument(); } await user.click(screen.getByRole("button", { name: /send message/i })); await waitFor(() => { @@ -4876,7 +5071,6 @@ describe("ChatWindow Integration", () => { const restoredInput = await screen.findByRole("textbox"); expect(restoredInput).toHaveValue("original persisted prompt"); - expect(screen.getByText(/Converter choices could not be restored/)).toBeInTheDocument(); expect(screen.getAllByText("evidence.png", { exact: false })).toHaveLength(1); expect(screen.queryByText(/converted\.pdf/i)).not.toBeInTheDocument(); @@ -5579,6 +5773,7 @@ describe("ChatWindow Integration", () => { describe("first-prompt branching", () => { async function setupFirstPromptChat(options: { sent?: boolean; imageResponse?: boolean; convertedPrompt?: boolean; withAttachment?: boolean; restorable?: boolean; + convertedMedia?: boolean; selectedPipeline?: boolean; } = {}) { const user = userEvent.setup(); const target = makeTarget({ @@ -5597,13 +5792,16 @@ describe("ChatWindow Integration", () => { source.messages[0].message_pieces[0] = { ...source.messages[0].message_pieces[0], original_value: "Original request", converted_value: options.convertedPrompt ? "Converted request" : "Original request", + ...(options.convertedMedia ? { converted_value: "stale.png", converted_value_data_type: "image_path" } : {}), prompt_metadata: { custom: "preserved" }, converter_identifiers: options.convertedPrompt ? [{ type: "SavedConverter" }] : [], }; if (options.withAttachment) source.messages[0].message_pieces.push({ id: "original-image", original_value_data_type: "image_path", original_value: "original.png", - converted_value_data_type: "image_path", converted_value: "original.png", + converted_value_data_type: options.convertedMedia ? "pdf_path" : "image_path", + converted_value: options.convertedMedia ? "stale.pdf" : "original.png", original_filename: "evidence.png", original_value_mime_type: "image/png", + prompt_metadata: { evidence: "original" }, scores: [], response_error: "none", }); source.messages[1].message_pieces[0] = { @@ -5635,9 +5833,10 @@ describe("ChatWindow Integration", () => { }); const selected = jest.fn(); function BranchHarness() { - const [conversation, setConversation] = useState("first-source"); + const [conversation, setConversation] = useState(options.selectedPipeline ? "other" : "first-source"); return <> + { ; } render(); - if (options.sent !== false) await screen.findByText(options.convertedPrompt ? "Converted request" : "Original request"); + if (options.selectedPipeline) { + await waitFor(() => expect(screen.getByPlaceholderText("Type prompt here")).toBeEnabled()); + await user.click(screen.getByRole("button", { name: "Toggle converter panel" })); + await user.click(await screen.findByRole("combobox", { name: "Add converter" })); + await user.click(await screen.findByRole("option", { name: /^first/ })); + await user.click(screen.getByRole("button", { name: "Open saved conversation" })); + } + if (options.sent !== false) await waitFor(() => expect( + screen.getAllByRole("button", { name: "Copy or Branch" }), + ).toHaveLength(2)); else await waitFor(() => expect(screen.getByPlaceholderText("Type prompt here")).toBeEnabled()); return { user, source, stored, selected, attack }; } @@ -5711,7 +5919,6 @@ describe("ChatWindow Integration", () => { await waitFor(() => expect(screen.getByPlaceholderText("Type prompt here")).toBeEnabled()); expect(screen.getByPlaceholderText("Type prompt here")).toHaveValue("Original request"); expect(screen.getByRole("button", { name: "Remove evidence.png" })).toBeInTheDocument(); - expect(screen.getByText(/Converter choices could not be restored/)).toBeInTheDocument(); expect(screen.queryByText("Original response")).not.toBeInTheDocument(); expect(mockedAttacksApi.submitMessageSend).not.toHaveBeenCalled(); await user.click(screen.getByRole("button", { name: "Send message" })); @@ -5724,6 +5931,145 @@ describe("ChatWindow Integration", () => { expect(mockedConvertersApi.previewConversion).not.toHaveBeenCalled(); }); + it.each([false, true])("keeps a selected pipeline for a stored prompt with converted media=%s", async (convertedMedia: boolean) => { + mockedConvertersApi.listConverters.mockResolvedValue({ + items: [makeConverterInstance("first", "Base64Converter")], + }); + const { user, selected, stored } = await setupFirstPromptChat({ + convertedPrompt: true, withAttachment: true, convertedMedia, selectedPipeline: true, + }); + const originalSource = stored.get("first-source"); + await branchFirstPrompt(user); + await waitFor(() => expect(selected).toHaveBeenCalledWith("first-branch")); + expect(screen.getByPlaceholderText("Type prompt here")).toHaveValue("Original request"); + expect(screen.getByRole("button", { name: "Remove evidence.png" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Remove converter first" })).toBeInTheDocument(); + expect(screen.queryByRole("textbox", { name: "Converted prompt" })).not.toBeInTheDocument(); + expect(screen.queryByTestId("converted-file-chip")).not.toBeInTheDocument(); + expect(mockedConvertersApi.previewConversion).not.toHaveBeenCalled(); + expect(mockedAttacksApi.submitMessageSend).not.toHaveBeenCalled(); + expect(mockedAttacksApi.saveConversation).toHaveBeenCalledWith(expect.objectContaining({ + destination: "same_attack", messages: [], + })); + expect(stored.get("first-source")).toBe(originalSource); + await user.click(screen.getByRole("button", { name: "Send message" })); + await waitFor(() => expect(mockedAttacksApi.submitMessageSend).toHaveBeenCalledTimes(1)); + expect(mockedAttacksApi.submitMessageSend).toHaveBeenLastCalledWith("first-attack", expect.objectContaining({ + target_conversation_id: "first-branch", pieces: [ + { data_type: "text", original_value: "Original request", prompt_metadata: { custom: "preserved" } }, + expect.objectContaining({ data_type: "image_path", original_value: "original.png" }), + ], + })); + expect(mockedAttacksApi.submitMessageSend.mock.calls[0][1].pieces.every( + (piece: AddMessageRequest['pieces'][number]) => !piece.applied_converter_ids + && !piece.applied_converter_provenance && piece.converted_value === undefined, + )).toBe(true); + }); + + it.each([false, true])("restores original media and metadata on Undo with a changed pipeline=%s", async (changed: boolean) => { + mockedConvertersApi.listConverters.mockResolvedValue({ + items: [makeConverterInstance("first", "TextToImageConverter", ["text"], ["image_path"])], + }); + const { user, selected, stored, source, attack } = await setupFirstPromptChat({ + convertedPrompt: true, withAttachment: true, convertedMedia: true, selectedPipeline: true, + }); + const originalSource = JSON.stringify(stored.get("first-source")); + await branchFirstPrompt(user); + await waitFor(() => expect(selected).toHaveBeenCalledWith("first-branch")); + selected.mockClear(); + mockedConvertersApi.previewConversion.mockResolvedValue({ + original_value: "Original request", original_value_data_type: "text", + converted_value: "new.png", converted_value_data_type: "image_path", + steps: [{ + converter_id: "first", converter_type: "TextToImageConverter", input_value: "Original request", + input_data_type: "text", output_value: "new.png", output_data_type: "image_path", + }], + }); + await user.click(screen.getByRole("button", { name: "Convert" })); + expect(await screen.findByTestId("converted-file-chip")).toHaveTextContent("new.png"); + mockSendResult.mockImplementation(async (_attackId: string, request: AddMessageRequest) => { + const messages = { + ...source, conversation_id: request.target_conversation_id, + messages: [{ + ...source.messages[0], + message_pieces: request.pieces.map((piece: AddMessageRequest['pieces'][number], index: number) => ({ + id: `media-sent-${index}`, original_value: piece.original_value, + original_value_data_type: piece.data_type ?? "text", + converted_value: piece.converted_value ?? piece.original_value, + converted_value_data_type: piece.converted_value_data_type ?? piece.data_type ?? "text", + prompt_metadata: piece.prompt_metadata, scores: [], response_error: "none" as const, + })), + }, source.messages[1]], + }; + stored.set(request.target_conversation_id, messages); + return { attack, messages }; + }); + await user.click(screen.getByRole("button", { name: "Send message" })); + await waitFor(() => expect(screen.getByRole("button", { name: "Undo last send" })).toBeEnabled()); + const sentSource = JSON.stringify(stored.get("first-branch")); + if (changed) await user.click(screen.getByRole("button", { name: "Remove converter first" })); + const empty = { conversation_id: "first-undo", messages: [], target_response_status: null }; + stored.set("first-undo", empty); + mockedAttacksApi.createConversation.mockResolvedValue({ conversation_id: "first-undo", created_at: "" }); + await user.click(screen.getByRole("button", { name: "Undo last send" })); + await waitFor(() => expect(selected).toHaveBeenCalledWith("first-undo")); + expect(screen.getByPlaceholderText("Type prompt here")).toHaveValue("Original request"); + expect(screen.getByRole("button", { name: "Remove evidence.png" })).toBeInTheDocument(); + if (changed) expect(screen.queryByTestId("converted-file-chip")).not.toBeInTheDocument(); + else expect(screen.getByTestId("converted-file-chip")).toHaveTextContent("new.png"); + expect(mockedConvertersApi.previewConversion).toHaveBeenCalledTimes(1); + expect(mockedAttacksApi.submitMessageSend).toHaveBeenCalledTimes(1); + expect(JSON.stringify(stored.get("first-source"))).toBe(originalSource); + expect(JSON.stringify(stored.get("first-branch"))).toBe(sentSource); + await user.click(screen.getByRole("button", { name: "Send message" })); + await waitFor(() => expect(mockedAttacksApi.submitMessageSend).toHaveBeenCalledTimes(2)); + const pieces = mockedAttacksApi.submitMessageSend.mock.calls[1][1].pieces; + expect(pieces[0]).toMatchObject({ + original_value: "Original request", prompt_metadata: { custom: "preserved" }, + }); + expect(pieces[1]).toMatchObject({ + original_value: "original.png", data_type: "image_path", prompt_metadata: { evidence: "original" }, + }); + if (changed) expect(pieces.every((piece: AddMessageRequest['pieces'][number]) => ( + piece.converted_value === undefined && !piece.applied_converter_ids && !piece.applied_converter_provenance + ))).toBe(true); + else expect(pieces[0]).toMatchObject({ converted_value: "new.png", applied_converter_ids: ["first"] }); + }); + + it.each(["load", "save"])("keeps converter changes made during a pending branch %s", async (phase: string) => { + mockedConvertersApi.listConverters.mockResolvedValue({ + items: [makeConverterInstance("first", "Base64Converter")], + }); + const { user, selected, attack, stored } = await setupFirstPromptChat({ + convertedPrompt: true, selectedPipeline: true, + }); + const source = stored.get("first-source"); + if (!source) throw new Error("Missing source"); + let finishLoad: ((value: ConversationMessagesResponse) => void) | undefined; + let finishSave: ((value: AddMessageResponse) => void) | undefined; + if (phase === "load") { + mockedAttacksApi.getMessages.mockImplementationOnce(() => new Promise((resolve) => { finishLoad = resolve; })); + } else { + mockedAttacksApi.saveConversation.mockImplementationOnce(() => new Promise((resolve) => { finishSave = resolve; })); + } + await branchFirstPrompt(user); + if (phase === "save") await waitFor(() => expect(mockedAttacksApi.saveConversation).toHaveBeenCalledTimes(1)); + await user.click(screen.getByRole("button", { name: "Remove converter first" })); + await act(async () => { + if (finishLoad) finishLoad(source); + if (finishSave) finishSave({ + attack, messages: { conversation_id: "first-branch", messages: [], target_response_status: null }, + }); + }); + expect(await screen.findByText(phase === "load" ? /view, prompt, or runtime changed/ : /without replacing your current draft/)) + .toBeInTheDocument(); + expect(selected).not.toHaveBeenCalled(); + expect(screen.getByPlaceholderText("Type prompt here")).toHaveValue(""); + expect(screen.queryByRole("button", { name: "Remove converter first" })).not.toBeInTheDocument(); + expect(mockedConvertersApi.previewConversion).not.toHaveBeenCalled(); + if (phase === "load") expect(mockedAttacksApi.saveConversation).not.toHaveBeenCalled(); + }); + it("keeps assistant history blocked for a single-turn target", async () => { const { user } = await setupFirstPromptChat(); await user.click(screen.getAllByRole("button", { name: "Copy or Branch" })[1]); @@ -7667,38 +8013,4 @@ describe("ChatWindow Integration", () => { }) }) - describe('converter restoration warning', () => { - it('does not show converter restoration warning on fresh empty chat with no conversation', async () => { - render( - - - , - ) - expect(screen.queryByText(/Converter choices could not be restored/)).not.toBeInTheDocument() - expect(screen.getByText('There are no messages in this conversation yet.')).toBeInTheDocument() - }) - - it('does not show converter restoration warning while fresh chat is loading without active target', async () => { - render( - - - , - ) - expect(screen.queryByText(/Converter choices could not be restored/)).not.toBeInTheDocument() - }) - }) }) diff --git a/frontend/src/components/Chat/ChatWindow.tsx b/frontend/src/components/Chat/ChatWindow.tsx index 24727b822f..a4dca960d1 100644 --- a/frontend/src/components/Chat/ChatWindow.tsx +++ b/frontend/src/components/Chat/ChatWindow.tsx @@ -143,7 +143,6 @@ interface RecoverableSendDraft { source: 'live' | 'persisted' converterGeneration?: string pipelines?: Record - missingConverterSelections: boolean } interface PendingSend { @@ -180,6 +179,20 @@ interface UndoableSend { history: Array> } +function preparePromptRestore( + saved: ChatConverterSnapshot | undefined, current: ChatConverterSnapshot, +): { snapshot: ChatConverterSnapshot; pipelineChanged: boolean } { + const pipelineChanged = saved + ? JSON.stringify(current.pipelines) !== JSON.stringify(saved.pipelines) + : Object.values(current.pipelines).some((stages: ConverterPipelineStage[]) => stages.length > 0) + return { + pipelineChanged, + snapshot: saved && !pipelineChanged ? saved : { + ...current, workingInputs: {}, stageResults: {}, errors: {}, applied: {}, + }, + } +} + interface RepeatSendGroup { source: PendingSend progress: MessageSendStatus @@ -272,7 +285,6 @@ function getPersistedProcessingRecovery( attachments: (originalDraft.attachments ?? []).map((attachment) => ({ ...attachment })), conversions: {}, source: 'persisted', - missingConverterSelections: failedRequest.message_pieces.some((piece) => Boolean(piece.converter_identifiers?.length)), } } @@ -487,6 +499,8 @@ export default function ChatWindow({ const [systemPrompt, setSystemPrompt] = useState('') const [draftAttachments, setDraftAttachments] = useState([]) const converters = useChatConverters(chatInputText, draftAttachments) + const convertersRef = useRef(converters) + useLayoutEffect(() => { convertersRef.current = converters }, [converters]) const { applied: activePieceConversions, restore: restoreConversions } = converters const [undoableSend, setUndoableSend] = useState(null) const [isUndoingSend, setIsUndoingSend] = useState(false) @@ -499,7 +513,6 @@ export default function ChatWindow({ useEffect(() => () => { preserveOnUnmountRef.current?.() }, []) const [recoverableSends, setRecoverableSends] = useState>({}) const [isRecoveringProcessingError, setIsRecoveringProcessingError] = useState(false) - const [recoveredDraftWarningConversationId, setRecoveredDraftWarningConversationId] = useState(null) const [panelRefreshKey, setPanelRefreshKey] = useState(0) const inputBoxRef = useRef(null) const recoveryInFlightRef = useRef(false) @@ -608,8 +621,6 @@ export default function ChatWindow({ savedRecovery?.converterGeneration !== undefined && savedRecovery.converterGeneration !== runtime.generation ? { ...savedRecovery, conversions: {}, pipelines: undefined, source: 'persisted', - missingConverterSelections: savedRecovery.missingConverterSelections - || Object.keys(savedRecovery.conversions).length > 0 || Boolean(Object.keys(savedRecovery.pipelines ?? {}).length), } : savedRecovery ), [savedRecovery, runtime.generation]) @@ -961,7 +972,6 @@ export default function ChatWindow({ converterGeneration: operation.converterGeneration, pipelines: operation.pipelines, source: 'live', - missingConverterSelections: false, } : recovery if (isLatestRead) { loadedUserPieceIdsRef.current.set(effectiveConvId, userPieceIds(response.messages)) @@ -1185,7 +1195,7 @@ export default function ChatWindow({ ) if (!sameGeneration) setSendIssue(operation.conversationId, { ...sendIssue, - description: 'Prompt restored. Converter choices could not be restored after the runtime changed; review them before sending.', + description: 'Prompt restored after the runtime changed. Review converters before sending.', }) } @@ -1541,7 +1551,7 @@ export default function ChatWindow({ [createAndSelectConversation], ) - const restoreRecoverableDraft = useCallback((destinationConversationId: string): void => { + const restoreRecoverableDraft = useCallback((): void => { if (!recoverableSend) { return } const attachments = recoverableSend.attachments.map(withDraftIdentity) setChatInputText(recoverableSend.originalValue) @@ -1555,7 +1565,6 @@ export default function ChatWindow({ recoverableSend.textMetadata, ) inputBoxRef.current?.focus() - setRecoveredDraftWarningConversationId(recoverableSend.missingConverterSelections ? destinationConversationId : null) }, [restoreConversions, recoverableSend]) const handleRecoverProcessingError = useCallback(async (): Promise => { @@ -1603,7 +1612,7 @@ export default function ChatWindow({ onSelectConversation(response.conversation_id) setIsPanelOpen(!isNarrowScreen) - restoreRecoverableDraft(response.conversation_id) + restoreRecoverableDraft() } catch (err) { if (getViewedConversationId() === sourceConversationId) { appendConversationCreationError(err) @@ -1814,14 +1823,8 @@ export default function ChatWindow({ ) || Object.keys(converters.applied).length) { return 'Clear the current prompt and conversion edits before restoring a sent prompt.' } - if (snapshot - ? JSON.stringify(converters.pipelines) !== JSON.stringify(snapshot.pipelines) - : Object.values(converters.pipelines).some((stages: ConverterPipelineStage[]) => stages.length > 0)) { - return snapshot - ? 'The converter pipeline changed after sending. Restore is unavailable for this prompt.' - : 'Clear the current converter pipeline before restoring a stored prompt.' - } - if (snapshot && Object.keys(snapshot.errors).length > 0) { + const { pipelineChanged } = preparePromptRestore(snapshot, converters.snapshot()) + if (snapshot && Object.keys(snapshot.errors).length > 0 && !pipelineChanged) { return 'The previous conversion was incomplete. Restore is unavailable for this prompt.' } return undefined @@ -1847,6 +1850,7 @@ export default function ChatWindow({ const sourceId = viewedConversationId const draftRevision = inputBoxRef.current?.getDraftRevision() const navigationRevision = navigationRevisionRef.current + const branchConverterSnapshot = converters.snapshot() const selectedPieceIds = messages[messageIndex]?.displayPieces?.map((piece: MessageDisplayPiece) => piece.pieceId) ?? [] const restoresFirstPrompt = destination === 'same_attack' && activeTarget && canBranchFirstPrompt(activeTarget, messages, messageIndex) @@ -1855,6 +1859,8 @@ export default function ChatWindow({ && navigationRevisionRef.current === navigationRevision && inputBoxRef.current?.getDraftRevision() === draftRevision && launchStateRef.current.generation === runtime.generation && launchStateRef.current.ready + && !convertersRef.current.isConverting + && JSON.stringify(convertersRef.current.snapshot()) === JSON.stringify(branchConverterSnapshot) copyingRef.current = true setIsLoadingEdit(true) setEditorError(null) @@ -1874,7 +1880,6 @@ export default function ChatWindow({ let copiedMessages = toConversationDraft(sourceMessages) let restoredPrompt: ReturnType | undefined let promptSnapshot: ChatConverterSnapshot | undefined - let missingConverterSelections = false if (restoresFirstPrompt && activeTarget) { const selected = source.messages[messageIndex] if (!canBranchFirstPrompt(activeTarget, source.messages, messageIndex) @@ -1886,13 +1891,18 @@ export default function ChatWindow({ && firstPromptSend.historySignature === conversationPieceSignature(source) ? firstPromptSend : undefined const restoreError = promptRestoreDisabledReason(saved?.converters) if (restoreError) throw new Error(restoreError) - promptSnapshot = saved?.converters ?? { - generation: runtime.generation, pipelines: {}, workingInputs: {}, stageResults: {}, errors: {}, applied: {}, - } + const restoration = preparePromptRestore(saved?.converters, branchConverterSnapshot) + promptSnapshot = restoration.snapshot + const promptMessage = copiedMessages[copiedMessages.length - 1] restoredPrompt = saved ? { - text: saved.originalValue, attachments: saved.attachments, textMetadata: saved.textMetadata, converters: saved.converters, - } : draftMessageToComposer(copiedMessages[copiedMessages.length - 1], promptSnapshot) - missingConverterSelections = !saved && selected.message_pieces.some((piece: BackendMessagePiece) => Boolean(piece.converter_identifiers?.length)) + text: saved.originalValue, attachments: saved.attachments, textMetadata: saved.textMetadata, converters: promptSnapshot, + } : draftMessageToComposer(restoration.pipelineChanged ? { + ...promptMessage, + pieces: promptMessage.pieces.map((piece: ConversationDraftPiece) => ({ + ...piece, converted_value: undefined, converted_value_data_type: undefined, + applied_converter_ids: undefined, applied_converter_provenance: undefined, + })), + } : promptMessage, promptSnapshot) copiedMessages = copiedMessages.slice(0, -1) } if (isFailedPrompt && activeTarget @@ -1919,7 +1929,7 @@ export default function ChatWindow({ }) if (getViewedConversationId() !== sourceId || inputBoxRef.current?.getDraftRevision() !== draftRevision) return onConversationCreated(response.attack_result_id, response.conversation_id, objective, target) - restoreRecoverableDraft(response.conversation_id) + restoreRecoverableDraft() setIsPanelOpen(true) setPanelRefreshKey((key: number) => key + 1) return @@ -1954,11 +1964,10 @@ export default function ChatWindow({ } else onConversationCreated(response.attack.attack_result_id, response.messages.conversation_id, response.attack.objective, target) onAttackChange?.(response.attack) - if (isFailedPrompt) restoreRecoverableDraft(response.messages.conversation_id) + if (isFailedPrompt) restoreRecoverableDraft() if (restoredPrompt && promptSnapshot) { restoreEditorPrompt(restoredPrompt, promptSnapshot) setUndoableSend(null) - setRecoveredDraftWarningConversationId(missingConverterSelections ? response.messages.conversation_id : null) } setIsPanelOpen(true) } catch (error) { @@ -2032,6 +2041,16 @@ export default function ChatWindow({ if (undoDisabledReason || !undoableSend || undoInFlight.current) return const saved = undoableSend const revision = inputBoxRef.current?.getDraftRevision() + const navigationRevision = navigationRevisionRef.current + const converterSnapshot = converters.snapshot() + const { snapshot: restoredConverters } = preparePromptRestore(saved.converters, converterSnapshot) + const draftUnchanged = (): boolean => viewedAttackRef.current === saved.attackResultId + && getViewedConversationId() === saved.conversationId + && navigationRevisionRef.current === navigationRevision + && inputBoxRef.current?.getDraftRevision() === revision + && launchStateRef.current.generation === saved.converters.generation && launchStateRef.current.ready + && !convertersRef.current.isConverting + && JSON.stringify(convertersRef.current.snapshot()) === JSON.stringify(converterSnapshot) let branchAttempted = false undoInFlight.current = true setIsUndoingSend(true) @@ -2044,10 +2063,7 @@ export default function ChatWindow({ setUndoableSend(null) throw new Error('The conversation changed after this send. Undo is no longer available.') } - const draftUnchanged = (): boolean => viewedAttackRef.current === saved.attackResultId - && getViewedConversationId() === saved.conversationId - && inputBoxRef.current?.getDraftRevision() === revision - if (!draftUnchanged() || launchStateRef.current.generation !== saved.converters.generation) { + if (!draftUnchanged()) { throw new Error('The view, prompt, or runtime changed. Your current draft was not replaced.') } branchAttempted = true @@ -2059,7 +2075,7 @@ export default function ChatWindow({ attacksApi.getAttack(saved.attackResultId), ]) setUndoableSend(null) - if (!draftUnchanged() || launchStateRef.current.generation !== saved.converters.generation) { + if (!draftUnchanged()) { setEditorNotice(`Undo created conversation ${branch.conversation_id} without replacing your current draft.`) return } @@ -2067,11 +2083,10 @@ export default function ChatWindow({ markConversationLoaded(branch.conversation_id) onSelectConversation(branch.conversation_id) onAttackChange?.(attack) - setChatInputText(saved.originalValue) - setDraftAttachments(saved.attachments) - inputBoxRef.current?.restoreDraft(saved.originalValue, saved.attachments, saved.textMetadata) - converters.restoreSnapshot(saved.converters, saved.originalValue, saved.attachments) - inputBoxRef.current?.focus() + restoreEditorPrompt({ + text: saved.originalValue, attachments: saved.attachments, textMetadata: saved.textMetadata, + converters: restoredConverters, + }, restoredConverters) setEditorNotice('Last send undone. The original sent conversation remains in history.') } catch (error) { if (branchAttempted) setUndoableSend(null) @@ -2472,11 +2487,6 @@ export default function ChatWindow({ )}