mirror of
https://github.com/aaif-goose/goose.git
synced 2026-07-03 14:10:03 +02:00
Clean up: Remove PR description and backup files
- Removed pr_description.md - Removed all component backup files (.backup, .backup2, etc.) - Branch now contains only clean production code
This commit is contained in:
@@ -1,80 +0,0 @@
|
||||
# Rich Text Input with Action and Mention Pills
|
||||
|
||||
This PR introduces a sophisticated rich text input system that replaces plain text with visual pills for actions and file mentions, creating a more intuitive and modern chat interface.
|
||||
|
||||
## 🎯 Features
|
||||
|
||||
### Visual Pills
|
||||
- **Action Pills**: Blue circles with white icons for `/` commands (e.g., `[Quick Task]`)
|
||||
- **Mention Pills**: Blue diamonds for `@` file references (e.g., `@filename.txt`)
|
||||
- **Consistent Styling**: White pill backgrounds with blue accent icons
|
||||
- **Theme Support**: Adapts to light/dark modes
|
||||
|
||||
### User Experience
|
||||
- **Natural Text Flow**: Pills display inline with text content
|
||||
- **Text Selection**: Full support for click/drag, double-click, triple-click
|
||||
- **Backspace Removal**: Delete entire pills as single units
|
||||
- **Proper Cursor**: Black blinking cursor with correct positioning
|
||||
- **Multi-line Support**: Cursor stays aligned on line breaks
|
||||
|
||||
### Components Added
|
||||
- `RichChatInput.tsx`: Main rich text input with pill rendering
|
||||
- `ActionPill.tsx`: Blue circle pills for actions
|
||||
- `MentionPill.tsx`: Blue diamond pills for file mentions
|
||||
- `MessageContent.tsx`: Enhanced message display with pills
|
||||
|
||||
## 🔧 Technical Implementation
|
||||
|
||||
### Architecture
|
||||
- **Hidden Textarea**: Handles actual input and selection
|
||||
- **Visual Display**: Renders pills and cursor overlay
|
||||
- **Perfect Alignment**: Synchronized font, padding, and positioning
|
||||
- **Event Handling**: Proper mouse/keyboard event delegation
|
||||
|
||||
### Pattern Matching
|
||||
- **Actions**: `/action` → `[Action Name]` → Blue circle pill
|
||||
- **Mentions**: `@filename` → Blue diamond pill
|
||||
- **Mixed Content**: Supports both types inline with text
|
||||
|
||||
### Accessibility
|
||||
- **Keyboard Navigation**: Full keyboard support maintained
|
||||
- **Screen Readers**: Proper ARIA attributes and roles
|
||||
- **Selection**: Standard text selection behaviors preserved
|
||||
|
||||
## 🎨 Visual Design
|
||||
|
||||
### Before
|
||||
```
|
||||
Hello /quick-task and @file.txt world
|
||||
```
|
||||
|
||||
### After
|
||||
```
|
||||
Hello [Quick Task] and @file.txt world
|
||||
↑ blue circle ↑ blue diamond
|
||||
```
|
||||
|
||||
### Message History
|
||||
Pills appear in sent messages maintaining the same visual style and inline flow.
|
||||
|
||||
## 🧪 Testing
|
||||
|
||||
Tested extensively for:
|
||||
- ✅ Action pill creation via `/` command
|
||||
- ✅ File mention pills via `@` command
|
||||
- ✅ Text selection and highlighting
|
||||
- ✅ Cursor positioning and movement
|
||||
- ✅ Backspace pill removal
|
||||
- ✅ Multi-line text handling
|
||||
- ✅ Light/dark theme compatibility
|
||||
- ✅ Message history display
|
||||
|
||||
## 🚀 Impact
|
||||
|
||||
This creates a modern chat interface similar to Slack/Discord with:
|
||||
- **Improved UX**: Visual pills are easier to understand than plain text
|
||||
- **Better Organization**: Clear distinction between actions and file references
|
||||
- **Enhanced Workflow**: Faster recognition of commands and mentions
|
||||
- **Professional Appearance**: Polished, modern interface design
|
||||
|
||||
The implementation maintains full backward compatibility while significantly enhancing the user experience.
|
||||
@@ -1,30 +0,0 @@
|
||||
import React from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
interface ActionPillProps {
|
||||
actionId: string;
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
onRemove: () => void;
|
||||
}
|
||||
|
||||
export const ActionPill: React.FC<ActionPillProps> = ({ label, icon, onRemove }) => {
|
||||
return (
|
||||
<div className="inline-flex items-center gap-1.5 px-2 py-1 bg-bgProminent text-textProminentInverse rounded-full text-xs font-medium border border-borderProminent">
|
||||
<span className="flex items-center gap-1">
|
||||
{icon}
|
||||
{label}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRemove}
|
||||
className="flex items-center justify-center w-4 h-4 rounded-full hover:bg-white/20 transition-colors"
|
||||
aria-label={`Remove ${label} action`}
|
||||
>
|
||||
<X size={10} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ActionPill;
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,271 +0,0 @@
|
||||
import { useRef, useMemo, useState, useEffect, useCallback } from 'react';
|
||||
import LinkPreview from './LinkPreview';
|
||||
import ImagePreview from './ImagePreview';
|
||||
import { extractUrls } from '../utils/urlUtils';
|
||||
import { extractImagePaths, removeImagePathsFromText } from '../utils/imageUtils';
|
||||
import MarkdownContent from './MarkdownContent';
|
||||
import { Message, getTextContent } from '../types/message';
|
||||
import MessageCopyLink from './MessageCopyLink';
|
||||
import { formatMessageTimestamp } from '../utils/timeUtils';
|
||||
import Edit from './icons/Edit';
|
||||
import { Button } from './ui/button';
|
||||
|
||||
interface UserMessageProps {
|
||||
message: Message;
|
||||
onMessageUpdate?: (messageId: string, newContent: string) => void;
|
||||
}
|
||||
|
||||
export default function UserMessage({ message, onMessageUpdate }: UserMessageProps) {
|
||||
const contentRef = useRef<HTMLDivElement | null>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [editContent, setEditContent] = useState('');
|
||||
const [hasBeenEdited, setHasBeenEdited] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Extract text content from the message
|
||||
const textContent = getTextContent(message);
|
||||
|
||||
// Extract image paths from the message
|
||||
const imagePaths = extractImagePaths(textContent);
|
||||
|
||||
// Remove image paths from text for display - memoized for performance
|
||||
const displayText = useMemo(
|
||||
() => removeImagePathsFromText(textContent, imagePaths),
|
||||
[textContent, imagePaths]
|
||||
);
|
||||
|
||||
// Memoize the timestamp
|
||||
const timestamp = useMemo(() => formatMessageTimestamp(message.created), [message.created]);
|
||||
|
||||
// Extract URLs which explicitly contain the http:// or https:// protocol
|
||||
const urls = useMemo(() => extractUrls(displayText, []), [displayText]);
|
||||
|
||||
// Effect to handle message content changes and ensure persistence
|
||||
useEffect(() => {
|
||||
// Log content display for debugging
|
||||
window.electron.logInfo(
|
||||
`Displaying content for message: ${message.id} content: ${displayText}`
|
||||
);
|
||||
|
||||
// If we're not editing, update the edit content to match the current message
|
||||
if (!isEditing) {
|
||||
setEditContent(displayText);
|
||||
}
|
||||
}, [message.content, displayText, message.id, isEditing]);
|
||||
|
||||
// Initialize edit mode with current message content
|
||||
const initializeEditMode = useCallback(() => {
|
||||
setEditContent(displayText);
|
||||
setError(null);
|
||||
window.electron.logInfo(`Entering edit mode with content: ${displayText}`);
|
||||
}, [displayText]);
|
||||
|
||||
// Handle edit button click
|
||||
const handleEditClick = useCallback(() => {
|
||||
const newEditingState = !isEditing;
|
||||
setIsEditing(newEditingState);
|
||||
|
||||
// Initialize edit content when entering edit mode
|
||||
if (newEditingState) {
|
||||
initializeEditMode();
|
||||
window.electron.logInfo(`Edit interface shown for message: ${message.id}`);
|
||||
|
||||
// Focus the textarea after a brief delay to ensure it's rendered
|
||||
setTimeout(() => {
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.focus();
|
||||
textareaRef.current.setSelectionRange(
|
||||
textareaRef.current.value.length,
|
||||
textareaRef.current.value.length
|
||||
);
|
||||
}
|
||||
}, 50);
|
||||
}
|
||||
|
||||
window.electron.logInfo(`Edit state toggled: ${newEditingState} for message: ${message.id}`);
|
||||
}, [isEditing, initializeEditMode, message.id]);
|
||||
|
||||
// Handle content changes in edit mode
|
||||
const handleContentChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const newContent = e.target.value;
|
||||
setEditContent(newContent);
|
||||
setError(null); // Clear any previous errors
|
||||
window.electron.logInfo(`Content changed: ${newContent}`);
|
||||
}, []);
|
||||
|
||||
// Handle save action
|
||||
const handleSave = useCallback(() => {
|
||||
// Exit edit mode immediately
|
||||
setIsEditing(false);
|
||||
|
||||
// Check if content has actually changed
|
||||
if (editContent !== displayText) {
|
||||
// Validate content
|
||||
if (editContent.trim().length === 0) {
|
||||
setError('Message cannot be empty');
|
||||
return;
|
||||
}
|
||||
|
||||
// Update the message content through the callback
|
||||
if (onMessageUpdate && message.id) {
|
||||
onMessageUpdate(message.id, editContent);
|
||||
setHasBeenEdited(true);
|
||||
}
|
||||
}
|
||||
}, [editContent, displayText, onMessageUpdate, message.id]);
|
||||
|
||||
// Handle cancel action
|
||||
const handleCancel = useCallback(() => {
|
||||
window.electron.logInfo('Cancel clicked - reverting to original content');
|
||||
setIsEditing(false);
|
||||
setEditContent(displayText); // Reset to original content
|
||||
setError(null);
|
||||
}, [displayText]);
|
||||
|
||||
// Handle keyboard events for accessibility
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
window.electron.logInfo(
|
||||
`Key pressed: ${e.key}, metaKey: ${e.metaKey}, ctrlKey: ${e.ctrlKey}`
|
||||
);
|
||||
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
handleCancel();
|
||||
} else if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
window.electron.logInfo('Cmd+Enter detected, calling handleSave');
|
||||
handleSave();
|
||||
}
|
||||
},
|
||||
[handleCancel, handleSave]
|
||||
);
|
||||
|
||||
// Auto-resize textarea based on content
|
||||
useEffect(() => {
|
||||
if (textareaRef.current && isEditing) {
|
||||
textareaRef.current.style.height = 'auto';
|
||||
textareaRef.current.style.height = `${Math.min(textareaRef.current.scrollHeight, 200)}px`;
|
||||
}
|
||||
}, [editContent, isEditing]);
|
||||
|
||||
return (
|
||||
<div className="w-full mt-[16px] opacity-0 animate-[appear_150ms_ease-in_forwards]">
|
||||
<div className="flex flex-col group">
|
||||
{isEditing ? (
|
||||
// Truly wide, centered, in-place edit box replacing the bubble
|
||||
<div className="w-full max-w-4xl mx-auto bg-background-light dark:bg-background-dark text-text-prominent rounded-xl border border-border-subtle shadow-lg py-4 px-4 my-2 transition-all duration-200 ease-in-out">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={editContent}
|
||||
onChange={handleContentChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="w-full resize-none bg-transparent text-text-prominent placeholder:text-text-subtle border border-border-subtle rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-400 focus:border-blue-400 transition-all duration-200 text-base leading-relaxed"
|
||||
style={{
|
||||
minHeight: '120px',
|
||||
maxHeight: '300px',
|
||||
padding: '16px',
|
||||
fontFamily: 'inherit',
|
||||
lineHeight: '1.6',
|
||||
wordBreak: 'break-word',
|
||||
overflowWrap: 'break-word',
|
||||
}}
|
||||
placeholder="Edit your message..."
|
||||
aria-label="Edit message content"
|
||||
aria-describedby={error ? `error-${message.id}` : undefined}
|
||||
/>
|
||||
{/* Error message */}
|
||||
{error && (
|
||||
<div
|
||||
id={`error-${message.id}`}
|
||||
className="text-red-400 text-xs mt-2 mb-2"
|
||||
role="alert"
|
||||
aria-live="polite"
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-end gap-3 mt-4">
|
||||
<Button onClick={handleCancel} variant="ghost" aria-label="Cancel editing">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSave} aria-label="Save changes">
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
// Normal message display
|
||||
<div className="message flex justify-end w-full">
|
||||
<div className="flex-col max-w-[85%] w-fit">
|
||||
<div className="flex flex-col group">
|
||||
<div className="flex bg-background-accent text-text-on-accent rounded-xl py-2.5 px-4">
|
||||
<div ref={contentRef}>
|
||||
<MarkdownContent
|
||||
content={displayText}
|
||||
className="text-text-on-accent prose-a:text-text-on-accent prose-headings:text-text-on-accent prose-strong:text-text-on-accent prose-em:text-text-on-accent user-message"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Render images if any */}
|
||||
{imagePaths.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 mt-2">
|
||||
{imagePaths.map((imagePath, index) => (
|
||||
<ImagePreview key={index} src={imagePath} alt={`Pasted image ${index + 1}`} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="relative h-[22px] flex justify-end text-right">
|
||||
<div className="absolute w-40 font-mono right-0 text-xs text-text-muted pt-1 transition-all duration-200 group-hover:-translate-y-4 group-hover:opacity-0">
|
||||
{timestamp}
|
||||
</div>
|
||||
<div className="absolute right-0 pt-1 flex items-center gap-2">
|
||||
<button
|
||||
onClick={handleEditClick}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
handleEditClick();
|
||||
}
|
||||
}}
|
||||
className="flex items-center gap-1 text-xs text-text-subtle hover:cursor-pointer hover:text-text-prominent transition-all duration-200 opacity-0 group-hover:opacity-100 -translate-y-4 group-hover:translate-y-0 focus:outline-none focus:ring-2 focus:ring-blue-400 focus:ring-opacity-50 rounded"
|
||||
aria-label={`Edit message: ${displayText.substring(0, 50)}${displayText.length > 50 ? '...' : ''}`}
|
||||
aria-expanded={isEditing}
|
||||
title="Edit message"
|
||||
>
|
||||
<Edit className="h-3 w-3" />
|
||||
<span>Edit</span>
|
||||
</button>
|
||||
<MessageCopyLink text={displayText} contentRef={contentRef} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Edited indicator */}
|
||||
{hasBeenEdited && !isEditing && (
|
||||
<div className="text-xs text-text-subtle mt-1 text-right transition-opacity duration-200">
|
||||
Edited
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* TODO(alexhancock): Re-enable link previews once styled well again */}
|
||||
{/* TEMPORARILY DISABLED (dorien-koelemeijer): This is causing issues in properly "generating" tool calls
|
||||
that contain links and prevents security scanning */}
|
||||
{/* eslint-disable-next-line no-constant-binary-expression */}
|
||||
{false && urls.length > 0 && (
|
||||
<div className="flex flex-wrap mt-2">
|
||||
{urls.map((url, index) => (
|
||||
<LinkPreview key={index} url={url} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user