diff --git a/studio/src/features/ai/ChatPanel.tsx b/studio/src/features/ai/ChatPanel.tsx
index 0090b001..e9d8982e 100644
--- a/studio/src/features/ai/ChatPanel.tsx
+++ b/studio/src/features/ai/ChatPanel.tsx
@@ -57,8 +57,10 @@ export function ChatPanel({ file, refresh }: { readonly file: string; readonly r
)}
- {chat.messages.length || chat.error ? : }
-
+ {/* The keys reset each part's local state when the chat changes. They must differ: siblings that share a
+ key make React leave an orphaned transcript in the page on every render, in development and production alike. */}
+ {chat.messages.length || chat.error ? : }
+
);
}
diff --git a/studio/test/ai.browser.ts b/studio/test/ai.browser.ts
index ea767d20..19c43f2d 100644
--- a/studio/test/ai.browser.ts
+++ b/studio/test/ai.browser.ts
@@ -30,9 +30,10 @@ try {
assert.ok(composer && composer.x >= 0 && composer.x + composer.width <= 390);
assert.ok(await page.getByRole('button', { name: 'Voice input', exact: true }).isVisible());
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth), true);
+ await verifySingleTranscript(page);
assert.deepEqual(errors, []);
console.log(
- 'Chat drafts, switching, image preparation/removal, reload persistence, and mobile composer passed. Live AI and microphone recognition require a configured provider and an interactive microphone.',
+ 'Chat drafts, switching, image preparation/removal, reload persistence, single transcript across renders, and mobile composer passed. Live AI and microphone recognition require a configured provider and an interactive microphone.',
);
} finally {
await browser.close();
@@ -62,6 +63,45 @@ async function verifyDrafts(page: Page) {
assert.equal(await prompt.inputValue(), 'First draft');
}
}
+// The transcript and the composer once shared a React key. React then left one orphaned
+// transcript in the page per render, so a chat with history filled with stale copies.
+// Typing renders the panel once per keystroke, which reproduced the fault without a provider.
+async function verifySingleTranscript(page: Page) {
+ const keystrokeCount = 6;
+ await page.evaluate(async () => {
+ const thread = {
+ id: 'single-transcript',
+ title: 'Saved chat',
+ draft: '',
+ images: [],
+ model: '',
+ messages: [
+ { id: 'question', role: 'user', content: 'Saved question', images: [] },
+ { id: 'answer', role: 'assistant', content: 'Saved answer', images: [] },
+ ],
+ status: 'idle',
+ };
+ const database = await new Promise((resolve, reject) => {
+ const open = indexedDB.open('studio-ai-chats', 1);
+ open.onsuccess = () => resolve(open.result);
+ open.onerror = () => reject(new Error('Could not open the chat database'));
+ });
+ await new Promise((resolve, reject) => {
+ const transaction = database.transaction('chats', 'readwrite');
+ transaction.objectStore('chats').put({ threads: [thread], active: thread.id }, 'session');
+ transaction.oncomplete = () => resolve();
+ transaction.onerror = () => reject(new Error('Could not save the chat'));
+ });
+ database.close();
+ });
+ await page.reload();
+ await page.getByRole('button', { name: 'AI chat', exact: true }).click();
+ await page.getByText('Saved answer', { exact: true }).waitFor();
+ await page.getByLabel('AI prompt', { exact: true }).pressSequentially('x'.repeat(keystrokeCount));
+ assert.equal(await page.getByLabel('AI prompt', { exact: true }).inputValue(), 'x'.repeat(keystrokeCount));
+ assert.equal(await page.locator('.chat-transcript').count(), 1);
+ assert.equal(await page.getByText('Saved answer', { exact: true }).count(), 1);
+}
async function verifyImages(page: Page) {
const image = await page.screenshot({ clip: { x: 0, y: 0, width: 100, height: 100 } });
await page.getByLabel('Attach image files', { exact: true }).setInputFiles({ name: 'reference.png', mimeType: 'image/png', buffer: image });