Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: @ syntax for bringing files into chat context #475

Draft
wants to merge 3 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions electron/main/filesystem/filesystem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,11 @@ import * as path from 'path'
import chokidar from 'chokidar'
import { BrowserWindow } from 'electron'

import Store from 'electron-store'
import { FileInfo, FileInfoTree } from './types'
import addExtensionToFilenameIfNoExtensionPresent from '../path/path'
import { isFileNodeDirectory } from '../../../shared/utils'
import { StoreSchema, StoreKeys } from '../electron-store/storeConfig'

export const markdownExtensions = ['.md', '.markdown', '.mdown', '.mkdn', '.mkd']

Expand Down Expand Up @@ -217,3 +219,18 @@ export function splitDirectoryPathIntoBaseAndRepo(fullPath: string) {

return { localModelPath, repoName }
}

export const searchFiles = async (store: Store<StoreSchema>, searchTerm: string): Promise<string[]> => {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

style: Function marked as async but contains no await operations

const vaultDirectory = store.get(StoreKeys.DirectoryFromPreviousSession)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

logic: Using DirectoryFromPreviousSession instead of current window's vault directory could cause inconsistencies

if (!vaultDirectory || typeof vaultDirectory !== 'string') {
throw new Error('No valid vault directory found')
}
Comment on lines +202 to +204
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

style: Error handling could provide more context about why the vault directory is invalid


const allFiles = GetFilesInfoList(vaultDirectory)

const searchTermLower = searchTerm.toLowerCase()

const matchingFiles = allFiles.filter((file) => file.name.toLowerCase().startsWith(searchTermLower))
Comment on lines +206 to +210
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

style: Loading all files into memory before filtering could be inefficient for large vaults. Consider using a streaming/iterative approach


return matchingFiles.map((file) => file.path)
}
5 changes: 5 additions & 0 deletions electron/main/filesystem/ipcHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
GetFilesInfoListForListOfPaths,
startWatchingDirectory,
updateFileListForRenderer,
searchFiles,
} from './filesystem'
import { FileInfoTree, WriteFileProps, RenameFileProps, FileInfoWithContent } from './types'

Expand Down Expand Up @@ -193,6 +194,10 @@ const registerFileHandlers = (store: Store<StoreSchema>, _windowsManager: Window
}
return []
})

ipcMain.handle('search-files', async (_event, searchTerm: string) => {
return searchFiles(store, searchTerm)
})
Comment on lines +159 to +161
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

logic: No error handling for searchFiles - could throw unhandled exceptions if store is invalid or searchTerm is malformed. Consider wrapping in try/catch.

Comment on lines +159 to +161
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

logic: searchTerm parameter should be validated/sanitized to prevent injection attacks or crashes from malicious input

}

export default registerFileHandlers
1 change: 1 addition & 0 deletions electron/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ const fileSystem = {
moveFileOrDir: createIPCHandler<(sourcePath: string, destinationPath: string) => Promise<void>>('move-file-or-dir'),
getAllFilenamesInDirectory: createIPCHandler<(dirName: string) => Promise<string[]>>('get-files-in-directory'),
getFiles: createIPCHandler<(filePaths: string[]) => Promise<FileInfoWithContent[]>>('get-files'),
searchFiles: createIPCHandler<(searchTerm: string) => Promise<string[]>>('search-files'),
}

const path = {
Expand Down
151 changes: 113 additions & 38 deletions src/components/Chat/ChatInput.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import React from 'react'

import React, { useState, useRef } from 'react'
import { PiPaperPlaneRight } from 'react-icons/pi'
import { LoadingState } from '../../lib/llm/types'
import FileAutocomplete from './FileAutocomplete'

interface ChatInputProps {
userTextFieldInput: string
Expand All @@ -15,46 +15,121 @@ const ChatInput: React.FC<ChatInputProps> = ({
setUserTextFieldInput,
handleSubmitNewMessage,
loadingState,
}) => (
<div className="flex h-titlebar w-full items-center justify-center p-10">
<div className=" relative bottom-5 flex w-full max-w-3xl">
<div className="w-full rounded-lg border-2 border-solid border-neutral-700 p-3 focus-within:ring-1 focus-within:ring-[#8c8c8c]">
<div className="flex h-full pr-8">
<textarea
onKeyDown={(e) => {
if (userTextFieldInput && !e.shiftKey && e.key === 'Enter') {
e.preventDefault()
handleSubmitNewMessage()
}
}}
onChange={(e) => setUserTextFieldInput(e.target.value)}
value={userTextFieldInput}
className="mr-2 max-h-[50px] w-full resize-none overflow-y-auto border-0 bg-gray-300 focus:outline-none"
name="Outlined"
placeholder="Follow up..."
rows={1}
style={{
backgroundColor: 'rgba(255, 255, 255, 0)',
color: 'rgb(212 212 212)',
border: 'none',
}}
onInput={(e) => {
const target = e.target as HTMLTextAreaElement // Prevent TS inferring type error
target.style.height = 'auto'
target.style.height = `${Math.min(target.scrollHeight, 160)}px`
}}
/>
<div className="absolute right-3 top-1/2 -translate-y-1/2">
<PiPaperPlaneRight
color={userTextFieldInput && loadingState !== 'idle' ? 'white' : 'gray'}
onClick={userTextFieldInput ? handleSubmitNewMessage : undefined}
className={userTextFieldInput ? 'cursor-pointer' : ''}
}) => {
const [showFileAutocomplete, setShowFileAutocomplete] = useState(false)
const [autocompletePosition, setAutocompletePosition] = useState({ top: 0, left: 0 })
const [searchTerm, setSearchTerm] = useState('')
const textareaRef = useRef<HTMLTextAreaElement>(null)

const getCaretCoordinates = (element: HTMLTextAreaElement) => {
const { selectionStart, value } = element
const textBeforeCaret = value.substring(0, selectionStart)

// Create a hidden div with the same styling as textarea
const mirror = document.createElement('div')
mirror.style.cssText = window.getComputedStyle(element).cssText
mirror.style.height = 'auto'
mirror.style.position = 'absolute'
mirror.style.visibility = 'hidden'
mirror.style.whiteSpace = 'pre-wrap'
document.body.appendChild(mirror)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

logic: DOM element is added but not removed if component unmounts unexpectedly. Wrap in useEffect with cleanup.


// Create a span for the text before caret
const textNode = document.createTextNode(textBeforeCaret)
const span = document.createElement('span')
span.appendChild(textNode)
mirror.appendChild(span)

// Get coordinates
const coordinates = {
top: span.offsetTop + parseInt(window.getComputedStyle(element).lineHeight, 10) / 2,
left: span.offsetLeft,
}

// Clean up
document.body.removeChild(mirror)

return coordinates
}

const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (userTextFieldInput && !e.shiftKey && e.key === 'Enter') {
e.preventDefault()
handleSubmitNewMessage()
} else if (e.key === '@') {
const rect = e.currentTarget.getBoundingClientRect()
const position = getCaretCoordinates(e.currentTarget)
setAutocompletePosition({
top: rect.top + position.top,
left: rect.left + position.left,
})
setShowFileAutocomplete(true)
// console.log('showFileAutocomplete', showFileAutocomplete)
} else if (showFileAutocomplete && e.key === 'Escape') {
setShowFileAutocomplete(false)
}
}

const handleInput = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const { value } = e.target
setUserTextFieldInput(value)

// Handle @ mentions
const lastAtIndex = value.lastIndexOf('@')
if (lastAtIndex !== -1 && lastAtIndex < value.length) {
const searchText = value.slice(lastAtIndex + 1).split(/\s/)[0]
setSearchTerm(searchText)
} else {
setShowFileAutocomplete(false)
}
Comment on lines +89 to +96
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

logic: lastIndexOf('@') could match an @ character in a file path. Use regex to find last @ not inside a file reference.


// Adjust textarea height
e.target.style.height = 'auto'
e.target.style.height = `${Math.min(e.target.scrollHeight, 160)}px`
}

