Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions frontend/src/api/logging.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
},
};

/**
Expand Down
32 changes: 30 additions & 2 deletions frontend/src/pages/chat/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
});

Expand Down Expand Up @@ -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<HTMLFormElement>) {
e.preventDefault();

Expand All @@ -309,6 +323,20 @@ export default function Chat() {
</div>

<div className={classes.chatPanel}>
{visibleMessages.length > 0 ? (
<div className={classes.chatToolbar}>
<button
type="button"
title="Start a new conversation"
onClick={startNewConversation}
className={classes.newConversationBtn}
>
<AiOutlinePlus size={12} />
New conversation
</button>
</div>
) : null}

<div
ref={messagesContainerRef}
onScroll={handleScroll}
Expand Down
33 changes: 31 additions & 2 deletions frontend/src/pages/chat/styles.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,35 @@
flex-direction: column;
}

.chatToolbar {
flex-shrink: 0;
display: flex;
justify-content: flex-end;
padding: 8px 12px 0;
}

.newConversationBtn {
display: flex;
align-items: center;
gap: 4px;
font-size: 11px;
color: #8a8378;
background: none;
border: none;
cursor: pointer;
padding: 4px 6px;
border-radius: 6px;
font-family: 'DM Sans', sans-serif;
transition:
color 0.15s,
background-color 0.15s;
}

.newConversationBtn:hover {
color: #1c1c1c;
background: #f4f1ea;
}

.chatBody {
flex: 1;
overflow-y: auto;
Expand Down Expand Up @@ -119,8 +148,7 @@
}

.chatBubble {
max-width: 85%;
padding: 10px 14px;
padding: 10px 10px;
border-radius: 12px;
font-size: 13px;
line-height: 1.6;
Expand All @@ -131,6 +159,7 @@
background: #1c1c1c;
color: #faf8f4;
border-radius: 12px 12px 4px 12px;
max-width: 85%;
}

.ai .chatBubble {
Expand Down
104 changes: 104 additions & 0 deletions frontend/tests/chat-page-visual.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { test, expect } from '@playwright/test';
import { fulfillOpenAI, setupMockBackend } from './mockBackend';

/**
* Visual regression for the Chat page, in both states it can be in: the empty
* welcome screen (title + suggestion chips, no toolbar) and a settled
* conversation (two user turns, two assistant replies, "New conversation").
*
* Both run on the standalone editor's demo page with an untouched document, so
* the only thing that varies between the two snapshots is the chat panel.
*/

const FIRST_PROMPT = 'What is my main argument?';
const SECOND_PROMPT = 'Is the tone consistent?';

// Distinct per-turn replies, so the transcript snapshot shows two different
// bubbles rather than the same sentence twice. The second is markdown, which
// covers the Remark rendering path (a list) that plain text doesn't.
// Both are short so the four bubbles nearly fill the chat body rather than
// overflowing it far enough to push the first turn out of the snapshot.
const FIRST_REPLY = 'The document is empty, so there is no argument yet.';
const SECOND_REPLY =
'- Who is your reader?\n- How formal should you sound to them?';

test.beforeEach(async ({ page }) => {
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,
});
});
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading