From 6b26545ea075af8a6642e56146f6a5302d41b4b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D9=85=D8=AD=D9=85=D8=AF=20=D8=A7=D9=84=D8=AD=D9=88=D9=87?= =?UTF-8?q?=D8=B1=D9=8A?= Date: Thu, 5 Nov 2026 21:47:49 +0000 Subject: [PATCH] fix(studio): give the chat transcript and composer distinct React keys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ChatPanel keyed both ChatMessages and ChatComposer with chat.id under the same parent. With duplicate sibling keys, React's reconciler drops one of the old fibers from its key map without deleting its DOM node, so every render of a chat with history left an orphaned transcript in the page. The stale copies scrolled independently, kept "Thinking through your request…" rows that never cleared, and pushed the composer off-screen. The behaviour is the same in React's development and production builds; only the "two children with the same key" warning differs. It affects every place Studio runs: the hosted Worker/container, `astro-studio dev`, and the local Studio that Desktop opens in the browser. The Desktop renderer has no chat panel of its own. Prefixing the keys keeps the intended reset-on-chat-switch behaviour and makes the siblings distinct. The browser suite now seeds a saved chat, types six characters, and asserts a single transcript; without the fix it finds ten. --- studio/src/features/ai/ChatPanel.tsx | 6 ++-- studio/test/ai.browser.ts | 42 +++++++++++++++++++++++++++- 2 files changed, 45 insertions(+), 3 deletions(-) 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 });