diff --git a/client/src/components/Chat/ChatBody/AllCoversations/index.tsx b/client/src/components/Chat/ChatBody/AllCoversations/index.tsx index 552d5da691..e3b0a3cfb1 100644 --- a/client/src/components/Chat/ChatBody/AllCoversations/index.tsx +++ b/client/src/components/Chat/ChatBody/AllCoversations/index.tsx @@ -93,7 +93,7 @@ const AllConversations = ({ return (
{!openItem && ( -
+
{conversations.map((c) => ( {}} - setInputValue={() => {}} + setInputValueImperatively={() => {}} />
)} diff --git a/client/src/components/Chat/ChatBody/Conversation.tsx b/client/src/components/Chat/ChatBody/Conversation.tsx index 7e93a09def..ebb7e1e349 100644 --- a/client/src/components/Chat/ChatBody/Conversation.tsx +++ b/client/src/components/Chat/ChatBody/Conversation.tsx @@ -1,4 +1,4 @@ -import React, { Dispatch, SetStateAction, useContext } from 'react'; +import React, { useContext } from 'react'; import ScrollToBottom from 'react-scroll-to-bottom'; import { ChatMessage, @@ -17,7 +17,7 @@ type Props = { isLoading?: boolean; isHistory?: boolean; onMessageEdit: (queryId: string, i: number) => void; - setInputValue: Dispatch>; + setInputValueImperatively: (s: string) => void; }; const Conversation = ({ @@ -28,7 +28,7 @@ const Conversation = ({ isHistory, repoName, onMessageEdit, - setInputValue, + setInputValueImperatively, }: Props) => { const { navigatedItem } = useContext(AppNavigationContext); @@ -37,7 +37,7 @@ const Conversation = ({ {!isHistory && ( diff --git a/client/src/components/Chat/ChatBody/ConversationMessage/UserParsedQuery/LangChip.tsx b/client/src/components/Chat/ChatBody/ConversationMessage/UserParsedQuery/LangChip.tsx index 025cf9b229..9890d58315 100644 --- a/client/src/components/Chat/ChatBody/ConversationMessage/UserParsedQuery/LangChip.tsx +++ b/client/src/components/Chat/ChatBody/ConversationMessage/UserParsedQuery/LangChip.tsx @@ -8,8 +8,8 @@ type Props = { const LangChip = ({ lang }: Props) => { return ( diff --git a/client/src/components/Chat/ChatBody/ConversationMessage/UserParsedQuery/PathChip.tsx b/client/src/components/Chat/ChatBody/ConversationMessage/UserParsedQuery/PathChip.tsx index a20f299c07..f34ccfe25e 100644 --- a/client/src/components/Chat/ChatBody/ConversationMessage/UserParsedQuery/PathChip.tsx +++ b/client/src/components/Chat/ChatBody/ConversationMessage/UserParsedQuery/PathChip.tsx @@ -11,8 +11,8 @@ const PathChip = ({ path }: Props) => { const isFolder = useMemo(() => path.endsWith('/'), [path]); return ( {isFolder ? ( diff --git a/client/src/components/Chat/ChatBody/FirstMessage.tsx b/client/src/components/Chat/ChatBody/FirstMessage.tsx index 08d42d1816..adf8c4fef0 100644 --- a/client/src/components/Chat/ChatBody/FirstMessage.tsx +++ b/client/src/components/Chat/ChatBody/FirstMessage.tsx @@ -1,10 +1,4 @@ -import React, { - Dispatch, - memo, - SetStateAction, - useEffect, - useState, -} from 'react'; +import React, { memo, useEffect, useState } from 'react'; import { Trans, useTranslation } from 'react-i18next'; import { TutorialQuestionType } from '../../../types/api'; import { getTutorialQuestions } from '../../../services/api'; @@ -13,12 +7,12 @@ type Props = { repoName: string; repoRef: string; isEmptyConversation: boolean; - setInputValue: Dispatch>; + setInputValueImperatively: (s: string) => void; }; const FirstMessage = ({ repoName, - setInputValue, + setInputValueImperatively, repoRef, isEmptyConversation, }: Props) => { @@ -60,7 +54,7 @@ const FirstMessage = ({ className="px-3 py-1 rounded-full border border-chat-bg-divider bg-chat-bg-sub flex-shrink-0 caption text-label-base" onClick={() => { // setIsTutorialHidden(true); - setInputValue(t.question); + setInputValueImperatively(t.question); }} > {t.tag} diff --git a/client/src/components/Chat/ChatBody/index.tsx b/client/src/components/Chat/ChatBody/index.tsx index 03d9917f90..cc65d3f47d 100644 --- a/client/src/components/Chat/ChatBody/index.tsx +++ b/client/src/components/Chat/ChatBody/index.tsx @@ -16,7 +16,7 @@ type Props = { hideMessagesFrom: null | number; openHistoryItem: OpenChatHistoryItem | null; setOpenHistoryItem: Dispatch>; - setInputValue: Dispatch>; + setInputValueImperatively: (s: string) => void; }; const ChatBody = ({ @@ -29,7 +29,7 @@ const ChatBody = ({ hideMessagesFrom, openHistoryItem, setOpenHistoryItem, - setInputValue, + setInputValueImperatively, }: Props) => { useTranslation(); const { conversation, threadId } = useContext(ChatContext.Values); @@ -54,7 +54,7 @@ const ChatBody = ({ isLoading={isLoading} repoName={repoName} onMessageEdit={onMessageEdit} - setInputValue={setInputValue} + setInputValueImperatively={setInputValueImperatively} /> )} {!!queryIdToEdit && ( diff --git a/client/src/components/Chat/ChatFooter/Input/InputCore.tsx b/client/src/components/Chat/ChatFooter/Input/InputCore.tsx new file mode 100644 index 0000000000..f4ab33ca86 --- /dev/null +++ b/client/src/components/Chat/ChatFooter/Input/InputCore.tsx @@ -0,0 +1,212 @@ +import { memo, useCallback, useEffect, useMemo, useState } from 'react'; +import { EditorState, Transaction } from 'prosemirror-state'; +import { Schema } from 'prosemirror-model'; +import { keymap } from 'prosemirror-keymap'; +import { baseKeymap } from 'prosemirror-commands'; +import { + NodeViewComponentProps, + ProseMirror, + react, + ReactNodeViewConstructor, + useNodeViews, +} from '@nytimes/react-prosemirror'; +import { schema as basicSchema } from 'prosemirror-schema-basic'; +import * as icons from 'file-icons-js'; +import { useTranslation } from 'react-i18next'; +import { getFileExtensionForLang, InputEditorContent } from '../../../../utils'; +import { + ParsedQueryType, + ParsedQueryTypeEnum, +} from '../../../../types/general'; +import { getMentionsPlugin } from './mentionPlugin'; +import { addMentionNodes } from './utils'; +import { placeholderPlugin } from './placeholderPlugin'; + +const schema = new Schema({ + nodes: addMentionNodes(basicSchema.spec.nodes), + marks: basicSchema.spec.marks, +}); + +function Paragraph({ children }: NodeViewComponentProps) { + return

{children}

; +} + +const reactNodeViews: Record = { + paragraph: () => ({ + component: Paragraph, + dom: document.createElement('div'), + contentDOM: document.createElement('span'), + }), +}; + +type Props = { + getDataLang: (search: string) => Promise<{ id: string; display: string }[]>; + getDataPath: (search: string) => Promise<{ id: string; display: string }[]>; + initialValue?: Record | null; + onChange: (contents: InputEditorContent[]) => void; + onSubmit?: (s: { parsed: ParsedQueryType[]; plain: string }) => void; + placeholder: string; +}; + +const InputCore = ({ + getDataLang, + getDataPath, + initialValue, + onChange, + onSubmit, + placeholder, +}: Props) => { + const { t } = useTranslation(); + const mentionPlugin = useMemo( + () => + getMentionsPlugin({ + delay: 10, + getSuggestions: async ( + type: string, + text: string, + done: (s: Record[]) => void, + ) => { + const data = await Promise.all([ + getDataPath(text), + getDataLang(text), + ]); + done([...data[0], ...data[1]]); + }, + getSuggestionsHTML: (items) => { + return ( + '
' + + items + .map( + (i) => + `
${ + i.isFirst + ? `
+ ${t( + i.type === 'dir' + ? 'Directories' + : i.type === 'lang' + ? 'Languages' + : 'Files', + )} +
` + : '' + }
${ + i.type === 'dir' + ? ` ` + : `` + }${i.display}
`, + ) + .join('') + + '
' + ); + }, + }), + [], + ); + + const plugins = useMemo(() => { + return [ + keymap({ + ...baseKeymap, + Enter: (state) => { + const key = Object.keys(state).find((k) => + k.startsWith('autosuggestions'), + ); + // @ts-ignore + if (key && state[key]?.active) { + return false; + } + const parts = state.toJSON().doc.content[0]?.content; + // trying to submit with no text + if (!parts) { + return false; + } + onSubmit?.({ + parsed: + parts?.map((s: InputEditorContent) => + s.type === 'mention' + ? { + type: + s.attrs.type === 'lang' + ? ParsedQueryTypeEnum.LANG + : ParsedQueryTypeEnum.PATH, + text: s.attrs.id, + } + : { type: ParsedQueryTypeEnum.TEXT, text: s.text }, + ) || [], + plain: parts + ?.map((s: InputEditorContent) => + s.type === 'mention' ? `${s.attrs.type}:${s.attrs.id}` : s.text, + ) + .join(''), + }); + return true; + }, + 'Ctrl-Enter': baseKeymap.Enter, + 'Cmd-Enter': baseKeymap.Enter, + 'Shift-Enter': baseKeymap.Enter, + }), + placeholderPlugin(placeholder), + react(), + mentionPlugin, + ]; + }, [onSubmit]); + + const { nodeViews, renderNodeViews } = useNodeViews(reactNodeViews); + const [mount, setMount] = useState(null); + const [state, setState] = useState( + EditorState.create({ + doc: initialValue + ? schema.topNodeType.create(null, [schema.nodeFromJSON(initialValue)]) + : undefined, + schema, + plugins, + }), + ); + + useEffect(() => { + if (mount) { + setState( + EditorState.create({ + schema, + plugins, + doc: initialValue + ? schema.topNodeType.create(null, [ + schema.nodeFromJSON(initialValue), + ]) + : undefined, + }), + ); + } + }, [mount, initialValue, plugins]); + + const dispatchTransaction = useCallback( + (tr: Transaction) => setState((oldState) => oldState.apply(tr)), + [], + ); + + useEffect(() => { + const newValue = state.toJSON().doc.content[0]?.content; + onChange(newValue || []); + }, [state]); + + return ( +
+ +
+ {renderNodeViews()} + +
+ ); +}; + +export default memo(InputCore); diff --git a/client/src/components/Chat/ChatFooter/Input/mentionPlugin.ts b/client/src/components/Chat/ChatFooter/Input/mentionPlugin.ts new file mode 100644 index 0000000000..50fd9aaf4e --- /dev/null +++ b/client/src/components/Chat/ChatFooter/Input/mentionPlugin.ts @@ -0,0 +1,371 @@ +import { Plugin, PluginKey } from 'prosemirror-state'; +import { Decoration, DecorationSet, EditorView } from 'prosemirror-view'; +import { ResolvedPos } from 'prosemirror-model'; + +export function getRegexp(mentionTrigger: string, allowSpace?: boolean) { + return allowSpace + ? new RegExp('(^|\\s)' + mentionTrigger + '([\\w-\\+]*\\s?[\\w-\\+]*)$') + : new RegExp('(^|\\s)' + mentionTrigger + '([\\w-\\+]*)$'); +} + +const insertAfterSelect = String.fromCharCode(160); + +export function getMatch( + $position: ResolvedPos, + opts: { + mentionTrigger: string; + allowSpace?: boolean; + }, +) { + // take current para text content upto cursor start. + // this makes the regex simpler and parsing the matches easier. + const parastart = $position.before(); + const text = $position.doc.textBetween(parastart, $position.pos, '\n', '\0'); + + const regex = getRegexp(opts.mentionTrigger, opts.allowSpace); + + const match = text.match(regex); + + // if match found, return match with useful information. + if (match) { + // adjust match.index to remove the matched extra space + match.index = + match[0].startsWith(' ') || match[0].startsWith(insertAfterSelect) + ? (match.index || 0) + 1 + : match.index; + match[0] = + match[0].startsWith(' ') || match[0].startsWith(insertAfterSelect) + ? match[0].substring(1, match[0].length) + : match[0]; + + // The absolute position of the match in the document + const from = $position.start() + match.index!; + const to = from + match[0].length; + + const queryText = match[2]; + + return { + range: { from: from, to: to }, + queryText: queryText, + type: 'mention', + }; + } + // else if no match don't return anything. +} + +/** + * Util to debounce call to a function. + * >>> debounce(function(){}, 1000, this) + */ +export const debounce = (function () { + let timeoutId: number; + return function (func: () => void, timeout: number, context: any): number { + // @ts-ignore + context = context || this; + clearTimeout(timeoutId); + timeoutId = window.setTimeout(function () { + // @ts-ignore + func.apply(context, arguments); + }, timeout); + + return timeoutId; + }; +})(); + +type State = { + active: boolean; + range: { + from: number; + to: number; + }; + type: string; + text: string; + suggestions: Record[]; + index: number; +}; + +const getNewState = function () { + return { + active: false, + range: { + from: 0, + to: 0, + }, + type: '', + text: '', + suggestions: [], + index: 0, // current active suggestion index + }; +}; + +type Options = { + mentionTrigger: string; + allowSpace?: boolean; + activeClass: string; + suggestionTextClass?: string; + getSuggestions: ( + type: string, + text: string, + done: (s: Record[]) => void, + ) => void; + delay: number; + getSuggestionsHTML: (items: Record[], type: string) => string; +}; + +export function getMentionsPlugin(opts: Partial) { + // default options + const defaultOpts = { + mentionTrigger: '@', + allowSpace: true, + getSuggestions: ( + type: string, + text: string, + cb: (s: { name: string }[]) => void, + ) => { + cb([]); + }, + getSuggestionsHTML: (items: { name: string }[]) => + '
' + + items + .map((i) => '
' + i.name + '
') + .join('') + + '
', + activeClass: 'suggestion-item-active', + suggestionTextClass: 'prosemirror-suggestion', + maxNoOfSuggestions: 10, + delay: 500, + }; + + const options = Object.assign({}, defaultOpts, opts) as Options; + + // timeoutId for clearing debounced calls + let showListTimeoutId: number; + + // dropdown element + const el = document.createElement('div'); + + const showList = function ( + view: EditorView, + state: State, + suggestions: Record[], + opts: Options, + ) { + el.innerHTML = opts.getSuggestionsHTML(suggestions, state.type); + + // attach new item event handlers + el.querySelectorAll('.suggestion-item').forEach(function (itemNode, index) { + itemNode.addEventListener('click', function () { + select(view, state, opts); + view.focus(); + }); + // TODO: setIndex() needlessly queries. + // We already have the itemNode. SHOULD OPTIMIZE. + itemNode.addEventListener('mouseover', function () { + setIndex(index, state, opts); + }); + itemNode.addEventListener('mouseout', function () { + setIndex(index, state, opts); + }); + }); + + // highlight first element by default - like Facebook. + addClassAtIndex(state.index, opts.activeClass); + + // TODO: knock off domAtPos usage. It's not documented and is not officially a public API. + // It's used currently, only to optimize the the query for textDOM + const node = view.domAtPos(view.state.selection.$from.pos); + const paraDOM = node.node; + const textDOM = (paraDOM as HTMLElement).querySelector( + '.' + opts.suggestionTextClass, + ); + + const offset = textDOM?.getBoundingClientRect(); + + document.body.appendChild(el); + el.classList.add('suggestion-item-container'); + el.style.position = 'fixed'; + el.style.left = -9999 + 'px'; + const offsetLeft = offset?.left || 0; + setTimeout(() => { + el.style.left = + offsetLeft + el.clientWidth < window.innerWidth + ? offsetLeft + 'px' + : offsetLeft + + (window.innerWidth - (offsetLeft + el.clientWidth) - 10) + + 'px'; + }, 10); + + const bottom = window.innerHeight - (offset?.top || 0); + el.style.bottom = bottom + 'px'; + el.style.display = 'block'; + el.style.zIndex = '999999'; + }; + + const hideList = function () { + el.style.display = 'none'; + }; + + const removeClassAtIndex = function (index: number, className: string) { + const itemList = el.querySelector('.suggestion-item-list')?.childNodes; + const prevItem = itemList?.[index]; + (prevItem as HTMLElement)?.classList.remove(className); + }; + + const addClassAtIndex = function (index: number, className: string) { + const itemList = el.querySelector('.suggestion-item-list')?.childNodes; + const prevItem = itemList?.[index]; + (prevItem as HTMLElement)?.classList.add(className); + return prevItem as HTMLElement | undefined; + }; + + const setIndex = function (index: number, state: State, opts: Options) { + removeClassAtIndex(state.index, opts.activeClass); + state.index = index; + addClassAtIndex(state.index, opts.activeClass); + }; + + const goNext = function (view: EditorView, state: State, opts: Options) { + removeClassAtIndex(state.index, opts.activeClass); + state.index++; + state.index = state.index === state.suggestions.length ? 0 : state.index; + const el = addClassAtIndex(state.index, opts.activeClass); + el?.scrollIntoView({ block: 'nearest' }); + }; + + const goPrev = function (view: EditorView, state: State, opts: Options) { + removeClassAtIndex(state.index, opts.activeClass); + state.index--; + state.index = + state.index === -1 ? state.suggestions.length - 1 : state.index; + const el = addClassAtIndex(state.index, opts.activeClass); + el?.scrollIntoView({ block: 'nearest' }); + }; + + const select = function (view: EditorView, state: State, opts: Options) { + const item = state.suggestions[state.index]; + const attrs = { + ...item, + }; + const node = view.state.schema.nodes[state.type].create(attrs); + const spaceNode = view.state.schema.text(insertAfterSelect); + + const tr = view.state.tr.replaceWith(state.range.from, state.range.to, [ + node, + spaceNode, + ]); + + //var newState = view.state.apply(tr); + //view.updateState(newState); + view.dispatch(tr); + }; + + return new Plugin({ + key: new PluginKey('autosuggestions'), + + // we will need state to track if suggestion dropdown is currently active or not + state: { + init() { + return getNewState(); + }, + + apply(tr, state) { + // compute state.active for current transaction and return + const newState = getNewState(); + const selection = tr.selection; + if (selection.from !== selection.to) { + return newState; + } + + const $position = selection.$from; + const match = getMatch($position, options); + + // if match found update state + if (match) { + newState.active = true; + newState.range = match.range; + newState.type = match.type!; + newState.text = match.queryText; + } + + return newState; + }, + }, + + // We'll need props to hi-jack keydown/keyup & enter events when suggestion dropdown + // is active. + props: { + handleKeyDown(view, e) { + const state = this.getState(view.state); + + if (!state?.active && !state?.suggestions.length) { + return false; + } + + if (e.key === 'ArrowDown') { + goNext(view, state, options); + return true; + } else if (e.key === 'ArrowUp') { + goPrev(view, state, options); + return true; + } else if (e.key === 'Enter') { + select(view, state, options); + return true; + } else if (e.key === 'Escape') { + clearTimeout(showListTimeoutId); + hideList(); + // @ts-ignore + this.state = getNewState(); + return true; + } else { + // didn't handle. handover to prosemirror for handling. + return false; + } + }, + + // to decorate the currently active @mention text in ui + decorations(editorState) { + const { active, range } = this.getState(editorState) || {}; + + if (!active || !range) return null; + + return DecorationSet.create(editorState.doc, [ + Decoration.inline(range.from, range.to, { + nodeName: 'span', + class: options.suggestionTextClass, + }), + ]); + }, + }, + + // To track down state mutations and add dropdown reactions + view() { + return { + update: (view) => { + const state = this.key?.getState(view.state); + if (!state.active) { + hideList(); + clearTimeout(showListTimeoutId); + return; + } + // debounce the call to avoid multiple requests + showListTimeoutId = debounce( + function () { + // get suggestions and set new state + options.getSuggestions( + state.type, + state.text, + function (suggestions) { + // update `state` argument with suggestions + state.suggestions = suggestions; + showList(view, state, suggestions, options); + }, + ); + }, + options.delay, + this, + ); + }, + }; + }, + }); +} diff --git a/client/src/components/Chat/ChatFooter/Input/nodes.ts b/client/src/components/Chat/ChatFooter/Input/nodes.ts new file mode 100644 index 0000000000..03292d4e8e --- /dev/null +++ b/client/src/components/Chat/ChatFooter/Input/nodes.ts @@ -0,0 +1,81 @@ +import * as icons from 'file-icons-js'; +import { type AttributeSpec, type NodeSpec } from 'prosemirror-model'; +import { getFileExtensionForLang, splitPath } from '../../../../utils'; + +export const mentionNode: NodeSpec = { + group: 'inline', + inline: true, + atom: true, + + attrs: { + id: '' as AttributeSpec, + display: '' as AttributeSpec, + type: 'lang' as AttributeSpec, + isFirst: '' as AttributeSpec, + }, + + selectable: false, + draggable: false, + + toDOM: (node) => { + const folderIcon = document.createElement('span'); + folderIcon.innerHTML = ` + + `; + folderIcon.className = 'w-4 h-4 flex-shrink-0'; + return [ + 'span', + { + 'data-type': node.attrs.type, + 'data-id': node.attrs.id, + 'data-first': node.attrs.isFirst, + 'data-display': node.attrs.display, + class: + 'prosemirror-tag-node inline-flex gap-1.5 items-center align-bottom bg-chat-bg-border-hover rounded px-1', + }, + node.attrs.type === 'dir' + ? folderIcon + : [ + 'span', + { + class: `text-left w-4 h-4 file-icon flex-shrink-0 inline-flex items-center ${icons.getClassWithColor( + (node.attrs.type === 'lang' + ? node.attrs.display.includes(' ') + ? '.txt' + : getFileExtensionForLang(node.attrs.display, true) + : node.attrs.display) || '.txt', + )}`, + }, + '', + ], + node.attrs.type === 'lang' + ? node.attrs.display + : node.attrs.type === 'dir' + ? splitPath(node.attrs.display).slice(-2)[0] + : splitPath(node.attrs.display).pop(), + ]; + }, + + parseDOM: [ + { + // match tag with following CSS Selector + tag: 'span[data-type][data-id][data-first][data-display]', + + getAttrs: (dom) => { + const id = (dom as HTMLElement).getAttribute('data-id'); + const type = (dom as HTMLElement).getAttribute('data-type'); + const isFirst = (dom as HTMLElement).getAttribute('data-first'); + const display = (dom as HTMLElement).getAttribute('data-display'); + return { + id, + type, + isFirst, + display, + }; + }, + }, + ], +}; diff --git a/client/src/components/Chat/ChatFooter/Input/placeholderPlugin.ts b/client/src/components/Chat/ChatFooter/Input/placeholderPlugin.ts new file mode 100644 index 0000000000..5910bf246e --- /dev/null +++ b/client/src/components/Chat/ChatFooter/Input/placeholderPlugin.ts @@ -0,0 +1,20 @@ +import { Plugin } from 'prosemirror-state'; +import { EditorView } from 'prosemirror-view'; + +export const placeholderPlugin = (text: string) => { + const update = (view: EditorView) => { + if (view.state.doc.content.size > 2) { + view.dom.removeAttribute('data-placeholder'); + } else { + view.dom.setAttribute('data-placeholder', text); + } + }; + + return new Plugin({ + view(view) { + update(view); + + return { update }; + }, + }); +}; diff --git a/client/src/components/Chat/ChatFooter/Input/utils.ts b/client/src/components/Chat/ChatFooter/Input/utils.ts new file mode 100644 index 0000000000..dea740a9be --- /dev/null +++ b/client/src/components/Chat/ChatFooter/Input/utils.ts @@ -0,0 +1,9 @@ +import OrderedMap from 'orderedmap'; +import { type NodeSpec } from 'prosemirror-model'; +import { mentionNode } from './nodes'; + +export function addMentionNodes(nodes: OrderedMap) { + return nodes.append({ + mention: mentionNode, + }); +} diff --git a/client/src/components/Chat/ChatFooter/InputLoader.tsx b/client/src/components/Chat/ChatFooter/InputLoader.tsx index e8f1d015ee..03b871fe63 100644 --- a/client/src/components/Chat/ChatFooter/InputLoader.tsx +++ b/client/src/components/Chat/ChatFooter/InputLoader.tsx @@ -1,7 +1,9 @@ import { useEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; import { ChatLoadingStep } from '../../../types/general'; const InputLoader = ({ loadingSteps }: { loadingSteps: ChatLoadingStep[] }) => { + const { t } = useTranslation(); const [state, setState] = useState(-1); const [currIndex, setCurrIndex] = useState(-1); const steps = useRef(loadingSteps); @@ -63,7 +65,7 @@ const InputLoader = ({ loadingSteps }: { loadingSteps: ChatLoadingStep[] }) => { }`} />
- {loadingSteps?.[currIndex]?.displayText} + {loadingSteps?.[currIndex]?.displayText || t('Generating answer...')}
); diff --git a/client/src/components/Chat/ChatFooter/NLInput.tsx b/client/src/components/Chat/ChatFooter/NLInput.tsx index 48d3b12bd3..65f4e75ade 100644 --- a/client/src/components/Chat/ChatFooter/NLInput.tsx +++ b/client/src/components/Chat/ChatFooter/NLInput.tsx @@ -1,50 +1,42 @@ import React, { + Dispatch, memo, - ReactNode, + SetStateAction, useCallback, useContext, - useEffect, useMemo, - useRef, - useState, } from 'react'; import { Trans, useTranslation } from 'react-i18next'; -import { - MentionsInput, - Mention, - OnChangeHandlerFunc, - SuggestionDataItem, -} from 'react-mentions'; -import { - FeatherSelected, - FolderFilled, - QuillIcon, - SendIcon, - Sparkles, -} from '../../../icons'; +import { FeatherSelected, QuillIcon, SendIcon, Sparkles } from '../../../icons'; import ClearButton from '../../ClearButton'; import Tooltip from '../../Tooltip'; -import { ChatLoadingStep } from '../../../types/general'; +import { + ChatLoadingStep, + ParsedQueryType, + ParsedQueryTypeEnum, +} from '../../../types/general'; import LiteLoader from '../../Loaders/LiteLoader'; import { UIContext } from '../../../context/uiContext'; import { DeviceContext } from '../../../context/deviceContext'; import Button from '../../Button'; import { getAutocomplete } from '../../../services/api'; import { FileResItem, LangItem } from '../../../types/api'; -import FileIcon from '../../FileIcon'; -import { getFileExtensionForLang, splitPath } from '../../../utils'; +import { InputEditorContent } from '../../../utils'; import InputLoader from './InputLoader'; +import InputCore from './Input/InputCore'; type Props = { - id?: string; - value?: string; + value?: { parsed: ParsedQueryType[]; plain: string }; + valueToEdit?: Record | null; generationInProgress?: boolean; isStoppable?: boolean; showTooltip?: boolean; tooltipText?: string; onStop?: () => void; - onChange?: OnChangeHandlerFunc; - onSubmit?: () => void; + setInputValue: Dispatch< + SetStateAction<{ parsed: ParsedQueryType[]; plain: string }> + >; + onSubmit?: (s: { parsed: ParsedQueryType[]; plain: string }) => void; loadingSteps?: ChatLoadingStep[]; selectedLines?: [number, number] | null; setSelectedLines?: (l: [number, number] | null) => void; @@ -61,35 +53,10 @@ type SuggestionType = { const defaultPlaceholder = 'Send a message'; -const inputStyle = { - '&multiLine': { - highlighter: { - paddingTop: 16, - paddingBottom: 16, - }, - input: { - paddingTop: 16, - paddingBottom: 16, - outline: 'none', - }, - }, - suggestions: { - list: { - maxHeight: 500, - overflowY: 'auto', - backgroundColor: 'rgb(var(--chat-bg-shade))', - border: '1px solid rgb(var(--chat-bg-border))', - boxShadow: 'var(--shadow-high)', - padding: 4, - zIndex: 100, - }, - }, -}; - const NLInput = ({ - id, value, - onChange, + valueToEdit, + setInputValue, generationInProgress, isStoppable, onStop, @@ -101,38 +68,10 @@ const NLInput = ({ onMessageEditCancel, }: Props) => { const { t } = useTranslation(); - const inputRef = useRef(null); - const [isComposing, setComposition] = useState(false); const { setPromptGuideOpen } = useContext(UIContext.PromptGuide); const { tab } = useContext(UIContext.Tab); const { envConfig } = useContext(DeviceContext); - useEffect(() => { - if (inputRef.current) { - // We need to reset the height momentarily to get the correct scrollHeight for the textarea - inputRef.current.style.height = '56px'; - const scrollHeight = inputRef.current.scrollHeight; - - // We then set the height directly, outside of the render loop - // Trying to set this with state or a ref will product an incorrect value. - inputRef.current.style.height = - Math.max(Math.min(scrollHeight, 300), 56) + 'px'; - } - }, [inputRef.current, value]); - - const handleKeyDown = useCallback( - (e: React.KeyboardEvent) => { - if (isComposing) { - return true; - } - if (e.key === 'Enter' && !e.shiftKey && onSubmit) { - e.preventDefault(); - onSubmit(); - } - }, - [isComposing, onSubmit], - ); - const shouldShowLoader = useMemo( () => isStoppable && !!loadingSteps?.length && generationInProgress, [isStoppable, loadingSteps?.length, generationInProgress], @@ -145,10 +84,7 @@ const NLInput = ({ }, [envConfig?.bloop_user_profile?.prompt_guide]); const getDataPath = useCallback( - async ( - search: string, - callback: (a: { id: string; display: string }[]) => void, - ) => { + async (search: string) => { const respPath = await getAutocomplete( `path:${search} repo:${tab.name}&content=false`, ); @@ -168,7 +104,7 @@ const NLInput = ({ dirResults.forEach((fr, i) => { results.push({ id: fr, display: fr, type: 'dir', isFirst: i === 0 }); }); - callback(results); + return results; }, [tab.repoName], ); @@ -176,7 +112,7 @@ const NLInput = ({ const getDataLang = useCallback( async ( search: string, - callback: (a: { id: string; display: string }[]) => void, + // callback: (a: { id: string; display: string }[]) => void, ) => { const respLang = await getAutocomplete( `lang:${search} repo:${tab.name}&content=false`, @@ -188,88 +124,39 @@ const NLInput = ({ langResults.forEach((fr, i) => { results.push({ id: fr, display: fr, type: 'lang', isFirst: i === 0 }); }); - callback(results); + return results; }, [tab.name], ); - const renderPathSuggestion = useCallback( - ( - entry: SuggestionDataItem, - search: string, - highlightedDisplay: ReactNode, - index: number, - focused: boolean, - ) => { - const d = entry as SuggestionType; - return ( -
- {d.isFirst ? ( -
- {d.type === 'dir' ? 'Directories' : 'Files'} -
- ) : null} -
- {d.type === 'dir' ? ( - - ) : ( - - )} - {d.display} -
-
- ); - }, - [], - ); - - const pathTransform = useCallback((id: string, trans: string) => { - const split = splitPath(trans); - return `${split[split.length - 1] || split[split.length - 2]}`; - }, []); - - const onCompositionStart = useCallback(() => { - setComposition(true); + const onChangeInput = useCallback((inputState: InputEditorContent[]) => { + const newValue = inputState + .map((s) => + s.type === 'mention' ? `${s.attrs.type}:${s.attrs.id}` : s.text, + ) + .join(''); + const newValueParsed = inputState.map((s) => + s.type === 'mention' + ? { + type: + s.attrs.type === 'lang' + ? ParsedQueryTypeEnum.LANG + : ParsedQueryTypeEnum.PATH, + text: s.attrs.id, + } + : { type: ParsedQueryTypeEnum.TEXT, text: s.text }, + ); + setInputValue({ + plain: newValue, + parsed: newValueParsed, + }); }, []); - const onCompositionEnd = useCallback(() => { - // this event comes before keydown and sets state faster causing unintentional submit - setTimeout(() => setComposition(false), 10); - }, []); - - const renderLangSuggestion = useCallback( - ( - entry: SuggestionDataItem, - search: string, - highlightedDisplay: ReactNode, - index: number, - focused: boolean, - ) => { - const d = entry as SuggestionType; - return ( -
- {d.isFirst ? ( -
- Languages -
- ) : null} -
- - {d.display} -
-
- ); - }, - [], - ); + const onSubmitButtonClicked = useCallback(() => { + if (value && onSubmit) { + onSubmit(value); + } + }, [value, onSubmit]); return (
{shouldShowLoader && }
@@ -291,47 +178,26 @@ const NLInput = ({
) : selectedLines ? ( - ) : value ? ( + ) : value?.plain ? ( ) : ( )}
- - - - + ) : ( +
+ {!shouldShowLoader && Generating answer...} +
+ )} {isStoppable || selectedLines ? (
@@ -342,8 +208,12 @@ const NLInput = ({ />
- ) : value && !queryIdToEdit ? ( -