From 7106b84bd8f22aca60c6c5146d63f823dacc027b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=92=89=E8=90=81?= Date: Sat, 1 Aug 2026 07:43:30 +0800 Subject: [PATCH 1/2] fix(web-shell): stabilize mobile composer effects --- packages/web-shell/client/App.tsx | 1 + .../client/components/ChatEditor.test.tsx | 13 +++++++++ .../client/components/ChatEditor.tsx | 3 ++ .../SpecularComposerEffect.test.tsx | 28 +++++++++++++++++-- .../components/SpecularComposerEffect.tsx | 11 +++++++- .../e2e/web-shell.composer.mobile.spec.ts | 28 +++++++++++++++++++ 6 files changed, 80 insertions(+), 4 deletions(-) diff --git a/packages/web-shell/client/App.tsx b/packages/web-shell/client/App.tsx index 5c94ab8bca0..55a51b0057e 100644 --- a/packages/web-shell/client/App.tsx +++ b/packages/web-shell/client/App.tsx @@ -10149,6 +10149,7 @@ export function App({ composerInput={composerInput} composerInputVersion={composerInputVersion} placeholderText={composerPlaceholderText} + animatePlaceholder={isChatEmptyState} /> {CustomComposerFooter && ( void; onAttachmentsChange?: (hasAttachments: boolean) => void; placeholderText?: string; + animatePlaceholder?: boolean; disabled?: boolean; followupState?: UseDaemonFollowupSuggestionReturn['followupState']; customization?: WebShellCustomization; @@ -400,6 +401,18 @@ describe('ChatEditor animation layers', () => { expect(container.querySelector('[data-typewriter-visible]')).toBeNull(); }); + it('does not mount the typewriter when placeholder animation is disabled', () => { + const container = renderChatEditor({ + placeholderText: 'abc', + animatePlaceholder: false, + }); + + expect( + container.querySelector('[data-web-shell-composer-typewriter]'), + ).toBeNull(); + expect(container.querySelector('[data-typewriter-visible]')).toBeNull(); + }); + it('shows the full placeholder without a caret under prefers-reduced-motion', () => { const originalMatchMedia = window.matchMedia; window.matchMedia = vi.fn( diff --git a/packages/web-shell/client/components/ChatEditor.tsx b/packages/web-shell/client/components/ChatEditor.tsx index 7102600d2ac..6a2aa6c56bb 100644 --- a/packages/web-shell/client/components/ChatEditor.tsx +++ b/packages/web-shell/client/components/ChatEditor.tsx @@ -128,6 +128,7 @@ interface ChatEditorProps { cancelArmed?: boolean; disabled?: boolean; placeholderText?: string; + animatePlaceholder?: boolean; commands: CommandInfo[]; skills?: SkillInfo[]; slashCommandCategoryOrder?: CommandDisplayCategoryOrder; @@ -1148,6 +1149,7 @@ export const ChatEditor = memo( cancelArmed = false, disabled = false, placeholderText = 'Type a message...', + animatePlaceholder = true, commands, skills = [], slashCommandCategoryOrder, @@ -1285,6 +1287,7 @@ export const ChatEditor = memo( const hasSlashMenu = Boolean(slashMenu); const hasAtMenu = Boolean(atMenu); const showTypewriterPlaceholder = + animatePlaceholder && !disabled && Boolean(placeholderText) && !core.hasInput() && diff --git a/packages/web-shell/client/components/SpecularComposerEffect.test.tsx b/packages/web-shell/client/components/SpecularComposerEffect.test.tsx index 773d7048447..503fb7744de 100644 --- a/packages/web-shell/client/components/SpecularComposerEffect.test.tsx +++ b/packages/web-shell/client/components/SpecularComposerEffect.test.tsx @@ -474,7 +474,7 @@ describe('specular effect pointer leave', () => { }); describe('specular effect WebGL cleanup', () => { - it('releases WebGL resources on unmount', () => { + it('removes a lost canvas and releases WebGL resources on unmount', () => { const loseContext = vi.fn(); const glStub = { ARRAY_BUFFER: 0x8892, @@ -521,8 +521,12 @@ describe('specular effect WebGL cleanup', () => { vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue( glStub as unknown as WebGL2RenderingContext, ); - vi.spyOn(window, 'requestAnimationFrame').mockImplementation(() => 1); - vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => {}); + const requestAnimationFrameSpy = vi + .spyOn(window, 'requestAnimationFrame') + .mockImplementation(() => 1); + const cancelAnimationFrameSpy = vi + .spyOn(window, 'cancelAnimationFrame') + .mockImplementation(() => {}); function Harness() { const composerRef = useRef(null); @@ -545,6 +549,24 @@ describe('specular effect WebGL cleanup', () => { container.querySelector('[data-web-shell-composer-specular] canvas'), ).not.toBeNull(); + const canvas = container.querySelector('canvas')!; + const contextLost = new Event('webglcontextlost', { cancelable: true }); + act(() => canvas.dispatchEvent(contextLost)); + + expect(contextLost.defaultPrevented).toBe(true); + expect(cancelAnimationFrameSpy).toHaveBeenCalled(); + expect( + container.querySelector('[data-web-shell-composer-specular] canvas'), + ).toBeNull(); + + requestAnimationFrameSpy.mockClear(); + act(() => { + window.dispatchEvent( + new MouseEvent('pointermove', { clientX: 100, clientY: 100 }), + ); + }); + expect(requestAnimationFrameSpy).not.toHaveBeenCalled(); + act(() => root.unmount()); container.remove(); diff --git a/packages/web-shell/client/components/SpecularComposerEffect.tsx b/packages/web-shell/client/components/SpecularComposerEffect.tsx index c3599276e15..248e51e8868 100644 --- a/packages/web-shell/client/components/SpecularComposerEffect.tsx +++ b/packages/web-shell/client/components/SpecularComposerEffect.tsx @@ -192,7 +192,7 @@ export function SpecularComposerEffect({ }; const startLoop = () => { - if (!running) { + if (!running && canvas.isConnected) { running = true; lastFrame = performance.now(); frameId = window.requestAnimationFrame(draw); @@ -303,6 +303,14 @@ export function SpecularComposerEffect({ }; const resizeObserver = new ResizeObserver(resize); + const onContextLost = (event: Event) => { + event.preventDefault(); + running = false; + window.cancelAnimationFrame(frameId); + resizeObserver.disconnect(); + canvas.remove(); + }; + canvas.addEventListener('webglcontextlost', onContextLost); resizeObserver.observe(target); resize(); window.addEventListener('pointermove', onPointerMove, { passive: true }); @@ -328,6 +336,7 @@ export function SpecularComposerEffect({ ); target.removeEventListener('focusin', onFocusIn); target.removeEventListener('focusout', onFocusOut); + canvas.removeEventListener('webglcontextlost', onContextLost); canvas.remove(); gl.deleteBuffer(buffer); gl.deleteProgram(program); diff --git a/packages/web-shell/client/e2e/web-shell.composer.mobile.spec.ts b/packages/web-shell/client/e2e/web-shell.composer.mobile.spec.ts index abc5ade603c..c0257b088cc 100644 --- a/packages/web-shell/client/e2e/web-shell.composer.mobile.spec.ts +++ b/packages/web-shell/client/e2e/web-shell.composer.mobile.spec.ts @@ -32,6 +32,34 @@ test('renders the textarea backend instead of CodeMirror on touch devices', asyn await expect(page.locator(COMPOSER_TEXTAREA)).toBeVisible(); await expect(page.locator('.cm-editor')).toHaveCount(0); + await expect( + page.locator('[data-web-shell-composer-typewriter]'), + ).toHaveCount(0); +}); + +test('keeps the composer usable after WebGL context loss', async ({ + page, +}, testInfo) => { + const scenario = createWebShellDaemonScenario(); + const daemon = await installScenario(page, scenario, testInfo); + + await gotoSession(page, scenario, daemon); + const canvas = page.locator('[data-web-shell-composer-specular] canvas'); + await expect(canvas).toBeVisible(); + + await canvas.evaluate((element) => { + const gl = element.getContext('webgl2'); + const extension = gl?.getExtension('WEBGL_lose_context'); + if (!extension) throw new Error('WEBGL_lose_context unavailable'); + extension.loseContext(); + }); + + await expect(canvas).toHaveCount(0); + const textarea = page.locator(COMPOSER_TEXTAREA); + await expect(textarea).toBeVisible(); + await textarea.fill('still usable'); + await expect(textarea).toHaveValue('still usable'); + await expect(page.locator('[data-web-shell-composer-submit]')).toBeEnabled(); }); test('keeps voice controls reachable on an extra-narrow touch viewport', async ({ From eca1d7a396d41b9c936b6a13512c9e44988f275f Mon Sep 17 00:00:00 2001 From: qwen-code-dev-bot Date: Sat, 1 Aug 2026 02:14:21 +0000 Subject: [PATCH 2/2] fix(web-shell): tear down all specular listeners on WebGL context loss (#8263) --- .../web-shell/client/components/ChatPane.tsx | 1 + .../SpecularComposerEffect.test.tsx | 22 ++++++++++--- .../components/SpecularComposerEffect.tsx | 33 ++++++++++--------- .../e2e/web-shell.composer.mobile.spec.ts | 4 +++ 4 files changed, 41 insertions(+), 19 deletions(-) diff --git a/packages/web-shell/client/components/ChatPane.tsx b/packages/web-shell/client/components/ChatPane.tsx index 232908919a2..3c9f702ee40 100644 --- a/packages/web-shell/client/components/ChatPane.tsx +++ b/packages/web-shell/client/components/ChatPane.tsx @@ -881,6 +881,7 @@ export function ChatPane({ sessionId={connection.sessionId} atWorkspaceCwd={paneWorkspaceCwd} placeholderText={t('splitView.composerPlaceholder')} + animatePlaceholder={false} /> {CustomComposerFooter && ( { container.querySelector('[data-web-shell-composer-specular] canvas'), ).not.toBeNull(); - const canvas = container.querySelector('canvas')!; - const contextLost = new Event('webglcontextlost', { cancelable: true }); - act(() => canvas.dispatchEvent(contextLost)); + const removeWindowListenerSpy = vi.spyOn(window, 'removeEventListener'); + const removeDocumentListenerSpy = vi.spyOn( + document.documentElement, + 'removeEventListener', + ); + + const canvas = container.querySelector( + '[data-web-shell-composer-specular] canvas', + )!; + act(() => canvas.dispatchEvent(new Event('webglcontextlost'))); - expect(contextLost.defaultPrevented).toBe(true); expect(cancelAnimationFrameSpy).toHaveBeenCalled(); + expect(removeWindowListenerSpy).toHaveBeenCalledWith( + 'pointermove', + expect.any(Function), + ); + expect(removeDocumentListenerSpy).toHaveBeenCalledWith( + 'pointerleave', + expect.any(Function), + ); expect( container.querySelector('[data-web-shell-composer-specular] canvas'), ).toBeNull(); diff --git a/packages/web-shell/client/components/SpecularComposerEffect.tsx b/packages/web-shell/client/components/SpecularComposerEffect.tsx index 248e51e8868..ca7577d294b 100644 --- a/packages/web-shell/client/components/SpecularComposerEffect.tsx +++ b/packages/web-shell/client/components/SpecularComposerEffect.tsx @@ -192,7 +192,7 @@ export function SpecularComposerEffect({ }; const startLoop = () => { - if (!running && canvas.isConnected) { + if (!running && !disposed) { running = true; lastFrame = performance.now(); frameId = window.requestAnimationFrame(draw); @@ -303,13 +303,27 @@ export function SpecularComposerEffect({ }; const resizeObserver = new ResizeObserver(resize); - const onContextLost = (event: Event) => { - event.preventDefault(); + let disposed = false; + const teardown = () => { + if (disposed) return; + disposed = true; running = false; window.cancelAnimationFrame(frameId); + frameId = 0; resizeObserver.disconnect(); + window.removeEventListener('pointermove', onPointerMove); + document.documentElement.removeEventListener( + 'pointerleave', + onPointerLeave, + ); + target.removeEventListener('focusin', onFocusIn); + target.removeEventListener('focusout', onFocusOut); + canvas.removeEventListener('webglcontextlost', onContextLost); canvas.remove(); }; + const onContextLost = () => { + teardown(); + }; canvas.addEventListener('webglcontextlost', onContextLost); resizeObserver.observe(target); resize(); @@ -326,18 +340,7 @@ export function SpecularComposerEffect({ startLoop(); return () => { - running = false; - window.cancelAnimationFrame(frameId); - resizeObserver.disconnect(); - window.removeEventListener('pointermove', onPointerMove); - document.documentElement.removeEventListener( - 'pointerleave', - onPointerLeave, - ); - target.removeEventListener('focusin', onFocusIn); - target.removeEventListener('focusout', onFocusOut); - canvas.removeEventListener('webglcontextlost', onContextLost); - canvas.remove(); + teardown(); gl.deleteBuffer(buffer); gl.deleteProgram(program); gl.deleteShader(vertexShader); diff --git a/packages/web-shell/client/e2e/web-shell.composer.mobile.spec.ts b/packages/web-shell/client/e2e/web-shell.composer.mobile.spec.ts index c0257b088cc..df8477b23e3 100644 --- a/packages/web-shell/client/e2e/web-shell.composer.mobile.spec.ts +++ b/packages/web-shell/client/e2e/web-shell.composer.mobile.spec.ts @@ -44,6 +44,10 @@ test('keeps the composer usable after WebGL context loss', async ({ const daemon = await installScenario(page, scenario, testInfo); await gotoSession(page, scenario, daemon); + const hasWebgl2 = await page.evaluate( + () => !!document.createElement('canvas').getContext('webgl2'), + ); + test.skip(!hasWebgl2, 'WebGL2 is unavailable on this runner'); const canvas = page.locator('[data-web-shell-composer-specular] canvas'); await expect(canvas).toBeVisible();