const handleFileSelect = (filePath: string) => {
const lastAtIndex = userTextFieldInput.lastIndexOf('@')
const newValue = `${userTextFieldInput.slice(0, lastAtIndex)}@${filePath} ${userTextFieldInput.slice(
lastAtIndex + searchTerm.length + 1,
)}`

Comment on lines +103 to +108
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

logic: handleFileSelect doesn't handle case where searchTerm is undefined, could cause incorrect slicing

setUserTextFieldInput(newValue)
setShowFileAutocomplete(false)
}

return (
<div className="flex h-titlebar w-full items-center justify-center p-10">
<div className="relative bottom-5 flex w-full max-w-3xl">
<div className="w-full rounded-lg border-2 border-solid border-neutral-700 p-3 focus-within:ring-1 focus-within:ring-[#8c8c8c]">
<div className="flex h-full pr-8">
<textarea
ref={textareaRef}
onKeyDown={handleKeyDown}
onChange={handleInput}
value={userTextFieldInput}
className="mr-2 max-h-[50px] w-full resize-none overflow-y-auto border-0 bg-transparent text-foreground focus:outline-none"
placeholder="Type @ to reference files..."
rows={1}
/>
<div className="absolute right-3 top-1/2 -translate-y-1/2">
<PiPaperPlaneRight
color={userTextFieldInput && loadingState !== 'idle' ? 'white' : 'gray'}
onClick={userTextFieldInput ? handleSubmitNewMessage : undefined}
className={userTextFieldInput ? 'cursor-pointer' : ''}
/>
</div>
</div>
</div>
<FileAutocomplete
searchTerm={searchTerm}
position={autocompletePosition}
onSelect={handleFileSelect}
visible={showFileAutocomplete}
/>
</div>
</div>
</div>
)
)
}

export default ChatInput
40 changes: 40 additions & 0 deletions src/components/Chat/FileAutocomplete.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import React, { useEffect, useState } from 'react'

interface FileAutocompleteProps {
searchTerm: string
position: { top: number; left: number }
onSelect: (filePath: string) => void
visible: boolean
}

const FileAutocomplete: React.FC<FileAutocompleteProps> = ({ searchTerm, position, onSelect, visible }) => {
const [files, setFiles] = useState<string[]>([])

useEffect(() => {
const searchFiles = async () => {
if (searchTerm && visible) {
// Use the electron API to search for files
const results = await window.fileSystem.searchFiles(searchTerm)
setFiles(results)
Comment on lines +17 to +18
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

logic: searchFiles() could fail but has no error handling. Add try/catch and show error state to user.

}
}
searchFiles()
}, [searchTerm, visible])
Comment on lines +13 to +22
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

style: useEffect runs on every searchTerm change without debouncing, could cause performance issues with rapid typing


if (!visible || !searchTerm) return null

return (
<div
className="absolute z-50 max-h-48 w-64 overflow-y-auto rounded-md border border-neutral-700 bg-background shadow-lg"
style={{ top: position.top, left: position.left }}
>
{files.map((file) => (
<div key={file} className="cursor-pointer px-4 py-2 hover:bg-neutral-700" onClick={() => onSelect(file)}>
{file.split('/').pop()}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

logic: file.split('/').pop() could return undefined if path ends in /

</div>
))}
Comment on lines +35 to +39
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

style: no empty state UI when search returns no results

</div>
)
}

export default FileAutocomplete
Loading