diff --git a/lib/shared/src/index.ts b/lib/shared/src/index.ts index f98bb80b7de..d87e430ca93 100644 --- a/lib/shared/src/index.ts +++ b/lib/shared/src/index.ts @@ -256,6 +256,7 @@ export { isNodeResponse, INCLUDE_EVERYTHING_CONTEXT_FILTERS, EXCLUDE_EVERYTHING_CONTEXT_FILTERS, + PromptMode, type BrowserOrNodeResponse, type LogEventMode, type ContextFilters, diff --git a/lib/shared/src/misc/rpc/webviewAPI.ts b/lib/shared/src/misc/rpc/webviewAPI.ts index 7b542101704..c533c948502 100644 --- a/lib/shared/src/misc/rpc/webviewAPI.ts +++ b/lib/shared/src/misc/rpc/webviewAPI.ts @@ -162,6 +162,7 @@ export type PromptsMigrationStatus = | SuccessfulPromptsMigrationStatus | FailedPromptsMigrationStatus | PromptsMigrationSkipStatus + | NoPromptsMigrationNeeded interface InitialPromptsMigrationStatus { type: 'initial_migration' @@ -195,3 +196,7 @@ interface FailedPromptsMigrationStatus { interface PromptsMigrationSkipStatus { type: 'migration_skip' } + +interface NoPromptsMigrationNeeded { + type: 'no_migration_needed' +} diff --git a/lib/shared/src/sourcegraph-api/graphql/client.ts b/lib/shared/src/sourcegraph-api/graphql/client.ts index f1d626272fc..27f3c646c2c 100644 --- a/lib/shared/src/sourcegraph-api/graphql/client.ts +++ b/lib/shared/src/sourcegraph-api/graphql/client.ts @@ -431,13 +431,22 @@ export interface Prompt { } export interface PromptInput { - ownerId: string + owner: string name: string description: string definitionText: string + draft: boolean + autoSubmit: boolean + mode: PromptMode visibility?: 'PUBLIC' | 'SECRET' } +export enum PromptMode { + CHAT = 'CHAT', + EDIT = 'EDIT', + INSERT = 'INSERT', +} + interface ContextFiltersResponse { site: { codyContextFilters: { diff --git a/vscode/src/chat/chat-view/prompts-migration.ts b/vscode/src/chat/chat-view/prompts-migration.ts index 7aa2587d220..07374401591 100644 --- a/vscode/src/chat/chat-view/prompts-migration.ts +++ b/vscode/src/chat/chat-view/prompts-migration.ts @@ -1,4 +1,5 @@ import { + type CodyCommandMode, type PromptsMigrationStatus, currentAuthStatusAuthed, distinctUntilChanged, @@ -13,7 +14,9 @@ import { } from '@sourcegraph/cody-shared' import { Observable, Subject } from 'observable-fns' +import { PromptMode } from '@sourcegraph/cody-shared' import { getCodyCommandList } from '../../commands/CommandsController' +import { sleep } from '../../completions/utils' import { remoteReposForAllWorkspaceFolders } from '../../repository/remoteRepos' import { localStorage } from '../../services/LocalStorageProvider' @@ -38,14 +41,20 @@ export function getPromptsMigrationInfo(): Observable { // Don't run migration if you're already run this before (ignore any other new commands // that had been added after first migration run const migrationMap = localStorage.get>(PROMPTS_MIGRATION_KEY) ?? {} - const commands = getCodyCommandList().filter(command => command.type !== 'default') + const commands = [] // getCodyCommandList().filter(command => command.type !== 'default') - if (!repository || migrationMap[repository?.id ?? ''] || commands.length === 0) { - return Observable.of({ + if (!repository || migrationMap[repoKey(repository?.id ?? '')]) { + return Observable.of({ type: 'migration_skip', }) } + if (commands.length === 0) { + return Observable.of({ + type: 'no_migration_needed', + }) + } + return PROMPTS_MIGRATION_RESULT }) ) @@ -86,6 +95,8 @@ export async function startPromptsMigration(): Promise { try { const prompts = await graphqlClient.queryPrompts(commandKey.replace(/\s+/g, '-')) + await sleep(3000) + // If there is no prompts associated with the command include this // command to migration if (prompts.length === 0) { @@ -103,29 +114,43 @@ export async function startPromptsMigration(): Promise { allCommandsToMigrate: commandsToMigrate.length, }) - for (let index = 0; index < commands.length; index++) { - const command = commands[index] - const commandKey = (command.key ?? command.slashCommand).replace(/\s+/g, '-') - - const newPrompt = await graphqlClient.createPrompt({ - ownerId: currentUserId, - name: `migrated-command-${commandKey}`, - description: `Migrated from command ${commandKey}`, - definitionText: command.prompt, - visibility: 'SECRET', - }) + for (let index = 0; index < commandsToMigrate.length; index++) { + try { + const command = commandsToMigrate[index] + const commandKey = (command.key ?? command.slashCommand).replace(/\s+/g, '-') + + const newPrompt = await graphqlClient.createPrompt({ + owner: currentUserId, + name: commandKey, + description: `Migrated from command ${commandKey}`, + definitionText: command.prompt, + draft: false, + autoSubmit: false, + mode: commandModeToPromptMode(command.mode), + visibility: 'SECRET', + }) + + await sleep(3000) + + // Change prompt visibility to PUBLIC if it's admin performing migration + // TODO: [VK] Remove it and use visibility field in prompt creation (current API limitation) + if (authStatus.siteAdmin) { + await graphqlClient.transferPromptOwnership({ id: newPrompt.id, visibility: 'PUBLIC' }) + } - // Change prompt visibility to PUBLIC if it's admin performing migration - // TODO: [VK] Remove it and use visibility field in prompt creation (current API limitation) - if (authStatus.siteAdmin) { - await graphqlClient.transferPromptOwnership({ id: newPrompt.id, visibility: 'PUBLIC' }) + PROMPTS_MIGRATION_STATUS.next({ + type: 'migrating', + commandsMigrated: index + 1, + allCommandsToMigrate: commandsToMigrate.length, + }) + } catch (error: any) { + PROMPTS_MIGRATION_STATUS.next({ + type: 'migration_failed', + errorMessage: error.toString(), + }) + + return } - - PROMPTS_MIGRATION_STATUS.next({ - type: 'migrating', - commandsMigrated: index + 1, - allCommandsToMigrate: commandsToMigrate.length, - }) } const repositories = (await firstResultFromOperation(remoteReposForAllWorkspaceFolders)) ?? [] @@ -133,9 +158,27 @@ export async function startPromptsMigration(): Promise { if (repository) { const migrationMap = localStorage.get>(PROMPTS_MIGRATION_KEY) ?? {} - migrationMap[repository.id] = true + migrationMap[repoKey(repository.id)] = true await localStorage.set(PROMPTS_MIGRATION_KEY, migrationMap) } PROMPTS_MIGRATION_STATUS.next({ type: 'migration_success' }) } + +function commandModeToPromptMode(commandMode?: CodyCommandMode): PromptMode { + switch (commandMode) { + case 'ask': + return PromptMode.CHAT + case 'edit': + return PromptMode.EDIT + case 'insert': + return PromptMode.INSERT + + default: + return PromptMode.CHAT + } +} + +function repoKey(repositoryId: string) { + return `prefix8-${repositoryId}` +} diff --git a/vscode/src/chat/utils.test.ts b/vscode/src/chat/utils.test.ts index d755ec6a216..8bd1e376b5e 100644 --- a/vscode/src/chat/utils.test.ts +++ b/vscode/src/chat/utils.test.ts @@ -25,6 +25,7 @@ describe('validateAuthStatus', () => { endpoint: DOTCOM_URL.toString(), primaryEmail: 'alice@example.com', hasVerifiedEmail: true, + siteAdmin: true, username: 'alice', organizations: { nodes: [{ id: 'x', name: 'foo' }] }, }) @@ -32,6 +33,7 @@ describe('validateAuthStatus', () => { endpoint: DOTCOM_URL.toString(), authenticated: true, username: 'alice', + siteAdmin: true, hasVerifiedEmail: true, requiresVerifiedEmail: true, isFireworksTracingEnabled: false, @@ -46,6 +48,7 @@ describe('validateAuthStatus', () => { newAuthStatus({ authenticated: true, endpoint: 'https://example.com', + siteAdmin: true, username: 'alice', }) ).toStrictEqual({ @@ -53,6 +56,7 @@ describe('validateAuthStatus', () => { hasVerifiedEmail: false, endpoint: 'https://example.com', isFireworksTracingEnabled: false, + siteAdmin: true, primaryEmail: undefined, requiresVerifiedEmail: false, pendingValidation: false, diff --git a/vscode/webviews/AppWrapperForTest.tsx b/vscode/webviews/AppWrapperForTest.tsx index 4eaf43070d7..991016293e1 100644 --- a/vscode/webviews/AppWrapperForTest.tsx +++ b/vscode/webviews/AppWrapperForTest.tsx @@ -96,6 +96,8 @@ export const AppWrapperForTest: FunctionComponent<{ children: ReactNode }> = ({ initialContext: () => Observable.of([]), hydratePromptMessage: text => Observable.of(serializedPromptEditorStateFromText(text)), + promptsMigrationStatus: () => Observable.of({ type: 'no_migration_needed' }), + startPromptsMigration: () => Observable.of(), detectIntent: () => Observable.of(), resolvedConfig: () => Observable.of({ diff --git a/vscode/webviews/chat/components/WelcomeMessage.tsx b/vscode/webviews/chat/components/WelcomeMessage.tsx index adc9093c144..fc62a1b3abf 100644 --- a/vscode/webviews/chat/components/WelcomeMessage.tsx +++ b/vscode/webviews/chat/components/WelcomeMessage.tsx @@ -3,6 +3,7 @@ import { PromptList } from '../../components/promptList/PromptList' import { Button } from '../../components/shadcn/ui/button' import { useActionSelect } from '../../prompts/PromptsTab' import { View } from '../../tabs' +import { PromptMigrationWidget } from './../../components/promptsMigration/PromptsMigration' const localStorageKey = 'chat.welcome-message-dismissed' @@ -17,7 +18,8 @@ export const WelcomeMessage: FunctionComponent = ({ setView const runAction = useActionSelect() return ( -
+
+
= props => { tabIndex={0} shouldFilter={false} defaultValue={showInitialSelectedItem ? undefined : 'xxx-no-item'} - className={clsx(styles.list, { + className={clsx(className, styles.list, { [styles.listChips]: appearanceMode === 'chips-list', })} > diff --git a/vscode/webviews/components/promptsMigration/PromptsMigration.module.css b/vscode/webviews/components/promptsMigration/PromptsMigration.module.css index 2833d4fab68..712b56ec559 100644 --- a/vscode/webviews/components/promptsMigration/PromptsMigration.module.css +++ b/vscode/webviews/components/promptsMigration/PromptsMigration.module.css @@ -18,11 +18,13 @@ font-size: 1rem; font-weight: 500; margin-bottom: 0.25rem; + color: var(--vscode-dropdown-foreground); } .description-text { - margin-bottom: 0.75rem; line-height: 1rem; + margin-bottom: 0.75rem; + color: var(--vscode-dropdown-foreground); } .actions { @@ -77,3 +79,9 @@ border-left: 0.5rem solid #d8000c; font-weight: bold; } + +.close { + padding: 0.25rem; + align-self: flex-start; + margin: -0.25rem -0.25rem 0 auto; +} diff --git a/vscode/webviews/components/promptsMigration/PromptsMigration.story.tsx b/vscode/webviews/components/promptsMigration/PromptsMigration.story.tsx index acc1002fe85..7009b0f74a9 100644 --- a/vscode/webviews/components/promptsMigration/PromptsMigration.story.tsx +++ b/vscode/webviews/components/promptsMigration/PromptsMigration.story.tsx @@ -17,43 +17,49 @@ type Story = StoryObj export const DefaultInitialState: Story = { args: { - status: 'initial', - isMigrationAvailable: false, + status: { type: 'no_migration_needed' }, }, } export const InitialStateWithAvailableMigration: Story = { args: { - status: 'initial', - isMigrationAvailable: true, + status: { type: 'initial_migration' }, }, } export const LoadingStateScanning: Story = { args: { - status: 'loading', - migratedPrompts: 0, - promptsToMigrate: undefined, + status: { + type: 'migrating', + commandsMigrated: 0, + allCommandsToMigrate: undefined, + }, }, } export const LoadingStateMigrating: Story = { args: { - status: 'loading', - migratedPrompts: 1, - promptsToMigrate: 10, + status: { + type: 'migrating', + commandsMigrated: 0, + allCommandsToMigrate: 10, + }, }, } export const ErroredStateMigrating: Story = { args: { - status: 'error', - errorMessage: 'some migration error happened', + status: { + type: 'migration_failed', + errorMessage: 'some migration error happened', + }, }, } export const SuccessfulStateMigrating: Story = { args: { - status: 'finished', + status: { + type: 'migration_success', + }, }, } diff --git a/vscode/webviews/components/promptsMigration/PromptsMigration.tsx b/vscode/webviews/components/promptsMigration/PromptsMigration.tsx index 18db9ca732d..0f8265fb9b6 100644 --- a/vscode/webviews/components/promptsMigration/PromptsMigration.tsx +++ b/vscode/webviews/components/promptsMigration/PromptsMigration.tsx @@ -1,53 +1,120 @@ import * as Progress from '@radix-ui/react-progress' import { clsx } from 'clsx' -import { ArrowRight, BookText, LucideExternalLink, PencilRuler, SquareChevronRight } from 'lucide-react' +import { + ArrowRight, + BookText, + LucideExternalLink, + PencilRuler, + SquareChevronRight, + X, +} from 'lucide-react' import type { FC } from 'react' import { LoadingDots } from '../../chat/components/LoadingDots' +import { useLocalStorage } from '../../components/hooks' import { Button } from '../../components/shadcn/ui/button' +import type { PromptsMigrationStatus } from '@sourcegraph/cody-shared' +import { useExtensionAPI, useObservable } from '@sourcegraph/prompt-editor' +import { useCallback, useMemo } from 'react' import styles from './PromptsMigration.module.css' -type PromptsMigrationProps = - | { status: 'initial'; isMigrationAvailable: boolean } - | { status: 'loading'; migratedPrompts: number; promptsToMigrate: number | undefined } - | { status: 'error'; errorMessage: string } - | { status: 'finished' } +interface PromptMigrationWidgetProps { + dismissible?: boolean + className?: string +} + +export const PromptMigrationWidget: FC = props => { + const { dismissible, className } = props + const api = useExtensionAPI() + const { value } = useObservable( + useMemo(() => api.promptsMigrationStatus(), [api.promptsMigrationStatus]) + ) + + const handleMigrationStart = useCallback(() => { + void api.startPromptsMigration().subscribe(() => {}) + }, [api.startPromptsMigration]) + + if (!value || value.type === 'migration_skip') { + return null + } + + return ( + + ) +} + +interface PromptsMigrationProps { + status: PromptsMigrationStatus + dismissible?: boolean + className?: string + onMigrationStart?: () => void +} export const PromptsMigration: FC = props => { - const { status } = props + const { status, dismissible, className, onMigrationStart } = props + const [wasDismissed, setDismissed] = useLocalStorage('cody.prompt-migration-banner') + + if (dismissible && wasDismissed) { + return null + } return ( -
+
+ + {dismissible && ( + + )}
- {status === 'initial' && ( - + {status.type === 'initial_migration' && ( + )} - {status === 'loading' && ( + {status.type === 'no_migration_needed' && ( + + )} + + {status.type === 'migrating' && ( )} - {status === 'error' && } - {status === 'finished' && } + {status.type === 'migration_failed' && ( + + )} + {status.type === 'migration_success' && }
) } interface PromptsMigrationInitial { isMigrationAvailable: boolean + onMigrationStart?: () => void } const PromptsMigrationInitial: FC = props => { - const { isMigrationAvailable } = props + const { isMigrationAvailable, onMigrationStart } = props return ( <> @@ -59,7 +126,7 @@ const PromptsMigrationInitial: FC = props => {
{isMigrationAvailable && ( - diff --git a/vscode/webviews/prompts/PromptsTab.module.css b/vscode/webviews/prompts/PromptsTab.module.css index 653d0dfa6aa..8bffb7a518b 100644 --- a/vscode/webviews/prompts/PromptsTab.module.css +++ b/vscode/webviews/prompts/PromptsTab.module.css @@ -2,3 +2,12 @@ .prompts-input { background-color: var(--vscode-sideBar-background); } + +.prompts-container { + height: min-content !important; + overflow: visible !important; +} + +.prompt-migration-widget { + margin: 1rem 1rem -0.75rem 1rem; +} diff --git a/vscode/webviews/prompts/PromptsTab.tsx b/vscode/webviews/prompts/PromptsTab.tsx index 1b92ee30b35..94f4e3ef91d 100644 --- a/vscode/webviews/prompts/PromptsTab.tsx +++ b/vscode/webviews/prompts/PromptsTab.tsx @@ -7,6 +7,7 @@ import { getVSCodeAPI } from '../utils/VSCodeApi' import { firstValueFrom } from '@sourcegraph/cody-shared' import { useExtensionAPI } from '@sourcegraph/prompt-editor' +import { PromptMigrationWidget } from '../components/promptsMigration/PromptsMigration' import styles from './PromptsTab.module.css' export const PromptsTab: React.FC<{ @@ -15,7 +16,8 @@ export const PromptsTab: React.FC<{ const runAction = useActionSelect() return ( -
+
+ runAction(item, setView)} + className={styles.promptsContainer} inputClassName={styles.promptsInput} />