diff --git a/src/__tests__/renderer/components/InputArea.test.tsx b/src/__tests__/renderer/components/InputArea.test.tsx index 3ecb4e3242..27843b578b 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,91 @@ 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 decoration only for a recognized mention while keeping native text', () => { + 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 as HTMLElement).style.color).toBe('transparent'); + expect((mentionDecoration as HTMLElement).style.color).toBe('transparent'); + expect(textarea).toHaveStyle({ color: mockTheme.colors.textMain }); + }); + + 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(); + + 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: mockTheme.colors.textMain }); + }); + + 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/renderer/components/InputArea/components/InputTextarea.tsx b/src/renderer/components/InputArea/components/InputTextarea.tsx index 8e2969f9a1..f1644a5086 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,14 +134,29 @@ 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