diff --git a/frontend/src/api/logging.ts b/frontend/src/api/logging.ts index 8100c2e7..521e57e2 100644 --- a/frontend/src/api/logging.ts +++ b/frontend/src/api/logging.ts @@ -201,6 +201,10 @@ export const chatLog = { responseError(log: LogFn, data: { error: string; code?: string }) { return emit(log, 'chat', 'response_error', data); }, + /** The writer discarded the transcript and started a new conversation. */ + conversationReset(log: LogFn) { + return emit(log, 'chat', 'conversation_reset'); + }, }; /** diff --git a/frontend/src/pages/chat/index.tsx b/frontend/src/pages/chat/index.tsx index 9c70025d..f560223c 100644 --- a/frontend/src/pages/chat/index.tsx +++ b/frontend/src/pages/chat/index.tsx @@ -7,7 +7,11 @@ import { useRef, useState, } from 'react'; -import { AiOutlineArrowDown, AiOutlineSend } from 'react-icons/ai'; +import { + AiOutlineArrowDown, + AiOutlinePlus, + AiOutlineSend, +} from 'react-icons/ai'; import { Remark } from 'react-remark'; import { @@ -243,7 +247,6 @@ export default function Chat() { providerOptions: openaiProviderOptions, instructions: CHAT_INSTRUCTIONS, messages: newMessages.slice(0, -1) as ModelMessage[], - maxOutputTokens: 1024, abortSignal: requestController.signal, }); @@ -286,6 +289,17 @@ export default function Chat() { } } + function startNewConversation() { + activeRequestControllerRef.current?.abort(); + activeRequestControllerRef.current = null; + updateSendingMessage(false); + setErrorInfo(null); + setFailedMessage(null); + updateMessage(''); + updateChatMessages([]); + chatLog.conversationReset(log); + } + async function sendMessage(e: React.FormEvent) { e.preventDefault(); @@ -309,6 +323,20 @@ export default function Chat() {
+ {visibleMessages.length > 0 ? ( +
+ +
+ ) : null} +
{ + await setupMockBackend(page); + + // Override the shared chat reply with a per-turn one. Later routes win in + // Playwright, so this replaces the route setupMockBackend registered. + await page.route('**/openai/responses', async (route) => { + const body = route.request().postDataJSON() as { + input?: Array<{ content?: string | Array<{ text?: string }> }>; + }; + // Flatten the turns; the request carries every message sent so far, so the + // second prompt's presence is what distinguishes turn two from turn one. + const text = JSON.stringify(body.input ?? []); + await fulfillOpenAI( + route, + text.includes(SECOND_PROMPT) ? SECOND_REPLY : FIRST_REPLY, + ); + }); + + await page.goto('/editor.html?page=demo'); + // Draft is the default page — wait for it to confirm the app has loaded. + await expect(page.locator('button[aria-label="Examples"]')).toBeVisible({ + timeout: 15000, + }); + await page.locator('button', { hasText: 'Chat' }).click(); +}); + +test('Chat: the starting page — visual regression', async ({ page }) => { + await expect( + page.getByText('What do you think about your document so far?'), + ).toBeVisible(); + // All four suggestion chips, so the snapshot isn't taken mid-render. + await expect( + page.locator('button', { hasText: FIRST_PROMPT }), + ).toBeVisible(); + await expect( + page.locator('button', { hasText: 'What am I missing?' }), + ).toBeVisible(); + + await expect(page).toHaveScreenshot('chat-start.png', { + fullPage: true, + }); +}); + +test('Chat: a two-turn conversation — visual regression', async ({ page }) => { + // Turn one via a suggestion chip; turn two via the input box, so the snapshot + // covers both ways in. + await page.locator('button', { hasText: FIRST_PROMPT }).click(); + await expect(page.getByText(FIRST_REPLY)).toBeVisible({ timeout: 5000 }); + + const input = page.locator('textarea[placeholder*="Ask"]'); + await input.fill(SECOND_PROMPT); + await page.locator('button[title="Send message"]').click(); + await expect( + page.getByText('How formal should you sound to them?'), + ).toBeVisible({ timeout: 5000 }); + + // The typing indicator animates while a reply is in flight; the input is + // re-enabled only once the stream finishes, so this settles the transcript. + await expect(input).toBeEnabled(); + + // Pin the transcript to the bottom, and keep pinning until it stays there. + // The page's own auto-scroll is smooth and fires before react-remark has + // finished rendering the markdown, so the resting offset of a transcript + // that overflows is a race — one a pixel comparison reads as a diff. A + // scrollTop assignment aborts any smooth scroll still in flight, and the + // poll re-pins if late-rendered markdown grows the content underneath it. + const chatBody = page.locator('[class*="chatBody"]'); + await expect + .poll(() => + chatBody.evaluate((el) => { + el.scrollTop = el.scrollHeight; + return el.scrollHeight - el.clientHeight - el.scrollTop; + }), + ) + .toBe(0); + + await expect(page).toHaveScreenshot('chat-two-turns.png', { + fullPage: true, + }); +}); diff --git a/frontend/tests/chat-page-visual.spec.ts-snapshots/chat-start-chromium-linux.png b/frontend/tests/chat-page-visual.spec.ts-snapshots/chat-start-chromium-linux.png new file mode 100644 index 00000000..3f19fe12 Binary files /dev/null and b/frontend/tests/chat-page-visual.spec.ts-snapshots/chat-start-chromium-linux.png differ diff --git a/frontend/tests/chat-page-visual.spec.ts-snapshots/chat-two-turns-chromium-linux.png b/frontend/tests/chat-page-visual.spec.ts-snapshots/chat-two-turns-chromium-linux.png new file mode 100644 index 00000000..14ee88d5 Binary files /dev/null and b/frontend/tests/chat-page-visual.spec.ts-snapshots/chat-two-turns-chromium-linux.png differ