Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 86 additions & 3 deletions src/__tests__/renderer/components/InputArea.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -212,6 +214,7 @@ const createDefaultProps = (
describe('InputArea', () => {
beforeEach(() => {
vi.clearAllMocks();
useSessionStore.setState({ sessions: [], groups: [] });
});

afterEach(() => {
Expand All @@ -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(<InputArea {...props} />);
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(<InputArea {...props} />);
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(<InputArea {...props} />);
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<HTMLTextAreaElement>;

const { container } = render(
<InputTextarea
session={createMockSession({ inputMode: 'ai' })}
theme={mockTheme}
isTerminalMode={false}
inputValue="check @src/index.ts now"
spellCheckEnabled={false}
inputRef={inputRef}
onInputFocus={vi.fn()}
onChange={vi.fn()}
handleInputKeyDown={vi.fn()}
handlePaste={vi.fn()}
handleDrop={vi.fn()}
/>
);
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', () => {
Expand Down
77 changes: 63 additions & 14 deletions src/renderer/components/InputArea/components/InputTextarea.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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;
Expand All @@ -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
Expand Down Expand Up @@ -72,6 +73,7 @@ export const InputTextarea = memo(function InputTextarea({
const overlayEnabled = !isTerminalMode;

const overlayRef = useRef<HTMLDivElement>(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
Expand Down Expand Up @@ -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;
Comment on lines +104 to +105

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Stale Selection Hides Overlay

When the user selects text, removes the only recognized mention so the overlay unmounts, then brings a mention back, hasSelection can still be true from the old selection. The remounted overlay starts with visibility: hidden until another selection or change event runs, so mention decoration can disappear even though there is no active selection.


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.
Expand All @@ -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 <textarea> 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 <textarea>, 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.
Expand All @@ -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',
Expand All @@ -154,7 +194,7 @@ export const InputTextarea = memo(function InputTextarea({
$
</span>
)}
{overlayEnabled && (
{overlayRendered && (
<div
ref={overlayRef}
aria-hidden="true"
Expand All @@ -166,17 +206,20 @@ export const InputTextarea = memo(function InputTextarea({
zIndex: 0,
whiteSpace: 'pre-wrap',
padding: '0.75rem 0.75rem 0 0.75rem',
color: theme.colors.textMain,
// The overlay paints decoration only. Keeping every glyph transparent
// prevents doubled text during typing and selection.
color: 'transparent',
visibility: overlayVisible ? 'visible' : 'hidden',
}}
>
{segments.map((seg, i) => {
if (seg.kind === 'text') {
return <span key={i}>{seg.value}</span>;
}
// 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 =
Expand All @@ -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
Expand All @@ -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) => {
Expand Down
Loading