agent-ecosystem/src/renderer/components/ui/MentionableTextarea.tsx
iliya baabae0594 fix: resolve all ESLint errors breaking CI validate job
- Fix import sort order in 6 files (TeamDataService, TeamProvisioningService,
  ChatHistory, SortableTab, LeadThoughtsGroup, MemberDraftRow)
- Replace slow regex with string manipulation in MemberStatsComputer
- Use RegExp.exec() instead of String.match() in toolSummary
- Fix non-backtracking regex pattern in toolSummary parser
- Remove unnecessary type assertions in TeamDataService and TeamProvisioningService
- Convert for-loop to for-of in TeamDataService
- Fix no-param-reassign in TeamDataService.sendMessage
- Convert function component to arrow function in LeadThoughtsGroup
- Fix nested functions depth in MentionableTextarea tip rotation
- Use optional chain in teamSlice
2026-03-06 16:36:37 +02:00

735 lines
25 KiB
TypeScript

import * as React from 'react';
import { getTeamColorSet } from '@renderer/constants/teamColors';
import { useFileSuggestions } from '@renderer/hooks/useFileSuggestions';
import { useMentionDetection } from '@renderer/hooks/useMentionDetection';
import { cn } from '@renderer/lib/utils';
import { chipToken } from '@renderer/types/inlineChip';
import {
createChipFromSelection,
findChipBoundary,
reconcileChips,
removeChipTokenFromText,
} from '@renderer/utils/chipUtils';
import { AutoResizeTextarea } from './auto-resize-textarea';
import { ChipInteractionLayer } from './ChipInteractionLayer';
import { CodeChipBadge } from './CodeChipBadge';
import { MentionSuggestionList } from './MentionSuggestionList';
import type { AutoResizeTextareaProps } from './auto-resize-textarea';
import type { InlineChip } from '@renderer/types/inlineChip';
import type { MentionSuggestion } from '@renderer/types/mention';
// ---------------------------------------------------------------------------
// Segment types
// ---------------------------------------------------------------------------
interface TextSegment {
type: 'text';
value: string;
}
interface MentionSegment {
type: 'mention';
value: string;
suggestion: MentionSuggestion;
}
interface ChipSegment {
type: 'chip';
value: string;
chip: InlineChip;
}
type Segment = TextSegment | MentionSegment | ChipSegment;
// ---------------------------------------------------------------------------
// Mention segment parsing (splits text into plain text + @mention segments)
// ---------------------------------------------------------------------------
/**
* Splits text into alternating text / @mention segments.
*
* Rules:
* - `@` must be at start of text or preceded by whitespace
* - The name after `@` must exactly match a suggestion name (case-insensitive)
* - The character after the name must be whitespace, punctuation, or end-of-text
* - Longer names are tried first (greedy matching)
*/
function parseMentionSegments(text: string, suggestions: MentionSuggestion[]): Segment[] {
if (!text || suggestions.length === 0) return [{ type: 'text', value: text }];
// Sort by name length descending for greedy matching
const sorted = [...suggestions].sort((a, b) => b.name.length - a.name.length);
const segments: Segment[] = [];
let i = 0;
let textStart = 0;
while (i < text.length) {
if (text[i] !== '@') {
i++;
continue;
}
// @ must be at start or after whitespace
if (i > 0) {
const ch = text[i - 1];
if (ch !== ' ' && ch !== '\t' && ch !== '\n' && ch !== '\r') {
i++;
continue;
}
}
let matched = false;
for (const suggestion of sorted) {
const end = i + 1 + suggestion.name.length;
if (end > text.length) continue;
if (text.slice(i + 1, end).toLowerCase() !== suggestion.name.toLowerCase()) continue;
// Character after name must be boundary
if (end < text.length) {
const after = text[end];
// eslint-disable-next-line no-useless-escape -- escaped chars needed for regex character class
if (!/[\s,.:;!?\)\]\}\-]/.test(after)) continue;
}
// Flush preceding text
if (i > textStart) {
segments.push({ type: 'text', value: text.slice(textStart, i) });
}
segments.push({ type: 'mention', value: text.slice(i, end), suggestion });
i = end;
textStart = i;
matched = true;
break;
}
if (!matched) i++;
}
if (textStart < text.length) {
segments.push({ type: 'text', value: text.slice(textStart) });
}
return segments;
}
// ---------------------------------------------------------------------------
// Extended segment parser: chips + mentions
// ---------------------------------------------------------------------------
/**
* Parses text into segments: first extracts chip tokens, then runs mention parsing
* on the text fragments between chips.
*/
function parseSegments(
text: string,
suggestions: MentionSuggestion[],
chips: InlineChip[]
): Segment[] {
if (!text) return [{ type: 'text', value: text }];
if (chips.length === 0) return parseMentionSegments(text, suggestions);
// Build a map of chip tokens for fast lookup
const chipTokenMap = new Map<string, InlineChip>();
for (const chip of chips) {
chipTokenMap.set(chipToken(chip), chip);
}
// Find all chip token positions, sorted by index
const chipPositions: { start: number; end: number; token: string; chip: InlineChip }[] = [];
for (const [token, chip] of chipTokenMap) {
let searchFrom = 0;
while (searchFrom < text.length) {
const idx = text.indexOf(token, searchFrom);
if (idx === -1) break;
chipPositions.push({ start: idx, end: idx + token.length, token, chip });
searchFrom = idx + 1;
}
}
chipPositions.sort((a, b) => a.start - b.start);
if (chipPositions.length === 0) return parseMentionSegments(text, suggestions);
const segments: Segment[] = [];
let lastEnd = 0;
for (const pos of chipPositions) {
// Text before this chip → parse for mentions
if (pos.start > lastEnd) {
const fragment = text.slice(lastEnd, pos.start);
segments.push(...parseMentionSegments(fragment, suggestions));
}
segments.push({ type: 'chip', value: pos.token, chip: pos.chip });
lastEnd = pos.end;
}
// Remaining text after last chip → parse for mentions
if (lastEnd < text.length) {
segments.push(...parseMentionSegments(text.slice(lastEnd), suggestions));
}
return segments;
}
// Default fallback color for mentions without a team color
const DEFAULT_MENTION_BG = 'rgba(59, 130, 246, 0.15)';
const DEFAULT_MENTION_TEXT = '#60a5fa';
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
interface MentionableTextareaProps extends Omit<
AutoResizeTextareaProps,
'value' | 'onChange' | 'onKeyDown' | 'onSelect'
> {
value: string;
onValueChange: (v: string) => void;
suggestions: MentionSuggestion[];
hintText?: string;
showHint?: boolean;
/** Content rendered at the right side of the footer row (e.g. "Draft saved") */
footerRight?: React.ReactNode;
/** Content rendered in the bottom-right corner inside the textarea (e.g. send button) */
cornerAction?: React.ReactNode;
/** Inline code chips to display as badges */
chips?: InlineChip[];
/** Called when a chip is removed (by X button, backspace, or reconciliation) */
onChipRemove?: (chipId: string) => void;
/** Project path for @file search. When provided, enables file suggestions alongside members. */
projectPath?: string | null;
/** Called when a file chip is created via @ selection. Parent must add chip to state. */
onFileChipInsert?: (chip: InlineChip) => void;
/** Called when Enter (without Shift) is pressed. */
onModEnter?: () => void;
}
export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, MentionableTextareaProps>(
(
{
value,
onValueChange,
suggestions,
hintText,
showHint = true,
footerRight,
cornerAction,
chips = [],
onChipRemove,
projectPath,
onFileChipInsert,
onModEnter,
style,
className,
...textareaProps
},
forwardedRef
) => {
const internalRef = React.useRef<HTMLTextAreaElement | null>(null);
const backdropRef = React.useRef<HTMLDivElement>(null);
const [scrollTop, setScrollTop] = React.useState(0);
// --- File search activation ---
const enableFiles = !!projectPath;
const setRefs = React.useCallback(
(node: HTMLTextAreaElement | null) => {
internalRef.current = node;
if (typeof forwardedRef === 'function') {
forwardedRef(node);
} else if (forwardedRef) {
// eslint-disable-next-line no-param-reassign -- ref merging requires mutation
forwardedRef.current = node;
}
},
[forwardedRef]
);
const {
isOpen,
query,
filteredSuggestions: memberSuggestions,
selectedIndex,
dropdownPosition,
selectSuggestion,
dismiss,
getTriggerIndex,
handleKeyDown: mentionHandleKeyDown,
handleChange: mentionHandleChange,
handleSelect: mentionHandleSelect,
} = useMentionDetection({
suggestions,
value,
onValueChange,
textareaRef: internalRef,
enableTriggerAlways: enableFiles,
});
// --- File suggestions ---
const { suggestions: fileSuggestions, loading: filesLoading } = useFileSuggestions(
enableFiles ? projectPath : null,
query,
isOpen && enableFiles
);
// Merged suggestion list: members first, then files
const allSuggestions = React.useMemo(() => {
if (!enableFiles) return memberSuggestions;
if (fileSuggestions.length === 0) return memberSuggestions;
return [...memberSuggestions, ...fileSuggestions];
}, [enableFiles, memberSuggestions, fileSuggestions]);
// When files are enabled, manage our own selectedIndex for the merged list
const [mergedIndex, setMergedIndex] = React.useState(0);
// Reset merged index when suggestions change or query changes
React.useEffect(() => {
setMergedIndex(0);
}, [query, allSuggestions.length]);
// Effective index: use merged when files enabled, hook's index otherwise
const effectiveIndex = enableFiles ? mergedIndex : selectedIndex;
const effectiveSuggestions = enableFiles ? allSuggestions : memberSuggestions;
// --- File selection handler ---
const handleFileSelect = React.useCallback(
(s: MentionSuggestion) => {
const textarea = internalRef.current;
const triggerIdx = getTriggerIndex();
if (!textarea || triggerIdx < 0 || !s.filePath) return;
const replaceStart = triggerIdx;
const replaceEnd = triggerIdx + 1 + query.length;
const before = value.slice(0, replaceStart);
const after = value.slice(replaceEnd);
if (onFileChipInsert && onChipRemove) {
// Chip mode: create InlineChip and insert chip token
const chip = createChipFromSelection(
{
type: 'sendMessage',
filePath: s.filePath,
fromLine: null,
toLine: null,
selectedText: '',
formattedContext: '',
displayPath: s.relativePath,
},
chips
);
if (chip) {
const token = chipToken(chip);
const newValue = before + token + after;
onValueChange(newValue);
onFileChipInsert(chip);
dismiss();
requestAnimationFrame(() => {
const cursor = before.length + token.length;
textarea.setSelectionRange(cursor, cursor);
});
} else {
// Duplicate chip — just dismiss
dismiss();
}
} else {
// Text mode: insert backtick-wrapped relative path
const displayPath = s.relativePath ?? s.name;
const insertion = `\`${displayPath}\` `;
const newValue = before + insertion + after;
onValueChange(newValue);
dismiss();
requestAnimationFrame(() => {
const cursor = before.length + insertion.length;
textarea.setSelectionRange(cursor, cursor);
});
}
},
[getTriggerIndex, query, value, chips, onValueChange, onFileChipInsert, onChipRemove, dismiss]
);
// --- Merged selection handler ---
const handleMergedSelect = React.useCallback(
(s: MentionSuggestion) => {
if (s.type === 'file') {
handleFileSelect(s);
} else {
selectSuggestion(s);
}
},
[handleFileSelect, selectSuggestion]
);
// Sync backdrop font with textarea computed font to prevent caret drift.
React.useLayoutEffect(() => {
const textarea = internalRef.current;
const backdrop = backdropRef.current;
if (!textarea || !backdrop) return;
const cs = window.getComputedStyle(textarea);
backdrop.style.font = cs.font;
backdrop.style.letterSpacing = cs.letterSpacing;
backdrop.style.wordSpacing = cs.wordSpacing;
backdrop.style.textIndent = cs.textIndent;
backdrop.style.textTransform = cs.textTransform;
backdrop.style.tabSize = cs.tabSize;
}, [value]);
// --- Overlay activation ---
const hasOverlay = suggestions.length > 0 || chips.length > 0;
const segments = React.useMemo(
() => (hasOverlay ? parseSegments(value, suggestions, chips) : []),
[hasOverlay, value, suggestions, chips]
);
// Sync backdrop scroll with textarea scroll + track scrollTop for interaction layer
const handleScroll = React.useCallback(() => {
const textarea = internalRef.current;
const backdrop = backdropRef.current;
if (textarea) {
if (backdrop) {
backdrop.scrollTop = textarea.scrollTop;
}
setScrollTop(textarea.scrollTop);
}
}, []);
// --- Chip keyboard handling (atomic cursor / backspace / delete) ---
const handleChipKeyDown = React.useCallback(
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (chips.length === 0 || !onChipRemove) return;
const textarea = internalRef.current;
if (!textarea) return;
const { selectionStart, selectionEnd } = textarea;
// Only act on collapsed cursor
if (selectionStart !== selectionEnd && !e.shiftKey) return;
const cursorPos = selectionStart;
if (e.key === 'Backspace') {
// If cursor is at chip end → delete entire chip
const boundary = findChipBoundary(value, chips, cursorPos);
if (cursorPos === boundary?.end) {
e.preventDefault();
const newText = removeChipTokenFromText(value, boundary.chip);
onValueChange(newText);
onChipRemove(boundary.chip.id);
// Set cursor to where chip started
requestAnimationFrame(() => {
textarea.setSelectionRange(boundary.start, boundary.start);
});
}
} else if (e.key === 'Delete') {
// If cursor is at chip start → delete entire chip
const boundary = findChipBoundary(value, chips, cursorPos);
if (cursorPos === boundary?.start) {
e.preventDefault();
const newText = removeChipTokenFromText(value, boundary.chip);
onValueChange(newText);
onChipRemove(boundary.chip.id);
requestAnimationFrame(() => {
textarea.setSelectionRange(boundary.start, boundary.start);
});
}
} else if (e.key === 'ArrowLeft' && !e.shiftKey) {
// If cursor is at chip end → jump to chip start
const boundary = findChipBoundary(value, chips, cursorPos);
if (cursorPos === boundary?.end) {
e.preventDefault();
textarea.setSelectionRange(boundary.start, boundary.start);
}
} else if (e.key === 'ArrowRight' && !e.shiftKey) {
// If cursor is at chip start → jump to chip end
const boundary = findChipBoundary(value, chips, cursorPos);
if (cursorPos === boundary?.start) {
e.preventDefault();
textarea.setSelectionRange(boundary.end, boundary.end);
}
} else if (e.key === 'ArrowLeft' && e.shiftKey) {
// Extend selection past chip atomically
const boundary = findChipBoundary(value, chips, cursorPos);
if (cursorPos === boundary?.end) {
e.preventDefault();
textarea.setSelectionRange(boundary.start, selectionEnd);
}
} else if (e.key === 'ArrowRight' && e.shiftKey) {
const boundary = findChipBoundary(value, chips, cursorPos);
if (cursorPos === boundary?.start) {
e.preventDefault();
textarea.setSelectionRange(selectionStart, boundary.end);
}
}
},
[chips, onChipRemove, value, onValueChange]
);
// --- File-aware keyboard handler (replaces mention handler when files enabled) ---
const fileMentionHandleKeyDown = React.useCallback(
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (!isOpen || allSuggestions.length === 0) return;
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
setMergedIndex((prev) => (prev + 1) % allSuggestions.length);
break;
case 'ArrowUp':
e.preventDefault();
setMergedIndex((prev) => (prev - 1 + allSuggestions.length) % allSuggestions.length);
break;
case 'Enter':
e.preventDefault();
if (allSuggestions[mergedIndex]) {
handleMergedSelect(allSuggestions[mergedIndex]);
}
break;
case 'Escape':
e.preventDefault();
dismiss();
break;
}
},
[isOpen, allSuggestions, mergedIndex, handleMergedSelect, dismiss]
);
// Composed key handler: Mod+Enter submit → chip logic → mention logic
const composedHandleKeyDown = React.useCallback(
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
// Enter (without Shift) → submit; Shift+Enter → newline
if (e.key === 'Enter' && !e.shiftKey && onModEnter) {
e.preventDefault();
onModEnter();
return;
}
handleChipKeyDown(e);
if (!e.defaultPrevented) {
if (enableFiles) {
fileMentionHandleKeyDown(e);
} else {
mentionHandleKeyDown(e);
}
}
},
[onModEnter, handleChipKeyDown, enableFiles, fileMentionHandleKeyDown, mentionHandleKeyDown]
);
// --- Chip reconciliation on text change ---
const composedHandleChange = React.useCallback(
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
mentionHandleChange(e);
// Reconcile chips after text changes (paste/cut/undo)
if (chips.length > 0 && onChipRemove) {
const newText = e.target.value;
const surviving = reconcileChips(chips, newText);
if (surviving.length < chips.length) {
const survivingIds = new Set(surviving.map((c) => c.id));
for (const chip of chips) {
if (!survivingIds.has(chip.id)) {
onChipRemove(chip.id);
}
}
}
}
},
[mentionHandleChange, chips, onChipRemove]
);
// --- Snap cursor on click/select if inside chip ---
const composedHandleSelect = React.useCallback(
(e: React.SyntheticEvent<HTMLTextAreaElement>) => {
mentionHandleSelect(e);
if (chips.length > 0) {
const textarea = internalRef.current;
if (!textarea) return;
const { selectionStart, selectionEnd } = textarea;
// Only snap collapsed cursor
if (selectionStart !== selectionEnd) return;
const boundary = findChipBoundary(value, chips, selectionStart);
if (boundary && selectionStart > boundary.start && selectionStart < boundary.end) {
// Snap to nearest boundary
const distToStart = selectionStart - boundary.start;
const distToEnd = boundary.end - selectionStart;
const snapTo = distToStart <= distToEnd ? boundary.start : boundary.end;
requestAnimationFrame(() => {
textarea.setSelectionRange(snapTo, snapTo);
});
}
}
},
[mentionHandleSelect, chips, value]
);
// --- Chip remove handler (from X button in interaction layer) ---
const handleChipRemove = React.useCallback(
(chipId: string) => {
const chip = chips.find((c) => c.id === chipId);
if (chip) {
const newText = removeChipTokenFromText(value, chip);
onValueChange(newText);
}
onChipRemove?.(chipId);
},
[chips, value, onValueChange, onChipRemove]
);
// When overlay is active: textarea text is transparent, caret stays visible
const textareaStyle: React.CSSProperties | undefined = hasOverlay
? {
...style,
color: 'transparent',
caretColor: 'var(--color-text)',
position: 'relative' as const,
zIndex: 10,
background: 'transparent',
}
: style;
// --- Rotating tips ---
const rotatingTips = React.useMemo(
() => [
'Tip: Use @ to mention team members or search files',
'Tip: Mention "create a task" to add it to the board',
"Tip: Don't overload the team lead with tasks — ask them to delegate to teammates",
],
[]
);
const [tipIndex, setTipIndex] = React.useState(0);
const [tipVisible, setTipVisible] = React.useState(true);
const advanceTip = React.useCallback(() => {
setTipIndex((prev) => (prev + 1) % rotatingTips.length);
setTipVisible(true);
}, [rotatingTips.length]);
React.useEffect(() => {
const interval = setInterval(() => {
setTipVisible(false);
setTimeout(advanceTip, 300);
}, 10000);
return () => clearInterval(interval);
}, [advanceTip]);
const resolvedHintText = hintText ?? rotatingTips[tipIndex];
const showHintRow = showHint && (suggestions.length > 0 || enableFiles);
const showFooter = showHintRow || footerRight;
return (
<div className="relative">
{/* Inner wrapper for textarea + backdrop overlay */}
<div className="relative">
{hasOverlay ? (
<div
ref={backdropRef}
className={cn(
'pointer-events-none absolute inset-0 z-0 overflow-hidden rounded-md border border-transparent px-3 py-2 text-sm text-[var(--color-text)]',
cornerAction && 'pb-12 pr-[4.25rem]'
)}
style={{
whiteSpace: 'pre-wrap',
wordWrap: 'break-word',
overflowWrap: 'break-word',
}}
aria-hidden="true"
>
{segments.map((seg, idx) => {
if (seg.type === 'text') {
return <React.Fragment key={idx}>{seg.value}</React.Fragment>;
}
if (seg.type === 'chip') {
return <CodeChipBadge key={idx} chip={seg.chip} tokenText={seg.value} />;
}
// mention
const colorSet = seg.suggestion.color
? getTeamColorSet(seg.suggestion.color)
: null;
const bg = colorSet?.badge ?? DEFAULT_MENTION_BG;
const fg = colorSet?.text ?? DEFAULT_MENTION_TEXT;
return (
<span
key={idx}
style={{
backgroundColor: bg,
color: fg,
borderRadius: '3px',
boxShadow: `0 0 0 1.5px ${bg}`,
}}
>
{seg.value}
</span>
);
})}{' '}
</div>
) : null}
<AutoResizeTextarea
ref={setRefs}
value={value}
onChange={composedHandleChange}
onKeyDown={composedHandleKeyDown}
onSelect={composedHandleSelect}
{...textareaProps}
className={cn(className, cornerAction && 'pb-12 pr-[4.25rem]')}
onScroll={handleScroll}
style={textareaStyle}
/>
{chips.length > 0 && onChipRemove ? (
<ChipInteractionLayer
chips={chips}
value={value}
textareaRef={internalRef}
scrollTop={scrollTop}
onRemove={handleChipRemove}
/>
) : null}
{cornerAction ? (
<div className="pointer-events-none absolute bottom-2 right-2 z-20 flex items-end justify-end">
<div className="pointer-events-auto">{cornerAction}</div>
</div>
) : null}
</div>
{showFooter ? (
<div className="mt-1 flex items-center justify-between">
{showHintRow ? (
<span
className="text-[10px] text-[var(--color-text-muted)] transition-opacity duration-300"
style={{ opacity: tipVisible ? 1 : 0 }}
>
{resolvedHintText}
</span>
) : (
<span />
)}
{footerRight}
</div>
) : null}
{isOpen && dropdownPosition ? (
<div className="absolute left-0 z-50 w-full" style={{ top: `${dropdownPosition.top}px` }}>
<MentionSuggestionList
suggestions={effectiveSuggestions}
selectedIndex={effectiveIndex}
onSelect={enableFiles ? handleMergedSelect : selectSuggestion}
query={query}
hasFileSearch={enableFiles}
filesLoading={enableFiles && filesLoading}
/>
</div>
) : null}
</div>
);
}
);
MentionableTextarea.displayName = 'MentionableTextarea';