goose/ui/desktop/src/components/RichChatInput.tsx.backup
spencrmartin 21d76fd18b debug: fix TypeScript conflicts and add extensive spell check debugging
- Fix import conflicts between local and imported checkSpelling functions
- Add @ts-ignore for typo-js import compatibility
- Add extensive console logging throughout spell check process
- Add fallback spell checker for testing if Typo.js fails
- Install @types/typo-js for better TypeScript support
- This should help identify why spell check highlighting isn't working
2025-09-25 09:36:52 -04:00

504 lines
18 KiB
Text

import React, { useRef, useEffect, useState, useCallback, forwardRef, useImperativeHandle } from 'react';
import { ActionPill } from './ActionPill';
import MentionPill from './MentionPill';
import { Zap, Code, FileText, Search, Play, Settings } from 'lucide-react';
interface RichChatInputProps {
value: string;
onChange: (value: string, cursorPos?: number) => void;
onKeyDown?: (e: React.KeyboardEvent<HTMLDivElement>) => void;
onPaste?: (e: React.ClipboardEvent<HTMLDivElement>) => void;
onFocus?: () => void;
onBlur?: () => void;
onCompositionStart?: () => void;
onCompositionEnd?: () => void;
placeholder?: string;
disabled?: boolean;
className?: string;
style?: React.CSSProperties;
autoFocus?: boolean;
'data-testid'?: string;
rows?: number;
}
// Action mapping for pill display
const ACTION_MAP = {
'quick-task': { label: 'Quick Task', icon: <Zap size={12} /> },
'generate-code': { label: 'Generate Code', icon: <Code size={12} /> },
'create-document': { label: 'Create Document', icon: <FileText size={12} /> },
'search-files': { label: 'Search Files', icon: <Search size={12} /> },
'run-command': { label: 'Run Command', icon: <Play size={12} /> },
'settings': { label: 'Settings', icon: <Settings size={12} /> },
};
export interface RichChatInputRef {
focus: () => void;
blur: () => void;
setSelectionRange: (start: number, end: number) => void;
getBoundingClientRect: () => DOMRect;
}
export const RichChatInput = forwardRef<RichChatInputRef, RichChatInputProps>(({
value,
onChange,
onKeyDown,
onPaste,
onFocus,
onBlur,
onCompositionStart,
onCompositionEnd,
placeholder,
disabled,
className,
style,
autoFocus,
'data-testid': testId,
rows = 1,
}, ref) => {
const hiddenTextareaRef = useRef<HTMLTextAreaElement>(null);
const displayRef = useRef<HTMLDivElement>(null);
const [isFocused, setIsFocused] = useState(false);
const [cursorPosition, setCursorPosition] = useState(0);
// Expose methods to parent component
useImperativeHandle(ref, () => ({
focus: () => hiddenTextareaRef.current?.focus(),
blur: () => hiddenTextareaRef.current?.blur(),
setSelectionRange: (start: number, end: number) => {
hiddenTextareaRef.current?.setSelectionRange(start, end);
setCursorPosition(start);
},
getBoundingClientRect: () => {
return displayRef.current?.getBoundingClientRect() || new DOMRect();
},
}), []);
// Update cursor position when selection changes
const updateCursorPosition = useCallback(() => {
if (hiddenTextareaRef.current) {
setCursorPosition(hiddenTextareaRef.current.selectionStart);
}
}, []);
// Parse and render content with action pills and cursor
const renderContent = useCallback(() => {
// Show placeholder when there's no text content (regardless of focus state)
if (!value.trim()) {
return (
<div className="relative min-h-[1.5em] flex items-center">
<span className="text-text-muted pointer-events-none select-none">
{placeholder}
</span>
{isFocused && (
<span
className="border-l-2 border-text-default inline-block align-baseline absolute left-0"
style={{
animation: "blink 1s step-end infinite",
height: "1em",
verticalAlign: "baseline",
top: "50%",
transform: "translateY(-50%)"
}}
/>
)}
</div>
);
}
const parts: React.ReactNode[] = [];
const actionRegex = /\[([^\]]+)\]/g;
const mentionRegex = /@([^\s]+)/g; // Match @filename patterns
let lastIndex = 0;
let match;
let keyCounter = 0;
let currentPos = 0; // Track position for cursor placement
// Helper function to add cursor if needed
const addCursorIfNeeded = (position: number) => {
if (isFocused && cursorPosition === position) {
parts.push(
<span key={`cursor-${keyCounter++}`} className="border-l-2 border-text-default inline-block align-baseline" style={{ animation: "blink 1s step-end infinite", height: "1em", marginLeft: "1px", verticalAlign: "baseline" }} />
);
}
};
console.log('🎨 RichChatInput renderContent called with value:', value);
console.log('🔍 Looking for action and mention patterns with regex:', { actionRegex, mentionRegex });
// Find all actions and mentions, then sort by position
const allMatches = [];
// Find all action matches
let actionMatch;
actionRegex.lastIndex = 0; // Reset regex
while ((actionMatch = actionRegex.exec(value)) !== null) {
allMatches.push({
type: 'action',
match: actionMatch,
index: actionMatch.index,
length: actionMatch[0].length,
content: actionMatch[1]
});
}
// Find all mention matches
let mentionMatch;
mentionRegex.lastIndex = 0; // Reset regex
console.log('🔍 Searching for mentions in value:', value);
console.log('🔍 Using mention regex:', mentionRegex);
while ((mentionMatch = mentionRegex.exec(value)) !== null) {
console.log('📁 Found mention match:', mentionMatch);
allMatches.push({
type: 'mention',
match: mentionMatch,
index: mentionMatch.index,
length: mentionMatch[0].length,
content: mentionMatch[1] // filename without @
});
}
// Sort matches by position
allMatches.sort((a, b) => a.index - b.index);
console.log('🔍 All matches found:', allMatches);
console.log('📊 Match breakdown:', {
actions: allMatches.filter(m => m.type === 'action').length,
mentions: allMatches.filter(m => m.type === 'mention').length,
total: allMatches.length
});
// Process all matches in order
for (const matchData of allMatches) {
const { type, match, index, length, content } = matchData;
console.log('✅ Found match:', { type, content, index });
// Add text before this match with potential cursor
const beforeMatch = value.slice(lastIndex, index);
if (beforeMatch) {
let textWithCursor = [];
for (let i = 0; i < beforeMatch.length; i++) {
if (isFocused && cursorPosition === currentPos) {
textWithCursor.push(
<span key={`cursor-${keyCounter++}`} className="border-l-2 border-text-default inline-block align-baseline" style={{ animation: "blink 1s step-end infinite", height: "1em", marginLeft: "1px", verticalAlign: "baseline" }} />
);
}
textWithCursor.push(beforeMatch[i]);
currentPos++;
}
parts.push(
<span key={`text-${keyCounter++}`} className="inline whitespace-pre-wrap">
{textWithCursor}
</span>
);
}
// Add cursor before match if needed
if (isFocused && cursorPosition === currentPos) {
parts.push(
<span key={`cursor-${keyCounter++}`} className="border-l-2 border-text-default inline-block align-baseline" style={{ animation: "blink 1s step-end infinite", height: "1em", marginLeft: "1px", verticalAlign: "baseline" }} />
);
}
if (type === 'action') {
// Handle action pills
const actionLabel = content;
const actionEntry = Object.entries(ACTION_MAP).find(
([_, config]) => config.label === actionLabel
);
console.log('🏷️ Creating action pill:', { actionLabel, actionEntry });
if (actionEntry) {
const [actionId, config] = actionEntry;
parts.push(
<ActionPill
key={`action-${keyCounter++}`}
actionId={actionId}
label={config.label}
icon={config.icon}
variant="default"
size="sm"
onRemove={() => handleRemoveAction(actionLabel)}
/>
);
} else {
// If no matching action, render as text
parts.push(
<span key={`text-${keyCounter++}`} className="inline whitespace-pre-wrap">
{match[0]}
</span>
);
}
} else if (type === 'mention') {
// Handle mention pills
const fileName = content; // filename without @
const filePath = `@${fileName}`; // full mention text
console.log('📁 Creating mention pill:', { fileName, filePath });
parts.push(
<MentionPill
key={`mention-${keyCounter++}`}
fileName={fileName}
filePath={filePath}
variant="default"
size="sm"
onRemove={() => handleRemoveMention(fileName)}
/>
);
}
currentPos += length;
lastIndex = index + length;
}
// Add remaining text with potential cursor
const remainingText = value.slice(lastIndex);
if (remainingText) {
let textWithCursor = [];
for (let i = 0; i < remainingText.length; i++) {
if (isFocused && cursorPosition === currentPos) {
textWithCursor.push(
<span key={`cursor-${keyCounter++}`} className="border-l-2 border-text-default inline-block align-baseline" style={{ animation: "blink 1s step-end infinite", height: "1em", marginLeft: "1px", verticalAlign: "baseline" }} />
);
}
textWithCursor.push(remainingText[i]);
currentPos++;
}
parts.push(
<span key={`text-${keyCounter++}`} className="inline whitespace-pre-wrap">
{textWithCursor}
</span>
);
}
// Add cursor at the end if needed
if (isFocused && cursorPosition === currentPos) {
parts.push(
<span key={`cursor-${keyCounter++}`} className="border-l-2 border-text-default inline-block align-baseline" style={{ animation: "blink 1s step-end infinite", height: "1em", marginLeft: "1px", verticalAlign: "baseline" }} />
);
}
return (
<div className="whitespace-pre-wrap min-h-[1.5em] leading-relaxed">
{parts.length > 0 ? parts : (
isFocused && (
<span className="border-l-2 border-text-default inline-block align-baseline" style={{ animation: "blink 1s step-end infinite", height: "1em", marginLeft: "1px", verticalAlign: "baseline" }} />
)
)}
</div>
);
}, [value, isFocused, placeholder, cursorPosition]);
const handleRemoveAction = useCallback((actionLabel: string) => {
const actionText = `[${actionLabel}]`;
const newValue = value.replace(actionText, '');
onChange(newValue);
}, [value, onChange]);
const handleRemoveMention = useCallback((fileName: string) => {
const mentionText = `@${fileName}`;
const newValue = value.replace(mentionText, '');
onChange(newValue);
}, [value, onChange]);
const handleTextareaChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
const newValue = e.target.value;
const newCursorPos = e.target.selectionStart;
console.log('🔄 RichChatInput: onChange', { newValue, newCursorPos });
console.log('🎨 Will trigger re-render with new value:', newValue);
onChange(newValue, newCursorPos);
setCursorPosition(newCursorPos);
}, [onChange]);
const handleTextareaKeyDown = useCallback((e: React.KeyboardEvent<HTMLTextAreaElement>) => {
// Update cursor position on key events
setTimeout(updateCursorPosition, 0);
// Handle backspace on action and mention pills
if (e.key === 'Backspace') {
const cursorPos = e.currentTarget.selectionStart;
const beforeCursor = value.slice(0, cursorPos);
console.log('🔙 Backspace pressed, cursor at:', cursorPos);
console.log('🔙 Text before cursor:', beforeCursor);
// Check if cursor is right after an action pill [Action]
const actionMatch = beforeCursor.match(/\[([^\]]+)\]$/);
if (actionMatch) {
console.log('🔙 Found action pill to remove:', actionMatch[1]);
e.preventDefault();
handleRemoveAction(actionMatch[1]);
return;
}
// Check if cursor is right after a mention pill @filename
const mentionMatch = beforeCursor.match(/@([^\s]+)$/);
if (mentionMatch) {
console.log('🔙 Found mention pill to remove:', mentionMatch[1]);
e.preventDefault();
handleRemoveMention(mentionMatch[1]);
return;
}
}
// Create a proper synthetic event that maintains all the original event properties
const syntheticEvent = {
...e,
key: e.key,
shiftKey: e.shiftKey,
altKey: e.altKey,
ctrlKey: e.ctrlKey,
metaKey: e.metaKey,
preventDefault: () => e.preventDefault(),
stopPropagation: () => e.stopPropagation(),
currentTarget: {
...e.currentTarget,
value: e.currentTarget.value,
selectionStart: e.currentTarget.selectionStart,
selectionEnd: e.currentTarget.selectionEnd,
getBoundingClientRect: () => displayRef.current?.getBoundingClientRect() || new DOMRect(),
},
target: {
...e.currentTarget,
value: e.currentTarget.value,
selectionStart: e.currentTarget.selectionStart,
selectionEnd: e.currentTarget.selectionEnd,
getBoundingClientRect: () => displayRef.current?.getBoundingClientRect() || new DOMRect(),
},
} as any;
onKeyDown?.(syntheticEvent);
}, [value, handleRemoveAction, onKeyDown, updateCursorPosition]);
const handleTextareaPaste = useCallback((e: React.ClipboardEvent<HTMLTextAreaElement>) => {
// Update cursor position after paste
setTimeout(updateCursorPosition, 0);
// Create proper synthetic event
const syntheticEvent = {
...e,
preventDefault: () => e.preventDefault(),
stopPropagation: () => e.stopPropagation(),
clipboardData: e.clipboardData,
currentTarget: displayRef.current,
target: displayRef.current,
} as any;
onPaste?.(syntheticEvent);
}, [onPaste, updateCursorPosition]);
const handleTextareaFocus = useCallback(() => {
setIsFocused(true);
updateCursorPosition();
onFocus?.();
}, [onFocus, updateCursorPosition]);
const handleTextareaBlur = useCallback(() => {
setIsFocused(false);
onBlur?.();
}, [onBlur]);
// Removed handleDisplayClick - let the hidden textarea handle all mouse events naturally
// Handle selection changes (cursor movement)
const handleSelectionChange = useCallback(() => {
if (document.activeElement === hiddenTextareaRef.current) {
updateCursorPosition();
}
}, [updateCursorPosition]);
// Auto-focus effect
useEffect(() => {
if (autoFocus && hiddenTextareaRef.current) {
hiddenTextareaRef.current.focus();
}
}, [autoFocus]);
// Listen for selection changes to update cursor position
useEffect(() => {
document.addEventListener('selectionchange', handleSelectionChange);
return () => {
document.removeEventListener('selectionchange', handleSelectionChange);
};
}, [handleSelectionChange]);
return (
<div className="relative rich-text-input">
{/* Hidden textarea for actual input handling */}
<textarea
ref={hiddenTextareaRef}
value={value}
onChange={handleTextareaChange}
onKeyDown={handleTextareaKeyDown}
onPaste={handleTextareaPaste}
onFocus={handleTextareaFocus}
onBlur={handleTextareaBlur}
onCompositionStart={onCompositionStart}
onCompositionEnd={onCompositionEnd}
disabled={disabled}
data-testid={testId}
className="absolute inset-0 w-full h-full resize-none"
style={{
position: 'absolute',
left: 0,
top: 0,
width: '100%',
height: '100%',
opacity: 0.15, // Slightly more visible for selection
zIndex: 2, // Higher z-index to capture mouse events
background: 'transparent',
border: 'none',
outline: 'none',
resize: 'none',
color: 'rgba(59, 130, 246, 0.2)', // Slightly more visible blue text
caretColor: 'transparent', // Hide caret (we show our own)
pointerEvents: 'auto', // Ensure it can receive mouse events
fontFamily: 'Cash Sans, sans-serif', // Match exact font
fontSize: '0.875rem', // Match text-sm (14px)
lineHeight: '1.5', // Match leading-relaxed
padding: '12px 80px 6px 12px', // Match original: px-3 pt-3 pb-1.5 pr-20
margin: '0',
boxSizing: 'border-box',
whiteSpace: 'pre-wrap', // Match visual display
wordWrap: 'break-word',
}}
className="absolute inset-0 w-full h-full resize-none selection:bg-blue-500 selection:text-white"
rows={rows}
/>
{/* Visual display with action pills and cursor */}
<div
ref={displayRef}
className={`${className} cursor-text relative`}
style={{
...style,
minHeight: `${rows * 1.5}em`,
zIndex: 1, // Lower z-index, behind textarea
pointerEvents: 'none', // Don't interfere with text selection
userSelect: 'none', // Prevent selection on visual layer
WebkitUserSelect: 'none',
fontFamily: 'Cash Sans, sans-serif', // Match textarea font
fontSize: '0.875rem', // Match textarea size
lineHeight: '1.5', // Match textarea line height
padding: '12px 80px 6px 12px', // Match original: px-3 pt-3 pb-1.5 pr-20
margin: '0',
whiteSpace: 'pre-wrap', // Match textarea
wordWrap: 'break-word',
}}
role="textbox"
aria-multiline="true"
aria-placeholder={placeholder}
>
{renderContent()}
</div>
</div>
);
});
RichChatInput.displayName = 'RichChatInput';
export default RichChatInput;