From c6797fef28b4f3a3e7588e1657635d55fcc72d00 Mon Sep 17 00:00:00 2001 From: Naman Kumar Date: Wed, 16 Oct 2024 19:18:27 +0530 Subject: [PATCH] Include draft prompts for viewer (#5856) closes: https://linear.app/sourcegraph/issue/SRCH-1138/make-your-drafts-be-available-in-the-editor closes: https://linear.app/sourcegraph/issue/SRCH-1173/prompts-have-the-option-to-be-set-to-automatically-run-without-the Blocked on: https://github.com/sourcegraph/sourcegraph/pull/1003 This PR includes the new `includeViewerDrafts: true` argument in prompts query, added as part of the backend PR. This will allow to list user's owned draft prompts along with all the non draft prompts accessible to the user. This PR also integrates the new `autoSubmit` field on prompts, added as part of the backend PR. The prompts with autoSubmit set as true will be automatically executed in one click when the user selected them. For other only the input box will be updated with the prompt text as currently. First the backend change PR should be merged and deployed on both dotcom and s2 for this PR to be merged. ## Test plan - Visit the prompts screen and make sure the user's draft prompts are being listed. https://www.loom.com/share/4d74ba90ba6643a29d0798ad977a5c39 ## Changelog - List viewer's draft prompts in the Prompts Library. --- lib/prompt-editor/src/PromptEditor.tsx | 96 +++++++++++-------- .../src/sourcegraph-api/graphql/client.ts | 6 +- .../src/sourcegraph-api/graphql/queries.ts | 35 ++++++- vscode/src/chat/protocol.ts | 14 ++- .../assistant/AssistantMessageCell.tsx | 2 +- .../human/editor/HumanMessageEditor.tsx | 51 ++++++---- .../components/promptList/PromptList.tsx | 6 +- vscode/webviews/prompts/PromptsTab.tsx | 5 +- 8 files changed, 147 insertions(+), 68 deletions(-) diff --git a/lib/prompt-editor/src/PromptEditor.tsx b/lib/prompt-editor/src/PromptEditor.tsx index 7e30c38bcea9..b27c58410cbe 100644 --- a/lib/prompt-editor/src/PromptEditor.tsx +++ b/lib/prompt-editor/src/PromptEditor.tsx @@ -51,10 +51,10 @@ interface Props extends KeyboardEventPluginProps { export interface PromptEditorRefAPI { getSerializedValue(): SerializedPromptEditorValue - setFocus(focus: boolean, options?: { moveCursorToEnd?: boolean }): void - appendText(text: string, ensureWhitespaceBefore?: boolean): void - addMentions(items: ContextItem[]): void - setInitialContextMentions(items: ContextItem[]): void + setFocus(focus: boolean, options?: { moveCursorToEnd?: boolean }, cb?: () => void): void + appendText(text: string, cb?: () => void): void + addMentions(items: ContextItem[], cb?: () => void): void + setInitialContextMentions(items: ContextItem[], cb?: () => void): void setEditorState(state: SerializedPromptEditorState): void } @@ -92,7 +92,8 @@ export const PromptEditor: FunctionComponent = ({ } return toSerializedPromptEditorValue(editorRef.current) }, - setFocus(focus, { moveCursorToEnd } = {}): void { + // biome-ignore lint/style/useDefaultParameterLast: + setFocus(focus, { moveCursorToEnd } = {}, cb): void { const editor = editorRef.current if (editor) { if (focus) { @@ -123,24 +124,31 @@ export const PromptEditor: FunctionComponent = ({ // on initial load, for some reason. setTimeout(doFocus) }, - { tag: 'skip-scroll-into-view' } + { tag: 'skip-scroll-into-view', onUpdate: cb } ) } else { editor.blur() + cb?.() } + } else { + cb?.() } }, - appendText(text: string, ensureWhitespaceBefore?: boolean): void { - editorRef.current?.update(() => { - const root = $getRoot() - root.selectEnd() - $insertNodes([$createTextNode(`${getWhitespace(root)}${text}`)]) - root.selectEnd() - }) + appendText(text: string, cb?: () => void): void { + editorRef.current?.update( + () => { + const root = $getRoot() + root.selectEnd() + $insertNodes([$createTextNode(`${getWhitespace(root)}${text}`)]) + root.selectEnd() + }, + { onUpdate: cb } + ) }, - addMentions(items: ContextItem[]) { + addMentions(items: ContextItem[], cb?: () => void): void { const editor = editorRef.current if (!editor) { + cb?.() return } @@ -161,42 +169,50 @@ export const PromptEditor: FunctionComponent = ({ }) } if (ops.create.length === 0) { + cb?.() return } - editorRef.current?.update(() => { - const nodesToInsert = lexicalNodesForContextItems(ops.create, { - isFromInitialContext: false, - }) - $insertNodes([$createTextNode(getWhitespace($getRoot())), ...nodesToInsert]) - const lastNode = nodesToInsert.at(-1) - if (lastNode) { - $selectAfter(lastNode) - } - }) + editorRef.current?.update( + () => { + const nodesToInsert = lexicalNodesForContextItems(ops.create, { + isFromInitialContext: false, + }) + $insertNodes([$createTextNode(getWhitespace($getRoot())), ...nodesToInsert]) + const lastNode = nodesToInsert.at(-1) + if (lastNode) { + $selectAfter(lastNode) + } + }, + { onUpdate: cb } + ) }, - setInitialContextMentions(items: ContextItem[]) { + setInitialContextMentions(items: ContextItem[], cb?: () => void): void { const editor = editorRef.current if (!editor) { + cb?.() return } - editor.update(() => { - if (!hasSetInitialContext.current || isEditorContentOnlyInitialContext(editor)) { - if (isEditorContentOnlyInitialContext(editor)) { - // Only clear in this case so that we don't clobber any text that was - // inserted before initial context was received. - $getRoot().clear() + editor.update( + () => { + if (!hasSetInitialContext.current || isEditorContentOnlyInitialContext(editor)) { + if (isEditorContentOnlyInitialContext(editor)) { + // Only clear in this case so that we don't clobber any text that was + // inserted before initial context was received. + $getRoot().clear() + } + const nodesToInsert = lexicalNodesForContextItems(items, { + isFromInitialContext: true, + }) + $setSelection($getRoot().selectStart()) // insert at start + $insertNodes(nodesToInsert) + $selectEnd() + hasSetInitialContext.current = true } - const nodesToInsert = lexicalNodesForContextItems(items, { - isFromInitialContext: true, - }) - $setSelection($getRoot().selectStart()) // insert at start - $insertNodes(nodesToInsert) - $selectEnd() - hasSetInitialContext.current = true - } - }) + }, + { onUpdate: cb } + ) }, }), [] diff --git a/lib/shared/src/sourcegraph-api/graphql/client.ts b/lib/shared/src/sourcegraph-api/graphql/client.ts index 746ab6285692..8185e327e2e7 100644 --- a/lib/shared/src/sourcegraph-api/graphql/client.ts +++ b/lib/shared/src/sourcegraph-api/graphql/client.ts @@ -45,6 +45,7 @@ import { HIGHLIGHTED_FILE_QUERY, LEGACY_CHAT_INTENT_QUERY, LEGACY_CONTEXT_SEARCH_QUERY, + LEGACY_PROMPTS_QUERY_5_8, LOG_EVENT_MUTATION, LOG_EVENT_MUTATION_DEPRECATED, PACKAGE_LIST_QUERY, @@ -413,6 +414,7 @@ export interface Prompt { } description?: string draft: boolean + autoSubmit?: boolean definition: { text: string } @@ -1111,8 +1113,10 @@ export class SourcegraphGraphQLAPIClient { } public async queryPrompts(query: string, signal?: AbortSignal): Promise { + const hasIncludeViewerDraftsArg = await this.isValidSiteVersion({ minimumVersion: '5.9.0' }) + const response = await this.fetchSourcegraphAPI>( - PROMPTS_QUERY, + hasIncludeViewerDraftsArg ? PROMPTS_QUERY : LEGACY_PROMPTS_QUERY_5_8, { query }, signal ) diff --git a/lib/shared/src/sourcegraph-api/graphql/queries.ts b/lib/shared/src/sourcegraph-api/graphql/queries.ts index e2217b2f784e..77498c3c43e2 100644 --- a/lib/shared/src/sourcegraph-api/graphql/queries.ts +++ b/lib/shared/src/sourcegraph-api/graphql/queries.ts @@ -309,7 +309,8 @@ query ContextFilters { } }` -export const PROMPTS_QUERY = ` +// Legacy prompts query supported up to Sourcegraph 5.8.0. Newer versions include the `includeViewerDrafts` argument. +export const LEGACY_PROMPTS_QUERY_5_8 = ` query ViewerPrompts($query: String!) { prompts(query: $query, first: 100, includeDrafts: false, viewerIsAffiliated: true, orderBy: PROMPT_UPDATED_AT) { nodes { @@ -340,6 +341,38 @@ query ViewerPrompts($query: String!) { } }` +export const PROMPTS_QUERY = ` +query ViewerPrompts($query: String!) { + prompts(query: $query, first: 100, includeDrafts: false, includeViewerDrafts: true, viewerIsAffiliated: true, orderBy: PROMPT_UPDATED_AT) { + nodes { + id + name + nameWithOwner + owner { + namespaceName + } + description + draft + autoSubmit + definition { + text + } + url + createdBy { + id + username + displayName + avatarURL + } + } + totalCount + pageInfo { + hasNextPage + endCursor + } + } +}` + export const REPO_NAME_QUERY = ` query ResolveRepoName($cloneURL: String!) { repository(cloneURL: $cloneURL) { diff --git a/vscode/src/chat/protocol.ts b/vscode/src/chat/protocol.ts index cfa8a3523a1d..643a7b21a4d3 100644 --- a/vscode/src/chat/protocol.ts +++ b/vscode/src/chat/protocol.ts @@ -121,8 +121,17 @@ export type WebviewMessage = snippet: string } | { command: 'rpc/request'; message: RequestMessage } - | { command: 'chatSession'; action: 'duplicate' | 'new'; sessionID?: string | undefined | null } - | { command: 'log'; level: 'debug' | 'error'; filterLabel: string; message: string } + | { + command: 'chatSession' + action: 'duplicate' | 'new' + sessionID?: string | undefined | null + } + | { + command: 'log' + level: 'debug' | 'error' + filterLabel: string + message: string + } export interface SmartApplyResult { taskId: FixupTaskID @@ -161,6 +170,7 @@ export type ExtensionMessage = addContextItemsToLastHumanInput?: ContextItem[] | null | undefined appendTextToLastPromptEditor?: string | null | undefined smartApplyResult?: SmartApplyResult | undefined | null + submitHumanInput?: boolean | undefined | null } | ({ type: 'attribution' } & ExtensionAttributionMessage) | { type: 'rpc/response'; message: ResponseMessage } diff --git a/vscode/webviews/chat/cells/messageCell/assistant/AssistantMessageCell.tsx b/vscode/webviews/chat/cells/messageCell/assistant/AssistantMessageCell.tsx index ba368f743d80..260b89fa7260 100644 --- a/vscode/webviews/chat/cells/messageCell/assistant/AssistantMessageCell.tsx +++ b/vscode/webviews/chat/cells/messageCell/assistant/AssistantMessageCell.tsx @@ -227,7 +227,7 @@ export function makeHumanMessageInfo( if (humanEditorRef.current?.getSerializedValue().text.trim().endsWith('@')) { humanEditorRef.current?.setFocus(true, { moveCursorToEnd: true }) } else { - humanEditorRef.current?.appendText('@', true) + humanEditorRef.current?.appendText('@') } }, } diff --git a/vscode/webviews/chat/cells/messageCell/human/editor/HumanMessageEditor.tsx b/vscode/webviews/chat/cells/messageCell/human/editor/HumanMessageEditor.tsx index a217efbd3b00..e3301166d041 100644 --- a/vscode/webviews/chat/cells/messageCell/human/editor/HumanMessageEditor.tsx +++ b/vscode/webviews/chat/cells/messageCell/human/editor/HumanMessageEditor.tsx @@ -235,7 +235,7 @@ export const HumanMessageEditor: FunctionComponent<{ if (editorRef.current.getSerializedValue().text.trim().endsWith('@')) { editorRef.current.setFocus(true, { moveCursorToEnd: true }) } else { - editorRef.current.appendText('@', true) + editorRef.current.appendText('@') } const value = editorRef.current.getSerializedValue() @@ -256,42 +256,51 @@ export const HumanMessageEditor: FunctionComponent<{ // Set up the message listener so the extension can control the input field. useClientActionListener( useCallback( - ({ addContextItemsToLastHumanInput, appendTextToLastPromptEditor }) => { - if (addContextItemsToLastHumanInput) { - // Add new context to chat from the "Cody Add Selection to Cody Chat" - // command, etc. Only add to the last human input field. - if (isSent) { - return - } - if ( - !addContextItemsToLastHumanInput || - addContextItemsToLastHumanInput.length === 0 - ) { - return + ({ addContextItemsToLastHumanInput, appendTextToLastPromptEditor, submitHumanInput }) => { + // Add new context to chat from the "Cody Add Selection to Cody Chat" + // command, etc. Only add to the last human input field. + if (isSent) { + return + } + + const updates: Promise[] = [] + const awaitUpdate = () => { + let resolve: (value?: unknown) => void + updates.push( + new Promise(r => { + resolve = r + }) + ) + + return () => { + resolve?.() } + } + + if (addContextItemsToLastHumanInput && addContextItemsToLastHumanInput.length > 0) { const editor = editorRef.current if (editor) { - editor.addMentions(addContextItemsToLastHumanInput) + editor.addMentions(addContextItemsToLastHumanInput, awaitUpdate()) editor.setFocus(true) } } if (appendTextToLastPromptEditor) { - // Append text to the last human input field. - if (isSent) { - return - } - // Schedule append text task to the next tick to avoid collisions with // initial text set (add initial mentions first then append text from prompt) + const onUpdate = awaitUpdate() requestAnimationFrame(() => { if (editorRef.current) { - editorRef.current.appendText(appendTextToLastPromptEditor) + editorRef.current.appendText(appendTextToLastPromptEditor, onUpdate) } }) } + + if (submitHumanInput) { + Promise.all(updates).then(() => onSubmitClick()) + } }, - [isSent] + [isSent, onSubmitClick] ) ) diff --git a/vscode/webviews/components/promptList/PromptList.tsx b/vscode/webviews/components/promptList/PromptList.tsx index 3711d90c5a5b..52f9d10de24c 100644 --- a/vscode/webviews/components/promptList/PromptList.tsx +++ b/vscode/webviews/components/promptList/PromptList.tsx @@ -82,12 +82,14 @@ export const PromptList: FC = props => { } const isPrompt = action.actionType === 'prompt' + const isPromptAutoSubmit = action.actionType === 'prompt' && action.autoSubmit const isCommand = action.actionType === 'command' const isBuiltInCommand = isCommand && action.type === 'default' telemetryRecorder.recordEvent('cody.promptList', 'select', { metadata: { isPrompt: isPrompt ? 1 : 0, + isPromptAutoSubmit: isPromptAutoSubmit ? 1 : 0, isCommand: isCommand ? 1 : 0, isCommandBuiltin: isBuiltInCommand ? 1 : 0, isCommandCustom: !isBuiltInCommand ? 1 : 0, @@ -160,7 +162,9 @@ export const PromptList: FC = props => { tabIndex={0} shouldFilter={false} defaultValue={showInitialSelectedItem ? undefined : 'xxx-no-item'} - className={clsx(styles.list, { [styles.listChips]: appearanceMode === 'chips-list' })} + className={clsx(styles.list, { + [styles.listChips]: appearanceMode === 'chips-list', + })} > {showSearch && ( diff --git a/vscode/webviews/prompts/PromptsTab.tsx b/vscode/webviews/prompts/PromptsTab.tsx index 91aab8814708..771194e72197 100644 --- a/vscode/webviews/prompts/PromptsTab.tsx +++ b/vscode/webviews/prompts/PromptsTab.tsx @@ -47,7 +47,10 @@ export function useActionSelect() { case 'prompt': { setView(View.Chat) dispatchClientAction( - { appendTextToLastPromptEditor: action.definition.text }, + { + appendTextToLastPromptEditor: action.definition.text, + submitHumanInput: action.autoSubmit, + }, // Buffer because PromptEditor is not guaranteed to be mounted after the `setView` // call above, and it needs to be mounted to receive the action. { buffer: true }