diff --git a/refact-agent/gui/src/components/ChatForm/ChatControls.tsx b/refact-agent/gui/src/components/ChatForm/ChatControls.tsx index dcb009663..b897ac364 100644 --- a/refact-agent/gui/src/components/ChatForm/ChatControls.tsx +++ b/refact-agent/gui/src/components/ChatForm/ChatControls.tsx @@ -8,6 +8,7 @@ import { Box, Switch, Badge, + Button, } from "@radix-ui/themes"; import { Select } from "../Select"; import { type Config } from "../../features/Config/configSlice"; @@ -36,6 +37,7 @@ import { setToolUse, } from "../../features/Chat/Thread"; import { useAppSelector, useAppDispatch, useCapsForToolUse } from "../../hooks"; +import { useAttachedFiles } from "./useCheckBoxes"; export const ApplyPatchSwitch: React.FC = () => { const dispatch = useAppDispatch(); @@ -223,6 +225,7 @@ export type ChatControlsProps = { ) => void; host: Config["host"]; + attachedFiles: ReturnType; }; const ChatControlCheckBox: React.FC<{ @@ -294,6 +297,7 @@ export const ChatControls: React.FC = ({ checkboxes, onCheckedChange, host, + attachedFiles, }) => { const refs = useTourRefs(); const dispatch = useAppDispatch(); @@ -343,6 +347,18 @@ export const ChatControls: React.FC = ({ ); })} + {host !== "web" && ( + + )} + {showControls && ( { await user.type(textarea, "foo"); await user.keyboard("{Enter}"); const markdown = "```python\nprint(1)\n```\n"; - const cursor = app.store.getState().active_file.cursor; - - const expected = `@file foo.txt:${ - cursor ? cursor + 1 : 1 - }\n${markdown}\nfoo\n`; + const expected = `${markdown}\nfoo\n`; expect(fakeOnSubmit).toHaveBeenCalledWith(expected); }); diff --git a/refact-agent/gui/src/components/ChatForm/ChatForm.tsx b/refact-agent/gui/src/components/ChatForm/ChatForm.tsx index eb689f8c6..11e048ee0 100644 --- a/refact-agent/gui/src/components/ChatForm/ChatForm.tsx +++ b/refact-agent/gui/src/components/ChatForm/ChatForm.tsx @@ -19,7 +19,6 @@ import { useSendChatRequest, useCompressChat, useAutoFocusOnce, - // useTotalTokenUsage, } from "../../hooks"; import { ErrorCallout, Callout } from "../Callout"; import { ComboBox } from "../ComboBox"; @@ -30,17 +29,16 @@ import { useCommandCompletionAndPreviewFiles } from "./useCommandCompletionAndPr import { useAppSelector, useAppDispatch } from "../../hooks"; import { clearError, getErrorMessage } from "../../features/Errors/errorsSlice"; import { useTourRefs } from "../../features/Tour"; -import { useCheckboxes } from "./useCheckBoxes"; +import { useAttachedFiles, useCheckboxes } from "./useCheckBoxes"; import { useInputValue } from "./useInputValue"; import { clearInformation, getInformationMessage, - // setInformation, } from "../../features/Errors/informationSlice"; import { InformationCallout } from "../Callout/Callout"; import { ToolConfirmation } from "./ToolConfirmation"; import { getPauseReasonsWithPauseStatus } from "../../features/ToolConfirmation/confirmationSlice"; -import { AttachFileButton, FileList } from "../Dropzone"; +import { AttachImagesButton, FileList } from "../Dropzone"; import { useAttachedImages } from "../../hooks/useAttachedImages"; import { enableSend, @@ -51,7 +49,6 @@ import { selectLastSentCompression, selectMessages, selectPreventSend, - // selectThreadMaximumTokens, selectThreadToolUse, selectToolUse, } from "../../features/Chat"; @@ -59,7 +56,6 @@ import { telemetryApi } from "../../services/refact"; import { push } from "../../features/Pages/pagesSlice"; import { AgentCapabilities } from "./AgentCapabilities"; import { TokensPreview } from "./TokensPreview"; -// import { useUsageCounter } from "../UsageCounter/useUsageCounter"; import classNames from "classnames"; import { ArchiveIcon } from "@radix-ui/react-icons"; @@ -97,6 +93,7 @@ export const ChatForm: React.FC = ({ const lastSentCompression = useAppSelector(selectLastSentCompression); const { compressChat, compressChatRequest } = useCompressChat(); const autoFocus = useAutoFocusOnce(); + const attachedFiles = useAttachedFiles(); const shouldAgentCapabilitiesBeShown = useMemo(() => { return threadToolUse === "agent"; @@ -161,7 +158,6 @@ export const ChatForm: React.FC = ({ checkboxes, onToggleCheckbox, unCheckAll, - setFileInteracted, setLineSelectionInteracted, } = useCheckboxes(); @@ -184,21 +180,23 @@ export const ChatForm: React.FC = ({ const handleSubmit = useCallback(() => { const trimmedValue = value.trim(); if (!disableSend && trimmedValue.length > 0) { + const valueWithFiles = attachedFiles.addFilesToInput(trimmedValue); const valueIncludingChecks = addCheckboxValuesToInput( - trimmedValue, + valueWithFiles, checkboxes, ); - setFileInteracted(false); + // TODO: add @files setLineSelectionInteracted(false); onSubmit(valueIncludingChecks); setValue(() => ""); unCheckAll(); + attachedFiles.removeAll(); } }, [ value, disableSend, + attachedFiles, checkboxes, - setFileInteracted, setLineSelectionInteracted, onSubmit, setValue, @@ -241,7 +239,6 @@ export const ChatForm: React.FC = ({ setValue(command); const trimmedCommand = command.trim(); if (!trimmedCommand) { - setFileInteracted(false); setLineSelectionInteracted(false); } @@ -251,7 +248,7 @@ export const ChatForm: React.FC = ({ handleHelpInfo(null); } }, - [handleHelpInfo, setValue, setFileInteracted, setLineSelectionInteracted], + [handleHelpInfo, setValue, setLineSelectionInteracted], ); const handleAgentIntegrationsClick = useCallback(() => { @@ -423,7 +420,9 @@ export const ChatForm: React.FC = ({ /> )} {config.features?.images !== false && - isMultimodalitySupportedForCurrentModel && } + isMultimodalitySupportedForCurrentModel && ( + + )} {/* TODO: Reserved space for microphone button coming later on */} = ({ - + ); diff --git a/refact-agent/gui/src/components/ChatForm/useCheckBoxes.ts b/refact-agent/gui/src/components/ChatForm/useCheckBoxes.ts index c9c7ea7f6..7727c0e99 100644 --- a/refact-agent/gui/src/components/ChatForm/useCheckBoxes.ts +++ b/refact-agent/gui/src/components/ChatForm/useCheckBoxes.ts @@ -1,93 +1,99 @@ import { useState, useMemo, useCallback, useEffect } from "react"; import { selectSelectedSnippet } from "../../features/Chat/selectedSnippet"; -import { selectActiveFile } from "../../features/Chat/activeFile"; +import { FileInfo, selectActiveFile } from "../../features/Chat/activeFile"; import { useConfig, useAppSelector } from "../../hooks"; import type { Checkbox } from "./ChatControls"; -import { - selectIsStreaming, - selectMessages, -} from "../../features/Chat/Thread/selectors"; +import { selectMessages } from "../../features/Chat/Thread/selectors"; import { createSelector } from "@reduxjs/toolkit"; - -const shouldShowSelector = createSelector( - [selectMessages, selectIsStreaming], - (messages, isStreaming) => { - return messages.length === 0 && !isStreaming; - }, -); +import { filename } from "../../utils"; +import { ideAttachFileToChat } from "../../hooks"; const messageLengthSelector = createSelector( [selectMessages], (messages) => messages.length, ); -const useAttachActiveFile = ( - interacted: boolean, - hasSnippet: boolean, -): [Checkbox, () => void] => { +// TODO: add ide event here. + +export function useAttachedFiles() { + const [files, setFiles] = useState([]); const activeFile = useAppSelector(selectActiveFile); - const shouldShow = useAppSelector(shouldShowSelector); - const messageLength = useAppSelector(messageLengthSelector); - const filePathWithLines = useMemo(() => { - const hasLines = activeFile.line1 !== null && activeFile.line2 !== null; - - if (!hasLines) return activeFile.path; - return `${activeFile.path}:${ - activeFile.cursor ? activeFile.cursor + 1 : activeFile.line1 - }`; - }, [activeFile.path, activeFile.cursor, activeFile.line1, activeFile.line2]); - - const [attachFileCheckboxData, setAttachFile] = useState({ - name: "file_upload", - checked: !!activeFile.name && messageLength === 0 && hasSnippet, - label: "Attach", - value: filePathWithLines, - disabled: !activeFile.name, - fileName: activeFile.name, - hide: !shouldShow, - info: { - text: "Attaches the current file as context. If the file is large, it prefers the code near the current cursor position. Equivalent to @file name.ext:CURSOR_LINE in the text.", - link: "https://docs.refact.ai/features/ai-chat/", - linkText: "documentation", + const attached = useMemo(() => { + const maybeAttached = files.find((file) => file.path === activeFile.path); + return !!maybeAttached; + }, [activeFile.path, files]); + + const addFile = useCallback(() => { + if (attached) return; + setFiles((prev) => { + return [...prev, activeFile]; + }); + }, [attached, activeFile]); + + const removeFile = useCallback((fileToRemove: FileInfo) => { + setFiles((prev) => { + return prev.filter((file) => file.path !== fileToRemove.path); + }); + }, []); + + const addFilesToInput = useCallback( + (str: string) => { + if (files.length === 0) return str; + const result = files.reduce((acc, file) => { + const hasLines = file.line1 !== null && file.line2 !== null; + if (!hasLines) return `@file ${file.path}\n${acc}`; + const line = file.cursor ? file.cursor + 1 : file.line1; + return `@file ${file.path}:${line}\n${acc}`; + }, str); + return result; }, - }); + [files], + ); - useEffect(() => { - if (!interacted) { - setAttachFile((prev) => { - return { - ...prev, - hide: !shouldShow, - value: filePathWithLines, - disabled: !activeFile.name, - fileName: activeFile.name, - // checked: interacted ? prev.checked : !!activeFile.name && shouldShow, - checked: !!activeFile.name && shouldShow && hasSnippet, - }; - }); - } - }, [activeFile.name, filePathWithLines, hasSnippet, interacted, shouldShow]); + const removeAll = useCallback(() => { + setFiles([]); + }, []); useEffect(() => { - if (messageLength > 0 && attachFileCheckboxData.hide === false) { - setAttachFile((prev) => { - return { ...prev, hide: true, checked: false }; + const handleIdeAttachFile = (filePath: string) => { + const fileInfo: FileInfo = { + name: filename(filePath), + path: filePath, + line1: null, + line2: null, + cursor: null, + can_paste: false, + }; + setFiles((prev) => { + const maybeEntered = prev.find((file) => file.path === filePath); + if (maybeEntered) return prev; + return [...prev, fileInfo]; }); - } - }, [attachFileCheckboxData.hide, messageLength]); + }; - const onToggleAttachFile = useCallback(() => { - setAttachFile((prev) => { - return { - ...prev, - checked: !prev.checked, - }; - }); + const listener = (event: MessageEvent) => { + if (ideAttachFileToChat.match(event.data)) { + handleIdeAttachFile(event.data.payload); + } + }; + + window.addEventListener("message", listener); + return () => { + window.removeEventListener("message", listener); + }; }, []); - return [attachFileCheckboxData, onToggleAttachFile]; -}; + return { + files, + activeFile, + addFile, + removeFile, + attached, + addFilesToInput, + removeAll, + }; +} const useAttachSelectedSnippet = ( interacted: boolean, @@ -167,66 +173,45 @@ const useAttachSelectedSnippet = ( }; export type Checkboxes = { - file_upload: Checkbox; + // file_upload: Checkbox; selected_lines: Checkbox; }; export const useCheckboxes = () => { - // creating 2 different states instead of only one being used for both checkboxes + // creating different states instead of only one being used for checkboxes const [lineSelectionInteracted, setLineSelectionInteracted] = useState(false); - const [fileInteracted, setFileInteracted] = useState(false); const [attachedSelectedSnippet, onToggleAttachedSelectedSnippet] = useAttachSelectedSnippet(lineSelectionInteracted); - const [attachFileCheckboxData, onToggleAttachFile] = useAttachActiveFile( - fileInteracted, - attachedSelectedSnippet.checked, - ); - const checkboxes = useMemo( () => ({ - file_upload: attachFileCheckboxData, selected_lines: attachedSelectedSnippet, }), - [attachFileCheckboxData, attachedSelectedSnippet], + [attachedSelectedSnippet], ); const onToggleCheckbox = useCallback( (name: string) => { switch (name) { - case "file_upload": - onToggleAttachFile(); - setFileInteracted(true); - break; case "selected_lines": onToggleAttachedSelectedSnippet(); - setFileInteracted(true); setLineSelectionInteracted(true); break; } }, - [onToggleAttachFile, onToggleAttachedSelectedSnippet], + [onToggleAttachedSelectedSnippet], ); const unCheckAll = useCallback(() => { - if (attachFileCheckboxData.checked) { - onToggleAttachFile(); - } if (attachedSelectedSnippet.checked) { onToggleAttachedSelectedSnippet(); } - }, [ - attachFileCheckboxData.checked, - attachedSelectedSnippet.checked, - onToggleAttachFile, - onToggleAttachedSelectedSnippet, - ]); + }, [attachedSelectedSnippet.checked, onToggleAttachedSelectedSnippet]); return { checkboxes, onToggleCheckbox, - setFileInteracted, setLineSelectionInteracted, unCheckAll, }; diff --git a/refact-agent/gui/src/components/ChatForm/useCommandCompletionAndPreviewFiles.ts b/refact-agent/gui/src/components/ChatForm/useCommandCompletionAndPreviewFiles.ts index 9626cb4bb..80663f75a 100644 --- a/refact-agent/gui/src/components/ChatForm/useCommandCompletionAndPreviewFiles.ts +++ b/refact-agent/gui/src/components/ChatForm/useCommandCompletionAndPreviewFiles.ts @@ -110,8 +110,7 @@ function useGetCommandPreviewQuery( function useGetPreviewFiles(query: string, checkboxes: Checkboxes) { const queryWithCheckboxes = useMemo( () => addCheckboxValuesToInput(query, checkboxes), - // eslint-disable-next-line react-hooks/exhaustive-deps - [checkboxes, query, checkboxes.file_upload.value], + [checkboxes, query], ); const [previewQuery, setPreviewQuery] = useState(queryWithCheckboxes); @@ -125,11 +124,7 @@ function useGetPreviewFiles(query: string, checkboxes: Checkboxes) { useEffect(() => { debounceSetPreviewQuery(queryWithCheckboxes); - }, [ - debounceSetPreviewQuery, - queryWithCheckboxes, - checkboxes.file_upload.value, - ]); + }, [debounceSetPreviewQuery, queryWithCheckboxes]); const previewFileResponse = useGetCommandPreviewQuery(previewQuery); return previewFileResponse; diff --git a/refact-agent/gui/src/components/ChatForm/utils.ts b/refact-agent/gui/src/components/ChatForm/utils.ts index 38a684930..839d4ab0f 100644 --- a/refact-agent/gui/src/components/ChatForm/utils.ts +++ b/refact-agent/gui/src/components/ChatForm/utils.ts @@ -16,10 +16,6 @@ export function addCheckboxValuesToInput( result = `${checkboxes.selected_lines.value ?? ""}\n` + result; } - if (checkboxes.file_upload.checked && checkboxes.file_upload.hide !== true) { - result = `@file ${checkboxes.file_upload.value ?? ""}\n` + result; - } - if (!result.endsWith("\n")) { result += "\n"; } @@ -27,6 +23,7 @@ export function addCheckboxValuesToInput( return result; } +// TODO: delete this if unused export function activeFileToContextFile(fileInfo: FileInfo): ChatContextFile { const content = fileInfo.content ?? ""; return { diff --git a/refact-agent/gui/src/components/Dropzone/Dropzone.tsx b/refact-agent/gui/src/components/Dropzone/Dropzone.tsx index 73168cc87..eeb28868f 100644 --- a/refact-agent/gui/src/components/Dropzone/Dropzone.tsx +++ b/refact-agent/gui/src/components/Dropzone/Dropzone.tsx @@ -6,6 +6,7 @@ import { useAttachedImages } from "../../hooks/useAttachedImages"; import { TruncateLeft } from "../Text"; import { telemetryApi } from "../../services/refact/telemetry"; import { useCapsForToolUse } from "../../hooks"; +import { useAttachedFiles } from "../ChatForm/useCheckBoxes"; export const FileUploadContext = createContext<{ open: () => void; @@ -77,7 +78,7 @@ export const DropzoneProvider: React.FC< export const DropzoneConsumer = FileUploadContext.Consumer; -export const AttachFileButton = () => { +export const AttachImagesButton = () => { const [sendTelemetryEvent] = telemetryApi.useLazySendTelemetryChatEventQuery(); const attachFileOnClick = useCallback( @@ -121,29 +122,52 @@ export const AttachFileButton = () => { ); }; -export const FileList = () => { +type FileListProps = { + attachedFiles: ReturnType; +}; +export const FileList: React.FC = ({ attachedFiles }) => { const { images, removeImage } = useAttachedImages(); - if (images.length === 0) return null; + if (images.length === 0 && attachedFiles.files.length === 0) return null; return ( {images.map((file, index) => { const key = `image-${file.name}-${index}`; return ( - + fileName={file.name} + /> + ); + })} + {attachedFiles.files.map((file, index) => { + const key = `file-${file.path}-${index}`; + return ( + attachedFiles.removeFile(file)} + /> ); })} ); }; + +const FileButton: React.FC<{ fileName: string; onClick: () => void }> = ({ + fileName, + onClick, +}) => { + return ( + + ); +}; diff --git a/refact-agent/gui/src/components/Dropzone/index.tsx b/refact-agent/gui/src/components/Dropzone/index.tsx index 164bf09b8..03debddab 100644 --- a/refact-agent/gui/src/components/Dropzone/index.tsx +++ b/refact-agent/gui/src/components/Dropzone/index.tsx @@ -2,6 +2,6 @@ export { DropzoneProvider, DropzoneConsumer, FileUploadContext, - AttachFileButton, + AttachImagesButton, FileList, } from "./Dropzone"; diff --git a/refact-agent/gui/src/events/index.ts b/refact-agent/gui/src/events/index.ts index dc897daa9..b6f987208 100644 --- a/refact-agent/gui/src/events/index.ts +++ b/refact-agent/gui/src/events/index.ts @@ -78,6 +78,8 @@ export { ideToolCallResponse, } from "../hooks/useEventBusForIDE"; +export { ideAttachFileToChat } from "../hooks/useEventBusForApp"; + export const fim = { request, ready, diff --git a/refact-agent/gui/src/hooks/index.ts b/refact-agent/gui/src/hooks/index.ts index 6dfb21e34..28be44bc3 100644 --- a/refact-agent/gui/src/hooks/index.ts +++ b/refact-agent/gui/src/hooks/index.ts @@ -35,3 +35,4 @@ export * from "./useCompressChat"; export * from "./useAutoFocusOnce"; export * from "./useHideScroll"; export * from "./useCompressionStop"; +export * from "./useEventBusForApp"; diff --git a/refact-agent/gui/src/hooks/useEventBusForApp.ts b/refact-agent/gui/src/hooks/useEventBusForApp.ts index 51738a162..269201c55 100644 --- a/refact-agent/gui/src/hooks/useEventBusForApp.ts +++ b/refact-agent/gui/src/hooks/useEventBusForApp.ts @@ -13,6 +13,9 @@ import { selectPages, } from "../features/Pages/pagesSlice"; import { ideToolCallResponse } from "./useEventBusForIDE"; +import { createAction } from "@reduxjs/toolkit/react"; + +export const ideAttachFileToChat = createAction("ide/attachFileToChat"); export function useEventBusForApp() { const config = useConfig();