From c7ade40a796e7cc1ba207c1b02988364fae46062 Mon Sep 17 00:00:00 2001 From: anastasiia Date: Tue, 21 Nov 2023 12:37:29 -0500 Subject: [PATCH 01/18] use react-prose for autocomplete input --- .../Chat/ChatFooter/Input/InputCore.tsx | 129 ++++++ .../Chat/ChatFooter/Input/mentionPlugin.ts | 411 ++++++++++++++++++ .../components/Chat/ChatFooter/Input/nodes.ts | 88 ++++ .../components/Chat/ChatFooter/Input/utils.ts | 15 + .../components/Chat/ChatFooter/NLInput.tsx | 75 ++-- package-lock.json | 78 ++++ package.json | 6 + 7 files changed, 765 insertions(+), 37 deletions(-) create mode 100644 client/src/components/Chat/ChatFooter/Input/InputCore.tsx create mode 100644 client/src/components/Chat/ChatFooter/Input/mentionPlugin.ts create mode 100644 client/src/components/Chat/ChatFooter/Input/nodes.ts create mode 100644 client/src/components/Chat/ChatFooter/Input/utils.ts 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..e012919e3c --- /dev/null +++ b/client/src/components/Chat/ChatFooter/Input/InputCore.tsx @@ -0,0 +1,129 @@ +import { memo, useCallback, useState } from 'react'; +import { EditorState, Transaction } from 'prosemirror-state'; +import { Schema } from 'prosemirror-model'; +import { + NodeViewComponentProps, + ProseMirror, + react, + ReactNodeViewConstructor, + useNodeViews, +} from '@nytimes/react-prosemirror'; +import { schema as basicSchema } from 'prosemirror-schema-basic'; +import { getMentionsPlugin } from './mentionPlugin'; +import { addMentionNodes, addTagNodes } from './utils'; + +const schema = new Schema({ + nodes: addTagNodes(addMentionNodes(basicSchema.spec.nodes)), + marks: basicSchema.spec.marks, +}); + +const mentionsData = [ + { name: 'Anna Brown', id: '103', email: 'anna@gmail.com' }, + { name: 'John Doe', id: '101', email: 'joe@gmail.com' }, + { name: 'Joe Lewis', id: '102', email: 'lewis@gmail.com' }, +]; + +const tagsData = [ + { tag: 'index.ts' }, + { tag: 'server.rs' }, + { tag: 'component.jsx' }, +]; + +/** + * IMPORTANT: outer div's "suggestion-item-list" class is mandatory. The plugin uses this class for querying. + * IMPORTANT: inner div's "suggestion-item" class is mandatory too for the same reasons + */ +const getMentionSuggestionsHTML = (items: Record[]) => { + return ( + '
' + + items + .map((i) => '
' + i.name + '
') + .join('') + + '
' + ); +}; + +/** + * IMPORTANT: outer div's "suggestion-item-list" class is mandatory. The plugin uses this class for querying. + * IMPORTANT: inner div's "suggestion-item" class is mandatory too for the same reasons + */ +const getTagSuggestionsHTML = (items: Record[]) => { + return ( + '
' + + items + .map((i) => '
' + i.tag + '
') + .join('') + + '
' + ); +}; + +export const mentionPlugin = getMentionsPlugin({ + getSuggestions: ( + type: string, + text: string, + done: (s: Record[]) => void, + ) => { + setTimeout(() => { + if (type === 'mention') { + done(mentionsData); + } else { + done(tagsData); + } + }, 0); + }, + getSuggestionsHTML: (items, type) => { + if (type === 'mention') { + return getMentionSuggestionsHTML(items); + } + return getTagSuggestionsHTML(items); + }, +}); + +const editorState = EditorState.create({ + doc: schema.topNodeType.create(null, [ + schema.nodes.paragraph.createAndFill()!, + // schema.nodes.list.createAndFill()!, + ]), + schema, + plugins: [react(), mentionPlugin], +}); + +function Paragraph({ children }: NodeViewComponentProps) { + return

{children}

; +} + +const reactNodeViews: Record = { + paragraph: () => ({ + component: Paragraph, + dom: document.createElement('div'), + contentDOM: document.createElement('span'), + }), +}; + +type Props = {}; + +const InputCore = ({}: Props) => { + const { nodeViews, renderNodeViews } = useNodeViews(reactNodeViews); + const [mount, setMount] = useState(null); + const [state, setState] = useState(editorState); + + const dispatchTransaction = useCallback( + (tr: Transaction) => setState((oldState) => oldState.apply(tr)), + [], + ); + 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..058c27da91 --- /dev/null +++ b/client/src/components/Chat/ChatFooter/Input/mentionPlugin.ts @@ -0,0 +1,411 @@ +import { Plugin, PluginKey } from 'prosemirror-state'; +import { Decoration, DecorationSet, EditorView } from 'prosemirror-view'; +import { ResolvedPos } from 'prosemirror-model'; + +export function getRegexp( + mentionTrigger: string, + hashtagTrigger: string, + allowSpace?: boolean, +) { + const mention = allowSpace + ? new RegExp('(^|\\s)' + mentionTrigger + '([\\w-\\+]+\\s?[\\w-\\+]*)$') + : new RegExp('(^|\\s)' + mentionTrigger + '([\\w-\\+]+)$'); + + // hashtags should never allow spaces. I mean, what's the point of allowing spaces in hashtags? + const tag = new RegExp('(^|\\s)' + hashtagTrigger + '([\\w-]+)$'); + + return { + mention: mention, + tag: tag, + }; +} + +export function getMatch( + $position: ResolvedPos, + opts: { + mentionTrigger: string; + hashtagTrigger: 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.hashtagTrigger, + opts.allowSpace, + ); + + // only one of the below matches will be true. + const mentionMatch = text.match(regex.mention); + const tagMatch = text.match(regex.tag); + + const match = mentionMatch || tagMatch; + + // set type of match + let type; + if (mentionMatch) { + type = 'mention'; + } else if (tagMatch) { + type = 'tag'; + } + + // 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.index! + 1 : match.index; + match[0] = match[0].startsWith(' ') + ? 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: type, + }; + } + // 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: '', //mention or tag + text: '', + suggestions: [], + index: 0, // current active suggestion index + }; +}; + +type Options = { + mentionTrigger: string; + hashtagTrigger: 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; +}; +/** + * @param {JSONObject} opts + * @returns {Plugin} + */ +export function getMentionsPlugin(opts: Partial) { + // default options + const defaultOpts = { + mentionTrigger: '@', + hashtagTrigger: '#', + 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'); + + // current Idx + let index = 0; + + // ----- methods operating on above properties ----- + + 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); + + // get current @mention span left and top. + // 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, + ); + + // TODO: should add null check case for textDOM + const offset = textDOM?.getBoundingClientRect(); + + // TODO: think about outsourcing this positioning logic as options + document.body.appendChild(el); + el.classList.add('suggestion-item-container'); + el.style.position = 'fixed'; + el.style.left = offset?.left + 'px'; + + const top = (textDOM as HTMLElement)?.offsetHeight + (offset?.top || 0); + el.style.top = top + '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); + }; + + 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; + addClassAtIndex(state.index, opts.activeClass); + }; + + 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; + addClassAtIndex(state.index, opts.activeClass); + }; + + const select = function (view: EditorView, state: State, opts: Options) { + const item = state.suggestions[state.index]; + let attrs; + if (state.type === 'mention') { + attrs = { + name: item.name, + id: item.id, + email: item.email, + }; + } else { + attrs = { + tag: item.tag, + }; + } + const node = view.state.schema.nodes[state.type].create(attrs); + const tr = view.state.tr.replaceWith( + state.range.from, + state.range.to, + node, + ); + + //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); + + // don't handle if no suggestions or not in active mode + if (!state?.active && !state?.suggestions.length) { + return false; + } + + // if any of the below keys, override with custom handlers. + let down, up, enter, esc; + enter = e.keyCode === 13; + down = e.keyCode === 40; + up = e.keyCode === 38; + esc = e.keyCode === 27; + + if (down) { + goNext(view, state, options); + return true; + } else if (up) { + goPrev(view, state, options); + return true; + } else if (enter) { + select(view, state, options); + return true; + } else if (esc) { + 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: opts.suggestionTextClass, + }), + ]); + }, + }, + + // To track down state mutations and add dropdown reactions + view() { + return { + update: (view) => { + const state = this.key?.getState(view.state); + if (!state.text) { + 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..187959e0b9 --- /dev/null +++ b/client/src/components/Chat/ChatFooter/Input/nodes.ts @@ -0,0 +1,88 @@ +import * as icons from 'file-icons-js'; +import { type AttributeSpec, type NodeSpec } from 'prosemirror-model'; + +export const mentionNode: NodeSpec = { + group: 'inline', + inline: true, + atom: true, + + attrs: { + id: '' as AttributeSpec, + name: '' as AttributeSpec, + email: '' as AttributeSpec, + }, + + selectable: false, + draggable: false, + + toDOM: (node) => { + return [ + 'span', + { + 'data-mention-id': node.attrs.id, + 'data-mention-name': node.attrs.name, + 'data-mention-email': node.attrs.email, + class: 'prosemirror-mention-node', + }, + '@' + node.attrs.name || node.attrs.email, + ]; + }, + + parseDOM: [ + { + // match tag with following CSS Selector + tag: 'span[data-mention-id][data-mention-name][data-mention-email]', + + getAttrs: (dom) => { + const id = (dom as HTMLElement).getAttribute('data-mention-id'); + const name = (dom as HTMLElement).getAttribute('data-mention-name'); + const email = (dom as HTMLElement).getAttribute('data-mention-email'); + return { + id: id, + name: name, + email: email, + }; + }, + }, + ], +}; + +export const tagNode: NodeSpec = { + group: 'inline', + inline: true, + atom: true, + + attrs: { + tag: '' as AttributeSpec, + }, + + selectable: false, + draggable: false, + + toDOM: (node) => { + return [ + 'span', + { + 'data-tag': node.attrs.tag, + class: + 'prosemirror-tag-node file-icon inline-flex items-center flex-shrink-0 align-middle ' + + icons.getClassWithColor(node.attrs.tag), + }, + node.attrs.tag, + ]; + }, + + parseDOM: [ + { + // match tag with following CSS Selector + tag: 'span[data-tag]', + + getAttrs: (dom) => { + const tag = (dom as HTMLElement).getAttribute('data-tag'); + return { + tag: tag, + }; + }, + }, + ], +}; 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..0cceb3fd8a --- /dev/null +++ b/client/src/components/Chat/ChatFooter/Input/utils.ts @@ -0,0 +1,15 @@ +import OrderedMap from 'orderedmap'; +import { type NodeSpec } from 'prosemirror-model'; +import { tagNode, mentionNode } from './nodes'; + +export function addMentionNodes(nodes: OrderedMap) { + return nodes.append({ + mention: mentionNode, + }); +} + +export function addTagNodes(nodes: OrderedMap) { + return nodes.append({ + tag: tagNode, + }); +} diff --git a/client/src/components/Chat/ChatFooter/NLInput.tsx b/client/src/components/Chat/ChatFooter/NLInput.tsx index 48d3b12bd3..4b214574b1 100644 --- a/client/src/components/Chat/ChatFooter/NLInput.tsx +++ b/client/src/components/Chat/ChatFooter/NLInput.tsx @@ -34,6 +34,7 @@ import { FileResItem, LangItem } from '../../../types/api'; import FileIcon from '../../FileIcon'; import { getFileExtensionForLang, splitPath } from '../../../utils'; import InputLoader from './InputLoader'; +import InputCore from './Input/InputCore'; type Props = { id?: string; @@ -280,8 +281,7 @@ const NLInput = ({ } transition-all ease-out duration-150 flex-grow-0 relative z-100`} >
{shouldShowLoader && }
@@ -297,41 +297,42 @@ const NLInput = ({ )}
- - - - + + {/**/} + {/* */} + {/* */} + {/**/} {isStoppable || selectedLines ? (
diff --git a/package-lock.json b/package-lock.json index c80da9ab40..7b8eedb805 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,6 +6,7 @@ "": { "name": "@bloop/root", "dependencies": { + "@nytimes/react-prosemirror": "^0.4.2", "@rive-app/react-canvas": "^4.0.0", "@sentry/integrations": "^7.60.1", "@sentry/react": "^7.60.1", @@ -27,6 +28,11 @@ "lodash.throttle": "^4.1.1", "npm-run-all": "^4.1.5", "prismjs": "^1.29.0", + "prosemirror-mentions": "^1.0.2", + "prosemirror-model": "^1.19.3", + "prosemirror-schema-basic": "^1.2.2", + "prosemirror-state": "^1.4.3", + "prosemirror-view": "^1.32.4", "react": "^18.2.0", "react-dom": "^18.2.0", "react-i18next": "^13.0.2", @@ -3181,6 +3187,20 @@ "node": ">= 8" } }, + "node_modules/@nytimes/react-prosemirror": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@nytimes/react-prosemirror/-/react-prosemirror-0.4.2.tgz", + "integrity": "sha512-RFJT+GMG/cuMpXHd73k0hqImYsNx2iWs+9Wjs2YuRjVSMVANRhkOKq7Tatt1UcNMFA14kZW66t3Qb29pjdRgCQ==", + "engines": { + "node": ">=16.9" + }, + "peerDependencies": { + "prosemirror-state": "^1.0.0", + "prosemirror-view": "^1.0.0", + "react": ">=17", + "react-dom": ">=17" + } + }, "node_modules/@pkgr/utils": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/@pkgr/utils/-/utils-2.3.1.tgz", @@ -12663,6 +12683,11 @@ "node": ">= 0.8.0" } }, + "node_modules/orderedmap": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz", + "integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==" + }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -13197,6 +13222,59 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/prosemirror-mentions": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/prosemirror-mentions/-/prosemirror-mentions-1.0.2.tgz", + "integrity": "sha512-d9O1IT69NQvASN4K+/ki5FaiAs5yK5qnueZiRHtlnsy80V74hVINIdDp2HQkFM26/TLZ4xbkjXTakjv4X4ZRiQ==", + "peerDependencies": { + "prosemirror-state": "^1.2.2", + "prosemirror-view": "^1.4.2" + } + }, + "node_modules/prosemirror-model": { + "version": "1.19.3", + "resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.19.3.tgz", + "integrity": "sha512-tgSnwN7BS7/UM0sSARcW+IQryx2vODKX4MI7xpqY2X+iaepJdKBPc7I4aACIsDV/LTaTjt12Z56MhDr9LsyuZQ==", + "dependencies": { + "orderedmap": "^2.0.0" + } + }, + "node_modules/prosemirror-schema-basic": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/prosemirror-schema-basic/-/prosemirror-schema-basic-1.2.2.tgz", + "integrity": "sha512-/dT4JFEGyO7QnNTe9UaKUhjDXbTNkiWTq/N4VpKaF79bBjSExVV2NXmJpcM7z/gD7mbqNjxbmWW5nf1iNSSGnw==", + "dependencies": { + "prosemirror-model": "^1.19.0" + } + }, + "node_modules/prosemirror-state": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.3.tgz", + "integrity": "sha512-goFKORVbvPuAQaXhpbemJFRKJ2aixr+AZMGiquiqKxaucC6hlpHNZHWgz5R7dS4roHiwq9vDctE//CZ++o0W1Q==", + "dependencies": { + "prosemirror-model": "^1.0.0", + "prosemirror-transform": "^1.0.0", + "prosemirror-view": "^1.27.0" + } + }, + "node_modules/prosemirror-transform": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.8.0.tgz", + "integrity": "sha512-BaSBsIMv52F1BVVMvOmp1yzD3u65uC3HTzCBQV1WDPqJRQ2LuHKcyfn0jwqodo8sR9vVzMzZyI+Dal5W9E6a9A==", + "dependencies": { + "prosemirror-model": "^1.0.0" + } + }, + "node_modules/prosemirror-view": { + "version": "1.32.4", + "resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.32.4.tgz", + "integrity": "sha512-WoT+ZYePp0WQvp5coABAysheZg9WttW3TSEUNgsfDQXmVOJlnjkbFbXicKPvWFLiC0ZjKt1ykbyoVKqhVnCiSQ==", + "dependencies": { + "prosemirror-model": "^1.16.0", + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.1.0" + } + }, "node_modules/proxy-from-env": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", diff --git a/package.json b/package.json index 06f8738d7a..dc3f88c8fb 100644 --- a/package.json +++ b/package.json @@ -68,6 +68,7 @@ "vite-plugin-environment": "^1.1.3" }, "dependencies": { + "@nytimes/react-prosemirror": "^0.4.2", "@rive-app/react-canvas": "^4.0.0", "@sentry/integrations": "^7.60.1", "@sentry/react": "^7.60.1", @@ -89,6 +90,11 @@ "lodash.throttle": "^4.1.1", "npm-run-all": "^4.1.5", "prismjs": "^1.29.0", + "prosemirror-mentions": "^1.0.2", + "prosemirror-model": "^1.19.3", + "prosemirror-schema-basic": "^1.2.2", + "prosemirror-state": "^1.4.3", + "prosemirror-view": "^1.32.4", "react": "^18.2.0", "react-dom": "^18.2.0", "react-i18next": "^13.0.2", From 04141fc71ffaa4f64a6f49c612e6ff1496194bcb Mon Sep 17 00:00:00 2001 From: anastasiia Date: Wed, 22 Nov 2023 08:51:13 -0500 Subject: [PATCH 02/18] fix suggestions, remove outline on input --- client/src/components/Chat/ChatFooter/Input/mentionPlugin.ts | 2 +- client/src/index.css | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/client/src/components/Chat/ChatFooter/Input/mentionPlugin.ts b/client/src/components/Chat/ChatFooter/Input/mentionPlugin.ts index 058c27da91..2f2694b740 100644 --- a/client/src/components/Chat/ChatFooter/Input/mentionPlugin.ts +++ b/client/src/components/Chat/ChatFooter/Input/mentionPlugin.ts @@ -371,7 +371,7 @@ export function getMentionsPlugin(opts: Partial) { return DecorationSet.create(editorState.doc, [ Decoration.inline(range.from, range.to, { nodeName: 'span', - class: opts.suggestionTextClass, + class: options.suggestionTextClass, }), ]); }, diff --git a/client/src/index.css b/client/src/index.css index 98de07be90..a0ce1f9445 100644 --- a/client/src/index.css +++ b/client/src/index.css @@ -1506,3 +1506,7 @@ h5, .h5 { .padding-start > *:first-child { padding-left: 32px; } + +.ProseMirror:focus { + outline: none; +} From 3975df6c7f3a38b9914597190d42b66c105e9007 Mon Sep 17 00:00:00 2001 From: anastasiia Date: Wed, 22 Nov 2023 08:54:34 -0500 Subject: [PATCH 03/18] # behaves as @ --- .../Chat/ChatFooter/Input/InputCore.tsx | 8 +++---- .../Chat/ChatFooter/Input/mentionPlugin.ts | 4 +--- .../components/Chat/ChatFooter/Input/nodes.ts | 24 +++++++------------ 3 files changed, 14 insertions(+), 22 deletions(-) diff --git a/client/src/components/Chat/ChatFooter/Input/InputCore.tsx b/client/src/components/Chat/ChatFooter/Input/InputCore.tsx index e012919e3c..4d553074d8 100644 --- a/client/src/components/Chat/ChatFooter/Input/InputCore.tsx +++ b/client/src/components/Chat/ChatFooter/Input/InputCore.tsx @@ -18,9 +18,9 @@ const schema = new Schema({ }); const mentionsData = [ - { name: 'Anna Brown', id: '103', email: 'anna@gmail.com' }, - { name: 'John Doe', id: '101', email: 'joe@gmail.com' }, - { name: 'Joe Lewis', id: '102', email: 'lewis@gmail.com' }, + { tag: 'index.ts' }, + { tag: 'server.rs' }, + { tag: 'component.jsx' }, ]; const tagsData = [ @@ -37,7 +37,7 @@ const getMentionSuggestionsHTML = (items: Record[]) => { return ( '
' + items - .map((i) => '
' + i.name + '
') + .map((i) => '
' + i.tag + '
') .join('') + '
' ); diff --git a/client/src/components/Chat/ChatFooter/Input/mentionPlugin.ts b/client/src/components/Chat/ChatFooter/Input/mentionPlugin.ts index 2f2694b740..a4677a2249 100644 --- a/client/src/components/Chat/ChatFooter/Input/mentionPlugin.ts +++ b/client/src/components/Chat/ChatFooter/Input/mentionPlugin.ts @@ -270,9 +270,7 @@ export function getMentionsPlugin(opts: Partial) { let attrs; if (state.type === 'mention') { attrs = { - name: item.name, - id: item.id, - email: item.email, + tag: item.tag, }; } else { attrs = { diff --git a/client/src/components/Chat/ChatFooter/Input/nodes.ts b/client/src/components/Chat/ChatFooter/Input/nodes.ts index 187959e0b9..012012eb4f 100644 --- a/client/src/components/Chat/ChatFooter/Input/nodes.ts +++ b/client/src/components/Chat/ChatFooter/Input/nodes.ts @@ -7,9 +7,7 @@ export const mentionNode: NodeSpec = { atom: true, attrs: { - id: '' as AttributeSpec, - name: '' as AttributeSpec, - email: '' as AttributeSpec, + tag: '' as AttributeSpec, }, selectable: false, @@ -19,28 +17,24 @@ export const mentionNode: NodeSpec = { return [ 'span', { - 'data-mention-id': node.attrs.id, - 'data-mention-name': node.attrs.name, - 'data-mention-email': node.attrs.email, - class: 'prosemirror-mention-node', + 'data-mention': node.attrs.tag, + class: + 'prosemirror-tag-node file-icon inline-flex items-center flex-shrink-0 align-middle ' + + icons.getClassWithColor(node.attrs.tag), }, - '@' + node.attrs.name || node.attrs.email, + node.attrs.tag, ]; }, parseDOM: [ { // match tag with following CSS Selector - tag: 'span[data-mention-id][data-mention-name][data-mention-email]', + tag: 'span[data-mention]', getAttrs: (dom) => { - const id = (dom as HTMLElement).getAttribute('data-mention-id'); - const name = (dom as HTMLElement).getAttribute('data-mention-name'); - const email = (dom as HTMLElement).getAttribute('data-mention-email'); + const tag = (dom as HTMLElement).getAttribute('data-mention'); return { - id: id, - name: name, - email: email, + tag: tag, }; }, }, From c9d6102f559c64471e39bf7e1a7a05ff179a7993 Mon Sep 17 00:00:00 2001 From: anastasiia Date: Wed, 22 Nov 2023 09:40:31 -0500 Subject: [PATCH 04/18] fix suggestions position to top, display icon, pull from api --- .../Chat/ChatFooter/Input/InputCore.tsx | 106 ++++++++++++------ .../Chat/ChatFooter/Input/mentionPlugin.ts | 9 +- .../components/Chat/ChatFooter/Input/nodes.ts | 39 +++++-- .../components/Chat/ChatFooter/NLInput.tsx | 6 +- client/src/index.css | 12 ++ 5 files changed, 119 insertions(+), 53 deletions(-) diff --git a/client/src/components/Chat/ChatFooter/Input/InputCore.tsx b/client/src/components/Chat/ChatFooter/Input/InputCore.tsx index 4d553074d8..8017750593 100644 --- a/client/src/components/Chat/ChatFooter/Input/InputCore.tsx +++ b/client/src/components/Chat/ChatFooter/Input/InputCore.tsx @@ -1,4 +1,4 @@ -import { memo, useCallback, useState } from 'react'; +import { memo, useCallback, useMemo, useState } from 'react'; import { EditorState, Transaction } from 'prosemirror-state'; import { Schema } from 'prosemirror-model'; import { @@ -9,6 +9,8 @@ import { useNodeViews, } from '@nytimes/react-prosemirror'; import { schema as basicSchema } from 'prosemirror-schema-basic'; +import * as icons from 'file-icons-js'; +import { getFileExtensionForLang } from '../../../../utils'; import { getMentionsPlugin } from './mentionPlugin'; import { addMentionNodes, addTagNodes } from './utils'; @@ -35,9 +37,15 @@ const tagsData = [ */ const getMentionSuggestionsHTML = (items: Record[]) => { return ( - '
' + + '
' + items - .map((i) => '
' + i.tag + '
') + .map( + (i) => + `
${i.display}
`, + ) .join('') + '
' ); @@ -57,37 +65,6 @@ const getTagSuggestionsHTML = (items: Record[]) => { ); }; -export const mentionPlugin = getMentionsPlugin({ - getSuggestions: ( - type: string, - text: string, - done: (s: Record[]) => void, - ) => { - setTimeout(() => { - if (type === 'mention') { - done(mentionsData); - } else { - done(tagsData); - } - }, 0); - }, - getSuggestionsHTML: (items, type) => { - if (type === 'mention') { - return getMentionSuggestionsHTML(items); - } - return getTagSuggestionsHTML(items); - }, -}); - -const editorState = EditorState.create({ - doc: schema.topNodeType.create(null, [ - schema.nodes.paragraph.createAndFill()!, - // schema.nodes.list.createAndFill()!, - ]), - schema, - plugins: [react(), mentionPlugin], -}); - function Paragraph({ children }: NodeViewComponentProps) { return

{children}

; } @@ -100,9 +77,66 @@ const reactNodeViews: Record = { }), }; -type Props = {}; +type Props = { + getDataLang: (search: string) => Promise<{ id: string; display: string }[]>; +}; -const InputCore = ({}: Props) => { +const InputCore = ({ getDataLang }: Props) => { + const mentionPlugin = useMemo( + () => + getMentionsPlugin({ + getSuggestions: async ( + type: string, + text: string, + done: (s: Record[]) => void, + ) => { + const data = await getDataLang(text); + done(data); + // setTimeout(() => { + // if (type === 'mention') { + // done(mentionsData); + // } else { + // done(tagsData); + // } + // }, 0); + }, + getSuggestionsHTML: (items, type) => { + if (type === 'mention') { + return ( + '
' + + items + .map( + (i) => + `
${ + i.display + }
`, + ) + .join('') + + '
' + ); + } + return getTagSuggestionsHTML(items); + }, + }), + [], + ); + + const editorState = useMemo( + () => + EditorState.create({ + doc: schema.topNodeType.create(null, [ + schema.nodes.paragraph.createAndFill()!, + // schema.nodes.list.createAndFill()!, + ]), + schema, + plugins: [react(), mentionPlugin], + }), + [schema, mentionPlugin], + ); const { nodeViews, renderNodeViews } = useNodeViews(reactNodeViews); const [mount, setMount] = useState(null); const [state, setState] = useState(editorState); diff --git a/client/src/components/Chat/ChatFooter/Input/mentionPlugin.ts b/client/src/components/Chat/ChatFooter/Input/mentionPlugin.ts index a4677a2249..82644c3e21 100644 --- a/client/src/components/Chat/ChatFooter/Input/mentionPlugin.ts +++ b/client/src/components/Chat/ChatFooter/Input/mentionPlugin.ts @@ -204,7 +204,6 @@ export function getMentionsPlugin(opts: Partial) { // highlight first element by default - like Facebook. addClassAtIndex(state.index, opts.activeClass); - // get current @mention span left and top. // 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); @@ -213,17 +212,15 @@ export function getMentionsPlugin(opts: Partial) { '.' + opts.suggestionTextClass, ); - // TODO: should add null check case for textDOM const offset = textDOM?.getBoundingClientRect(); - // TODO: think about outsourcing this positioning logic as options document.body.appendChild(el); el.classList.add('suggestion-item-container'); el.style.position = 'fixed'; el.style.left = offset?.left + 'px'; - const top = (textDOM as HTMLElement)?.offsetHeight + (offset?.top || 0); - el.style.top = top + 'px'; + const bottom = window.innerHeight - (offset?.top || 0); + el.style.bottom = bottom + 'px'; el.style.display = 'block'; el.style.zIndex = '999999'; }; @@ -270,7 +267,7 @@ export function getMentionsPlugin(opts: Partial) { let attrs; if (state.type === 'mention') { attrs = { - tag: item.tag, + ...item, }; } else { attrs = { diff --git a/client/src/components/Chat/ChatFooter/Input/nodes.ts b/client/src/components/Chat/ChatFooter/Input/nodes.ts index 012012eb4f..6e159d5dde 100644 --- a/client/src/components/Chat/ChatFooter/Input/nodes.ts +++ b/client/src/components/Chat/ChatFooter/Input/nodes.ts @@ -1,5 +1,6 @@ import * as icons from 'file-icons-js'; import { type AttributeSpec, type NodeSpec } from 'prosemirror-model'; +import { getFileExtensionForLang } from '../../../../utils'; export const mentionNode: NodeSpec = { group: 'inline', @@ -7,7 +8,10 @@ export const mentionNode: NodeSpec = { atom: true, attrs: { - tag: '' as AttributeSpec, + id: '' as AttributeSpec, + display: '' as AttributeSpec, + type: 'lang' as AttributeSpec, + isFirst: '' as AttributeSpec, }, selectable: false, @@ -17,24 +21,43 @@ export const mentionNode: NodeSpec = { return [ 'span', { - 'data-mention': node.attrs.tag, + 'data-type': node.attrs.type, + 'data-id': node.attrs.id, + 'data-first': node.attrs.isFirst, + 'data-display': node.attrs.display, class: - 'prosemirror-tag-node file-icon inline-flex items-center flex-shrink-0 align-middle ' + - icons.getClassWithColor(node.attrs.tag), + 'prosemirror-tag-node inline-flex gap-1.5 items-center align-middle', }, - node.attrs.tag, + [ + 'span', + { + class: `text-left w-4 h-4 file-icon flex-shrink-0 inline-flex items-center ${icons.getClassWithColor( + (node.attrs.type === 'lang' + ? getFileExtensionForLang(node.attrs.display, true) + : node.attrs.display) || '.txt', + )}`, + }, + '', + ], + node.attrs.display, ]; }, parseDOM: [ { // match tag with following CSS Selector - tag: 'span[data-mention]', + tag: 'span[data-type][data-id][data-first][data-display]', getAttrs: (dom) => { - const tag = (dom as HTMLElement).getAttribute('data-mention'); + 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 { - tag: tag, + id, + type, + isFirst, + display, }; }, }, diff --git a/client/src/components/Chat/ChatFooter/NLInput.tsx b/client/src/components/Chat/ChatFooter/NLInput.tsx index 4b214574b1..e893c85ff6 100644 --- a/client/src/components/Chat/ChatFooter/NLInput.tsx +++ b/client/src/components/Chat/ChatFooter/NLInput.tsx @@ -177,7 +177,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`, @@ -189,7 +189,7 @@ const NLInput = ({ langResults.forEach((fr, i) => { results.push({ id: fr, display: fr, type: 'lang', isFirst: i === 0 }); }); - callback(results); + return results; }, [tab.name], ); @@ -297,7 +297,7 @@ const NLInput = ({ )}
- + {/* Date: Wed, 22 Nov 2023 12:09:37 -0500 Subject: [PATCH 05/18] set input value programatically --- .../Chat/ChatFooter/Input/InputCore.tsx | 140 +++++++----------- .../components/Chat/ChatFooter/NLInput.tsx | 34 ++++- .../src/components/Chat/ChatFooter/index.tsx | 5 + client/src/components/Chat/index.tsx | 31 +++- client/src/utils/index.ts | 18 +++ 5 files changed, 129 insertions(+), 99 deletions(-) diff --git a/client/src/components/Chat/ChatFooter/Input/InputCore.tsx b/client/src/components/Chat/ChatFooter/Input/InputCore.tsx index 8017750593..e85bfc2616 100644 --- a/client/src/components/Chat/ChatFooter/Input/InputCore.tsx +++ b/client/src/components/Chat/ChatFooter/Input/InputCore.tsx @@ -1,4 +1,4 @@ -import { memo, useCallback, useMemo, useState } from 'react'; +import { memo, useCallback, useEffect, useMemo, useState } from 'react'; import { EditorState, Transaction } from 'prosemirror-state'; import { Schema } from 'prosemirror-model'; import { @@ -10,7 +10,7 @@ import { } from '@nytimes/react-prosemirror'; import { schema as basicSchema } from 'prosemirror-schema-basic'; import * as icons from 'file-icons-js'; -import { getFileExtensionForLang } from '../../../../utils'; +import { getFileExtensionForLang, InputEditorContent } from '../../../../utils'; import { getMentionsPlugin } from './mentionPlugin'; import { addMentionNodes, addTagNodes } from './utils'; @@ -19,52 +19,6 @@ const schema = new Schema({ marks: basicSchema.spec.marks, }); -const mentionsData = [ - { tag: 'index.ts' }, - { tag: 'server.rs' }, - { tag: 'component.jsx' }, -]; - -const tagsData = [ - { tag: 'index.ts' }, - { tag: 'server.rs' }, - { tag: 'component.jsx' }, -]; - -/** - * IMPORTANT: outer div's "suggestion-item-list" class is mandatory. The plugin uses this class for querying. - * IMPORTANT: inner div's "suggestion-item" class is mandatory too for the same reasons - */ -const getMentionSuggestionsHTML = (items: Record[]) => { - return ( - '
' + - items - .map( - (i) => - `
${i.display}
`, - ) - .join('') + - '
' - ); -}; - -/** - * IMPORTANT: outer div's "suggestion-item-list" class is mandatory. The plugin uses this class for querying. - * IMPORTANT: inner div's "suggestion-item" class is mandatory too for the same reasons - */ -const getTagSuggestionsHTML = (items: Record[]) => { - return ( - '
' + - items - .map((i) => '
' + i.tag + '
') - .join('') + - '
' - ); -}; - function Paragraph({ children }: NodeViewComponentProps) { return

{children}

; } @@ -79,9 +33,11 @@ const reactNodeViews: Record = { type Props = { getDataLang: (search: string) => Promise<{ id: string; display: string }[]>; + initialValue?: Record | null; + onChange: (contents: InputEditorContent[]) => void; }; -const InputCore = ({ getDataLang }: Props) => { +const InputCore = ({ getDataLang, initialValue, onChange }: Props) => { const mentionPlugin = useMemo( () => getMentionsPlugin({ @@ -92,59 +48,65 @@ const InputCore = ({ getDataLang }: Props) => { ) => { const data = await getDataLang(text); done(data); - // setTimeout(() => { - // if (type === 'mention') { - // done(mentionsData); - // } else { - // done(tagsData); - // } - // }, 0); }, - getSuggestionsHTML: (items, type) => { - if (type === 'mention') { - return ( - '
' + - items - .map( - (i) => - `
${ - i.display - }
`, - ) - .join('') + - '
' - ); - } - return getTagSuggestionsHTML(items); + getSuggestionsHTML: (items) => { + return ( + '
' + + items + .map( + (i) => + `
${ + i.display + }
`, + ) + .join('') + + '
' + ); }, }), [], ); - const editorState = useMemo( - () => - EditorState.create({ - doc: schema.topNodeType.create(null, [ - schema.nodes.paragraph.createAndFill()!, - // schema.nodes.list.createAndFill()!, - ]), - schema, - plugins: [react(), mentionPlugin], - }), - [schema, mentionPlugin], - ); const { nodeViews, renderNodeViews } = useNodeViews(reactNodeViews); const [mount, setMount] = useState(null); - const [state, setState] = useState(editorState); + const [state, setState] = useState( + EditorState.create({ + doc: initialValue ? schema.nodeFromJSON(initialValue) : undefined, + schema, + plugins: [react(), mentionPlugin], + }), + ); + + useEffect(() => { + if (mount) { + setState( + EditorState.create({ + schema, + plugins: [react(), mentionPlugin], + doc: initialValue + ? schema.topNodeType.create(null, [ + schema.nodeFromJSON(initialValue), + ]) + : undefined, + }), + ); + } + }, [mount, initialValue]); const dispatchTransaction = useCallback( (tr: Transaction) => setState((oldState) => oldState.apply(tr)), [], ); + + useEffect(() => { + const newValue = state.toJSON().doc.content[0]?.content; + onChange(newValue || []); + }, [state]); + return (
| null; generationInProgress?: boolean; isStoppable?: boolean; showTooltip?: boolean; tooltipText?: string; onStop?: () => void; onChange?: OnChangeHandlerFunc; + setInputValue: Dispatch>; onSubmit?: () => void; loadingSteps?: ChatLoadingStep[]; selectedLines?: [number, number] | null; setSelectedLines?: (l: [number, number] | null) => void; queryIdToEdit?: string; onMessageEditCancel?: () => void; + submittedQuery: string; }; type SuggestionType = { @@ -90,7 +99,8 @@ const inputStyle = { const NLInput = ({ id, value, - onChange, + valueToEdit, + setInputValue, generationInProgress, isStoppable, onStop, @@ -100,6 +110,7 @@ const NLInput = ({ setSelectedLines, queryIdToEdit, onMessageEditCancel, + submittedQuery, }: Props) => { const { t } = useTranslation(); const inputRef = useRef(null); @@ -272,6 +283,17 @@ const NLInput = ({ [], ); + const onChangeInput = useCallback((inputState: InputEditorContent[]) => { + console.log('inputState', inputState); + const newValue = inputState + .map((s) => + s.type === 'mention' ? `${s.attrs.type}:${s.attrs.id}` : s.text, + ) + .join(''); + console.log('newValue', newValue); + setInputValue(newValue); + }, []); + return (
)}
- + {!(isStoppable && generationInProgress) && ( + + )} {/* | null; setInputValue: Dispatch>; onMessageEditCancel: () => void; setHistoryOpen: (b: boolean) => void; @@ -46,6 +47,7 @@ const ChatFooter = ({ openHistoryItem, isHistoryOpen, setHistoryOpen, + valueToEdit, }: Props) => { const { t } = useTranslation(); const { conversation, selectedLines, submittedQuery } = useContext( @@ -121,6 +123,8 @@ const ChatFooter = ({ onSubmit={onSubmit} onChange={handleInputChange} isStoppable={isLoading} + setInputValue={setInputValue} + valueToEdit={valueToEdit} loadingSteps={loadingSteps} generationInProgress={ (conversation[conversation.length - 1] as ChatMessageServer) @@ -131,6 +135,7 @@ const ChatFooter = ({ setSelectedLines={setSelectedLines} queryIdToEdit={queryIdToEdit} onMessageEditCancel={onMessageEditCancel} + submittedQuery={submittedQuery} /> {/*{isAutocompleteActive && (*/} diff --git a/client/src/components/Chat/index.tsx b/client/src/components/Chat/index.tsx index 814117d0de..e405946ac0 100644 --- a/client/src/components/Chat/index.tsx +++ b/client/src/components/Chat/index.tsx @@ -16,10 +16,7 @@ import { mapLoadingSteps } from '../../mappers/conversation'; import { findElementInCurrentTab } from '../../utils/domUtils'; import { conversationsCache } from '../../services/cache'; import useResizeableWidth from '../../hooks/useResizeableWidth'; -import { - concatenateParsedQuery, - splitUserInputAfterAutocomplete, -} from '../../utils'; +import { splitUserInputAfterAutocomplete } from '../../utils'; import DeprecatedClientModal from './ChatFooter/DeprecatedClientModal'; import ChatHeader from './ChatHeader'; import ChatBody from './ChatBody'; @@ -53,6 +50,9 @@ const Chat = () => { const [showPopup, setShowPopup] = useState(false); const [inputValue, setInputValue] = useState(''); const [queryIdToEdit, setQueryIdToEdit] = useState(''); + const [valueToEdit, setValueToEdit] = useState | null>( + null, + ); const [hideMessagesFrom, setHideMessagesFrom] = useState(null); const [openHistoryItem, setOpenHistoryItem] = useState(null); @@ -360,9 +360,24 @@ const Chat = () => { } setHideMessagesFrom(i); const mes = conversation[i] as ChatMessageUser; - setInputValue( - mes.parsedQuery ? concatenateParsedQuery(mes.parsedQuery) : mes.text!, - ); + setValueToEdit({ + type: 'paragraph', + content: mes.parsedQuery + ? mes.parsedQuery.map((pq) => + pq.type === 'text' + ? { type: 'text', text: pq.text } + : { + type: 'mention', + attrs: { + id: pq.text, + display: pq.text, + type: pq.type, + isFirst: false, + }, + }, + ) + : { type: 'text', text: mes.text! }, + }); }, [isLoading, conversation], ); @@ -370,6 +385,7 @@ const Chat = () => { const onMessageEditCancel = useCallback(() => { setQueryIdToEdit(''); setInputValue(''); + setValueToEdit(null); setHideMessagesFrom(null); }, []); @@ -411,6 +427,7 @@ const Chat = () => { onMessageEditCancel={onMessageEditCancel} hideMessagesFrom={hideMessagesFrom} setInputValue={setInputValue} + valueToEdit={valueToEdit} inputValue={inputValue} stopGenerating={stopGenerating} openHistoryItem={openHistoryItem} diff --git a/client/src/utils/index.ts b/client/src/utils/index.ts index 8c80fffaff..87c83a833f 100644 --- a/client/src/utils/index.ts +++ b/client/src/utils/index.ts @@ -440,3 +440,21 @@ export function concatenateParsedQuery(query: ParsedQueryType[]) { }); return result; } + +type InputEditorTextContent = { + type: 'text'; + text: string; +}; + +type InputEditorMentionContent = { + type: 'mention'; + attrs: { + type: 'lang' | 'path'; + id: string; + display: string; + }; +}; + +export type InputEditorContent = + | InputEditorTextContent + | InputEditorMentionContent; From bac289f76d50a81bb45a92965d531237655fbc1c Mon Sep 17 00:00:00 2001 From: anastasiia Date: Wed, 22 Nov 2023 12:46:15 -0500 Subject: [PATCH 06/18] handle key events --- .../Chat/ChatFooter/Input/InputCore.tsx | 49 ++++++++++++++-- .../components/Chat/ChatFooter/Input/nodes.ts | 6 +- .../components/Chat/ChatFooter/NLInput.tsx | 57 ++++++++++--------- .../src/components/Chat/ChatFooter/index.tsx | 22 ++----- client/src/components/Chat/index.tsx | 10 +--- package-lock.json | 26 +++++++++ package.json | 2 + 7 files changed, 113 insertions(+), 59 deletions(-) diff --git a/client/src/components/Chat/ChatFooter/Input/InputCore.tsx b/client/src/components/Chat/ChatFooter/Input/InputCore.tsx index e85bfc2616..7a9ff3c62d 100644 --- a/client/src/components/Chat/ChatFooter/Input/InputCore.tsx +++ b/client/src/components/Chat/ChatFooter/Input/InputCore.tsx @@ -1,6 +1,8 @@ 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, @@ -35,9 +37,15 @@ type Props = { getDataLang: (search: string) => Promise<{ id: string; display: string }[]>; initialValue?: Record | null; onChange: (contents: InputEditorContent[]) => void; + onSubmit?: (s: string) => void; }; -const InputCore = ({ getDataLang, initialValue, onChange }: Props) => { +const InputCore = ({ + getDataLang, + initialValue, + onChange, + onSubmit, +}: Props) => { const mentionPlugin = useMemo( () => getMentionsPlugin({ @@ -71,13 +79,44 @@ const InputCore = ({ getDataLang, initialValue, onChange }: Props) => { [], ); + 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; + } + console.log('submit', state); + onSubmit?.( + state + .toJSON() + .doc.content[0]?.content.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, + }), + react(), + mentionPlugin, + ]; + }, [onSubmit]); + const { nodeViews, renderNodeViews } = useNodeViews(reactNodeViews); const [mount, setMount] = useState(null); const [state, setState] = useState( EditorState.create({ doc: initialValue ? schema.nodeFromJSON(initialValue) : undefined, schema, - plugins: [react(), mentionPlugin], + plugins, }), ); @@ -86,7 +125,7 @@ const InputCore = ({ getDataLang, initialValue, onChange }: Props) => { setState( EditorState.create({ schema, - plugins: [react(), mentionPlugin], + plugins, doc: initialValue ? schema.topNodeType.create(null, [ schema.nodeFromJSON(initialValue), @@ -95,7 +134,7 @@ const InputCore = ({ getDataLang, initialValue, onChange }: Props) => { }), ); } - }, [mount, initialValue]); + }, [mount, initialValue, plugins]); const dispatchTransaction = useCallback( (tr: Transaction) => setState((oldState) => oldState.apply(tr)), @@ -108,7 +147,7 @@ const InputCore = ({ getDataLang, initialValue, onChange }: Props) => { }, [state]); return ( -
+
void; onChange?: OnChangeHandlerFunc; setInputValue: Dispatch>; - onSubmit?: () => void; + onSubmit?: (s: string) => void; loadingSteps?: ChatLoadingStep[]; selectedLines?: [number, number] | null; setSelectedLines?: (l: [number, number] | null) => void; @@ -119,31 +119,31 @@ const NLInput = ({ 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; + // 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]); - // 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 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, @@ -284,13 +284,11 @@ const NLInput = ({ ); const onChangeInput = useCallback((inputState: InputEditorContent[]) => { - console.log('inputState', inputState); const newValue = inputState .map((s) => s.type === 'mention' ? `${s.attrs.type}:${s.attrs.id}` : s.text, ) .join(''); - console.log('newValue', newValue); setInputValue(newValue); }, []); @@ -319,12 +317,15 @@ const NLInput = ({ )}
- {!(isStoppable && generationInProgress) && ( + {!isStoppable && !generationInProgress ? ( + ) : ( +
)} {/* { - if (e?.preventDefault) { - e.preventDefault(); - } + (value: string) => { if ( (conversation[conversation.length - 1] as ChatMessageServer) ?.isLoading || - !inputValue.trim() + !value.trim() ) { return; } @@ -73,10 +69,10 @@ const ChatFooter = ({ } blurInput(); setSubmittedQuery( - submittedQuery === inputValue ? `${inputValue} ` : inputValue, // to trigger new search if query hasn't changed + submittedQuery === value ? `${value} ` : value, // to trigger new search if query hasn't changed ); }, - [inputValue, conversation, submittedQuery, hideMessagesFrom], + [conversation, submittedQuery, hideMessagesFrom], ); const loadingSteps = useMemo(() => { @@ -116,7 +112,7 @@ const ChatFooter = ({ return (
-
+ - {/*{isAutocompleteActive && (*/} - {/* */} - {/*)}*/}
); }; diff --git a/client/src/components/Chat/index.tsx b/client/src/components/Chat/index.tsx index e405946ac0..fa364cc18c 100644 --- a/client/src/components/Chat/index.tsx +++ b/client/src/components/Chat/index.tsx @@ -25,7 +25,7 @@ import ChatFooter from './ChatFooter'; let prevEventSource: EventSource | undefined; const focusInput = () => { - findElementInCurrentTab('#question-input')?.focus(); + findElementInCurrentTab('.ProseMirror')?.focus(); }; const Chat = () => { @@ -71,10 +71,6 @@ const Chat = () => { if (!query) { return; } - const cleanQuery = query - .replace(/\|(path:.*?)\|/, '$1') - .replace(/\|(lang:.*?)\|/, '$1'); // clean up after autocomplete - console.log('query', query, 'cleanQuery', cleanQuery); prevEventSource?.close(); setInputValue(''); setLoading(true); @@ -85,7 +81,7 @@ const Chat = () => { ? `/explain?relative_path=${encodeURIComponent( options.filePath, )}&line_start=${options.lineStart}&line_end=${options.lineEnd}` - : `?q=${encodeURIComponent(cleanQuery)}${ + : `?q=${encodeURIComponent(query)}${ selectedBranch ? ` branch:${selectedBranch}` : '' }` }&repo_ref=${tab.repoRef}${ @@ -326,7 +322,7 @@ const Chat = () => { }; return [...newConversation, lastMessage]; }); - focusInput(); + setTimeout(focusInput, 100); }, []); useEffect(() => { diff --git a/package-lock.json b/package-lock.json index 7b8eedb805..9ee39656b6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,6 +28,8 @@ "lodash.throttle": "^4.1.1", "npm-run-all": "^4.1.5", "prismjs": "^1.29.0", + "prosemirror-commands": "^1.5.2", + "prosemirror-keymap": "^1.2.2", "prosemirror-mentions": "^1.0.2", "prosemirror-model": "^1.19.3", "prosemirror-schema-basic": "^1.2.2", @@ -13222,6 +13224,25 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/prosemirror-commands": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.5.2.tgz", + "integrity": "sha512-hgLcPaakxH8tu6YvVAaILV2tXYsW3rAdDR8WNkeKGcgeMVQg3/TMhPdVoh7iAmfgVjZGtcOSjKiQaoeKjzd2mQ==", + "dependencies": { + "prosemirror-model": "^1.0.0", + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.0.0" + } + }, + "node_modules/prosemirror-keymap": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/prosemirror-keymap/-/prosemirror-keymap-1.2.2.tgz", + "integrity": "sha512-EAlXoksqC6Vbocqc0GtzCruZEzYgrn+iiGnNjsJsH4mrnIGex4qbLdWWNza3AW5W36ZRrlBID0eM6bdKH4OStQ==", + "dependencies": { + "prosemirror-state": "^1.0.0", + "w3c-keyname": "^2.2.0" + } + }, "node_modules/prosemirror-mentions": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/prosemirror-mentions/-/prosemirror-mentions-1.0.2.tgz", @@ -15763,6 +15784,11 @@ "node": ">=0.10.0" } }, + "node_modules/w3c-keyname": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==" + }, "node_modules/w3c-xmlserializer": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-4.0.0.tgz", diff --git a/package.json b/package.json index dc3f88c8fb..ab01bf2d83 100644 --- a/package.json +++ b/package.json @@ -90,6 +90,8 @@ "lodash.throttle": "^4.1.1", "npm-run-all": "^4.1.5", "prismjs": "^1.29.0", + "prosemirror-commands": "^1.5.2", + "prosemirror-keymap": "^1.2.2", "prosemirror-mentions": "^1.0.2", "prosemirror-model": "^1.19.3", "prosemirror-schema-basic": "^1.2.2", From 7924191c4706127924a20325d5ea7cf0ab34d976 Mon Sep 17 00:00:00 2001 From: anastasiia Date: Thu, 23 Nov 2023 07:02:15 -0500 Subject: [PATCH 07/18] add a placeholder --- .../Chat/ChatFooter/Input/InputCore.tsx | 4 ++++ .../ChatFooter/Input/placeholderPlugin.ts | 20 +++++++++++++++++++ .../Chat/ChatFooter/InputLoader.tsx | 4 +++- .../components/Chat/ChatFooter/NLInput.tsx | 5 ++++- client/src/index.css | 7 +++++++ client/src/locales/en.json | 5 +++-- client/src/locales/es.json | 5 +++-- client/src/locales/it.json | 5 +++-- client/src/locales/ja.json | 5 +++-- client/src/locales/zh-CN.json | 5 +++-- 10 files changed, 53 insertions(+), 12 deletions(-) create mode 100644 client/src/components/Chat/ChatFooter/Input/placeholderPlugin.ts diff --git a/client/src/components/Chat/ChatFooter/Input/InputCore.tsx b/client/src/components/Chat/ChatFooter/Input/InputCore.tsx index 7a9ff3c62d..9cab1b6a90 100644 --- a/client/src/components/Chat/ChatFooter/Input/InputCore.tsx +++ b/client/src/components/Chat/ChatFooter/Input/InputCore.tsx @@ -15,6 +15,7 @@ import * as icons from 'file-icons-js'; import { getFileExtensionForLang, InputEditorContent } from '../../../../utils'; import { getMentionsPlugin } from './mentionPlugin'; import { addMentionNodes, addTagNodes } from './utils'; +import { placeholderPlugin } from './placeholderPlugin'; const schema = new Schema({ nodes: addTagNodes(addMentionNodes(basicSchema.spec.nodes)), @@ -38,6 +39,7 @@ type Props = { initialValue?: Record | null; onChange: (contents: InputEditorContent[]) => void; onSubmit?: (s: string) => void; + placeholder: string; }; const InputCore = ({ @@ -45,6 +47,7 @@ const InputCore = ({ initialValue, onChange, onSubmit, + placeholder, }: Props) => { const mentionPlugin = useMemo( () => @@ -105,6 +108,7 @@ const InputCore = ({ 'Ctrl-Enter': baseKeymap.Enter, 'Cmd-Enter': baseKeymap.Enter, }), + placeholderPlugin(placeholder), react(), mentionPlugin, ]; 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..0e913b4fcd --- /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.textContent) { + 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/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 b4ad98eac0..a690361607 100644 --- a/client/src/components/Chat/ChatFooter/NLInput.tsx +++ b/client/src/components/Chat/ChatFooter/NLInput.tsx @@ -323,9 +323,12 @@ const NLInput = ({ initialValue={valueToEdit} onChange={onChangeInput} onSubmit={onSubmit} + placeholder={t(defaultPlaceholder)} /> ) : ( -
+
+ {!shouldShowLoader && Generating answer...} +
)} {/* Date: Thu, 23 Nov 2023 07:17:04 -0500 Subject: [PATCH 08/18] trigger suggestions with empty text, remove tag trigger, fix checks for focused input --- .../Chat/ChatFooter/Input/InputCore.tsx | 4 +- .../Chat/ChatFooter/Input/mentionPlugin.ts | 67 ++++--------------- .../components/Chat/ChatFooter/Input/nodes.ts | 40 ----------- .../components/Chat/ChatFooter/Input/utils.ts | 8 +-- .../components/CodeBlock/CodeFull/index.tsx | 8 ++- .../CodeFullSelectable/CodeContainer.tsx | 9 +-- client/src/pages/RepoTab/Content.tsx | 3 +- client/src/utils/domUtils.ts | 3 +- 8 files changed, 30 insertions(+), 112 deletions(-) diff --git a/client/src/components/Chat/ChatFooter/Input/InputCore.tsx b/client/src/components/Chat/ChatFooter/Input/InputCore.tsx index 9cab1b6a90..4e98655800 100644 --- a/client/src/components/Chat/ChatFooter/Input/InputCore.tsx +++ b/client/src/components/Chat/ChatFooter/Input/InputCore.tsx @@ -14,11 +14,11 @@ import { schema as basicSchema } from 'prosemirror-schema-basic'; import * as icons from 'file-icons-js'; import { getFileExtensionForLang, InputEditorContent } from '../../../../utils'; import { getMentionsPlugin } from './mentionPlugin'; -import { addMentionNodes, addTagNodes } from './utils'; +import { addMentionNodes } from './utils'; import { placeholderPlugin } from './placeholderPlugin'; const schema = new Schema({ - nodes: addTagNodes(addMentionNodes(basicSchema.spec.nodes)), + nodes: addMentionNodes(basicSchema.spec.nodes), marks: basicSchema.spec.marks, }); diff --git a/client/src/components/Chat/ChatFooter/Input/mentionPlugin.ts b/client/src/components/Chat/ChatFooter/Input/mentionPlugin.ts index 82644c3e21..39e46bb252 100644 --- a/client/src/components/Chat/ChatFooter/Input/mentionPlugin.ts +++ b/client/src/components/Chat/ChatFooter/Input/mentionPlugin.ts @@ -2,29 +2,16 @@ import { Plugin, PluginKey } from 'prosemirror-state'; import { Decoration, DecorationSet, EditorView } from 'prosemirror-view'; import { ResolvedPos } from 'prosemirror-model'; -export function getRegexp( - mentionTrigger: string, - hashtagTrigger: string, - allowSpace?: boolean, -) { - const mention = allowSpace - ? new RegExp('(^|\\s)' + mentionTrigger + '([\\w-\\+]+\\s?[\\w-\\+]*)$') - : new RegExp('(^|\\s)' + mentionTrigger + '([\\w-\\+]+)$'); - - // hashtags should never allow spaces. I mean, what's the point of allowing spaces in hashtags? - const tag = new RegExp('(^|\\s)' + hashtagTrigger + '([\\w-]+)$'); - - return { - mention: mention, - tag: tag, - }; +export function getRegexp(mentionTrigger: string, allowSpace?: boolean) { + return allowSpace + ? new RegExp('(^|\\s)' + mentionTrigger + '([\\w-\\+]*\\s?[\\w-\\+]*)$') + : new RegExp('(^|\\s)' + mentionTrigger + '([\\w-\\+]*)$'); } export function getMatch( $position: ResolvedPos, opts: { mentionTrigger: string; - hashtagTrigger: string; allowSpace?: boolean; }, ) { @@ -33,25 +20,9 @@ export function getMatch( const parastart = $position.before(); const text = $position.doc.textBetween(parastart, $position.pos, '\n', '\0'); - const regex = getRegexp( - opts.mentionTrigger, - opts.hashtagTrigger, - opts.allowSpace, - ); - - // only one of the below matches will be true. - const mentionMatch = text.match(regex.mention); - const tagMatch = text.match(regex.tag); - - const match = mentionMatch || tagMatch; + const regex = getRegexp(opts.mentionTrigger, opts.allowSpace); - // set type of match - let type; - if (mentionMatch) { - type = 'mention'; - } else if (tagMatch) { - type = 'tag'; - } + const match = text.match(regex); // if match found, return match with useful information. if (match) { @@ -70,7 +41,7 @@ export function getMatch( return { range: { from: from, to: to }, queryText: queryText, - type: type, + type: 'mention', }; } // else if no match don't return anything. @@ -114,7 +85,7 @@ const getNewState = function () { from: 0, to: 0, }, - type: '', //mention or tag + type: '', text: '', suggestions: [], index: 0, // current active suggestion index @@ -123,7 +94,6 @@ const getNewState = function () { type Options = { mentionTrigger: string; - hashtagTrigger: string; allowSpace?: boolean; activeClass: string; suggestionTextClass?: string; @@ -135,15 +105,11 @@ type Options = { delay: number; getSuggestionsHTML: (items: Record[], type: string) => string; }; -/** - * @param {JSONObject} opts - * @returns {Plugin} - */ + export function getMentionsPlugin(opts: Partial) { // default options const defaultOpts = { mentionTrigger: '@', - hashtagTrigger: '#', allowSpace: true, getSuggestions: ( type: string, @@ -264,16 +230,9 @@ export function getMentionsPlugin(opts: Partial) { const select = function (view: EditorView, state: State, opts: Options) { const item = state.suggestions[state.index]; - let attrs; - if (state.type === 'mention') { - attrs = { - ...item, - }; - } else { - attrs = { - tag: item.tag, - }; - } + const attrs = { + ...item, + }; const node = view.state.schema.nodes[state.type].create(attrs); const tr = view.state.tr.replaceWith( state.range.from, @@ -377,7 +336,7 @@ export function getMentionsPlugin(opts: Partial) { return { update: (view) => { const state = this.key?.getState(view.state); - if (!state.text) { + if (!state.active) { hideList(); clearTimeout(showListTimeoutId); return; diff --git a/client/src/components/Chat/ChatFooter/Input/nodes.ts b/client/src/components/Chat/ChatFooter/Input/nodes.ts index 852745bad3..fb6d2a2497 100644 --- a/client/src/components/Chat/ChatFooter/Input/nodes.ts +++ b/client/src/components/Chat/ChatFooter/Input/nodes.ts @@ -65,43 +65,3 @@ export const mentionNode: NodeSpec = { }, ], }; - -export const tagNode: NodeSpec = { - group: 'inline', - inline: true, - atom: true, - - attrs: { - tag: '' as AttributeSpec, - }, - - selectable: false, - draggable: false, - - toDOM: (node) => { - return [ - 'span', - { - 'data-tag': node.attrs.tag, - class: - 'prosemirror-tag-node file-icon inline-flex items-center flex-shrink-0 align-middle ' + - icons.getClassWithColor(node.attrs.tag), - }, - node.attrs.tag, - ]; - }, - - parseDOM: [ - { - // match tag with following CSS Selector - tag: 'span[data-tag]', - - getAttrs: (dom) => { - const tag = (dom as HTMLElement).getAttribute('data-tag'); - return { - tag: tag, - }; - }, - }, - ], -}; diff --git a/client/src/components/Chat/ChatFooter/Input/utils.ts b/client/src/components/Chat/ChatFooter/Input/utils.ts index 0cceb3fd8a..dea740a9be 100644 --- a/client/src/components/Chat/ChatFooter/Input/utils.ts +++ b/client/src/components/Chat/ChatFooter/Input/utils.ts @@ -1,15 +1,9 @@ import OrderedMap from 'orderedmap'; import { type NodeSpec } from 'prosemirror-model'; -import { tagNode, mentionNode } from './nodes'; +import { mentionNode } from './nodes'; export function addMentionNodes(nodes: OrderedMap) { return nodes.append({ mention: mentionNode, }); } - -export function addTagNodes(nodes: OrderedMap) { - return nodes.append({ - tag: tagNode, - }); -} diff --git a/client/src/components/CodeBlock/CodeFull/index.tsx b/client/src/components/CodeBlock/CodeFull/index.tsx index 0ce3e2bfea..54c37dad36 100644 --- a/client/src/components/CodeBlock/CodeFull/index.tsx +++ b/client/src/components/CodeBlock/CodeFull/index.tsx @@ -19,7 +19,10 @@ import useAppNavigation from '../../../hooks/useAppNavigation'; import SearchOnPage from '../../SearchOnPage'; import useKeyboardNavigation from '../../../hooks/useKeyboardNavigation'; import { MAX_LINES_BEFORE_VIRTUALIZE } from '../../../consts/code'; -import { findElementInCurrentTab } from '../../../utils/domUtils'; +import { + findElementInCurrentTab, + isFocusInInput, +} from '../../../utils/domUtils'; import CodeContainer from './CodeContainer'; import ExplainButton from './ExplainButton'; @@ -286,8 +289,7 @@ const CodeFull = ({ if ( (event.ctrlKey || event.metaKey) && event.key === 'a' && - (event.target as HTMLElement)?.tagName !== 'INPUT' && - (event.target as HTMLElement)?.tagName !== 'TEXTAREA' + !isFocusInInput() ) { // Prevent the default action (i.e. selecting all text in the browser) event.preventDefault(); diff --git a/client/src/components/CodeBlock/CodeFullSelectable/CodeContainer.tsx b/client/src/components/CodeBlock/CodeFullSelectable/CodeContainer.tsx index 6f8c80c606..1e7e609d7f 100644 --- a/client/src/components/CodeBlock/CodeFullSelectable/CodeContainer.tsx +++ b/client/src/components/CodeBlock/CodeFullSelectable/CodeContainer.tsx @@ -12,7 +12,10 @@ import React, { } from 'react'; import { Token as TokenType } from '../../../types/prism'; import { hashCode, mergeRanges } from '../../../utils'; -import { findElementInCurrentTab } from '../../../utils/domUtils'; +import { + findElementInCurrentTab, + isFocusInInput, +} from '../../../utils/domUtils'; import { CODE_LINE_HEIGHT } from '../../../consts/code'; import useKeyboardNavigation from '../../../hooks/useKeyboardNavigation'; import SelectionHandler from './SelectionHandler'; @@ -181,9 +184,7 @@ const CodeContainerSelectable = ({ const handleKeyEvent = useCallback( (e: KeyboardEvent) => { if ( - !['TEXTAREA', 'INPUT'].includes( - document.activeElement?.tagName || '', - ) && + !isFocusInInput() && !e.shiftKey && !e.ctrlKey && !e.metaKey && diff --git a/client/src/pages/RepoTab/Content.tsx b/client/src/pages/RepoTab/Content.tsx index e3f2323924..7d340b3468 100644 --- a/client/src/pages/RepoTab/Content.tsx +++ b/client/src/pages/RepoTab/Content.tsx @@ -20,6 +20,7 @@ import useKeyboardNavigation from '../../hooks/useKeyboardNavigation'; import { RepoTabType } from '../../types/general'; import { AppNavigationContext } from '../../context/appNavigationContext'; import HomePage from '../HomeTab/Content'; +import { isFocusInInput } from '../../utils/domUtils'; import RepositoryPage from './Repository'; import ResultsPage from './Results'; import ViewResult from './ResultFull'; @@ -54,7 +55,7 @@ const ContentContainer = ({ tab }: { tab: RepoTabType }) => { const handleKeyEvent = useCallback((e: KeyboardEvent) => { if ( e.key === 'Escape' && - document.activeElement?.tagName !== 'INPUT' && + !isFocusInInput() && !document.getElementsByClassName('modal-or-sidebar').length ) { e.stopPropagation(); diff --git a/client/src/utils/domUtils.ts b/client/src/utils/domUtils.ts index 130dc64c09..c09d2266c6 100644 --- a/client/src/utils/domUtils.ts +++ b/client/src/utils/domUtils.ts @@ -14,4 +14,5 @@ export const findAllElementsInCurrentTab = < }; export const isFocusInInput = () => - ['INPUT', 'TEXTAREA'].includes(document.activeElement?.tagName || ''); + ['INPUT', 'TEXTAREA'].includes(document.activeElement?.tagName || '') || + (document.activeElement as HTMLElement)?.isContentEditable; From 2334e49b5c2bd51a5280081ed4a7c327c5600699 Mon Sep 17 00:00:00 2001 From: anastasiia Date: Thu, 23 Nov 2023 08:42:45 -0500 Subject: [PATCH 09/18] implement design styles, fix position of suggestions at the screen border --- .../Chat/ChatFooter/Input/InputCore.tsx | 41 ++++++++++++----- .../Chat/ChatFooter/Input/mentionPlugin.ts | 18 ++++++-- .../components/Chat/ChatFooter/Input/nodes.ts | 44 ++++++++++++------- .../components/Chat/ChatFooter/NLInput.tsx | 8 ++-- client/src/index.css | 4 +- 5 files changed, 80 insertions(+), 35 deletions(-) diff --git a/client/src/components/Chat/ChatFooter/Input/InputCore.tsx b/client/src/components/Chat/ChatFooter/Input/InputCore.tsx index 4e98655800..5f5831699b 100644 --- a/client/src/components/Chat/ChatFooter/Input/InputCore.tsx +++ b/client/src/components/Chat/ChatFooter/Input/InputCore.tsx @@ -12,6 +12,7 @@ import { } 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 { getMentionsPlugin } from './mentionPlugin'; import { addMentionNodes } from './utils'; @@ -36,6 +37,7 @@ const reactNodeViews: Record = { 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: string) => void; @@ -44,11 +46,13 @@ type Props = { const InputCore = ({ getDataLang, + getDataPath, initialValue, onChange, onSubmit, placeholder, }: Props) => { + const { t } = useTranslation(); const mentionPlugin = useMemo( () => getMentionsPlugin({ @@ -57,22 +61,39 @@ const InputCore = ({ text: string, done: (s: Record[]) => void, ) => { - const data = await getDataLang(text); - done(data); + const data = await Promise.all([ + getDataPath(text), + getDataLang(text), + ]); + done([...data[0], ...data[1]]); }, getSuggestionsHTML: (items) => { return ( - '
' + + '
' + items .map( (i) => - `
${ - i.display - }
`, + `
${ + i.isFirst + ? `
+ ${t( + i.type === 'dir' + ? 'Directories' + : i.type === 'lang' + ? 'Languages' + : 'Files', + )} +
` + : '' + }
${ + i.type === 'dir' + ? ` ` + : `` + }${i.display}
`, ) .join('') + '
' diff --git a/client/src/components/Chat/ChatFooter/Input/mentionPlugin.ts b/client/src/components/Chat/ChatFooter/Input/mentionPlugin.ts index 39e46bb252..e02bbe4a0b 100644 --- a/client/src/components/Chat/ChatFooter/Input/mentionPlugin.ts +++ b/client/src/components/Chat/ChatFooter/Input/mentionPlugin.ts @@ -183,7 +183,16 @@ export function getMentionsPlugin(opts: Partial) { document.body.appendChild(el); el.classList.add('suggestion-item-container'); el.style.position = 'fixed'; - el.style.left = offset?.left + 'px'; + 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'; @@ -205,6 +214,7 @@ export function getMentionsPlugin(opts: Partial) { 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) { @@ -217,7 +227,8 @@ export function getMentionsPlugin(opts: Partial) { removeClassAtIndex(state.index, opts.activeClass); state.index++; state.index = state.index === state.suggestions.length ? 0 : state.index; - addClassAtIndex(state.index, opts.activeClass); + const el = addClassAtIndex(state.index, opts.activeClass); + el?.scrollIntoView({ block: 'nearest' }); }; const goPrev = function (view: EditorView, state: State, opts: Options) { @@ -225,7 +236,8 @@ export function getMentionsPlugin(opts: Partial) { state.index--; state.index = state.index === -1 ? state.suggestions.length - 1 : state.index; - addClassAtIndex(state.index, opts.activeClass); + const el = addClassAtIndex(state.index, opts.activeClass); + el?.scrollIntoView({ block: 'nearest' }); }; const select = function (view: EditorView, state: State, opts: Options) { diff --git a/client/src/components/Chat/ChatFooter/Input/nodes.ts b/client/src/components/Chat/ChatFooter/Input/nodes.ts index fb6d2a2497..a263300456 100644 --- a/client/src/components/Chat/ChatFooter/Input/nodes.ts +++ b/client/src/components/Chat/ChatFooter/Input/nodes.ts @@ -1,6 +1,6 @@ import * as icons from 'file-icons-js'; import { type AttributeSpec, type NodeSpec } from 'prosemirror-model'; -import { getFileExtensionForLang } from '../../../../utils'; +import { getFileExtensionForLang, splitPath } from '../../../../utils'; export const mentionNode: NodeSpec = { group: 'inline', @@ -18,6 +18,14 @@ export const mentionNode: NodeSpec = { draggable: false, toDOM: (node) => { + const folderIcon = document.createElement('span'); + folderIcon.innerHTML = ` + + `; + folderIcon.className = 'w-4 h-4 flex-shrink-0'; return [ 'span', { @@ -28,20 +36,26 @@ export const mentionNode: NodeSpec = { class: 'prosemirror-tag-node inline-flex gap-1.5 items-center align-bottom', }, - [ - '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.display, + 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(), ]; }, diff --git a/client/src/components/Chat/ChatFooter/NLInput.tsx b/client/src/components/Chat/ChatFooter/NLInput.tsx index a690361607..f5d6fc73b1 100644 --- a/client/src/components/Chat/ChatFooter/NLInput.tsx +++ b/client/src/components/Chat/ChatFooter/NLInput.tsx @@ -157,10 +157,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`, ); @@ -180,7 +177,7 @@ const NLInput = ({ dirResults.forEach((fr, i) => { results.push({ id: fr, display: fr, type: 'dir', isFirst: i === 0 }); }); - callback(results); + return results; }, [tab.repoName], ); @@ -320,6 +317,7 @@ const NLInput = ({ {!isStoppable && !generationInProgress ? ( Date: Thu, 23 Nov 2023 09:25:53 -0500 Subject: [PATCH 10/18] add space after selection, fix placeholder after deleting space after first insertion --- .../components/Chat/ChatFooter/Input/mentionPlugin.ts | 9 +++++---- .../Chat/ChatFooter/Input/placeholderPlugin.ts | 2 +- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/client/src/components/Chat/ChatFooter/Input/mentionPlugin.ts b/client/src/components/Chat/ChatFooter/Input/mentionPlugin.ts index e02bbe4a0b..de7d785813 100644 --- a/client/src/components/Chat/ChatFooter/Input/mentionPlugin.ts +++ b/client/src/components/Chat/ChatFooter/Input/mentionPlugin.ts @@ -246,11 +246,12 @@ export function getMentionsPlugin(opts: Partial) { ...item, }; const node = view.state.schema.nodes[state.type].create(attrs); - const tr = view.state.tr.replaceWith( - state.range.from, - state.range.to, + const spaceNode = view.state.schema.text(String.fromCharCode(160)); + + const tr = view.state.tr.replaceWith(state.range.from, state.range.to, [ node, - ); + spaceNode, + ]); //var newState = view.state.apply(tr); //view.updateState(newState); diff --git a/client/src/components/Chat/ChatFooter/Input/placeholderPlugin.ts b/client/src/components/Chat/ChatFooter/Input/placeholderPlugin.ts index 0e913b4fcd..5910bf246e 100644 --- a/client/src/components/Chat/ChatFooter/Input/placeholderPlugin.ts +++ b/client/src/components/Chat/ChatFooter/Input/placeholderPlugin.ts @@ -3,7 +3,7 @@ import { EditorView } from 'prosemirror-view'; export const placeholderPlugin = (text: string) => { const update = (view: EditorView) => { - if (view.state.doc.textContent) { + if (view.state.doc.content.size > 2) { view.dom.removeAttribute('data-placeholder'); } else { view.dom.setAttribute('data-placeholder', text); From cb8d78d248221f2363e3c6906d80fb61cdf12535 Mon Sep 17 00:00:00 2001 From: anastasiia Date: Thu, 23 Nov 2023 10:46:17 -0500 Subject: [PATCH 11/18] handle setting input value imperatively (edit message, tutorial question, clear on submit) --- .../Chat/ChatBody/AllCoversations/index.tsx | 4 +- .../components/Chat/ChatBody/Conversation.tsx | 8 +- .../components/Chat/ChatBody/FirstMessage.tsx | 14 +- client/src/components/Chat/ChatBody/index.tsx | 6 +- .../Chat/ChatFooter/Input/InputCore.tsx | 32 ++- .../components/Chat/ChatFooter/Input/nodes.ts | 2 +- .../components/Chat/ChatFooter/NLInput.tsx | 243 +++--------------- .../src/components/Chat/ChatFooter/index.tsx | 31 +-- client/src/components/Chat/index.tsx | 127 ++++++--- .../CodeBlock/CodeFull/ExplainButton.tsx | 15 +- client/src/context/chatContext.ts | 10 +- .../context/providers/ChatContextProvider.tsx | 10 +- client/src/pages/RepoTab/ResultFull/index.tsx | 14 +- .../src/pages/RepoTab/ResultModal/index.tsx | 13 +- 14 files changed, 215 insertions(+), 314 deletions(-) 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/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 index 5f5831699b..9b596aa806 100644 --- a/client/src/components/Chat/ChatFooter/Input/InputCore.tsx +++ b/client/src/components/Chat/ChatFooter/Input/InputCore.tsx @@ -14,6 +14,10 @@ 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'; @@ -40,7 +44,7 @@ type Props = { getDataPath: (search: string) => Promise<{ id: string; display: string }[]>; initialValue?: Record | null; onChange: (contents: InputEditorContent[]) => void; - onSubmit?: (s: string) => void; + onSubmit?: (s: { parsed: ParsedQueryType[]; plain: string }) => void; placeholder: string; }; @@ -115,15 +119,25 @@ const InputCore = ({ if (key && state[key]?.active) { return false; } - console.log('submit', state); - onSubmit?.( - state - .toJSON() - .doc.content[0]?.content.map((s: InputEditorContent) => + const parts = state.toJSON().doc.content[0]?.content; + 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, @@ -139,7 +153,9 @@ const InputCore = ({ const [mount, setMount] = useState(null); const [state, setState] = useState( EditorState.create({ - doc: initialValue ? schema.nodeFromJSON(initialValue) : undefined, + doc: initialValue + ? schema.topNodeType.create(null, [schema.nodeFromJSON(initialValue)]) + : undefined, schema, plugins, }), diff --git a/client/src/components/Chat/ChatFooter/Input/nodes.ts b/client/src/components/Chat/ChatFooter/Input/nodes.ts index a263300456..03292d4e8e 100644 --- a/client/src/components/Chat/ChatFooter/Input/nodes.ts +++ b/client/src/components/Chat/ChatFooter/Input/nodes.ts @@ -34,7 +34,7 @@ export const mentionNode: NodeSpec = { 'data-first': node.attrs.isFirst, 'data-display': node.attrs.display, class: - 'prosemirror-tag-node inline-flex gap-1.5 items-center align-bottom', + '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 diff --git a/client/src/components/Chat/ChatFooter/NLInput.tsx b/client/src/components/Chat/ChatFooter/NLInput.tsx index f5d6fc73b1..65f4e75ade 100644 --- a/client/src/components/Chat/ChatFooter/NLInput.tsx +++ b/client/src/components/Chat/ChatFooter/NLInput.tsx @@ -1,65 +1,47 @@ 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, - InputEditorContent, - 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; - setInputValue: Dispatch>; - onSubmit?: (s: string) => 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; queryIdToEdit?: string; onMessageEditCancel?: () => void; - submittedQuery: string; }; type SuggestionType = { @@ -71,33 +53,7 @@ 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, valueToEdit, setInputValue, @@ -110,41 +66,12 @@ const NLInput = ({ setSelectedLines, queryIdToEdit, onMessageEditCancel, - submittedQuery, }: 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], @@ -202,93 +129,35 @@ const NLInput = ({ [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 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 onChangeInput = useCallback((inputState: InputEditorContent[]) => { const newValue = inputState .map((s) => s.type === 'mention' ? `${s.attrs.type}:${s.attrs.id}` : s.text, ) .join(''); - setInputValue(newValue); + 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 onSubmitButtonClicked = useCallback(() => { + if (value && onSubmit) { + onSubmit(value); + } + }, [value, onSubmit]); + return (
) : selectedLines ? ( - ) : value ? ( + ) : value?.plain ? ( ) : ( @@ -328,41 +198,6 @@ const NLInput = ({ {!shouldShowLoader && Generating answer...}
)} - {/**/} - {/* */} - {/* */} - {/**/} {isStoppable || selectedLines ? (
@@ -373,8 +208,12 @@ const NLInput = ({ />
- ) : value && !queryIdToEdit ? ( -