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 whose native caret is positioned by the RAW glyphs, so - // the decoration must add ZERO inline advance or the caret drifts off the text + // Style for a single mention chip in the LIVE overlay. The overlay sits behind + // the native , so it draws only the chip background and border while + // the textarea remains the sole source of visible glyphs, caret, and selection. + // The decoration must add ZERO inline advance or it drifts off the native text // (measured >200px on a long path). Two tricks keep it width-exact: // 1. The border is drawn with `inset box-shadow`, never `border`/`outline`, // because box-shadow does not participate in layout. @@ -135,7 +175,7 @@ export const InputTextarea = memo(function InputTextarea({ // box-decoration-break keeps the fill/border intact if a long mention wraps. const mentionChipStyle = (typeColor: string): React.CSSProperties => ({ backgroundColor: chipColors.bg, - color: chipColors.text, + color: 'transparent', borderRadius: '6px', padding: '0 3px', margin: '0 -3px', @@ -154,7 +194,7 @@ export const InputTextarea = memo(function InputTextarea({ $ )} - {overlayEnabled && ( + {overlayRendered && ( {segments.map((seg, i) => { if (seg.kind === 'text') { return {seg.value}; } - // Render the mention as a width-EXACT chip over the raw token + // Render the mention as a width-EXACT chip behind the raw token // (`@path` / `@name`). It keeps the sent pill's fill + border + a // type-color accent, but NOT its icon or truncated label: those change - // the glyph advance and drift the native caret (see mentionChipStyle). + // the glyph advance and drift from the native caret (see mentionChipStyle). // The compact icon+truncation pill still renders in the sent transcript // (RenderedMentionChip), where there is no caret to keep aligned. const typeColor = @@ -196,11 +239,13 @@ export const InputTextarea = memo(function InputTextarea({ className={`relative flex-1 bg-transparent text-sm outline-none ${isTerminalMode ? 'pl-1.5' : 'pl-3'} pt-3 pr-3 resize-none min-h-[3.5rem] scrollbar-thin`} style={{ ...SHARED_TYPOGRAPHY, - color: overlayEnabled ? 'transparent' : theme.colors.textMain, + // Native text is always visible. The overlay underneath contributes only + // the mention chip decoration, never a second copy of the glyphs. + color: theme.colors.textMain, caretColor: theme.colors.textMain, maxHeight: '11rem', // Sit above the decorative overlay so the caret + native selection win. - zIndex: overlayEnabled ? 1 : undefined, + zIndex: overlayRendered ? 1 : undefined, }} placeholder={ isTerminalMode @@ -211,8 +256,12 @@ export const InputTextarea = memo(function InputTextarea({ spellCheck={spellCheckEnabled} onFocus={onInputFocus} onBlur={onInputBlur} - onChange={onChange} - onScroll={overlayEnabled ? (e) => syncOverlayScroll(e.currentTarget) : undefined} + onChange={(e) => { + updateSelectionState(e.currentTarget); + onChange(e); + }} + onSelect={(e) => updateSelectionState(e.currentTarget)} + onScroll={overlayRendered ? (e) => syncOverlayScroll(e.currentTarget) : undefined} onKeyDown={handleInputKeyDown} onPaste={handlePaste} onDrop={(e) => {