diff --git a/docs/design/web-shell/web-shell-specular-composer-animation.md b/docs/design/web-shell/web-shell-specular-composer-animation.md
deleted file mode 100644
index 3317d729082..00000000000
--- a/docs/design/web-shell/web-shell-specular-composer-animation.md
+++ /dev/null
@@ -1,73 +0,0 @@
-# Web Shell Specular Composer Animation
-
-## Goal
-
-Replace the current composer-only DAC glow with the supplied specular edge
-animation, and add the supplied DotField animation behind the empty new-session
-view. Existing composer behavior, layout, controls, and keyboard handling remain
-unchanged.
-
-## Scope
-
-- Render a WebGL edge highlight around the complete composer surface.
-- Follow pointer direction while the composer is unfocused and within 250 CSS
- pixels of the pointer.
-- Continue from the current highlight angle and rotate at 0.85 radians per
- second while the editor is focused.
-- Keep the supplied light and dark animation colors and support live theme
- changes.
-- Render the DotField only while `isChatEmptyState` is true.
-- Play the empty composer placeholder twice with a three-second pause, then
- leave the complete placeholder visible.
-- Preserve the current 12 pixel composer radius and use that radius for the
- shader geometry.
-- Preserve all existing CodeMirror, mobile textarea, toolbar, attachment,
- submission, and disabled/running behavior.
-
-The demo's page layout, toolbar styling, send button styling, and fixed
-20-pixel composer radius are not part of this change.
-
-## Structure
-
-`SpecularComposerEffect` owns the composer canvas and all pointer, focus,
-resize, animation-frame, and WebGL resources. It is an inert sibling of the
-composer content and never receives pointer events.
-
-`NewSessionDotField` owns the empty-state background canvas. `App` mounts it
-only for the empty new-session state, so starting or loading a session removes
-the canvas and releases its resources.
-
-The composer effect uses WebGL2 directly, while the DotField uses the browser's
-2D canvas API, matching the handoff without adding a runtime dependency. If
-WebGL2 is unavailable, the specular canvas remains absent and the existing CSS
-surface continues to work.
-
-## Motion and accessibility
-
-The composer highlight uses the supplied 32 degree highlight and 68 degree
-fade, a one-CSS-pixel edge, 1.0 proximity intensity, and 1.4 focused intensity.
-Focus and blur both lock the current angle before changing modes, so the
-focused rotation stays clockwise and never jumps or reverses during
-hover/focus transitions. The hover/focus underlay expands four pixels around
-the existing radius.
-
-The DotField uses 1-pixel dots on a 15-pixel grid, a 500-pixel pointer radius,
-0.1 pointer force, a 67-pixel bulge, and a 160-pixel glow. The glow erases the
-canvas dots so the actual host background shows through without color
-interpolation.
-
-The typewriter stops as soon as the user interacts with the editor. If the
-empty editor loses focus, it gets another two-run sequence. Empty placeholder
-strings do not mount the effect.
-
-When `prefers-reduced-motion: reduce` is active, both animation loops remain
-disabled. Changes to the system preference apply without reloading the page.
-The composer retains its ordinary CSS border and focus affordance.
-
-## Lifecycle and verification
-
-Each effect cancels animation frames, disconnects its `ResizeObserver`, and
-removes event listeners. The composer effect also deletes its WebGL resources
-and loses its context. Tests cover empty-state mounting, non-empty removal,
-composer effect presence, and WebGL-unavailable fallback. Existing composer
-interaction tests guard against functional regressions.
diff --git a/packages/web-shell/client/App.module.css b/packages/web-shell/client/App.module.css
index 2ddf87fd185..5193e9a76a4 100644
--- a/packages/web-shell/client/App.module.css
+++ b/packages/web-shell/client/App.module.css
@@ -719,16 +719,10 @@
the wrapper must shrink to its content instead of filling the pane — the same
thing `.content` already does in this state. */
.appChatEmpty .chatViewWrap {
- position: relative;
- z-index: 1;
flex: 0 0 auto;
overflow: visible;
}
-.appChatEmpty .chatPane {
- position: relative;
-}
-
.footer {
position: relative;
width: min(100%, var(--chat-shell-width));
@@ -934,18 +928,12 @@
overflow: hidden;
}
- /* Override chatViewWrap's relative positioning so the absolute footer
- anchors to chatPane. The wrap remains a flex item, so the z-index: 1 it
- gets from `.appChatEmpty .chatViewWrap` still applies under static
- positioning and keeps the welcome content and composer above the dot
- field. The welcome-middle + custom-footer variant keeps the positioned
- wrap: `.footerWithCustomFooter` renders `display: contents` there, so
- the wrap must remain the containing block for `.bottomPanels`. */
+ /* The custom-footer variant renders the composer footer `display: contents`,
+ so this wrap stays the containing block for the absolutely-positioned
+ bottom panels (status panel) anchored above it. */
.appChatEmpty
- .chatViewWithMobileComposerBottom:not(
- .chatViewWithWelcomeMiddle.chatViewWithCustomFooter
- ) {
- position: static;
+ .chatViewWithMobileComposerBottom.chatViewWithWelcomeMiddle.chatViewWithCustomFooter {
+ position: relative;
}
.appChatEmpty .chatViewWithMobileComposerBottom .footer {
diff --git a/packages/web-shell/client/App.test.tsx b/packages/web-shell/client/App.test.tsx
index f6f8e9c2c9f..dfd70b9d875 100644
--- a/packages/web-shell/client/App.test.tsx
+++ b/packages/web-shell/client/App.test.tsx
@@ -693,12 +693,6 @@ vi.mock('./components/ChatEditor', async () => {
};
});
-vi.mock('./components/NewSessionDotField', () => ({
- NewSessionDotField: () => (
-
- ),
-}));
-
vi.mock('./components/MessageList', async () => {
const React = await import('react');
const { useInteractionBlocker } = await import('./interactionBlockContext');
@@ -4986,9 +4980,6 @@ describe('App composer footer renderer', () => {
expect(composer?.nextElementSibling).toBe(composerFooter);
expect(composerFooter?.parentElement).toBe(composer?.parentElement);
expect(composer?.parentElement?.nextElementSibling).toBe(shellFooter);
- expect(
- container.querySelector('[data-web-shell-new-session-dot-field]'),
- ).toBeNull();
});
it('updates composer footer state and renders it in the empty welcome state', async () => {
@@ -5041,9 +5032,6 @@ describe('App composer footer renderer', () => {
expect(
container.querySelector('[data-testid="composer-footer"]'),
).not.toBeNull();
- expect(
- container.querySelector('[data-web-shell-new-session-dot-field]'),
- ).not.toBeNull();
expect(composerFooterProps.at(-1)).toEqual({
disabled: false,
isRunning: false,
diff --git a/packages/web-shell/client/App.tsx b/packages/web-shell/client/App.tsx
index 926e2b16e90..e4381df503a 100644
--- a/packages/web-shell/client/App.tsx
+++ b/packages/web-shell/client/App.tsx
@@ -104,7 +104,6 @@ import {
} from './components/panels/EnvironmentPanel';
import { ChatContextHeader } from './components/ChatContextHeader';
import { WelcomeHeader } from './components/WelcomeHeader';
-import { NewSessionDotField } from './components/NewSessionDotField';
import { ApprovalModeDialog } from './components/dialogs/ApprovalModeDialog';
import { ResumeDialog } from './components/dialogs/ResumeDialog';
import { DialogShell } from './components/dialogs/DialogShell';
@@ -11574,10 +11573,6 @@ export function App({
.filter(Boolean)
.join(' ')}
>
- {isChatEmptyState &&
- !showMissingSessionState &&
- !activePanel &&
- mainView === 'chat' && }
{sidebarOptions.enabled &&
sidebarOptions.showCompactToggle &&
(!chatHeaderEnabled || isChatEmptyState) &&
@@ -12069,8 +12064,9 @@ export function App({
hasWelcomeMiddle
? styles.chatViewWithWelcomeMiddle
: undefined,
- // Marker class (no declarations): keeps the ':not(...)'
- // exclusion in App.module.css matching.
+ // Positioning hook: completes the compound selector in
+ // App.module.css that keeps this wrap relative so the
+ // absolutely-positioned bottom panels keep their anchor.
CustomFooter ? styles.chatViewWithCustomFooter : undefined,
activePanel ||
mainView !== 'chat' ||
@@ -12625,7 +12621,6 @@ export function App({
composerInput={composerInput}
composerInputVersion={composerInputVersion}
placeholderText={composerPlaceholderText}
- animatePlaceholder={isChatEmptyState}
/>
{CustomComposerFooter && (
:last-child {
display: grid;
min-width: 0;
diff --git a/packages/web-shell/client/components/ChatEditor.test.tsx b/packages/web-shell/client/components/ChatEditor.test.tsx
index 45686dab1af..fedf5dddd79 100644
--- a/packages/web-shell/client/components/ChatEditor.test.tsx
+++ b/packages/web-shell/client/components/ChatEditor.test.tsx
@@ -15,7 +15,6 @@ import type {
MobileComposerBackend,
SlashMenuState,
} from '../hooks/useComposerCore';
-import type { UseDaemonFollowupSuggestionReturn } from '@qwen-code/webui/daemon-react-sdk';
import { ChatEditor, type ComposerToolbarAction } from './ChatEditor';
import { WebShellPortalRootContext } from '../portalRoot';
@@ -111,7 +110,6 @@ const composerCoreState = vi.hoisted(() => ({
imageDropCapture: vi.fn(),
clearImageDragState: vi.fn(),
addTags: vi.fn(),
- shellMode: false,
imageDragActive: false,
onFileUploadRequest: undefined as
| ((targetDir: string, restoreQuery?: () => void) => void)
@@ -228,7 +226,7 @@ vi.mock('../hooks/useComposerCore', async (importOriginal) => {
clear: vi.fn(),
retryLast: vi.fn(),
replaceEditorText: vi.fn(),
- shellMode: composerCoreState.shellMode,
+ shellMode: false,
setShellMode: vi.fn(),
toggleShellMode: vi.fn(),
currentMode: 'default',
@@ -286,12 +284,6 @@ vi.mock('../live/LiveVoiceButton', () => ({
LiveVoiceButton: () => ,
}));
-vi.mock('./SpecularComposerEffect', () => ({
- SpecularComposerEffect: () => (
-
- ),
-}));
-
const mounted: Array<{
root: Root;
container: HTMLDivElement;
@@ -300,7 +292,6 @@ const mounted: Array<{
afterEach(() => {
composerCoreState.slashMenu = null;
- composerCoreState.shellMode = false;
composerCoreState.focus.mockReset();
composerCoreState.closeSlashMenu.mockReset();
composerCoreState.mobileComposer = null;
@@ -321,7 +312,6 @@ afterEach(() => {
mockComposerCoreState.pastedFiles = [];
mockComposerCoreState.removeTopTag.mockReset();
latestComposerCoreOptions.current = null;
- vi.useRealTimers();
});
interface ChatEditorRenderProps {
@@ -350,12 +340,9 @@ interface ChatEditorRenderProps {
tokenCount?: number;
contextWindow?: number;
onShowContextUsage?: () => void;
- placeholderText?: string;
- animatePlaceholder?: boolean;
disabled?: boolean;
atWorkspaceCwd?: string;
sessionId?: string;
- followupState?: UseDaemonFollowupSuggestionReturn['followupState'];
customization?: WebShellCustomization;
builtinAtProviders?: WebShellCustomization['builtinAtProviders'];
atProviders?: WebShellCustomization['atProviders'];
@@ -581,163 +568,6 @@ describe('ChatEditor context usage ring', () => {
});
});
-describe('ChatEditor animation layers', () => {
- it('mounts the inert specular layer without replacing composer controls', () => {
- const container = renderChatEditor({});
-
- expect(
- container.querySelector('[data-web-shell-composer-specular]'),
- ).not.toBeNull();
- expect(
- container.querySelector('[data-web-shell-composer-editor]'),
- ).not.toBeNull();
- expect(
- container.querySelector('[data-web-shell-composer-submit]'),
- ).not.toBeNull();
- });
-
- it('keeps the typewriter visible through automatic focus', () => {
- const container = renderChatEditor({});
- const editor = container.querySelector(
- '[data-web-shell-composer-editor]',
- );
- const outside = document.createElement('button');
- container.appendChild(outside);
- editor!.tabIndex = 0;
-
- act(() => editor!.focus());
- expect(container.querySelector('[data-typewriter-visible]')).not.toBeNull();
-
- act(() => {
- editor!.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true }));
- });
- expect(container.querySelector('[data-typewriter-visible]')).toBeNull();
-
- act(() => outside.focus());
- expect(container.querySelector('[data-typewriter-visible]')).not.toBeNull();
- });
-
- it('plays the typewriter twice and then keeps the completed text', () => {
- vi.useFakeTimers();
- const container = renderChatEditor({ placeholderText: 'abc' });
- const typewriter = () =>
- container.querySelector('[data-web-shell-composer-typewriter]');
-
- expect(typewriter()?.textContent).toBe('_');
-
- act(() => vi.advanceTimersByTime(3 * 45));
- expect(typewriter()?.textContent).toBe('abc_');
-
- act(() => vi.advanceTimersByTime(3000));
- expect(typewriter()?.textContent).toBe('_');
-
- act(() => vi.advanceTimersByTime(3 * 45));
- expect(typewriter()?.textContent).toBe('abc_');
-
- act(() => vi.advanceTimersByTime(3001));
- expect(typewriter()?.textContent).toBe('abc_');
- });
-
- it('does not mount the typewriter for an empty placeholder', () => {
- const container = renderChatEditor({ placeholderText: '' });
-
- expect(
- container.querySelector('[data-web-shell-composer-typewriter]'),
- ).toBeNull();
- 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(
- (query: string) =>
- ({
- matches: query === '(prefers-reduced-motion: reduce)',
- media: query,
- addEventListener: vi.fn(),
- removeEventListener: vi.fn(),
- }) as MediaQueryList,
- );
- try {
- const container = renderChatEditor({ placeholderText: 'abc' });
- const typewriter = container.querySelector(
- '[data-web-shell-composer-typewriter]',
- );
-
- expect(typewriter?.textContent).toBe('abc');
- } finally {
- window.matchMedia = originalMatchMedia;
- }
- });
-
- it('replays the typewriter sequence after the empty editor loses focus', () => {
- vi.useFakeTimers();
- const container = renderChatEditor({ placeholderText: 'abc' });
- const editor = container.querySelector(
- '[data-web-shell-composer-editor]',
- );
- const outside = document.createElement('button');
- container.appendChild(outside);
- editor!.tabIndex = 0;
- const typewriter = () =>
- container.querySelector('[data-web-shell-composer-typewriter]');
-
- act(() => editor!.focus());
- act(() => {
- editor!.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true }));
- });
- expect(typewriter()).toBeNull();
-
- act(() => outside.focus());
- expect(typewriter()?.textContent).toBe('_');
-
- act(() => vi.advanceTimersByTime(3 * 45));
- expect(typewriter()?.textContent).toBe('abc_');
- });
-
- it('hides the typewriter when disabled, in shell mode, or during a followup', () => {
- const typewriterOf = (container: HTMLElement) =>
- container.querySelector('[data-web-shell-composer-typewriter]');
-
- expect(
- typewriterOf(renderChatEditor({ placeholderText: 'abc' })),
- ).not.toBeNull();
-
- expect(
- typewriterOf(
- renderChatEditor({ placeholderText: 'abc', disabled: true }),
- ),
- ).toBeNull();
-
- composerCoreState.shellMode = true;
- expect(
- typewriterOf(renderChatEditor({ placeholderText: 'abc' })),
- ).toBeNull();
- composerCoreState.shellMode = false;
-
- expect(
- typewriterOf(
- renderChatEditor({
- placeholderText: 'abc',
- followupState: { suggestion: 'next', isVisible: true, shownAt: 0 },
- }),
- ),
- ).toBeNull();
- });
-});
-
describe('ChatEditor attachment reporting', () => {
it('reports whether the composer has tags or pasted images', () => {
const onEmptyAttachmentsChange = vi.fn();
diff --git a/packages/web-shell/client/components/ChatEditor.tsx b/packages/web-shell/client/components/ChatEditor.tsx
index 02ec0fc4fbc..e26b4051a69 100644
--- a/packages/web-shell/client/components/ChatEditor.tsx
+++ b/packages/web-shell/client/components/ChatEditor.tsx
@@ -31,8 +31,6 @@ import type { SkillInfo } from '../completions/slashCompletion';
import { useI18n } from '../i18n';
import type { DaemonReasoningControls } from '@qwen-code/webui/daemon-react-sdk';
import { useWebShellPortalRoot } from '../portalRoot';
-import { usePrefersReducedMotion } from '../hooks/usePrefersReducedMotion';
-import { SpecularComposerEffect } from './SpecularComposerEffect';
import {
useWebShellCustomization,
type WebShellComposerInput,
@@ -172,7 +170,6 @@ interface ChatEditorProps {
cancelArmed?: boolean;
disabled?: boolean;
placeholderText?: string;
- animatePlaceholder?: boolean;
commands: CommandInfo[];
skills?: SkillInfo[];
slashCommandCategoryOrder?: CommandDisplayCategoryOrder;
@@ -470,63 +467,89 @@ function QuickActionsIcon() {
);
}
-function TypewriterPlaceholder({ text }: { text: string }) {
- const totalRuns = 2;
- const replayDelay = 3000;
- const reducedMotion = usePrefersReducedMotion();
- const [visibleText, setVisibleText] = useState(reducedMotion ? text : '');
- const [finished, setFinished] = useState(false);
+function attachComposerGlow(glowRootEl: HTMLElement, inputEl: HTMLElement) {
+ let glowRaf: number | undefined;
+ let pulseRaf: number | undefined;
+ let pulseDecayTimer: number | undefined;
+ let typingTimer: number | undefined;
+ let glowCurrent = 0;
+ let pulseCurrent = 0;
+
+ const apply = (on: number, pulse: number) => {
+ glowRootEl.style.setProperty('--dac-glow-on', on.toFixed(4));
+ glowRootEl.style.setProperty('--dac-glow-pulse', pulse.toFixed(4));
+ };
- useEffect(() => {
- if (reducedMotion) {
- setVisibleText(text);
- setFinished(false);
- return undefined;
+ const animateGlow = (target: number) => {
+ if (glowRaf !== undefined) window.cancelAnimationFrame(glowRaf);
+ const start = glowCurrent;
+ const diff = target - start;
+ if (Math.abs(diff) < 0.001) {
+ glowCurrent = target;
+ apply(target, pulseCurrent);
+ return;
}
+ const t0 = performance.now();
+ const tick = (now: number) => {
+ const t = Math.min((now - t0) / 220, 1);
+ glowCurrent = start + diff * (1 - (1 - t) ** 2);
+ apply(glowCurrent, pulseCurrent);
+ glowRaf = t < 1 ? window.requestAnimationFrame(tick) : undefined;
+ };
+ glowRaf = window.requestAnimationFrame(tick);
+ };
- let run = 0;
- let timer = 0;
- const startRun = () => {
- let index = 0;
- setVisibleText('');
- setFinished(false);
- const typeNextCharacter = () => {
- index += 1;
- setVisibleText(text.slice(0, index));
- if (index === text.length) {
- run += 1;
- setFinished(true);
- if (run < totalRuns) {
- timer = window.setTimeout(startRun, replayDelay);
- }
- return;
- }
- timer = window.setTimeout(typeNextCharacter, 45);
- };
- timer = window.setTimeout(typeNextCharacter, 45);
+ const animatePulseDecay = () => {
+ if (pulseRaf !== undefined) window.cancelAnimationFrame(pulseRaf);
+ const start = pulseCurrent;
+ const t0 = performance.now();
+ const tick = (now: number) => {
+ const t = Math.min((now - t0) / 300, 1);
+ pulseCurrent = start * (1 - t);
+ apply(glowCurrent, pulseCurrent);
+ pulseRaf = t < 1 ? window.requestAnimationFrame(tick) : undefined;
};
- startRun();
- return () => window.clearTimeout(timer);
- }, [reducedMotion, text]);
+ pulseRaf = window.requestAnimationFrame(tick);
+ };
- return (
-
- {visibleText}
- {!reducedMotion && (
-
- _
-
- )}
-
- );
+ const setTyping = (on: boolean) => {
+ if (on) glowRootEl.setAttribute('data-dac-typing', '');
+ else glowRootEl.removeAttribute('data-dac-typing');
+ };
+
+ const onFocus = () => animateGlow(1);
+ const onBlur = () => {
+ animateGlow(0);
+ setTyping(false);
+ if (typingTimer !== undefined) window.clearTimeout(typingTimer);
+ };
+ const onKeydown = () => {
+ if (pulseRaf !== undefined) window.cancelAnimationFrame(pulseRaf);
+ if (pulseDecayTimer !== undefined) window.clearTimeout(pulseDecayTimer);
+ pulseCurrent = 1;
+ apply(glowCurrent, 1);
+ pulseDecayTimer = window.setTimeout(animatePulseDecay, 100);
+ setTyping(true);
+ if (typingTimer !== undefined) window.clearTimeout(typingTimer);
+ typingTimer = window.setTimeout(() => setTyping(false), 650);
+ };
+
+ inputEl.addEventListener('focus', onFocus);
+ inputEl.addEventListener('blur', onBlur);
+ inputEl.addEventListener('keydown', onKeydown);
+ if (document.activeElement === inputEl) animateGlow(1);
+
+ return () => {
+ if (glowRaf !== undefined) window.cancelAnimationFrame(glowRaf);
+ if (pulseRaf !== undefined) window.cancelAnimationFrame(pulseRaf);
+ if (pulseDecayTimer !== undefined) window.clearTimeout(pulseDecayTimer);
+ if (typingTimer !== undefined) window.clearTimeout(typingTimer);
+ inputEl.removeEventListener('focus', onFocus);
+ inputEl.removeEventListener('blur', onBlur);
+ inputEl.removeEventListener('keydown', onKeydown);
+ apply(0, 0);
+ setTyping(false);
+ };
}
function WidthModeIcon({ mode }: { mode: '1000' | 'wide' }) {
@@ -1412,7 +1435,6 @@ export const ChatEditor = memo(
cancelArmed = false,
disabled = false,
placeholderText = 'Type a message...',
- animatePlaceholder = true,
commands,
skills = [],
slashCommandCategoryOrder,
@@ -1817,7 +1839,6 @@ export const ChatEditor = memo(
const [quickActionsOpen, setQuickActionsOpen] = useState(false);
const [branchPickerOpen, setBranchPickerOpen] = useState(false);
const [showQuickActions, setShowQuickActions] = useState(isTouchLikeDevice);
- const [typewriterSuppressed, setTypewriterSuppressed] = useState(false);
const containerRef = useRef(null);
const slashPanelRef = useRef(null);
const slashDetailRef = useRef(null);
@@ -1845,14 +1866,7 @@ export const ChatEditor = memo(
const closeAtMenu = core.closeAtMenu;
const hasSlashMenu = Boolean(slashMenu);
const hasAtMenu = Boolean(atMenu);
- const showTypewriterPlaceholder =
- animatePlaceholder &&
- !disabled &&
- Boolean(placeholderText) &&
- !core.hasInput() &&
- !typewriterSuppressed &&
- !core.shellMode &&
- !followupState?.isVisible;
+ const editorViewRef = core.viewRef;
useEffect(() => {
if (typeof window === 'undefined' || !window.matchMedia) return;
@@ -1892,6 +1906,17 @@ export const ChatEditor = memo(
};
}, [hasAtMenu, hasSlashMenu, closeAtMenu, closeSlashMenu]);
+ // editorViewRef is stable for the component's lifetime, so this effect
+ // runs once and the glow stays attached to the initial contentDOM; it
+ // re-attaches only if the view ref itself is replaced (CodeMirror
+ // recreates contentDOM on view swap, which is uncommon).
+ useEffect(() => {
+ const glowRoot = containerRef.current;
+ const inputEl = editorViewRef.current?.contentDOM;
+ if (!glowRoot || !inputEl) return undefined;
+ return attachComposerGlow(glowRoot, inputEl);
+ }, [editorViewRef]);
+
useEffect(() => {
const container = containerRef.current;
const minWidth = chatWidthToggleMin;
@@ -2508,7 +2533,6 @@ export const ChatEditor = memo(
className={styles.container}
data-web-shell-composer-surface
data-upload-drag-active={uploadDragActive || undefined}
- data-typewriter-visible={showTypewriterPlaceholder || undefined}
data-image-drag-active={
(core.imageDragActive && !uploadDragActive) || undefined
}
@@ -2518,6 +2542,10 @@ export const ChatEditor = memo(
onDragOver={handleUploadDragOver}
onDragLeave={handleUploadDragLeave}
onDropCapture={handleUploadDrop}
+ // Legacy marker from the pre-#8098 glow implementation; no CSS or
+ // script consumes it today, kept as-is to stay faithful to the
+ // restored original.
+ data-dac-glow
onClick={() => {
setModeDropdownOpen(false);
setModelDropdownOpen(false);
@@ -2525,7 +2553,8 @@ export const ChatEditor = memo(
core.focus();
}}
>
-
+
+
{uploadEnabled && (
)}
- setTypewriterSuppressed(true)}
- onKeyDownCapture={() => setTypewriterSuppressed(true)}
- onBlurCapture={(event) => {
- if (!event.currentTarget.contains(event.relatedTarget)) {
- setTypewriterSuppressed(false);
- }
- }}
- >
- {showTypewriterPlaceholder && (
-
- )}
+
{core.shellMode && (
!
diff --git a/packages/web-shell/client/components/ChatPane.test.tsx b/packages/web-shell/client/components/ChatPane.test.tsx
index 54d0d6ec211..9433d57e28f 100644
--- a/packages/web-shell/client/components/ChatPane.test.tsx
+++ b/packages/web-shell/client/components/ChatPane.test.tsx
@@ -313,9 +313,6 @@ vi.mock('./ChatEditor', () => ({
);
}),
}));
-vi.mock('./SpecularComposerEffect', () => ({
- SpecularComposerEffect: () => null,
-}));
vi.mock('./QueuedPromptDisplay', () => ({
QueuedPromptDisplay: (props: any) => (
{CustomComposerFooter && (
(null);
- const canvasRef = useRef(null);
- const theme = useTheme();
- const reducedMotion = usePrefersReducedMotion();
-
- useEffect(() => {
- const root = rootRef.current;
- const canvas = canvasRef.current;
- const context = canvas?.getContext('2d', { alpha: true });
- if (!root || !canvas || !context) return undefined;
-
- const dots: Dot[] = [];
- const pointer = {
- x: -9999,
- y: -9999,
- previousX: -9999,
- previousY: -9999,
- speed: 0,
- };
- let width = 0;
- let height = 0;
- let engagement = 0;
- let glowOpacity = 0;
- let frameId = 0;
- let running = false;
- let needsRepaint = true;
- let speedTimer: number | undefined;
-
- const buildDots = () => {
- dots.length = 0;
- const step = 15;
- const columns = Math.floor(width / step);
- const rows = Math.floor(height / step);
- const paddingX = (width % step) / 2;
- const paddingY = (height % step) / 2;
- for (let row = 0; row < rows; row += 1) {
- for (let column = 0; column < columns; column += 1) {
- const anchorX = paddingX + column * step + step / 2;
- const anchorY = paddingY + row * step + step / 2;
- dots.push({
- anchorX,
- anchorY,
- x: anchorX,
- y: anchorY,
- });
- }
- }
- };
-
- const draw = () => {
- const targetEngagement = Math.min(pointer.speed / 5, 1);
- engagement += (targetEngagement - engagement) * 0.06;
- if (engagement < 0.001) engagement = 0;
- glowOpacity += (engagement - glowOpacity) * 0.08;
-
- let settled = engagement === 0 && glowOpacity < 0.001;
- if (settled) {
- for (const dot of dots) {
- if (
- Math.abs(dot.x - dot.anchorX) > 0.01 ||
- Math.abs(dot.y - dot.anchorY) > 0.01
- ) {
- settled = false;
- break;
- }
- }
- }
-
- if (settled && !needsRepaint) {
- running = false;
- frameId = 0;
- return;
- }
-
- context.clearRect(0, 0, width, height);
- context.fillStyle =
- theme === WebShellThemeId.Light
- ? 'rgba(172, 191, 255, 0.85)'
- : 'rgba(172, 191, 255, 0.42)';
- context.beginPath();
-
- const cursorRadiusSquared = 500 * 500;
- for (const dot of dots) {
- const dx = pointer.x - dot.anchorX;
- const dy = pointer.y - dot.anchorY;
- const distanceSquared = dx * dx + dy * dy;
- if (
- !reducedMotion &&
- distanceSquared < cursorRadiusSquared &&
- engagement > 0.01
- ) {
- const distance = Math.sqrt(distanceSquared);
- const amount = 1 - distance / 500;
- const push = amount * amount * 67 * engagement;
- const direction = Math.atan2(dy, dx);
- dot.x += (dot.anchorX - Math.cos(direction) * push - dot.x) * 0.15;
- dot.y += (dot.anchorY - Math.sin(direction) * push - dot.y) * 0.15;
- } else {
- dot.x += (dot.anchorX - dot.x) * 0.1;
- dot.y += (dot.anchorY - dot.y) * 0.1;
- }
- context.moveTo(dot.x + 0.5, dot.y);
- context.arc(dot.x, dot.y, 0.5, 0, Math.PI * 2);
- }
- context.fill();
-
- if (!reducedMotion && glowOpacity > 0.001) {
- const glow = context.createRadialGradient(
- pointer.x,
- pointer.y,
- 0,
- pointer.x,
- pointer.y,
- 160,
- );
- glow.addColorStop(0, 'rgba(0, 0, 0, 1)');
- glow.addColorStop(1, 'rgba(0, 0, 0, 0)');
- context.globalAlpha = glowOpacity;
- context.globalCompositeOperation = 'destination-out';
- context.fillStyle = glow;
- context.fillRect(pointer.x - 160, pointer.y - 160, 320, 320);
- context.globalCompositeOperation = 'source-over';
- context.globalAlpha = 1;
- }
-
- needsRepaint = false;
- if (!reducedMotion) frameId = window.requestAnimationFrame(draw);
- };
-
- const startLoop = () => {
- if (!reducedMotion && !running) {
- running = true;
- frameId = window.requestAnimationFrame(draw);
- }
- };
-
- const resize = () => {
- const dpr = Math.min(window.devicePixelRatio || 1, 2);
- const rect = root.getBoundingClientRect();
- width = rect.width;
- height = rect.height;
- canvas.width = Math.ceil(width * dpr);
- canvas.height = Math.ceil(height * dpr);
- context.setTransform(dpr, 0, 0, dpr, 0, 0);
- buildDots();
- needsRepaint = true;
- if (reducedMotion) draw();
- else startLoop();
- };
-
- const onPointerMove = (event: PointerEvent) => {
- const rect = root.getBoundingClientRect();
- const x = event.clientX - rect.left;
- const y = event.clientY - rect.top;
- if (pointer.previousX === -9999) {
- pointer.previousX = x;
- pointer.previousY = y;
- }
- pointer.x = x;
- pointer.y = y;
- startLoop();
- };
-
- const resizeObserver = new ResizeObserver(resize);
- resizeObserver.observe(root);
- resize();
- if (!reducedMotion) {
- window.addEventListener('pointermove', onPointerMove, { passive: true });
- speedTimer = window.setInterval(() => {
- const dx = pointer.previousX - pointer.x;
- const dy = pointer.previousY - pointer.y;
- const distance = Math.hypot(dx, dy);
- pointer.speed += (distance - pointer.speed) * 0.5;
- if (pointer.speed < 0.001) pointer.speed = 0;
- pointer.previousX = pointer.x;
- pointer.previousY = pointer.y;
- if (pointer.speed > 0) startLoop();
- }, 20);
- }
-
- return () => {
- window.cancelAnimationFrame(frameId);
- if (speedTimer !== undefined) window.clearInterval(speedTimer);
- resizeObserver.disconnect();
- window.removeEventListener('pointermove', onPointerMove);
- };
- }, [reducedMotion, theme]);
-
- return (
-
-
-
- );
-}
diff --git a/packages/web-shell/client/components/SpecularComposerEffect.test.tsx b/packages/web-shell/client/components/SpecularComposerEffect.test.tsx
deleted file mode 100644
index 06177e74609..00000000000
--- a/packages/web-shell/client/components/SpecularComposerEffect.test.tsx
+++ /dev/null
@@ -1,848 +0,0 @@
-// @vitest-environment jsdom
-
-import { act, useRef } from 'react';
-import { createRoot } from 'react-dom/client';
-import { afterEach, describe, expect, it, vi } from 'vitest';
-import { ThemeProvider, WebShellThemeId } from '../themeContext';
-import { NewSessionDotField } from './NewSessionDotField';
-import { SpecularComposerEffect } from './SpecularComposerEffect';
-
-const mounted: Array<{
- container: HTMLDivElement;
- root: ReturnType;
-}> = [];
-
-afterEach(() => {
- for (const { container, root } of mounted.splice(0)) {
- act(() => root.unmount());
- container.remove();
- }
- vi.useRealTimers();
- vi.restoreAllMocks();
-});
-
-describe('composer visual effects fallback', () => {
- it('keeps both animation layers inert when canvas contexts are unavailable', () => {
- vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(null);
-
- function Harness() {
- const composerRef = useRef(null);
- return (
-
-
-
-
- );
- }
-
- const requestAnimationFrameSpy = vi.spyOn(window, 'requestAnimationFrame');
- const setIntervalSpy = vi.spyOn(window, 'setInterval');
-
- const container = document.createElement('div');
- document.body.appendChild(container);
- const root = createRoot(container);
- mounted.push({ container, root });
- requestAnimationFrameSpy.mockClear();
- setIntervalSpy.mockClear();
- act(() => root.render( ));
-
- expect(
- container.querySelector('[data-web-shell-composer-editor]'),
- ).not.toBeNull();
- expect(
- container.querySelector('[data-web-shell-composer-specular] canvas'),
- ).toBeNull();
- expect(
- container.querySelector('[data-web-shell-new-session-dot-field] canvas'),
- ).not.toBeNull();
- expect(setIntervalSpy).not.toHaveBeenCalled();
- expect(requestAnimationFrameSpy).not.toHaveBeenCalled();
- });
-
- it('erases dots for the pointer glow regardless of the background color', () => {
- vi.useFakeTimers();
- const addColorStop = vi.fn();
- const compositeOperations: string[] = [];
- const globalAlphas: number[] = [];
- const contextStub = {
- arc: vi.fn(),
- beginPath: vi.fn(),
- clearRect: vi.fn(),
- createRadialGradient: vi.fn(() => ({ addColorStop })),
- fill: vi.fn(),
- fillRect: vi.fn(),
- moveTo: vi.fn(),
- setTransform: vi.fn(),
- };
- Object.defineProperty(contextStub, 'globalCompositeOperation', {
- configurable: true,
- get: () => compositeOperations.at(-1) ?? 'source-over',
- set: (value: string) => compositeOperations.push(value),
- });
- Object.defineProperty(contextStub, 'globalAlpha', {
- configurable: true,
- get: () => globalAlphas.at(-1) ?? 1,
- set: (value: number) => globalAlphas.push(value),
- });
- const context = contextStub as unknown as CanvasRenderingContext2D;
- vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(
- context,
- );
-
- let drawFrame: FrameRequestCallback | undefined;
- vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => {
- drawFrame = callback;
- return 1;
- });
-
- const container = document.createElement('div');
- container.style.setProperty('--background', '#ff00ff');
- document.body.appendChild(container);
- const root = createRoot(container);
- mounted.push({ container, root });
- act(() =>
- root.render(
-
-
- ,
- ),
- );
-
- // Move the pointer across two speed samples so the field measures a real
- // non-zero speed instead of relying on the unset previous-position seed.
- act(() => {
- window.dispatchEvent(
- new MouseEvent('pointermove', { clientX: 100, clientY: 100 }),
- );
- });
- act(() => {
- vi.advanceTimersByTime(20);
- });
- act(() => {
- window.dispatchEvent(
- new MouseEvent('pointermove', { clientX: 300, clientY: 300 }),
- );
- });
- act(() => {
- vi.advanceTimersByTime(20);
- drawFrame?.(0);
- });
-
- expect(addColorStop).toHaveBeenCalledWith(0, 'rgba(0, 0, 0, 1)');
- expect(addColorStop).toHaveBeenCalledWith(1, 'rgba(0, 0, 0, 0)');
- expect(compositeOperations).toEqual(['destination-out', 'source-over']);
- expect(globalAlphas).toHaveLength(2);
- expect(globalAlphas[0]).toBeLessThan(1);
- expect(globalAlphas[1]).toBe(1);
- });
-
- it('keeps both animation layers inert under prefers-reduced-motion', () => {
- vi.spyOn(window, 'matchMedia').mockImplementation(
- (query: string) =>
- ({
- matches: query === '(prefers-reduced-motion: reduce)',
- media: query,
- addEventListener: vi.fn(),
- removeEventListener: vi.fn(),
- }) as MediaQueryList,
- );
- const contextStub = {
- arc: vi.fn(),
- beginPath: vi.fn(),
- clearRect: vi.fn(),
- createRadialGradient: vi.fn(() => ({ addColorStop: vi.fn() })),
- fill: vi.fn(),
- fillRect: vi.fn(),
- moveTo: vi.fn(),
- setTransform: vi.fn(),
- };
- vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(
- contextStub as unknown as CanvasRenderingContext2D,
- );
- const requestAnimationFrameSpy = vi.spyOn(window, 'requestAnimationFrame');
- const setIntervalSpy = vi.spyOn(window, 'setInterval');
-
- function Harness() {
- const composerRef = useRef(null);
- return (
-
-
-
-
- );
- }
-
- const container = document.createElement('div');
- document.body.appendChild(container);
- const root = createRoot(container);
- mounted.push({ container, root });
- requestAnimationFrameSpy.mockClear();
- setIntervalSpy.mockClear();
- act(() => root.render( ));
-
- expect(
- container.querySelector('[data-web-shell-composer-specular] canvas'),
- ).toBeNull();
- expect(
- container.querySelector('[data-web-shell-new-session-dot-field] canvas'),
- ).not.toBeNull();
- expect(setIntervalSpy).not.toHaveBeenCalled();
- expect(requestAnimationFrameSpy).not.toHaveBeenCalled();
- });
-
- it('cancels the dot field interval and animation frame on unmount', () => {
- vi.useFakeTimers();
- const contextStub = {
- arc: vi.fn(),
- beginPath: vi.fn(),
- clearRect: vi.fn(),
- createRadialGradient: vi.fn(() => ({ addColorStop: vi.fn() })),
- fill: vi.fn(),
- fillRect: vi.fn(),
- moveTo: vi.fn(),
- setTransform: vi.fn(),
- };
- vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(
- contextStub as unknown as CanvasRenderingContext2D,
- );
- vi.spyOn(window, 'requestAnimationFrame').mockImplementation(() => 1);
- const cancelAnimationFrameSpy = vi
- .spyOn(window, 'cancelAnimationFrame')
- .mockImplementation(() => {});
- const clearIntervalSpy = vi
- .spyOn(window, 'clearInterval')
- .mockImplementation(() => {});
-
- const container = document.createElement('div');
- document.body.appendChild(container);
- const root = createRoot(container);
- act(() =>
- root.render(
-
-
- ,
- ),
- );
-
- act(() => root.unmount());
- container.remove();
-
- expect(clearIntervalSpy).toHaveBeenCalled();
- expect(cancelAnimationFrameSpy).toHaveBeenCalled();
- });
-});
-
-describe('specular effect idle bail-out', () => {
- it('stops the rAF loop only after brightness decays below the threshold', () => {
- const drawArrays = vi.fn();
- const clear = vi.fn();
- const glStub = {
- ARRAY_BUFFER: 0x8892,
- BLEND: 0x0be2,
- COLOR_BUFFER_BIT: 0x4000,
- COMPILE_STATUS: 0x8b81,
- FLOAT: 0x1406,
- FRAGMENT_SHADER: 0x8b30,
- LINK_STATUS: 0x8b82,
- ONE: 1,
- ONE_MINUS_SRC_ALPHA: 0x0303,
- STATIC_DRAW: 0x88e4,
- TRIANGLES: 0x0004,
- VERTEX_SHADER: 0x8b31,
- attachShader: vi.fn(),
- bindBuffer: vi.fn(),
- blendFunc: vi.fn(),
- bufferData: vi.fn(),
- clear,
- compileShader: vi.fn(),
- createBuffer: vi.fn(() => ({})),
- createProgram: vi.fn(() => ({})),
- createShader: vi.fn(() => ({})),
- deleteBuffer: vi.fn(),
- deleteProgram: vi.fn(),
- deleteShader: vi.fn(),
- drawArrays,
- enable: vi.fn(),
- enableVertexAttribArray: vi.fn(),
- getAttribLocation: vi.fn(() => 0),
- getExtension: vi.fn(() => ({ loseContext: vi.fn() })),
- getProgramParameter: vi.fn(() => true),
- getShaderParameter: vi.fn(() => true),
- getUniformLocation: vi.fn(() => ({})),
- linkProgram: vi.fn(),
- shaderSource: vi.fn(),
- uniform1f: vi.fn(),
- uniform2f: vi.fn(),
- uniform3f: vi.fn(),
- useProgram: vi.fn(),
- vertexAttribPointer: vi.fn(),
- viewport: vi.fn(),
- };
- vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(
- glStub as unknown as WebGL2RenderingContext,
- );
-
- const rafCallbacks: FrameRequestCallback[] = [];
- let rafId = 0;
- vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => {
- rafCallbacks.push(callback);
- return ++rafId;
- });
- vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => {});
-
- function Harness() {
- const composerRef = useRef(null);
- return (
-
-
-
- );
- }
-
- const container = document.createElement('div');
- document.body.appendChild(container);
- const root = createRoot(container);
- mounted.push({ container, root });
- act(() => root.render( ));
-
- expect(rafCallbacks.length).toBe(1);
-
- // Start at the real clock so the first delta is positive; the effect
- // seeds lastFrame from performance.now() and a synthetic clock starting
- // at zero would produce negative deltas that break the exponential decay.
- let now = performance.now();
- const drainFrames = (count: number, stepMs: number) => {
- for (let i = 0; i < count; i++) {
- const callback = rafCallbacks.shift();
- if (!callback) break;
- now += stepMs;
- callback(now);
- }
- };
-
- // Move the pointer close so proximity (and therefore brightness) rises,
- // then drain frames while the highlight is visibly above the threshold.
- act(() => {
- window.dispatchEvent(
- new MouseEvent('pointermove', { clientX: 100, clientY: 100 }),
- );
- });
- drainFrames(30, 16);
- const drawsWhileBright = drawArrays.mock.calls.length;
- expect(drawsWhileBright).toBeGreaterThan(0);
-
- // Move the pointer far away so proximity drops to zero and brightness
- // decays exponentially until the bail-out threshold is crossed.
- act(() => {
- window.dispatchEvent(
- new MouseEvent('pointermove', { clientX: 300, clientY: 300 }),
- );
- });
- drainFrames(120, 16);
-
- // The loop kept drawing through the decay, then stopped — no new rAF
- // callback queued, and the final frame cleared without drawing.
- expect(drawArrays.mock.calls.length).toBeGreaterThan(drawsWhileBright);
- expect(rafCallbacks.length).toBe(0);
- expect(clear.mock.calls.length).toBeGreaterThan(
- drawArrays.mock.calls.length,
- );
-
- // A nearby pointer move restarts the loop.
- act(() => {
- window.dispatchEvent(
- new MouseEvent('pointermove', { clientX: 100, clientY: 100 }),
- );
- });
- expect(rafCallbacks.length).toBe(1);
- });
-});
-
-describe('specular effect pointer leave', () => {
- it('resets proximity so the idle bail-out fires after the pointer leaves', () => {
- const drawArrays = vi.fn();
- const clear = vi.fn();
- const glStub = {
- ARRAY_BUFFER: 0x8892,
- BLEND: 0x0be2,
- COLOR_BUFFER_BIT: 0x4000,
- COMPILE_STATUS: 0x8b81,
- FLOAT: 0x1406,
- FRAGMENT_SHADER: 0x8b30,
- LINK_STATUS: 0x8b82,
- ONE: 1,
- ONE_MINUS_SRC_ALPHA: 0x0303,
- STATIC_DRAW: 0x88e4,
- TRIANGLES: 0x0004,
- VERTEX_SHADER: 0x8b31,
- attachShader: vi.fn(),
- bindBuffer: vi.fn(),
- blendFunc: vi.fn(),
- bufferData: vi.fn(),
- clear,
- compileShader: vi.fn(),
- createBuffer: vi.fn(() => ({})),
- createProgram: vi.fn(() => ({})),
- createShader: vi.fn(() => ({})),
- deleteBuffer: vi.fn(),
- deleteProgram: vi.fn(),
- deleteShader: vi.fn(),
- drawArrays,
- enable: vi.fn(),
- enableVertexAttribArray: vi.fn(),
- getAttribLocation: vi.fn(() => 0),
- getExtension: vi.fn(() => ({ loseContext: vi.fn() })),
- getProgramParameter: vi.fn(() => true),
- getShaderParameter: vi.fn(() => true),
- getUniformLocation: vi.fn(() => ({})),
- linkProgram: vi.fn(),
- shaderSource: vi.fn(),
- uniform1f: vi.fn(),
- uniform2f: vi.fn(),
- uniform3f: vi.fn(),
- useProgram: vi.fn(),
- vertexAttribPointer: vi.fn(),
- viewport: vi.fn(),
- };
- vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(
- glStub as unknown as WebGL2RenderingContext,
- );
-
- const rafCallbacks: FrameRequestCallback[] = [];
- let rafId = 0;
- vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => {
- rafCallbacks.push(callback);
- return ++rafId;
- });
- vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => {});
-
- function Harness() {
- const composerRef = useRef(null);
- return (
-
-
-
- );
- }
-
- const container = document.createElement('div');
- document.body.appendChild(container);
- const root = createRoot(container);
- mounted.push({ container, root });
- act(() => root.render( ));
-
- let now = performance.now();
- const drainFrames = (count: number, stepMs: number) => {
- for (let i = 0; i < count; i++) {
- const callback = rafCallbacks.shift();
- if (!callback) break;
- now += stepMs;
- callback(now);
- }
- };
-
- // Move the pointer close so proximity rises and the loop is drawing.
- act(() => {
- window.dispatchEvent(
- new MouseEvent('pointermove', { clientX: 100, clientY: 100 }),
- );
- });
- drainFrames(10, 16);
- expect(drawArrays.mock.calls.length).toBeGreaterThan(0);
-
- // Pointer leaves the document — proximity resets to zero.
- act(() => {
- document.documentElement.dispatchEvent(new MouseEvent('pointerleave'));
- });
-
- // Brightness decays and the idle bail-out stops the loop.
- drainFrames(120, 16);
- expect(rafCallbacks.length).toBe(0);
- });
-});
-
-describe('specular effect WebGL cleanup', () => {
- it('removes a lost canvas and releases WebGL resources on unmount', () => {
- const loseContext = vi.fn();
- const glStub = {
- ARRAY_BUFFER: 0x8892,
- BLEND: 0x0be2,
- COLOR_BUFFER_BIT: 0x4000,
- COMPILE_STATUS: 0x8b81,
- FLOAT: 0x1406,
- FRAGMENT_SHADER: 0x8b30,
- LINK_STATUS: 0x8b82,
- ONE: 1,
- ONE_MINUS_SRC_ALPHA: 0x0303,
- STATIC_DRAW: 0x88e4,
- TRIANGLES: 0x0004,
- VERTEX_SHADER: 0x8b31,
- attachShader: vi.fn(),
- bindBuffer: vi.fn(),
- blendFunc: vi.fn(),
- bufferData: vi.fn(),
- clear: vi.fn(),
- compileShader: vi.fn(),
- createBuffer: vi.fn(() => ({})),
- createProgram: vi.fn(() => ({})),
- createShader: vi.fn(() => ({})),
- deleteBuffer: vi.fn(),
- deleteProgram: vi.fn(),
- deleteShader: vi.fn(),
- drawArrays: vi.fn(),
- enable: vi.fn(),
- enableVertexAttribArray: vi.fn(),
- getAttribLocation: vi.fn(() => 0),
- getExtension: vi.fn(() => ({ loseContext })),
- getProgramParameter: vi.fn(() => true),
- getShaderParameter: vi.fn(() => true),
- getUniformLocation: vi.fn(() => ({})),
- linkProgram: vi.fn(),
- shaderSource: vi.fn(),
- uniform1f: vi.fn(),
- uniform2f: vi.fn(),
- uniform3f: vi.fn(),
- useProgram: vi.fn(),
- vertexAttribPointer: vi.fn(),
- viewport: vi.fn(),
- };
- vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(
- glStub as unknown as WebGL2RenderingContext,
- );
- const requestAnimationFrameSpy = vi
- .spyOn(window, 'requestAnimationFrame')
- .mockImplementation(() => 1);
- const cancelAnimationFrameSpy = vi
- .spyOn(window, 'cancelAnimationFrame')
- .mockImplementation(() => {});
-
- function Harness() {
- const composerRef = useRef(null);
- return (
-
-
-
- );
- }
-
- const container = document.createElement('div');
- document.body.appendChild(container);
- const root = createRoot(container);
- act(() => root.render( ));
-
- expect(
- container.querySelector('[data-web-shell-composer-specular] canvas'),
- ).not.toBeNull();
-
- 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(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();
-
- requestAnimationFrameSpy.mockClear();
- act(() => {
- window.dispatchEvent(
- new MouseEvent('pointermove', { clientX: 100, clientY: 100 }),
- );
- });
- expect(requestAnimationFrameSpy).not.toHaveBeenCalled();
-
- act(() => root.unmount());
- container.remove();
-
- expect(glStub.deleteBuffer).toHaveBeenCalled();
- expect(glStub.deleteProgram).toHaveBeenCalled();
- expect(glStub.deleteShader).toHaveBeenCalled();
- expect(loseContext).toHaveBeenCalled();
- });
-});
-
-describe('dot field settled skip', () => {
- it('skips canvas draws when all dots are settled and engagement is zero', () => {
- vi.useFakeTimers();
- const contextStub = {
- arc: vi.fn(),
- beginPath: vi.fn(),
- clearRect: vi.fn(),
- createRadialGradient: vi.fn(() => ({ addColorStop: vi.fn() })),
- fill: vi.fn(),
- fillRect: vi.fn(),
- moveTo: vi.fn(),
- setTransform: vi.fn(),
- };
- vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(
- contextStub as unknown as CanvasRenderingContext2D,
- );
-
- let drawFrame: FrameRequestCallback | undefined;
- vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => {
- drawFrame = callback;
- return 1;
- });
-
- const container = document.createElement('div');
- document.body.appendChild(container);
- const root = createRoot(container);
- mounted.push({ container, root });
- act(() =>
- root.render(
-
-
- ,
- ),
- );
-
- // Run many idle frames so dots settle and engagement stays 0
- for (let i = 0; i < 200; i++) {
- act(() => {
- vi.advanceTimersByTime(20);
- drawFrame?.(i * 16);
- });
- }
-
- const arcCallsAfterSettle = contextStub.arc.mock.calls.length;
- const fillCallsAfterSettle = contextStub.fill.mock.calls.length;
-
- // Run more frames — draws should be skipped
- for (let i = 200; i < 220; i++) {
- act(() => {
- vi.advanceTimersByTime(20);
- drawFrame?.(i * 16);
- });
- }
-
- expect(contextStub.arc.mock.calls.length).toBe(arcCallsAfterSettle);
- expect(contextStub.fill.mock.calls.length).toBe(fillCallsAfterSettle);
- });
-});
-
-describe('dot field pointer speed baseline', () => {
- it('does not report a phantom speed burst on the first pointer move', () => {
- vi.useFakeTimers();
- const addColorStop = vi.fn();
- const contextStub = {
- arc: vi.fn(),
- beginPath: vi.fn(),
- clearRect: vi.fn(),
- createRadialGradient: vi.fn(() => ({ addColorStop })),
- fill: vi.fn(),
- fillRect: vi.fn(),
- moveTo: vi.fn(),
- setTransform: vi.fn(),
- };
- vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(
- contextStub as unknown as CanvasRenderingContext2D,
- );
-
- let drawFrame: FrameRequestCallback | undefined;
- vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => {
- drawFrame = callback;
- return 1;
- });
-
- const container = document.createElement('div');
- document.body.appendChild(container);
- const root = createRoot(container);
- mounted.push({ container, root });
- act(() =>
- root.render(
-
-
- ,
- ),
- );
-
- // A lone first pointer move must seed the previous position so the speed
- // sample stays zero and no glow burst is drawn.
- act(() => {
- window.dispatchEvent(
- new MouseEvent('pointermove', { clientX: 100, clientY: 100 }),
- );
- vi.advanceTimersByTime(20);
- drawFrame?.(0);
- });
-
- expect(addColorStop).not.toHaveBeenCalled();
- });
-});
-
-describe('dot field static grid and idle loop', () => {
- const mockDotsRect = () =>
- vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue({
- width: 30,
- height: 30,
- top: 0,
- left: 0,
- right: 30,
- bottom: 30,
- x: 0,
- y: 0,
- toJSON: () => ({}),
- } as DOMRect);
-
- const makeContextStub = () => ({
- arc: vi.fn(),
- beginPath: vi.fn(),
- clearRect: vi.fn(),
- createRadialGradient: vi.fn(() => ({ addColorStop: vi.fn() })),
- fill: vi.fn(),
- fillRect: vi.fn(),
- moveTo: vi.fn(),
- setTransform: vi.fn(),
- });
-
- it('renders the static dot grid once under prefers-reduced-motion', () => {
- vi.spyOn(window, 'matchMedia').mockImplementation(
- (query: string) =>
- ({
- matches: query === '(prefers-reduced-motion: reduce)',
- media: query,
- addEventListener: vi.fn(),
- removeEventListener: vi.fn(),
- }) as MediaQueryList,
- );
- mockDotsRect();
- const contextStub = makeContextStub();
- vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(
- contextStub as unknown as CanvasRenderingContext2D,
- );
- const rafSpy = vi.spyOn(window, 'requestAnimationFrame');
-
- const container = document.createElement('div');
- document.body.appendChild(container);
- const root = createRoot(container);
- mounted.push({ container, root });
- rafSpy.mockClear();
- act(() =>
- root.render(
-
-
- ,
- ),
- );
-
- // A 30px box at a 15px step yields a 2×2 grid that must be painted even
- // though every dot is already settled on its anchor.
- expect(contextStub.arc.mock.calls.length).toBeGreaterThan(0);
- expect(contextStub.fill).toHaveBeenCalled();
- expect(rafSpy).not.toHaveBeenCalled();
- });
-
- it('paints the grid on the first frame before any pointer movement', () => {
- vi.useFakeTimers();
- mockDotsRect();
- const contextStub = makeContextStub();
- vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(
- contextStub as unknown as CanvasRenderingContext2D,
- );
-
- let drawFrame: FrameRequestCallback | undefined;
- vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => {
- drawFrame = callback;
- return 1;
- });
-
- const container = document.createElement('div');
- document.body.appendChild(container);
- const root = createRoot(container);
- mounted.push({ container, root });
- act(() =>
- root.render(
-
-
- ,
- ),
- );
-
- act(() => {
- drawFrame?.(0);
- });
-
- expect(contextStub.arc.mock.calls.length).toBeGreaterThan(0);
- expect(contextStub.fill).toHaveBeenCalled();
- });
-
- it('stops the rAF loop when idle and restarts on pointer movement', () => {
- vi.useFakeTimers();
- mockDotsRect();
- const contextStub = makeContextStub();
- vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(
- contextStub as unknown as CanvasRenderingContext2D,
- );
-
- const rafCallbacks: FrameRequestCallback[] = [];
- let rafId = 0;
- vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => {
- rafCallbacks.push(callback);
- return ++rafId;
- });
- vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => {});
-
- const container = document.createElement('div');
- document.body.appendChild(container);
- const root = createRoot(container);
- mounted.push({ container, root });
- act(() =>
- root.render(
-
-
- ,
- ),
- );
-
- expect(rafCallbacks.length).toBe(1);
-
- // First frame paints the grid and re-arms the loop.
- act(() => {
- rafCallbacks.shift()?.(0);
- });
- // The next idle frame settles and stops the loop without re-arming it.
- act(() => {
- vi.advanceTimersByTime(20);
- rafCallbacks.shift()?.(16);
- });
- expect(rafCallbacks.length).toBe(0);
-
- // Pointer movement wakes the loop again.
- act(() => {
- window.dispatchEvent(
- new MouseEvent('pointermove', { clientX: 100, clientY: 100 }),
- );
- });
- expect(rafCallbacks.length).toBe(1);
- });
-});
diff --git a/packages/web-shell/client/components/SpecularComposerEffect.tsx b/packages/web-shell/client/components/SpecularComposerEffect.tsx
deleted file mode 100644
index ca7577d294b..00000000000
--- a/packages/web-shell/client/components/SpecularComposerEffect.tsx
+++ /dev/null
@@ -1,361 +0,0 @@
-import { useEffect, useRef } from 'react';
-import type { RefObject } from 'react';
-import { useTheme, WebShellThemeId } from '../themeContext';
-import { usePrefersReducedMotion } from '../hooks/usePrefersReducedMotion';
-import styles from './ChatEditor.module.css';
-
-const VERTEX_SHADER = `#version 300 es
- in vec2 position;
- void main() {
- gl_Position = vec4(position, 0.0, 1.0);
- }
-`;
-
-const FRAGMENT_SHADER = `#version 300 es
- precision highp float;
- uniform vec2 center;
- uniform vec2 halfSize;
- uniform vec3 lineColor;
- uniform float radius;
- uniform float angle;
- uniform float intensity;
- uniform float px;
- uniform float shineSize;
- uniform float shineFade;
- uniform float thickness;
- out vec4 fragColor;
-
- float roundedRect(vec2 point) {
- vec2 edge = abs(point) - halfSize + radius;
- return length(max(edge, 0.0)) + min(max(edge.x, edge.y), 0.0) - radius;
- }
-
- float gaussianLine(float distanceToEdge, float sigma) {
- float normalized = distanceToEdge / (sigma + 0.000001);
- float falloff = mix(1.0, 1.6, smoothstep(0.0, 1.5, normalized));
- return exp(-falloff * normalized * normalized);
- }
-
- void main() {
- vec2 point = gl_FragCoord.xy - center;
- float distanceToEdge = roundedRect(point);
- vec2 light = vec2(cos(angle), sin(angle));
- vec2 normal = normalize(point / halfSize + 0.000001);
- float phi = acos(clamp(abs(dot(normal, light)), 0.0, 1.0));
- float rim = 1.0 - smoothstep(
- shineSize - shineFade,
- shineSize + shineFade + 0.0001,
- phi
- );
- float line = gaussianLine(distanceToEdge, thickness);
- float edgeClamp = 1.0 - smoothstep(
- 0.5 * px,
- 3.0 * px,
- abs(distanceToEdge)
- );
- float shine = line * rim * edgeClamp * intensity;
- fragColor = vec4(lineColor * shine, shine);
- }
-`;
-
-interface SpecularComposerEffectProps {
- targetRef: RefObject;
-}
-
-function compileShader(
- gl: WebGL2RenderingContext,
- type: number,
- source: string,
-): WebGLShader | null {
- const shader = gl.createShader(type);
- if (!shader) return null;
- gl.shaderSource(shader, source);
- gl.compileShader(shader);
- if (gl.getShaderParameter(shader, gl.COMPILE_STATUS)) return shader;
- gl.deleteShader(shader);
- return null;
-}
-
-export function SpecularComposerEffect({
- targetRef,
-}: SpecularComposerEffectProps) {
- const hostRef = useRef(null);
- const theme = useTheme();
- const reducedMotion = usePrefersReducedMotion();
-
- useEffect(() => {
- const target = targetRef.current;
- const host = hostRef.current;
- if (!target || !host || reducedMotion) {
- return undefined;
- }
-
- const canvas = document.createElement('canvas');
- const gl = canvas.getContext('webgl2', {
- alpha: true,
- antialias: true,
- premultipliedAlpha: true,
- });
- if (!gl) return undefined;
-
- const vertexShader = compileShader(gl, gl.VERTEX_SHADER, VERTEX_SHADER);
- const fragmentShader = compileShader(
- gl,
- gl.FRAGMENT_SHADER,
- FRAGMENT_SHADER,
- );
- const program = gl.createProgram();
- if (!vertexShader || !fragmentShader || !program) {
- if (vertexShader) gl.deleteShader(vertexShader);
- if (fragmentShader) gl.deleteShader(fragmentShader);
- if (program) gl.deleteProgram(program);
- gl.getExtension('WEBGL_lose_context')?.loseContext();
- return undefined;
- }
-
- gl.attachShader(program, vertexShader);
- gl.attachShader(program, fragmentShader);
- gl.linkProgram(program);
- if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
- gl.deleteProgram(program);
- gl.deleteShader(vertexShader);
- gl.deleteShader(fragmentShader);
- gl.getExtension('WEBGL_lose_context')?.loseContext();
- return undefined;
- }
-
- const buffer = gl.createBuffer();
- if (!buffer) {
- gl.deleteProgram(program);
- gl.deleteShader(vertexShader);
- gl.deleteShader(fragmentShader);
- gl.getExtension('WEBGL_lose_context')?.loseContext();
- return undefined;
- }
-
- host.appendChild(canvas);
- gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
- gl.bufferData(
- gl.ARRAY_BUFFER,
- new Float32Array([-1, -1, 3, -1, -1, 3]),
- gl.STATIC_DRAW,
- );
- const position = gl.getAttribLocation(program, 'position');
- gl.enableVertexAttribArray(position);
- gl.vertexAttribPointer(position, 2, gl.FLOAT, false, 0, 0);
- gl.useProgram(program);
- gl.enable(gl.BLEND);
- gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
-
- const center = gl.getUniformLocation(program, 'center');
- const halfSize = gl.getUniformLocation(program, 'halfSize');
- const lineColor = gl.getUniformLocation(program, 'lineColor');
- const radius = gl.getUniformLocation(program, 'radius');
- const angleUniform = gl.getUniformLocation(program, 'angle');
- const intensity = gl.getUniformLocation(program, 'intensity');
- const px = gl.getUniformLocation(program, 'px');
- const shineSize = gl.getUniformLocation(program, 'shineSize');
- const shineFade = gl.getUniformLocation(program, 'shineFade');
- const thickness = gl.getUniformLocation(program, 'thickness');
-
- let dpr = window.devicePixelRatio || 1;
- let width = 1;
- let height = 1;
- let cornerRadius = 12;
- let pointerAngle: number | null = null;
- let angle = 2.4;
- let idleAngle = 2.4;
- let proximity = 0;
- let brightness = 0;
- let focused = false;
- let lastFrame = performance.now();
- let frameId = 0;
- let running = false;
-
- const resize = () => {
- dpr = window.devicePixelRatio || 1;
- const rect = target.getBoundingClientRect();
- width = rect.width;
- height = rect.height;
- cornerRadius =
- Number.parseFloat(getComputedStyle(target).borderRadius) || 12;
- canvas.width = Math.ceil((width + 40) * dpr);
- canvas.height = Math.ceil((height + 40) * dpr);
- gl.viewport(0, 0, canvas.width, canvas.height);
- };
-
- const isEditorTarget = (eventTarget: EventTarget | null) => {
- const editor = target.querySelector('[data-web-shell-composer-editor]');
- return (
- eventTarget instanceof Node && Boolean(editor?.contains(eventTarget))
- );
- };
-
- const startLoop = () => {
- if (!running && !disposed) {
- running = true;
- lastFrame = performance.now();
- frameId = window.requestAnimationFrame(draw);
- }
- };
-
- const onPointerMove = (event: PointerEvent) => {
- const rect = target.getBoundingClientRect();
- const centerX = rect.left + rect.width / 2;
- const centerY = rect.top + rect.height / 2;
- const dx = Math.max(
- rect.left - event.clientX,
- 0,
- event.clientX - rect.right,
- );
- const dy = Math.max(
- rect.top - event.clientY,
- 0,
- event.clientY - rect.bottom,
- );
- const distance = Math.hypot(dx, dy);
- if (distance === 0) {
- const normalizedX =
- (event.clientX - centerX) / Math.max(rect.width / 2, 1);
- const normalizedY =
- (centerY - event.clientY) / Math.max(rect.height / 2, 1);
- pointerAngle =
- Math.atan2(2 / rect.height, -2 / rect.width) +
- normalizedX * 0.3 +
- normalizedY * 0.15;
- } else {
- pointerAngle = Math.atan2(
- centerY - event.clientY,
- event.clientX - centerX,
- );
- }
- const amount = Math.max(0, 1 - distance / 250);
- proximity = amount * amount * (3 - 2 * amount);
- startLoop();
- };
-
- const onPointerLeave = () => {
- proximity = 0;
- pointerAngle = null;
- };
-
- const onFocusIn = (event: FocusEvent) => {
- if (!isEditorTarget(event.target)) return;
- pointerAngle = angle;
- idleAngle = angle;
- focused = true;
- startLoop();
- };
- const onFocusOut = (event: FocusEvent) => {
- if (
- isEditorTarget(event.target) &&
- !isEditorTarget(event.relatedTarget)
- ) {
- pointerAngle = angle;
- focused = false;
- }
- };
-
- const draw = (now: number) => {
- const delta = Math.min((now - lastFrame) / 1000, 0.05);
- lastFrame = now;
- if (focused) idleAngle -= 0.85 * delta;
- else idleAngle = angle;
- const targetAngle = focused
- ? idleAngle
- : pointerAngle === null
- ? angle
- : pointerAngle;
- const difference =
- ((((targetAngle - angle + Math.PI) % (Math.PI * 2)) + Math.PI * 2) %
- (Math.PI * 2)) -
- Math.PI;
- angle += difference * (1 - Math.exp(-delta * 7));
- const targetBrightness = focused ? 1.4 : proximity;
- brightness +=
- (targetBrightness - brightness) * (1 - Math.exp(-delta * 8));
-
- if (brightness < 0.002 && !focused && proximity < 0.002) {
- running = false;
- frameId = 0;
- gl.clear(gl.COLOR_BUFFER_BIT);
- return;
- }
-
- gl.uniform2f(center, (20 + width / 2) * dpr, (20 + height / 2) * dpr);
- gl.uniform2f(halfSize, (width / 2) * dpr, (height / 2) * dpr);
- gl.uniform3f(
- lineColor,
- theme === WebShellThemeId.Light ? 0.2 : 0.403922,
- theme === WebShellThemeId.Light ? 0.360784 : 0.521569,
- 1,
- );
- gl.uniform1f(radius, Math.min(cornerRadius, height / 2) * dpr);
- gl.uniform1f(angleUniform, angle);
- gl.uniform1f(intensity, brightness);
- gl.uniform1f(px, dpr);
- gl.uniform1f(shineSize, (32 * Math.PI) / 180);
- gl.uniform1f(shineFade, (68 * Math.PI) / 180);
- gl.uniform1f(thickness, 0.5 * dpr);
- gl.clear(gl.COLOR_BUFFER_BIT);
- gl.drawArrays(gl.TRIANGLES, 0, 3);
- frameId = window.requestAnimationFrame(draw);
- };
-
- const resizeObserver = new ResizeObserver(resize);
- 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();
- window.addEventListener('pointermove', onPointerMove, { passive: true });
- document.documentElement.addEventListener('pointerleave', onPointerLeave);
- target.addEventListener('focusin', onFocusIn);
- target.addEventListener('focusout', onFocusOut);
- const activeElement = document.activeElement;
- focused =
- activeElement !== null &&
- target
- .querySelector('[data-web-shell-composer-editor]')
- ?.contains(activeElement) === true;
- startLoop();
-
- return () => {
- teardown();
- gl.deleteBuffer(buffer);
- gl.deleteProgram(program);
- gl.deleteShader(vertexShader);
- gl.deleteShader(fragmentShader);
- gl.getExtension('WEBGL_lose_context')?.loseContext();
- };
- }, [reducedMotion, targetRef, theme]);
-
- return (
-
- );
-}
diff --git a/packages/web-shell/client/e2e/utils/emptyMobileComposer.ts b/packages/web-shell/client/e2e/utils/emptyMobileComposer.ts
index dfd93be02c2..b6bb40cf7ba 100644
--- a/packages/web-shell/client/e2e/utils/emptyMobileComposer.ts
+++ b/packages/web-shell/client/e2e/utils/emptyMobileComposer.ts
@@ -8,7 +8,6 @@ import { expect, type Page } from '@playwright/test';
export const emptyMobileComposerSelectors = {
composerSurface: '[data-web-shell-composer-surface]',
- dotField: '[data-web-shell-new-session-dot-field]',
welcomeFooter: '[data-e2e-mobile-welcome-footer]',
welcomeHeader: '[data-e2e-mobile-welcome-header]',
} as const;
@@ -19,10 +18,6 @@ export interface EmptyMobileComposerLayout {
chatViewPosition: string;
chatViewZIndex: string;
composerTop: number;
- dotFieldAnchoredToChatPane: boolean;
- dotFieldCoversChatPane: boolean;
- dotFieldPointerEvents: string;
- dotFieldZIndex: string;
footerAnchoredToChatPane: boolean;
footerBottom: number;
footerPosition: string;
@@ -59,7 +54,6 @@ export async function expectEmptyMobileWelcomeChromeVisible(
const welcomeHeader = page.locator(
emptyMobileComposerSelectors.welcomeHeader,
);
- const dotField = page.locator(emptyMobileComposerSelectors.dotField);
await expect(composer).toBeVisible();
await expect(welcomeHeader).toBeVisible();
@@ -72,7 +66,6 @@ export async function expectEmptyMobileWelcomeChromeVisible(
page.locator(emptyMobileComposerSelectors.welcomeFooter),
).toHaveCount(0);
}
- await expect(dotField.locator('canvas')).toBeVisible();
}
export async function emptyMobileComposerLayout(
@@ -86,16 +79,12 @@ export async function emptyMobileComposerLayout(
const welcomeFooter = Array.from(
chatPane.querySelectorAll(selectors.welcomeFooter),
).find((candidate) => candidate.getClientRects().length > 0);
- const dotField = chatPane.querySelector(selectors.dotField);
if (!composer) {
throw new Error('Expected the empty mobile composer to be rendered.');
}
if (!welcomeHeader) {
throw new Error('Expected the mobile welcome header to be rendered.');
}
- if (!dotField) {
- throw new Error('Expected the new-session dot field to be rendered.');
- }
if (requireWelcomeFooter && !welcomeFooter) {
throw new Error('Expected the mobile welcome footer to be rendered.');
}
@@ -130,8 +119,6 @@ export async function emptyMobileComposerLayout(
const chatPaneRect = chatPane.getBoundingClientRect();
const chatPaneStyle = getComputedStyle(chatPane);
const chatViewStyle = getComputedStyle(chatView);
- const dotFieldRect = dotField.getBoundingClientRect();
- const dotFieldStyle = getComputedStyle(dotField);
const footerRect = footer.getBoundingClientRect();
const welcomeFooterRect = welcomeFooter?.getBoundingClientRect();
@@ -143,14 +130,6 @@ export async function emptyMobileComposerLayout(
chatViewPosition: chatViewStyle.position,
chatViewZIndex: chatViewStyle.zIndex,
composerTop: composer.getBoundingClientRect().top,
- dotFieldAnchoredToChatPane: dotField.offsetParent === chatPane,
- dotFieldCoversChatPane:
- Math.abs(dotFieldRect.top - chatPaneRect.top) <= 1 &&
- Math.abs(dotFieldRect.right - chatPaneRect.right) <= 1 &&
- Math.abs(dotFieldRect.bottom - chatPaneRect.bottom) <= 1 &&
- Math.abs(dotFieldRect.left - chatPaneRect.left) <= 1,
- dotFieldPointerEvents: dotFieldStyle.pointerEvents,
- dotFieldZIndex: dotFieldStyle.zIndex,
footerAnchoredToChatPane: footer.offsetParent === chatPane,
footerBottom: footerRect.bottom,
footerPosition: getComputedStyle(footer).position,
@@ -176,14 +155,8 @@ export function expectEmptyMobileComposerAnchored(
).toBeLessThanOrEqual(1);
expect(layout.chatViewPosition).toBe('static');
expect(layout.chatViewIsPaneFlexItem).toBe(true);
- expect(layout.chatViewZIndex).toBe('1');
+ expect(layout.chatViewZIndex).toBe('auto');
expect(layout.footerAnchoredToChatPane).toBe(true);
- expect(layout.dotFieldAnchoredToChatPane).toBe(true);
- expect(layout.dotFieldCoversChatPane).toBe(true);
- expect(layout.dotFieldPointerEvents).toBe('none');
- expect(Number(layout.chatViewZIndex)).toBeGreaterThan(
- Number(layout.dotFieldZIndex),
- );
if (options.requireWelcomeFooter !== false) {
if (
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 e23336cdf65..0d85c4286ba 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
@@ -38,9 +38,6 @@ 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('anchors the empty mobile composer with the textarea backend', async ({
@@ -65,35 +62,6 @@ test('anchors the empty mobile composer with the textarea backend', async ({
);
});
-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 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();
-
- 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 ({
page,
}, testInfo) => {
diff --git a/packages/web-shell/client/e2e/web-shell.smoke.spec.ts b/packages/web-shell/client/e2e/web-shell.smoke.spec.ts
index 78bd1f09c27..e55d9a052c5 100644
--- a/packages/web-shell/client/e2e/web-shell.smoke.spec.ts
+++ b/packages/web-shell/client/e2e/web-shell.smoke.spec.ts
@@ -851,7 +851,7 @@ test('anchors the empty mobile composer to the chat pane across the breakpoint @
await expect
.poll(() => emptyMobileComposerLayout(page))
.toMatchObject({
- chatViewPosition: 'relative',
+ chatViewPosition: 'static',
footerPosition: 'relative',
});
const wideLayout = await emptyMobileComposerLayout(page);