mirror of
https://github.com/block/goose.git
synced 2026-05-04 22:40:59 +00:00
- 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
504 lines
18 KiB
Text
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;
|