diff --git a/src/__tests__/renderer/components/CodeFence.test.tsx b/src/__tests__/renderer/components/CodeFence.test.tsx index 66349aaf7a..4ff136910d 100644 --- a/src/__tests__/renderer/components/CodeFence.test.tsx +++ b/src/__tests__/renderer/components/CodeFence.test.tsx @@ -193,4 +193,18 @@ describe('CodeFence', () => { vi.useRealTimers(); } }); + + it('reuses cached highlighted HTML on remount instead of flashing the plain fallback', async () => { + const code = 'const cachedHighlight = true;'; + const { unmount } = render(); + + await waitFor(() => { + expect(document.querySelector('.shiki-host')?.textContent).toContain('mocked'); + }); + + unmount(); + render(); + + expect(document.querySelector('.shiki-host')?.textContent).toContain('mocked'); + }); }); diff --git a/src/__tests__/renderer/components/InputArea.test.tsx b/src/__tests__/renderer/components/InputArea.test.tsx index 3ecb4e3242..2ae1e4f0ba 100644 --- a/src/__tests__/renderer/components/InputArea.test.tsx +++ b/src/__tests__/renderer/components/InputArea.test.tsx @@ -2,7 +2,9 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { render, screen, fireEvent, act, within, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { InputArea } from '../../../renderer/components/InputArea'; +import { InputTextarea } from '../../../renderer/components/InputArea/components/InputTextarea'; import { useComposerInputStore } from '../../../renderer/stores/composerInputStore'; +import { useSessionStore } from '../../../renderer/stores/sessionStore'; import { formatEnterToSend } from '../../../renderer/utils/shortcutFormatter'; import type { Session } from '../../../renderer/types'; import { createMockSession as baseCreateMockSession } from '../../helpers/mockSession'; @@ -212,6 +214,7 @@ const createDefaultProps = ( describe('InputArea', () => { beforeEach(() => { vi.clearAllMocks(); + useSessionStore.setState({ sessions: [], groups: [] }); }); afterEach(() => { @@ -227,11 +230,93 @@ describe('InputArea', () => { expect(screen.getByRole('textbox')).toBeInTheDocument(); }); - it('marks the AI mention overlay for mobile typography synchronization', () => { - const props = createDefaultProps(); + it('uses native textarea text when the AI draft has no recognized mention', () => { + const props = createDefaultProps({ inputValue: 'plain text with unknown @todo token' }); const { container } = render(); + const textarea = screen.getByRole('textbox'); + + expect(container.querySelector('.maestro-input-text-overlay')).not.toBeInTheDocument(); + expect(textarea).toHaveStyle({ color: mockTheme.colors.textMain }); + }); + + it('renders highlighted overlay text for a recognized mention', () => { + const session = createMockSession({ id: 'session-1', inputMode: 'ai' }); + const peer = createMockSession({ id: 'session-2', name: 'reviewer' }); + useSessionStore.setState({ sessions: [session, peer], groups: [] }); + const props = createDefaultProps({ session, inputValue: 'ask @reviewer to check' }); + const { container } = render(); + const textarea = screen.getByRole('textbox'); + + const overlay = container.querySelector('.maestro-input-text-overlay'); + const mentionDecoration = Array.from(overlay?.querySelectorAll('span') ?? []).find( + (element) => element.textContent === '@reviewer' + ); + + expect(overlay).toBeInTheDocument(); + expect(overlay).toHaveStyle({ color: mockTheme.colors.textMain }); + expect(mentionDecoration).toHaveStyle({ color: mockTheme.colors.textMain }); + expect(textarea).toHaveStyle({ color: 'rgba(0, 0, 0, 0)' }); + }); + + it('shows native text and hides the mention overlay during selection changes', () => { + const props = createDefaultProps({ inputValue: 'check @src/index.ts now' }); + const { container } = render(); + const textarea = screen.getByRole('textbox') as HTMLTextAreaElement; + const overlay = container.querySelector('.maestro-input-text-overlay'); + + expect(overlay).toBeInTheDocument(); + expect(overlay).toHaveStyle({ visibility: 'visible' }); + expect(textarea).toHaveStyle({ color: 'rgba(0, 0, 0, 0)' }); + + textarea.focus(); + textarea.setSelectionRange(0, 5); + fireEvent(document, new Event('selectionchange')); + + expect(overlay).toHaveStyle({ visibility: 'hidden' }); + expect(textarea).toHaveStyle({ color: mockTheme.colors.textMain }); + + textarea.setSelectionRange(5, 5); + fireEvent(document, new Event('selectionchange')); + + expect(overlay).toHaveStyle({ visibility: 'visible' }); + expect(textarea).toHaveStyle({ color: 'rgba(0, 0, 0, 0)' }); + }); + + it('aligns a newly mounted mention overlay with the textarea scroll position', () => { + const frames: FrameRequestCallback[] = []; + vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => { + frames.push(callback); + return frames.length; + }); + vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => undefined); + const inputRef = { current: null } as React.RefObject; + + const { container } = render( + + ); + const textarea = screen.getByRole('textbox') as HTMLTextAreaElement; + const overlay = container.querySelector('.maestro-input-text-overlay') as HTMLDivElement; + textarea.scrollTop = 20; + textarea.scrollLeft = 7; + + act(() => { + frames.splice(0).forEach((callback) => callback(0)); + }); - expect(container.querySelector('.maestro-input-text-overlay')).toBeInTheDocument(); + expect(overlay.scrollTop).toBe(20); + expect(overlay.scrollLeft).toBe(7); }); it('renders the notification settings button', () => { diff --git a/src/__tests__/renderer/components/InputArea/utils/textareaSizing.test.ts b/src/__tests__/renderer/components/InputArea/utils/textareaSizing.test.ts index 11094b989c..bbcda9e726 100644 --- a/src/__tests__/renderer/components/InputArea/utils/textareaSizing.test.ts +++ b/src/__tests__/renderer/components/InputArea/utils/textareaSizing.test.ts @@ -15,6 +15,16 @@ describe('InputArea textareaSizing utils', () => { expect(textarea.style.height).toBe('176px'); }); + it('honors the computed CSS max-height when it is lower than the caller cap', () => { + const textarea = document.createElement('textarea'); + textarea.style.maxHeight = '143px'; + Object.defineProperty(textarea, 'scrollHeight', { value: 172, configurable: true }); + + resizeTextareaToContent(textarea, 176); + + expect(textarea.style.height).toBe('143px'); + }); + it('resizes to exact content height below cap', () => { const textarea = document.createElement('textarea'); Object.defineProperty(textarea, 'scrollHeight', { value: 80, configurable: true }); diff --git a/src/__tests__/renderer/components/MermaidRenderer.test.tsx b/src/__tests__/renderer/components/MermaidRenderer.test.tsx index 031ae6972f..d338fd9cb1 100644 --- a/src/__tests__/renderer/components/MermaidRenderer.test.tsx +++ b/src/__tests__/renderer/components/MermaidRenderer.test.tsx @@ -1,7 +1,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { render, waitFor } from '@testing-library/react'; import { MermaidRenderer } from '../../../renderer/components/MermaidRenderer'; -import { mockTheme } from '../../helpers/mockTheme'; +import { createMockTheme, mockTheme } from '../../helpers/mockTheme'; // Mermaid is a static default import in MermaidRenderer. We stub parse (always // valid) and render (returns a caller-supplied SVG) so each test controls the @@ -49,4 +49,33 @@ describe('MermaidRenderer', () => { expect(container.querySelector('.mermaid-container svg')).not.toBeNull(); }); }); + + it('does not re-render or flash loading for an equivalent theme object', async () => { + renderMock.mockResolvedValue({ + svg: 'stable', + }); + + const chart = 'flowchart LR\nStable-->Theme'; + const { container, queryByText, rerender, unmount } = render( + + ); + + await waitFor(() => { + expect(container.querySelector('.mermaid-container svg')).not.toBeNull(); + }); + expect(renderMock).toHaveBeenCalledTimes(1); + + rerender(); + + expect(queryByText('Rendering diagram...')).not.toBeInTheDocument(); + expect(container.querySelector('.mermaid-container svg')).not.toBeNull(); + expect(renderMock).toHaveBeenCalledTimes(1); + + unmount(); + const remount = render(); + + expect(remount.queryByText('Rendering diagram...')).not.toBeInTheDocument(); + expect(remount.container.querySelector('.mermaid-container svg')).not.toBeNull(); + expect(renderMock).toHaveBeenCalledTimes(1); + }); }); diff --git a/src/__tests__/renderer/constants/themes.test.ts b/src/__tests__/renderer/constants/themes.test.ts index ddc27f7e13..5e2bb9407f 100644 --- a/src/__tests__/renderer/constants/themes.test.ts +++ b/src/__tests__/renderer/constants/themes.test.ts @@ -52,12 +52,12 @@ describe('THEMES constant', () => { } }); - it('should have exactly 20 themes (sync check with ThemeId type)', () => { + it('should have exactly 24 themes (sync check with ThemeId type)', () => { // This count should match the number of IDs in ThemeId union type. // If a new theme is added to THEMES without updating ThemeId, TypeScript errors. // If ThemeId is updated without adding to isValidThemeId array, other tests fail. // This test serves as an explicit reminder when themes are added/removed. - expect(themeIds.length).toBe(20); + expect(themeIds.length).toBe(24); }); it('should have theme.id matching its key', () => { diff --git a/src/__tests__/shared/theme-types.test.ts b/src/__tests__/shared/theme-types.test.ts index 931d0e5617..76d92216f6 100644 --- a/src/__tests__/shared/theme-types.test.ts +++ b/src/__tests__/shared/theme-types.test.ts @@ -9,7 +9,18 @@ import { isValidThemeId, type ThemeId } from '../../shared/theme-types'; describe('isValidThemeId', () => { // Sample of valid theme IDs (not exhaustive - that would couple tests to implementation) - const sampleValidIds = ['dracula', 'monokai', 'github-light', 'nord', 'olive-nights', 'pedurple']; + const sampleValidIds = [ + 'dracula', + 'monokai', + 'github-light', + 'nord', + 'olive-nights', + 'indigo-blue', + 'deep-wine-red', + 'yellow-dark-mustard', + 'deep-purple', + 'pedurple', + ]; it('should return true for valid theme IDs', () => { for (const id of sampleValidIds) { diff --git a/src/renderer/components/CodeFence/CodeFence.tsx b/src/renderer/components/CodeFence/CodeFence.tsx index fed6b11857..e4c01f8003 100644 --- a/src/renderer/components/CodeFence/CodeFence.tsx +++ b/src/renderer/components/CodeFence/CodeFence.tsx @@ -23,6 +23,8 @@ interface CodeFenceProps { } const FALLBACK_LANG = 'text'; +const HIGHLIGHT_CACHE_LIMIT = 80; +const highlightedHtmlCache = new Map(); // Auto-detection only needs to run once a fence has settled, not on every // streamed character. Debouncing the `code` value that feeds detection keeps @@ -35,6 +37,28 @@ function isExplicitLang(lang: string): boolean { return Boolean(lang) && lang !== FALLBACK_LANG; } +function highlightCacheKey(lang: string, code: string, mode: Theme['mode']): string { + return `${themeNameForMode(mode)}\0${lang}\0${code}`; +} + +function getCachedHighlightHtml(lang: string, code: string, mode: Theme['mode']): string | null { + return highlightedHtmlCache.get(highlightCacheKey(lang, code, mode)) ?? null; +} + +function rememberHighlightedHtml( + lang: string, + code: string, + mode: Theme['mode'], + html: string +): void { + const key = highlightCacheKey(lang, code, mode); + highlightedHtmlCache.set(key, html); + if (highlightedHtmlCache.size > HIGHLIGHT_CACHE_LIMIT) { + const oldestKey = highlightedHtmlCache.keys().next().value; + if (oldestKey) highlightedHtmlCache.delete(oldestKey); + } +} + /** * Renders a code fence with Shiki syntax highlighting. Falls back to a plain * `
` while Shiki loads, while detection runs, or when the resolved
@@ -47,14 +71,17 @@ export const CodeFence = memo(function CodeFence({
 	theme,
 	onCopy,
 }: CodeFenceProps) {
+	const initialResolvedLang = resolveLanguageSync(language) ?? language ?? FALLBACK_LANG;
 	// What the picker shows / what Shiki renders. Starts from the fence tag
 	// (resolved against our local alias table so common short tags like `js`
 	// surface as `javascript` on first paint), overridden by detection on
 	// no-language fences, overridden again by user picker choice.
-	const [resolvedLang, setResolvedLang] = useState(
-		() => resolveLanguageSync(language) ?? language ?? FALLBACK_LANG
+	const [resolvedLang, setResolvedLang] = useState(() => initialResolvedLang);
+	const [html, setHtml] = useState(() =>
+		initialResolvedLang === FALLBACK_LANG
+			? null
+			: getCachedHighlightHtml(initialResolvedLang, code, theme.mode)
 	);
-	const [html, setHtml] = useState(null);
 	const userOverrodeRef = useRef(false);
 
 	// Debounced code feeds auto-detection only. Without this, the effect below
@@ -104,6 +131,13 @@ export const CodeFence = memo(function CodeFence({
 			setHtml(null);
 			return;
 		}
+
+		const cached = getCachedHighlightHtml(resolvedLang, code, theme.mode);
+		if (cached) {
+			setHtml((prev) => (prev === cached ? prev : cached));
+			return;
+		}
+
 		let cancelled = false;
 		void (async () => {
 			try {
@@ -115,6 +149,7 @@ export const CodeFence = memo(function CodeFence({
 				}
 				const themeName = themeNameForMode(theme.mode);
 				const rendered = highlighter.codeToHtml(code, { lang, theme: themeName });
+				rememberHighlightedHtml(resolvedLang, code, theme.mode, rendered);
 				if (!cancelled) setHtml(rendered);
 			} catch (err) {
 				// Shiki failed (WASM load, missing grammar, malformed theme, …).
diff --git a/src/renderer/components/InputArea/components/InputTextarea.tsx b/src/renderer/components/InputArea/components/InputTextarea.tsx
index 8e2969f9a1..ed29955573 100644
--- a/src/renderer/components/InputArea/components/InputTextarea.tsx
+++ b/src/renderer/components/InputArea/components/InputTextarea.tsx
@@ -1,4 +1,4 @@
-import React, { memo, useMemo, useRef } from 'react';
+import React, { memo, useLayoutEffect, useMemo, useRef, useState } from 'react';
 import type { Session, Group, Theme } from '../../../types';
 import { getProviderDisplayName } from '../../../utils/sessionValidation';
 import { useSettingsStore } from '../../../stores/settingsStore';
@@ -10,6 +10,7 @@ import {
 } from '../../../utils/mentionChipResolve';
 import { useSessionStore } from '../../../stores/sessionStore';
 import { buildKnownMentionNameSet } from '../../../hooks/input/useAgentMentionCompletion';
+import { useEventListener } from '../../../hooks/utils/useEventListener';
 
 interface InputTextareaProps {
 	session: Session;
@@ -27,7 +28,7 @@ interface InputTextareaProps {
 }
 
 /**
- * Typography the transparent textarea and the highlight overlay MUST share
+ * Typography the native textarea and the decorative overlay MUST share
  * exactly, or the mention highlights drift away from the caret. Pulled into one
  * constant so the two layers can never disagree (font size / line height /
  * family / letter spacing). Padding is kept in sync separately: the textarea
@@ -72,6 +73,7 @@ export const InputTextarea = memo(function InputTextarea({
 	const overlayEnabled = !isTerminalMode;
 
 	const overlayRef = useRef(null);
+	const [hasSelection, setHasSelection] = useState(false);
 
 	// The mentionable agent/group roster (from this agent's vantage point).
 	// A bare `@word` only lights up when it names a known agent/group; unknown
@@ -99,6 +101,29 @@ export const InputTextarea = memo(function InputTextarea({
 		() => (overlayEnabled ? tokenizeMentions(inputValue, knownMentionNames) : []),
 		[overlayEnabled, inputValue, knownMentionNames]
 	);
+	const overlayRendered = overlayEnabled && segments.some((segment) => segment.kind !== 'text');
+	const overlayVisible = overlayRendered && !hasSelection;
+
+	const updateSelectionState = (target: HTMLTextAreaElement) => {
+		const nextHasSelection = target.selectionStart !== target.selectionEnd;
+		setHasSelection((current) => (current === nextHasSelection ? current : nextHasSelection));
+	};
+
+	// React's textarea onSelect fires on mouseup, after a drag selection has
+	// already become visible. Track the document selectionchange event so the
+	// decorative layer disappears during the drag itself.
+	useEventListener(
+		'selectionchange',
+		() => {
+			const textarea = inputRef.current;
+			if (!textarea || document.activeElement !== textarea) return;
+			updateSelectionState(textarea);
+		},
+		{
+			target: typeof document !== 'undefined' ? document : null,
+			enabled: overlayRendered,
+		}
+	);
 
 	// Keep the decorative overlay pinned to the textarea's scroll position so the
 	// mention highlights track the text as the input grows past one line.
@@ -109,15 +134,31 @@ export const InputTextarea = memo(function InputTextarea({
 		el.scrollLeft = target.scrollLeft;
 	};
 
+	// A newly mounted overlay misses any scroll that happened before the mention
+	// became recognizable. Sync after the textarea resize/scroll frame so its
+	// first painted position matches the native text.
+	useLayoutEffect(() => {
+		if (!overlayRendered) return;
+
+		const frameId = requestAnimationFrame(() => {
+			const textarea = inputRef.current;
+			if (textarea) syncOverlayScroll(textarea);
+		});
+
+		return () => cancelAnimationFrame(frameId);
+	}, [inputRef, inputValue, overlayRendered]);
+
 	// Chip palette shared with the sent-transcript pill (same fill + border), so
 	// the mention reads as the same object whether the user is typing it or reading
 	// it back in a bubble.
 	const chipColors = useMemo(() => getMentionChipColors(theme), [theme]);
 
-	// Style for a single mention chip in the LIVE overlay. The overlay sits over a
-	// transparent