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 ? (
-