mirror of
https://github.com/aljazceru/goose.git
synced 2025-12-17 06:04:23 +01:00
Add fuzzy file search functionality (#3240)
Co-authored-by: spencrmartin <spencermartin@squareup.com>
This commit is contained in:
@@ -149,7 +149,7 @@ mod tests {
|
|||||||
use mcp_core::{content::TextContent, Role};
|
use mcp_core::{content::TextContent, Role};
|
||||||
use std::env;
|
use std::env;
|
||||||
|
|
||||||
#[warn(dead_code)]
|
#[allow(dead_code)]
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
struct MockTestProvider {
|
struct MockTestProvider {
|
||||||
name: String,
|
name: String,
|
||||||
|
|||||||
17
demo.json
Normal file
17
demo.json
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"demo": {
|
||||||
|
"title": "Goose File Operations Demo",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"features": [
|
||||||
|
"File creation",
|
||||||
|
"File reading",
|
||||||
|
"Content modification",
|
||||||
|
"Multiple formats"
|
||||||
|
],
|
||||||
|
"metadata": {
|
||||||
|
"created_by": "Goose AI Assistant",
|
||||||
|
"created_at": "2025-07-02T17:33:32Z",
|
||||||
|
"file_type": "demonstration"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
17
demo.txt
Normal file
17
demo.txt
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
Hello, World!
|
||||||
|
|
||||||
|
This is a demonstration of file operations in Goose.
|
||||||
|
|
||||||
|
Here are some key points:
|
||||||
|
- Files can be created and edited
|
||||||
|
- Content can be viewed and modified
|
||||||
|
- Multiple file formats are supported
|
||||||
|
|
||||||
|
Current timestamp: 2025-07-02 17:33:32
|
||||||
|
|
||||||
|
--- UPDATE ---
|
||||||
|
This content was added after the initial file creation!
|
||||||
|
File modification operations include:
|
||||||
|
- String replacement
|
||||||
|
- Line insertion
|
||||||
|
- Content appending
|
||||||
6
package-lock.json
generated
Normal file
6
package-lock.json
generated
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"name": "goose",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {}
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import { Message } from '../types/message';
|
|||||||
import { useWhisper } from '../hooks/useWhisper';
|
import { useWhisper } from '../hooks/useWhisper';
|
||||||
import { WaveformVisualizer } from './WaveformVisualizer';
|
import { WaveformVisualizer } from './WaveformVisualizer';
|
||||||
import { toastError } from '../toasts';
|
import { toastError } from '../toasts';
|
||||||
|
import MentionPopover, { FileItemWithMatch } from './MentionPopover';
|
||||||
|
|
||||||
interface PastedImage {
|
interface PastedImage {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -65,6 +66,20 @@ export default function ChatInput({
|
|||||||
const [displayValue, setDisplayValue] = useState(initialValue); // For immediate visual feedback
|
const [displayValue, setDisplayValue] = useState(initialValue); // For immediate visual feedback
|
||||||
const [isFocused, setIsFocused] = useState(false);
|
const [isFocused, setIsFocused] = useState(false);
|
||||||
const [pastedImages, setPastedImages] = useState<PastedImage[]>([]);
|
const [pastedImages, setPastedImages] = useState<PastedImage[]>([]);
|
||||||
|
const [mentionPopover, setMentionPopover] = useState<{
|
||||||
|
isOpen: boolean;
|
||||||
|
position: { x: number; y: number };
|
||||||
|
query: string;
|
||||||
|
mentionStart: number;
|
||||||
|
selectedIndex: number;
|
||||||
|
}>({
|
||||||
|
isOpen: false,
|
||||||
|
position: { x: 0, y: 0 },
|
||||||
|
query: '',
|
||||||
|
mentionStart: -1,
|
||||||
|
selectedIndex: 0,
|
||||||
|
});
|
||||||
|
const mentionPopoverRef = useRef<{ getDisplayFiles: () => FileItemWithMatch[]; selectFile: (index: number) => void }>(null);
|
||||||
|
|
||||||
// Whisper hook for voice dictation
|
// Whisper hook for voice dictation
|
||||||
const {
|
const {
|
||||||
@@ -219,8 +234,48 @@ export default function ChatInput({
|
|||||||
|
|
||||||
const handleChange = (evt: React.ChangeEvent<HTMLTextAreaElement>) => {
|
const handleChange = (evt: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||||
const val = evt.target.value;
|
const val = evt.target.value;
|
||||||
|
const cursorPosition = evt.target.selectionStart;
|
||||||
|
|
||||||
setDisplayValue(val); // Update display immediately
|
setDisplayValue(val); // Update display immediately
|
||||||
debouncedSetValue(val); // Debounce the actual state update
|
debouncedSetValue(val); // Debounce the actual state update
|
||||||
|
|
||||||
|
// Check for @ mention
|
||||||
|
checkForMention(val, cursorPosition, evt.target);
|
||||||
|
};
|
||||||
|
|
||||||
|
const checkForMention = (text: string, cursorPosition: number, textArea: HTMLTextAreaElement) => {
|
||||||
|
// Find the last @ before the cursor
|
||||||
|
const beforeCursor = text.slice(0, cursorPosition);
|
||||||
|
const lastAtIndex = beforeCursor.lastIndexOf('@');
|
||||||
|
|
||||||
|
if (lastAtIndex === -1) {
|
||||||
|
// No @ found, close mention popover
|
||||||
|
setMentionPopover(prev => ({ ...prev, isOpen: false }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if there's a space between @ and cursor (which would end the mention)
|
||||||
|
const afterAt = beforeCursor.slice(lastAtIndex + 1);
|
||||||
|
if (afterAt.includes(' ') || afterAt.includes('\n')) {
|
||||||
|
setMentionPopover(prev => ({ ...prev, isOpen: false }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate position for the popover - position it above the chat input
|
||||||
|
const textAreaRect = textArea.getBoundingClientRect();
|
||||||
|
|
||||||
|
setMentionPopover(prev => ({
|
||||||
|
...prev,
|
||||||
|
isOpen: true,
|
||||||
|
position: {
|
||||||
|
x: textAreaRect.left,
|
||||||
|
y: textAreaRect.top, // Position at the top of the textarea
|
||||||
|
},
|
||||||
|
query: afterAt,
|
||||||
|
mentionStart: lastAtIndex,
|
||||||
|
selectedIndex: 0, // Reset selection when query changes
|
||||||
|
// filteredFiles will be populated by the MentionPopover component
|
||||||
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePaste = async (evt: React.ClipboardEvent<HTMLTextAreaElement>) => {
|
const handlePaste = async (evt: React.ClipboardEvent<HTMLTextAreaElement>) => {
|
||||||
@@ -425,6 +480,38 @@ export default function ChatInput({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleKeyDown = (evt: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
const handleKeyDown = (evt: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||||
|
// If mention popover is open, handle arrow keys and enter
|
||||||
|
if (mentionPopover.isOpen && mentionPopoverRef.current) {
|
||||||
|
if (evt.key === 'ArrowDown') {
|
||||||
|
evt.preventDefault();
|
||||||
|
const displayFiles = mentionPopoverRef.current.getDisplayFiles();
|
||||||
|
const maxIndex = Math.max(0, displayFiles.length - 1);
|
||||||
|
setMentionPopover(prev => ({
|
||||||
|
...prev,
|
||||||
|
selectedIndex: Math.min(prev.selectedIndex + 1, maxIndex)
|
||||||
|
}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (evt.key === 'ArrowUp') {
|
||||||
|
evt.preventDefault();
|
||||||
|
setMentionPopover(prev => ({
|
||||||
|
...prev,
|
||||||
|
selectedIndex: Math.max(prev.selectedIndex - 1, 0)
|
||||||
|
}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (evt.key === 'Enter') {
|
||||||
|
evt.preventDefault();
|
||||||
|
mentionPopoverRef.current.selectFile(mentionPopover.selectedIndex);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (evt.key === 'Escape') {
|
||||||
|
evt.preventDefault();
|
||||||
|
setMentionPopover(prev => ({ ...prev, isOpen: false }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Handle history navigation first
|
// Handle history navigation first
|
||||||
handleHistoryNavigation(evt);
|
handleHistoryNavigation(evt);
|
||||||
|
|
||||||
@@ -474,223 +561,256 @@ export default function ChatInput({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleMentionFileSelect = (filePath: string) => {
|
||||||
|
// Replace the @ mention with the file path
|
||||||
|
const beforeMention = displayValue.slice(0, mentionPopover.mentionStart);
|
||||||
|
const afterMention = displayValue.slice(mentionPopover.mentionStart + 1 + mentionPopover.query.length);
|
||||||
|
const newValue = `${beforeMention}${filePath}${afterMention}`;
|
||||||
|
|
||||||
|
setDisplayValue(newValue);
|
||||||
|
setValue(newValue);
|
||||||
|
setMentionPopover(prev => ({ ...prev, isOpen: false }));
|
||||||
|
textAreaRef.current?.focus();
|
||||||
|
|
||||||
|
// Set cursor position after the inserted file path
|
||||||
|
setTimeout(() => {
|
||||||
|
if (textAreaRef.current) {
|
||||||
|
const newCursorPosition = beforeMention.length + filePath.length;
|
||||||
|
textAreaRef.current.setSelectionRange(newCursorPosition, newCursorPosition);
|
||||||
|
}
|
||||||
|
}, 0);
|
||||||
|
};
|
||||||
|
|
||||||
const hasSubmittableContent =
|
const hasSubmittableContent =
|
||||||
displayValue.trim() || pastedImages.some((img) => img.filePath && !img.error && !img.isLoading);
|
displayValue.trim() || pastedImages.some((img) => img.filePath && !img.error && !img.isLoading);
|
||||||
const isAnyImageLoading = pastedImages.some((img) => img.isLoading);
|
const isAnyImageLoading = pastedImages.some((img) => img.isLoading);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<>
|
||||||
className={`flex flex-col relative h-auto border rounded-lg transition-colors ${
|
<div
|
||||||
isFocused
|
className={`flex flex-col relative h-auto border rounded-lg transition-colors ${
|
||||||
? 'border-borderProminent hover:border-borderProminent'
|
isFocused
|
||||||
: 'border-borderSubtle hover:border-borderStandard'
|
? 'border-borderProminent hover:border-borderProminent'
|
||||||
} bg-bgApp z-10`}
|
: 'border-borderSubtle hover:border-borderStandard'
|
||||||
>
|
} bg-bgApp z-10`}
|
||||||
<form onSubmit={onFormSubmit}>
|
>
|
||||||
<div className="relative">
|
<form onSubmit={onFormSubmit}>
|
||||||
<textarea
|
<div className="relative">
|
||||||
data-testid="chat-input"
|
<textarea
|
||||||
autoFocus
|
data-testid="chat-input"
|
||||||
id="dynamic-textarea"
|
autoFocus
|
||||||
placeholder={isRecording ? '' : 'What can goose help with? ⌘↑/⌘↓'}
|
id="dynamic-textarea"
|
||||||
value={displayValue}
|
placeholder={isRecording ? '' : 'What can goose help with? @ files • ⌘↑/⌘↓'}
|
||||||
onChange={handleChange}
|
value={displayValue}
|
||||||
onCompositionStart={handleCompositionStart}
|
onChange={handleChange}
|
||||||
onCompositionEnd={handleCompositionEnd}
|
onCompositionStart={handleCompositionStart}
|
||||||
onKeyDown={handleKeyDown}
|
onCompositionEnd={handleCompositionEnd}
|
||||||
onPaste={handlePaste}
|
onKeyDown={handleKeyDown}
|
||||||
onFocus={() => setIsFocused(true)}
|
onPaste={handlePaste}
|
||||||
onBlur={() => setIsFocused(false)}
|
onFocus={() => setIsFocused(true)}
|
||||||
ref={textAreaRef}
|
onBlur={() => setIsFocused(false)}
|
||||||
rows={1}
|
ref={textAreaRef}
|
||||||
style={{
|
rows={1}
|
||||||
minHeight: `${minHeight}px`,
|
style={{
|
||||||
maxHeight: `${maxHeight}px`,
|
minHeight: `${minHeight}px`,
|
||||||
overflowY: 'auto',
|
maxHeight: `${maxHeight}px`,
|
||||||
opacity: isRecording ? 0 : 1,
|
overflowY: 'auto',
|
||||||
}}
|
opacity: isRecording ? 0 : 1,
|
||||||
className="w-full pl-4 pr-[108px] outline-none border-none focus:ring-0 bg-transparent pt-3 pb-1.5 text-sm resize-none text-textStandard placeholder:text-textPlaceholder"
|
}}
|
||||||
/>
|
className="w-full pl-4 pr-[108px] outline-none border-none focus:ring-0 bg-transparent pt-3 pb-1.5 text-sm resize-none text-textStandard placeholder:text-textPlaceholder"
|
||||||
{isRecording && (
|
/>
|
||||||
<div className="absolute inset-0 flex items-center pl-4 pr-[108px] pt-3 pb-1.5">
|
{isRecording && (
|
||||||
<WaveformVisualizer
|
<div className="absolute inset-0 flex items-center pl-4 pr-[108px] pt-3 pb-1.5">
|
||||||
audioContext={audioContext}
|
<WaveformVisualizer
|
||||||
analyser={analyser}
|
audioContext={audioContext}
|
||||||
isRecording={isRecording}
|
analyser={analyser}
|
||||||
/>
|
isRecording={isRecording}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{pastedImages.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-2 p-2 border-t border-borderSubtle">
|
||||||
|
{pastedImages.map((img) => (
|
||||||
|
<div key={img.id} className="relative group w-20 h-20">
|
||||||
|
{img.dataUrl && (
|
||||||
|
<img
|
||||||
|
src={img.dataUrl} // Use dataUrl for instant preview
|
||||||
|
alt={`Pasted image ${img.id}`}
|
||||||
|
className={`w-full h-full object-cover rounded border ${img.error ? 'border-red-500' : 'border-borderStandard'}`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{img.isLoading && (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center bg-black bg-opacity-50 rounded">
|
||||||
|
<div className="animate-spin rounded-full h-6 w-6 border-t-2 border-b-2 border-white"></div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{img.error && !img.isLoading && (
|
||||||
|
<div className="absolute inset-0 flex flex-col items-center justify-center bg-black bg-opacity-75 rounded p-1 text-center">
|
||||||
|
<p className="text-red-400 text-[10px] leading-tight break-all mb-1">
|
||||||
|
{img.error.substring(0, 50)}
|
||||||
|
</p>
|
||||||
|
{img.dataUrl && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleRetryImageSave(img.id)}
|
||||||
|
className="bg-blue-600 hover:bg-blue-700 text-white rounded px-1 py-0.5 text-[8px] leading-none"
|
||||||
|
title="Retry saving image"
|
||||||
|
>
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!img.isLoading && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleRemovePastedImage(img.id)}
|
||||||
|
className="absolute -top-1 -right-1 bg-gray-700 hover:bg-red-600 text-white rounded-full w-5 h-5 flex items-center justify-center text-xs leading-none opacity-0 group-hover:opacity-100 focus:opacity-100 transition-opacity z-10"
|
||||||
|
aria-label="Remove image"
|
||||||
|
>
|
||||||
|
<Close className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
|
|
||||||
{pastedImages.length > 0 && (
|
{isLoading ? (
|
||||||
<div className="flex flex-wrap gap-2 p-2 border-t border-borderSubtle">
|
|
||||||
{pastedImages.map((img) => (
|
|
||||||
<div key={img.id} className="relative group w-20 h-20">
|
|
||||||
{img.dataUrl && (
|
|
||||||
<img
|
|
||||||
src={img.dataUrl} // Use dataUrl for instant preview
|
|
||||||
alt={`Pasted image ${img.id}`}
|
|
||||||
className={`w-full h-full object-cover rounded border ${img.error ? 'border-red-500' : 'border-borderStandard'}`}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{img.isLoading && (
|
|
||||||
<div className="absolute inset-0 flex items-center justify-center bg-black bg-opacity-50 rounded">
|
|
||||||
<div className="animate-spin rounded-full h-6 w-6 border-t-2 border-b-2 border-white"></div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{img.error && !img.isLoading && (
|
|
||||||
<div className="absolute inset-0 flex flex-col items-center justify-center bg-black bg-opacity-75 rounded p-1 text-center">
|
|
||||||
<p className="text-red-400 text-[10px] leading-tight break-all mb-1">
|
|
||||||
{img.error.substring(0, 50)}
|
|
||||||
</p>
|
|
||||||
{img.dataUrl && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => handleRetryImageSave(img.id)}
|
|
||||||
className="bg-blue-600 hover:bg-blue-700 text-white rounded px-1 py-0.5 text-[8px] leading-none"
|
|
||||||
title="Retry saving image"
|
|
||||||
>
|
|
||||||
Retry
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{!img.isLoading && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => handleRemovePastedImage(img.id)}
|
|
||||||
className="absolute -top-1 -right-1 bg-gray-700 hover:bg-red-600 text-white rounded-full w-5 h-5 flex items-center justify-center text-xs leading-none opacity-0 group-hover:opacity-100 focus:opacity-100 transition-opacity z-10"
|
|
||||||
aria-label="Remove image"
|
|
||||||
>
|
|
||||||
<Close className="w-3.5 h-3.5" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isLoading ? (
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
size="icon"
|
|
||||||
variant="ghost"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
onStop?.();
|
|
||||||
}}
|
|
||||||
className="absolute right-3 top-2 text-textSubtle rounded-full border border-borderSubtle hover:border-borderStandard hover:text-textStandard w-7 h-7 [&_svg]:size-4"
|
|
||||||
>
|
|
||||||
<Stop size={24} />
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{/* Microphone button - only show if dictation is enabled and configured */}
|
|
||||||
{canUseDictation && (
|
|
||||||
<>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
size="icon"
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => {
|
|
||||||
if (isRecording) {
|
|
||||||
stopRecording();
|
|
||||||
} else {
|
|
||||||
startRecording();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
disabled={isTranscribing}
|
|
||||||
className={`absolute right-12 top-2 transition-colors rounded-full w-7 h-7 [&_svg]:size-4 ${
|
|
||||||
isRecording
|
|
||||||
? 'bg-red-500 text-white hover:bg-red-600'
|
|
||||||
: isTranscribing
|
|
||||||
? 'text-textSubtle cursor-not-allowed animate-pulse'
|
|
||||||
: 'text-textSubtle hover:text-textStandard'
|
|
||||||
}`}
|
|
||||||
title={
|
|
||||||
isRecording
|
|
||||||
? `Stop recording (${Math.floor(recordingDuration)}s, ~${estimatedSize.toFixed(1)}MB)`
|
|
||||||
: isTranscribing
|
|
||||||
? 'Transcribing...'
|
|
||||||
: 'Start dictation'
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Microphone />
|
|
||||||
</Button>
|
|
||||||
{/* Recording/transcribing status indicator - positioned above the input */}
|
|
||||||
{(isRecording || isTranscribing) && (
|
|
||||||
<div className="absolute right-0 -top-8 bg-bgApp px-2 py-1 rounded text-xs whitespace-nowrap shadow-md border border-borderSubtle">
|
|
||||||
{isTranscribing ? (
|
|
||||||
<span className="text-blue-500 flex items-center gap-1">
|
|
||||||
<span className="inline-block w-2 h-2 bg-blue-500 rounded-full animate-pulse" />
|
|
||||||
Transcribing...
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span
|
|
||||||
className={`flex items-center gap-2 ${estimatedSize > 20 ? 'text-orange-500' : 'text-textSubtle'}`}
|
|
||||||
>
|
|
||||||
<span className="inline-block w-2 h-2 bg-red-500 rounded-full animate-pulse" />
|
|
||||||
{Math.floor(recordingDuration)}s • ~{estimatedSize.toFixed(1)}MB
|
|
||||||
{estimatedSize > 20 && <span className="text-xs">(near 25MB limit)</span>}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="button"
|
||||||
size="icon"
|
size="icon"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
disabled={
|
onClick={(e) => {
|
||||||
!hasSubmittableContent || isAnyImageLoading || isRecording || isTranscribing
|
e.preventDefault();
|
||||||
}
|
e.stopPropagation();
|
||||||
className={`absolute right-3 top-2 transition-colors rounded-full w-7 h-7 [&_svg]:size-4 ${
|
onStop?.();
|
||||||
!hasSubmittableContent || isAnyImageLoading || isRecording || isTranscribing
|
}}
|
||||||
? 'text-textSubtle cursor-not-allowed'
|
className="absolute right-3 top-2 text-textSubtle rounded-full border border-borderSubtle hover:border-borderStandard hover:text-textStandard w-7 h-7 [&_svg]:size-4"
|
||||||
: 'bg-bgAppInverse text-textProminentInverse hover:cursor-pointer'
|
|
||||||
}`}
|
|
||||||
title={
|
|
||||||
isAnyImageLoading
|
|
||||||
? 'Waiting for images to save...'
|
|
||||||
: isRecording
|
|
||||||
? 'Recording...'
|
|
||||||
: isTranscribing
|
|
||||||
? 'Transcribing...'
|
|
||||||
: 'Send'
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<Send />
|
<Stop size={24} />
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
) : (
|
||||||
)}
|
<>
|
||||||
</form>
|
{/* Microphone button - only show if dictation is enabled and configured */}
|
||||||
|
{canUseDictation && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => {
|
||||||
|
if (isRecording) {
|
||||||
|
stopRecording();
|
||||||
|
} else {
|
||||||
|
startRecording();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={isTranscribing}
|
||||||
|
className={`absolute right-12 top-2 transition-colors rounded-full w-7 h-7 [&_svg]:size-4 ${
|
||||||
|
isRecording
|
||||||
|
? 'bg-red-500 text-white hover:bg-red-600'
|
||||||
|
: isTranscribing
|
||||||
|
? 'text-textSubtle cursor-not-allowed animate-pulse'
|
||||||
|
: 'text-textSubtle hover:text-textStandard'
|
||||||
|
}`}
|
||||||
|
title={
|
||||||
|
isRecording
|
||||||
|
? `Stop recording (${Math.floor(recordingDuration)}s, ~${estimatedSize.toFixed(1)}MB)`
|
||||||
|
: isTranscribing
|
||||||
|
? 'Transcribing...'
|
||||||
|
: 'Start dictation'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Microphone />
|
||||||
|
</Button>
|
||||||
|
{/* Recording/transcribing status indicator - positioned above the input */}
|
||||||
|
{(isRecording || isTranscribing) && (
|
||||||
|
<div className="absolute right-0 -top-8 bg-bgApp px-2 py-1 rounded text-xs whitespace-nowrap shadow-md border border-borderSubtle">
|
||||||
|
{isTranscribing ? (
|
||||||
|
<span className="text-blue-500 flex items-center gap-1">
|
||||||
|
<span className="inline-block w-2 h-2 bg-blue-500 rounded-full animate-pulse" />
|
||||||
|
Transcribing...
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
className={`flex items-center gap-2 ${estimatedSize > 20 ? 'text-orange-500' : 'text-textSubtle'}`}
|
||||||
|
>
|
||||||
|
<span className="inline-block w-2 h-2 bg-red-500 rounded-full animate-pulse" />
|
||||||
|
{Math.floor(recordingDuration)}s • ~{estimatedSize.toFixed(1)}MB
|
||||||
|
{estimatedSize > 20 && <span className="text-xs">(near 25MB limit)</span>}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
disabled={
|
||||||
|
!hasSubmittableContent || isAnyImageLoading || isRecording || isTranscribing
|
||||||
|
}
|
||||||
|
className={`absolute right-3 top-2 transition-colors rounded-full w-7 h-7 [&_svg]:size-4 ${
|
||||||
|
!hasSubmittableContent || isAnyImageLoading || isRecording || isTranscribing
|
||||||
|
? 'text-textSubtle cursor-not-allowed'
|
||||||
|
: 'bg-bgAppInverse text-textProminentInverse hover:cursor-pointer'
|
||||||
|
}`}
|
||||||
|
title={
|
||||||
|
isAnyImageLoading
|
||||||
|
? 'Waiting for images to save...'
|
||||||
|
: isRecording
|
||||||
|
? 'Recording...'
|
||||||
|
: isTranscribing
|
||||||
|
? 'Transcribing...'
|
||||||
|
: 'Send'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Send />
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
|
||||||
<div className="flex items-center transition-colors text-textSubtle relative text-xs p-2 pr-3 border-t border-borderSubtle gap-2">
|
<div className="flex items-center transition-colors text-textSubtle relative text-xs p-2 pr-3 border-t border-borderSubtle gap-2">
|
||||||
<div className="gap-1 flex items-center justify-between w-full">
|
<div className="gap-1 flex items-center justify-between w-full">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
size="icon"
|
size="icon"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={handleFileSelect}
|
onClick={handleFileSelect}
|
||||||
className="text-textSubtle hover:text-textStandard w-7 h-7 [&_svg]:size-4"
|
className="text-textSubtle hover:text-textStandard w-7 h-7 [&_svg]:size-4"
|
||||||
>
|
>
|
||||||
<Attach />
|
<Attach />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<BottomMenu
|
<BottomMenu
|
||||||
setView={setView}
|
setView={setView}
|
||||||
numTokens={numTokens}
|
numTokens={numTokens}
|
||||||
inputTokens={inputTokens}
|
inputTokens={inputTokens}
|
||||||
outputTokens={outputTokens}
|
outputTokens={outputTokens}
|
||||||
messages={messages}
|
messages={messages}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
setMessages={setMessages}
|
setMessages={setMessages}
|
||||||
sessionCosts={sessionCosts}
|
sessionCosts={sessionCosts}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
<MentionPopover
|
||||||
|
ref={mentionPopoverRef}
|
||||||
|
isOpen={mentionPopover.isOpen}
|
||||||
|
onClose={() => setMentionPopover(prev => ({ ...prev, isOpen: false }))}
|
||||||
|
onSelect={handleMentionFileSelect}
|
||||||
|
position={mentionPopover.position}
|
||||||
|
query={mentionPopover.query}
|
||||||
|
selectedIndex={mentionPopover.selectedIndex}
|
||||||
|
onSelectedIndexChange={(index) => setMentionPopover(prev => ({ ...prev, selectedIndex: index }))}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
230
ui/desktop/src/components/FileIcon.tsx
Normal file
230
ui/desktop/src/components/FileIcon.tsx
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
interface FileIconProps {
|
||||||
|
fileName: string;
|
||||||
|
isDirectory: boolean;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FileIcon: React.FC<FileIconProps> = ({ fileName, isDirectory, className = "w-4 h-4" }) => {
|
||||||
|
if (isDirectory) {
|
||||||
|
return (
|
||||||
|
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ext = fileName.split('.').pop()?.toLowerCase();
|
||||||
|
|
||||||
|
// Image files
|
||||||
|
if (['png', 'jpg', 'jpeg', 'gif', 'svg', 'ico', 'webp', 'bmp', 'tiff', 'tif'].includes(ext || '')) {
|
||||||
|
return (
|
||||||
|
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/>
|
||||||
|
<circle cx="8.5" cy="8.5" r="1.5"/>
|
||||||
|
<polyline points="21,15 16,10 5,21"/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Video files
|
||||||
|
if (['mp4', 'mov', 'avi', 'mkv', 'webm', 'flv', 'wmv'].includes(ext || '')) {
|
||||||
|
return (
|
||||||
|
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<polygon points="23 7 16 12 23 17 23 7"/>
|
||||||
|
<rect x="1" y="5" width="15" height="14" rx="2" ry="2"/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Audio files
|
||||||
|
if (['mp3', 'wav', 'flac', 'aac', 'ogg', 'm4a'].includes(ext || '')) {
|
||||||
|
return (
|
||||||
|
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M9 18V5l12-2v13"/>
|
||||||
|
<circle cx="6" cy="18" r="3"/>
|
||||||
|
<circle cx="18" cy="16" r="3"/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Archive/compressed files
|
||||||
|
if (['zip', 'tar', 'gz', 'rar', '7z', 'bz2'].includes(ext || '')) {
|
||||||
|
return (
|
||||||
|
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M16 22h2a2 2 0 0 0 2-2V7.5L14.5 2H6a2 2 0 0 0-2 2v3"/>
|
||||||
|
<polyline points="14,2 14,8 20,8"/>
|
||||||
|
<path d="M10 20v-1a2 2 0 1 1 4 0v1a2 2 0 1 1-4 0Z"/>
|
||||||
|
<path d="M10 7h4"/>
|
||||||
|
<path d="M10 11h4"/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// PDF files
|
||||||
|
if (ext === 'pdf') {
|
||||||
|
return (
|
||||||
|
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
||||||
|
<polyline points="14,2 14,8 20,8"/>
|
||||||
|
<path d="M10 12h4"/>
|
||||||
|
<path d="M10 16h2"/>
|
||||||
|
<path d="M10 8h2"/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Design files
|
||||||
|
if (['ai', 'eps', 'sketch', 'fig', 'xd', 'psd'].includes(ext || '')) {
|
||||||
|
return (
|
||||||
|
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/>
|
||||||
|
<path d="M9 9h6v6h-6z"/>
|
||||||
|
<path d="M16 3.5a.5.5 0 0 1 .5-.5h3a.5.5 0 0 1 .5.5v3a.5.5 0 0 1-.5.5h-3a.5.5 0 0 1-.5-.5v-3z"/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// JavaScript/TypeScript files
|
||||||
|
if (['js', 'jsx', 'ts', 'tsx', 'mjs', 'cjs'].includes(ext || '')) {
|
||||||
|
return (
|
||||||
|
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
||||||
|
<polyline points="14,2 14,8 20,8"/>
|
||||||
|
<path d="M10 13l2 2 4-4"/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Python files
|
||||||
|
if (['py', 'pyw', 'pyc'].includes(ext || '')) {
|
||||||
|
return (
|
||||||
|
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
||||||
|
<polyline points="14,2 14,8 20,8"/>
|
||||||
|
<circle cx="10" cy="12" r="2"/>
|
||||||
|
<circle cx="14" cy="16" r="2"/>
|
||||||
|
<path d="M12 10c0-1 1-2 2-2s2 1 2 2-1 2-2 2"/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTML files
|
||||||
|
if (['html', 'htm', 'xhtml'].includes(ext || '')) {
|
||||||
|
return (
|
||||||
|
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
||||||
|
<polyline points="14,2 14,8 20,8"/>
|
||||||
|
<polyline points="9,13 9,17 15,17 15,13"/>
|
||||||
|
<line x1="12" y1="13" x2="12" y2="17"/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// CSS files
|
||||||
|
if (['css', 'scss', 'sass', 'less', 'stylus'].includes(ext || '')) {
|
||||||
|
return (
|
||||||
|
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
||||||
|
<polyline points="14,2 14,8 20,8"/>
|
||||||
|
<path d="M8 13h8"/>
|
||||||
|
<path d="M8 17h8"/>
|
||||||
|
<circle cx="12" cy="15" r="1"/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// JSON/Data files
|
||||||
|
if (['json', 'xml', 'yaml', 'yml', 'toml', 'csv'].includes(ext || '')) {
|
||||||
|
return (
|
||||||
|
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
||||||
|
<polyline points="14,2 14,8 20,8"/>
|
||||||
|
<path d="M9 13v-1a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v1"/>
|
||||||
|
<path d="M9 17v-1a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v1"/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Markdown files
|
||||||
|
if (['md', 'markdown', 'mdx'].includes(ext || '')) {
|
||||||
|
return (
|
||||||
|
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
||||||
|
<polyline points="14,2 14,8 20,8"/>
|
||||||
|
<line x1="16" y1="13" x2="8" y2="13"/>
|
||||||
|
<line x1="16" y1="17" x2="8" y2="17"/>
|
||||||
|
<polyline points="10,9 9,9 8,9"/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Database files
|
||||||
|
if (['sql', 'db', 'sqlite', 'sqlite3'].includes(ext || '')) {
|
||||||
|
return (
|
||||||
|
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<ellipse cx="12" cy="5" rx="9" ry="3"/>
|
||||||
|
<path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"/>
|
||||||
|
<path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Configuration files
|
||||||
|
if (['env', 'ini', 'cfg', 'conf', 'config', 'gitignore', 'dockerignore', 'editorconfig', 'prettierrc', 'eslintrc'].includes(ext || '') ||
|
||||||
|
['dockerfile', 'makefile', 'rakefile', 'gemfile'].includes(fileName.toLowerCase())) {
|
||||||
|
return (
|
||||||
|
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<circle cx="12" cy="12" r="3"/>
|
||||||
|
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1 1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Text files
|
||||||
|
if (['txt', 'log', 'readme', 'license', 'changelog', 'contributing'].includes(ext || '') ||
|
||||||
|
['readme', 'license', 'changelog', 'contributing'].includes(fileName.toLowerCase())) {
|
||||||
|
return (
|
||||||
|
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
||||||
|
<polyline points="14,2 14,8 20,8"/>
|
||||||
|
<line x1="16" y1="13" x2="8" y2="13"/>
|
||||||
|
<line x1="16" y1="17" x2="8" y2="17"/>
|
||||||
|
<polyline points="10,9 9,9 8,9"/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Executable files
|
||||||
|
if (['exe', 'app', 'deb', 'rpm', 'dmg', 'pkg', 'msi'].includes(ext || '')) {
|
||||||
|
return (
|
||||||
|
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<polygon points="14 2 18 6 18 20 6 20 6 4 14 4"/>
|
||||||
|
<polyline points="14,2 14,8 20,8"/>
|
||||||
|
<path d="M10 12l2 2 4-4"/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Script files
|
||||||
|
if (['sh', 'bash', 'zsh', 'fish', 'bat', 'cmd', 'ps1', 'rb', 'pl', 'php'].includes(ext || '')) {
|
||||||
|
return (
|
||||||
|
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<polyline points="4 17 10 11 4 5"/>
|
||||||
|
<line x1="12" y1="19" x2="20" y2="19"/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default file icon
|
||||||
|
return (
|
||||||
|
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
||||||
|
<polyline points="14,2 14,8 20,8"/>
|
||||||
|
<line x1="16" y1="13" x2="8" y2="13"/>
|
||||||
|
<line x1="16" y1="17" x2="8" y2="17"/>
|
||||||
|
<polyline points="10,9 9,9 8,9"/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
};
|
||||||
420
ui/desktop/src/components/MentionPopover.tsx
Normal file
420
ui/desktop/src/components/MentionPopover.tsx
Normal file
@@ -0,0 +1,420 @@
|
|||||||
|
import { useState, useEffect, useRef, useMemo, forwardRef, useImperativeHandle, useCallback } from 'react';
|
||||||
|
import { FileIcon } from './FileIcon';
|
||||||
|
|
||||||
|
interface FileItem {
|
||||||
|
path: string;
|
||||||
|
name: string;
|
||||||
|
isDirectory: boolean;
|
||||||
|
relativePath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FileItemWithMatch extends FileItem {
|
||||||
|
matchScore: number;
|
||||||
|
matches: number[];
|
||||||
|
matchedText: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MentionPopoverProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSelect: (filePath: string) => void;
|
||||||
|
position: { x: number; y: number };
|
||||||
|
query: string;
|
||||||
|
selectedIndex: number;
|
||||||
|
onSelectedIndexChange: (index: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enhanced fuzzy matching algorithm
|
||||||
|
const fuzzyMatch = (pattern: string, text: string): { score: number; matches: number[] } => {
|
||||||
|
if (!pattern) return { score: 0, matches: [] };
|
||||||
|
|
||||||
|
const patternLower = pattern.toLowerCase();
|
||||||
|
const textLower = text.toLowerCase();
|
||||||
|
const matches: number[] = [];
|
||||||
|
|
||||||
|
let patternIndex = 0;
|
||||||
|
let score = 0;
|
||||||
|
let consecutiveMatches = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < textLower.length && patternIndex < patternLower.length; i++) {
|
||||||
|
if (textLower[i] === patternLower[patternIndex]) {
|
||||||
|
matches.push(i);
|
||||||
|
patternIndex++;
|
||||||
|
consecutiveMatches++;
|
||||||
|
|
||||||
|
// Bonus for consecutive matches
|
||||||
|
score += consecutiveMatches * 3;
|
||||||
|
|
||||||
|
// Bonus for matches at word boundaries or path separators
|
||||||
|
if (i === 0 || textLower[i - 1] === '/' || textLower[i - 1] === '_' || textLower[i - 1] === '-' || textLower[i - 1] === '.') {
|
||||||
|
score += 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bonus for matching the start of the filename (after last /)
|
||||||
|
const lastSlash = textLower.lastIndexOf('/', i);
|
||||||
|
if (lastSlash !== -1 && i === lastSlash + 1) {
|
||||||
|
score += 15;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
consecutiveMatches = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only return a score if all pattern characters were matched
|
||||||
|
if (patternIndex === patternLower.length) {
|
||||||
|
// Less penalty for longer strings to allow nested files to rank well
|
||||||
|
score -= text.length * 0.05;
|
||||||
|
|
||||||
|
// Bonus for exact substring matches
|
||||||
|
if (textLower.includes(patternLower)) {
|
||||||
|
score += 20;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bonus for matching the filename specifically (not just the path)
|
||||||
|
const fileName = text.split('/').pop()?.toLowerCase() || '';
|
||||||
|
if (fileName.includes(patternLower)) {
|
||||||
|
score += 25;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { score, matches };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { score: -1, matches: [] };
|
||||||
|
};
|
||||||
|
|
||||||
|
const MentionPopover = forwardRef<
|
||||||
|
{ getDisplayFiles: () => FileItemWithMatch[]; selectFile: (index: number) => void },
|
||||||
|
MentionPopoverProps
|
||||||
|
>(({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
onSelect,
|
||||||
|
position,
|
||||||
|
query,
|
||||||
|
selectedIndex,
|
||||||
|
onSelectedIndexChange
|
||||||
|
}, ref) => {
|
||||||
|
const [files, setFiles] = useState<FileItem[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const popoverRef = useRef<HTMLDivElement>(null);
|
||||||
|
const listRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Filter and sort files based on query
|
||||||
|
const displayFiles = useMemo((): FileItemWithMatch[] => {
|
||||||
|
if (!query.trim()) {
|
||||||
|
return files.slice(0, 15).map(file => ({
|
||||||
|
...file,
|
||||||
|
matchScore: 0,
|
||||||
|
matches: [],
|
||||||
|
matchedText: file.name
|
||||||
|
})); // Show first 15 files when no query
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = files
|
||||||
|
.map(file => {
|
||||||
|
const nameMatch = fuzzyMatch(query, file.name);
|
||||||
|
const pathMatch = fuzzyMatch(query, file.relativePath);
|
||||||
|
const fullPathMatch = fuzzyMatch(query, file.path);
|
||||||
|
|
||||||
|
// Use the best match among name, relative path, and full path
|
||||||
|
let bestMatch = nameMatch;
|
||||||
|
let matchedText = file.name;
|
||||||
|
|
||||||
|
if (pathMatch.score > bestMatch.score) {
|
||||||
|
bestMatch = pathMatch;
|
||||||
|
matchedText = file.relativePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fullPathMatch.score > bestMatch.score) {
|
||||||
|
bestMatch = fullPathMatch;
|
||||||
|
matchedText = file.path;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...file,
|
||||||
|
matchScore: bestMatch.score,
|
||||||
|
matches: bestMatch.matches,
|
||||||
|
matchedText
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter(file => file.matchScore > 0)
|
||||||
|
.sort((a, b) => {
|
||||||
|
// Sort by score first, then prefer files over directories, then alphabetically
|
||||||
|
if (Math.abs(a.matchScore - b.matchScore) < 1) {
|
||||||
|
if (a.isDirectory !== b.isDirectory) {
|
||||||
|
return a.isDirectory ? 1 : -1; // Files first
|
||||||
|
}
|
||||||
|
return a.name.localeCompare(b.name);
|
||||||
|
}
|
||||||
|
return b.matchScore - a.matchScore;
|
||||||
|
})
|
||||||
|
.slice(0, 20); // Increase to 20 results
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}, [files, query]);
|
||||||
|
|
||||||
|
// Expose methods to parent component
|
||||||
|
useImperativeHandle(ref, () => ({
|
||||||
|
getDisplayFiles: () => displayFiles,
|
||||||
|
selectFile: (index: number) => {
|
||||||
|
if (displayFiles[index]) {
|
||||||
|
onSelect(displayFiles[index].path);
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}), [displayFiles, onSelect, onClose]);
|
||||||
|
|
||||||
|
// Scan files when component opens
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen && files.length === 0) {
|
||||||
|
scanFilesFromRoot();
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [isOpen, files.length]); // scanFilesFromRoot intentionally omitted to avoid circular dependency
|
||||||
|
|
||||||
|
// Handle clicks outside the popover
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
if (popoverRef.current && !popoverRef.current.contains(event.target as Node)) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isOpen) {
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
};
|
||||||
|
}, [isOpen, onClose]);
|
||||||
|
|
||||||
|
const scanDirectoryFromRoot = useCallback(async (dirPath: string, relativePath = '', depth = 0): Promise<FileItem[]> => {
|
||||||
|
// Increase depth limit for better file discovery
|
||||||
|
if (depth > 5) return [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const items = await window.electron.listFiles(dirPath);
|
||||||
|
const results: FileItem[] = [];
|
||||||
|
|
||||||
|
// Common directories to prioritize or skip
|
||||||
|
const priorityDirs = ['Desktop', 'Documents', 'Downloads', 'Projects', 'Development', 'Code', 'src', 'components', 'icons'];
|
||||||
|
const skipDirs = [
|
||||||
|
'.git', '.svn', '.hg', 'node_modules', '__pycache__', '.vscode', '.idea',
|
||||||
|
'target', 'dist', 'build', '.cache', '.npm', '.yarn', 'Library',
|
||||||
|
'System', 'Applications', '.Trash'
|
||||||
|
];
|
||||||
|
|
||||||
|
// Don't skip as many directories at deeper levels to find more files
|
||||||
|
const skipDirsAtDepth = depth > 2 ? ['.git', '.svn', '.hg', 'node_modules', '__pycache__'] : skipDirs;
|
||||||
|
|
||||||
|
// Sort items to prioritize certain directories
|
||||||
|
const sortedItems = items.sort((a, b) => {
|
||||||
|
const aPriority = priorityDirs.includes(a);
|
||||||
|
const bPriority = priorityDirs.includes(b);
|
||||||
|
if (aPriority && !bPriority) return -1;
|
||||||
|
if (!aPriority && bPriority) return 1;
|
||||||
|
return a.localeCompare(b);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Increase item limit per directory for better coverage
|
||||||
|
const itemLimit = depth === 0 ? 50 : depth === 1 ? 40 : 30;
|
||||||
|
|
||||||
|
for (const item of sortedItems.slice(0, itemLimit)) {
|
||||||
|
const fullPath = `${dirPath}/${item}`;
|
||||||
|
const itemRelativePath = relativePath ? `${relativePath}/${item}` : item;
|
||||||
|
|
||||||
|
// Skip hidden files and common ignore patterns
|
||||||
|
if (item.startsWith('.') || skipDirsAtDepth.includes(item)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// First, check if this looks like a file based on extension
|
||||||
|
const hasExtension = item.includes('.');
|
||||||
|
const ext = item.split('.').pop()?.toLowerCase();
|
||||||
|
const commonExtensions = [
|
||||||
|
// Code files
|
||||||
|
'txt', 'md', 'js', 'ts', 'jsx', 'tsx', 'py', 'java', 'cpp', 'c', 'h',
|
||||||
|
'css', 'html', 'json', 'xml', 'yaml', 'yml', 'toml', 'ini', 'cfg',
|
||||||
|
'sh', 'bat', 'ps1', 'rb', 'go', 'rs', 'php', 'sql', 'r', 'scala',
|
||||||
|
'swift', 'kt', 'dart', 'vue', 'svelte', 'astro', 'scss', 'less',
|
||||||
|
// Documentation
|
||||||
|
'readme', 'license', 'changelog', 'contributing',
|
||||||
|
// Config files
|
||||||
|
'gitignore', 'dockerignore', 'editorconfig', 'prettierrc', 'eslintrc',
|
||||||
|
// Images and assets
|
||||||
|
'png', 'jpg', 'jpeg', 'gif', 'svg', 'ico', 'webp', 'bmp', 'tiff', 'tif',
|
||||||
|
// Vector and design files
|
||||||
|
'ai', 'eps', 'sketch', 'fig', 'xd', 'psd',
|
||||||
|
// Other common files
|
||||||
|
'pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx'
|
||||||
|
];
|
||||||
|
|
||||||
|
// If it has a known file extension, treat it as a file
|
||||||
|
if (hasExtension && ext && commonExtensions.includes(ext)) {
|
||||||
|
results.push({
|
||||||
|
path: fullPath,
|
||||||
|
name: item,
|
||||||
|
isDirectory: false,
|
||||||
|
relativePath: itemRelativePath
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If it's a known file without extension (README, LICENSE, etc.)
|
||||||
|
const knownFiles = ['readme', 'license', 'changelog', 'contributing', 'dockerfile', 'makefile'];
|
||||||
|
if (!hasExtension && knownFiles.includes(item.toLowerCase())) {
|
||||||
|
results.push({
|
||||||
|
path: fullPath,
|
||||||
|
name: item,
|
||||||
|
isDirectory: false,
|
||||||
|
relativePath: itemRelativePath
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise, try to determine if it's a directory
|
||||||
|
try {
|
||||||
|
await window.electron.listFiles(fullPath);
|
||||||
|
|
||||||
|
// It's a directory
|
||||||
|
results.push({
|
||||||
|
path: fullPath,
|
||||||
|
name: item,
|
||||||
|
isDirectory: true,
|
||||||
|
relativePath: itemRelativePath
|
||||||
|
});
|
||||||
|
|
||||||
|
// Recursively scan directories more aggressively
|
||||||
|
if (depth < 4 || priorityDirs.includes(item)) {
|
||||||
|
const subFiles = await scanDirectoryFromRoot(fullPath, itemRelativePath, depth + 1);
|
||||||
|
results.push(...subFiles);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// If we can't list it and it doesn't have a known extension, skip it
|
||||||
|
// This could be a file with an unknown extension or a permission issue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error scanning directory ${dirPath}:`, error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const scanFilesFromRoot = useCallback(async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
// Start from common user directories for better performance
|
||||||
|
let startPath = '/Users'; // Default to macOS
|
||||||
|
if (window.electron.platform === 'win32') {
|
||||||
|
startPath = 'C:\\Users';
|
||||||
|
} else if (window.electron.platform === 'linux') {
|
||||||
|
startPath = '/home';
|
||||||
|
}
|
||||||
|
|
||||||
|
const scannedFiles = await scanDirectoryFromRoot(startPath);
|
||||||
|
setFiles(scannedFiles);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error scanning files from root:', error);
|
||||||
|
setFiles([]);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [scanDirectoryFromRoot]);
|
||||||
|
|
||||||
|
// Scroll selected item into view
|
||||||
|
useEffect(() => {
|
||||||
|
if (listRef.current) {
|
||||||
|
const selectedElement = listRef.current.children[selectedIndex] as HTMLElement;
|
||||||
|
if (selectedElement) {
|
||||||
|
selectedElement.scrollIntoView({ block: 'nearest' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [selectedIndex]);
|
||||||
|
|
||||||
|
const handleItemClick = (index: number) => {
|
||||||
|
onSelectedIndexChange(index);
|
||||||
|
onSelect(displayFiles[index].path);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
const displayedFiles = displayFiles.slice(0, 8); // Show up to 8 files
|
||||||
|
const remainingCount = displayFiles.length - displayedFiles.length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={popoverRef}
|
||||||
|
className="fixed z-50 bg-bgApp border border-borderStandard rounded-lg shadow-lg min-w-96 max-w-lg"
|
||||||
|
style={{
|
||||||
|
left: position.x,
|
||||||
|
top: position.y - 10, // Position above the chat input
|
||||||
|
transform: 'translateY(-100%)', // Move it fully above
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="p-3">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-4">
|
||||||
|
<div className="animate-spin rounded-full h-4 w-4 border-t-2 border-b-2 border-textSubtle"></div>
|
||||||
|
<span className="ml-2 text-sm text-textSubtle">Scanning files...</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div ref={listRef} className="space-y-1">
|
||||||
|
{displayedFiles.map((file, index) => (
|
||||||
|
<div
|
||||||
|
key={file.path}
|
||||||
|
onClick={() => handleItemClick(index)}
|
||||||
|
className={`flex items-center gap-3 p-2 rounded-md cursor-pointer transition-colors ${
|
||||||
|
index === selectedIndex
|
||||||
|
? 'bg-bgProminent text-textProminentInverse'
|
||||||
|
: 'hover:bg-bgSubtle'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex-shrink-0 text-textSubtle">
|
||||||
|
<FileIcon fileName={file.name} isDirectory={file.isDirectory} />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-sm truncate text-textStandard">
|
||||||
|
{file.name}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-textSubtle truncate">
|
||||||
|
{file.path}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{!isLoading && displayedFiles.length === 0 && query && (
|
||||||
|
<div className="p-4 text-center text-textSubtle text-sm">
|
||||||
|
No files found matching "{query}"
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isLoading && displayedFiles.length === 0 && !query && (
|
||||||
|
<div className="p-4 text-center text-textSubtle text-sm">
|
||||||
|
Start typing to search for files
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{remainingCount > 0 && (
|
||||||
|
<div className="mt-2 pt-2 border-t border-borderSubtle">
|
||||||
|
<div className="text-xs text-textSubtle text-center">
|
||||||
|
Show {remainingCount} more...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
MentionPopover.displayName = 'MentionPopover';
|
||||||
|
|
||||||
|
export default MentionPopover;
|
||||||
28
ui/desktop/src/floating-button-script.js
Normal file
28
ui/desktop/src/floating-button-script.js
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
// Handle window movement and docking detection
|
||||||
|
let isDragging = false;
|
||||||
|
let startX, startY;
|
||||||
|
|
||||||
|
document.addEventListener('mousedown', (e) => {
|
||||||
|
isDragging = true;
|
||||||
|
startX = e.screenX - window.screenX;
|
||||||
|
startY = e.screenY - window.screenY;
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('mouseup', () => {
|
||||||
|
if (isDragging) {
|
||||||
|
isDragging = false;
|
||||||
|
// Check for docking with parent window
|
||||||
|
if (window.electronFloating) {
|
||||||
|
window.electronFloating.checkDocking();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle click to focus parent window
|
||||||
|
document.addEventListener('click', (e) => {
|
||||||
|
if (!isDragging && window.electronFloating) {
|
||||||
|
window.electronFloating.focusParent();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Floating button script loaded');
|
||||||
Reference in New Issue
Block a user