From 3cd602dce56b96f42be3f31183c31980ea616ab4 Mon Sep 17 00:00:00 2001 From: Quinn Slack Date: Sat, 2 Mar 2024 21:09:44 -0800 Subject: [PATCH] move lib/ui into vscode/webviews, remove lib/ui (#3285) --- agent/src/esbuild.mjs | 2 - doc/dev/index.md | 1 - doc/dev/library-development.md | 59 -- knip.jsonc | 3 - lib/shared/src/common/platform.ts | 19 +- lib/shared/src/index.ts | 2 +- lib/ui/.gitignore | 3 - lib/ui/.storybook/main.ts | 17 - lib/ui/README.md | 3 - lib/ui/package.json | 27 - lib/ui/src/Chat.module.css | 79 -- lib/ui/src/Chat.tsx | 937 ------------------ lib/ui/src/globals.d.ts | 4 - lib/ui/src/index.css | 1 - lib/ui/src/index.ts | 0 lib/ui/tsconfig.json | 17 - package.json | 2 +- pnpm-lock.yaml | 110 +- tsconfig.json | 1 - vscode/.storybook/main.ts | 7 +- vscode/package.json | 6 +- vscode/src/chat/protocol.ts | 2 +- vscode/src/commands/index.ts | 4 +- .../src/commands/services/custom-commands.ts | 5 +- .../utils/codeblock-action-tracker.ts | 2 +- vscode/test/e2e/chat-edits.test.ts | 4 +- vscode/test/e2e/chat-history.test.ts | 3 + vscode/test/e2e/command-custom.test.ts | 2 + vscode/test/e2e/context-settings.test.ts | 2 + vscode/tsconfig.json | 6 +- vscode/webviews/App.tsx | 4 +- vscode/webviews/Chat.module.css | 101 +- vscode/webviews/Chat.story.tsx | 41 + vscode/webviews/Chat.tsx | 925 +++++++++++++---- vscode/webviews/ChatErrorNotice.story.tsx | 6 +- .../Components/ChatModelDropdownMenu.tsx | 12 +- .../Components/EnhancedContextSettings.tsx | 2 +- vscode/webviews/Components/FileLink.tsx | 2 +- vscode/webviews/SymbolLink.tsx | 2 +- vscode/webviews/UserContextSelector.tsx | 12 +- .../webviews}/chat/BlinkingCursor.module.css | 0 .../webviews}/chat/BlinkingCursor.tsx | 0 .../webviews}/chat/CodeBlocks.module.css | 0 .../webviews}/chat/CodeBlocks.tsx | 6 +- .../webviews}/chat/ErrorItem.module.css | 0 .../webviews}/chat/ErrorItem.tsx | 4 +- .../webviews}/chat/PreciseContext.tsx | 0 vscode/webviews/chat/TextArea.module.css | 42 + vscode/webviews/chat/TextArea.tsx | 139 +++ .../webviews}/chat/Transcript.module.css | 0 .../chat/Transcript.story.module.css | 0 .../webviews}/chat/Transcript.story.tsx | 9 +- .../webviews}/chat/Transcript.tsx | 18 +- .../webviews}/chat/TranscriptItem.module.css | 0 .../webviews}/chat/TranscriptItem.tsx | 14 +- .../chat/actions/TranscriptAction.module.css | 0 .../chat/actions/TranscriptAction.tsx | 0 .../chat/components/ChatActions.module.css | 0 .../chat/components/ChatActions.story.tsx | 64 ++ .../webviews}/chat/components/ChatActions.tsx | 3 +- .../chat/components/EnhancedContext.tsx | 0 .../src => vscode/webviews}/chat/fixtures.ts | 0 .../webviews}/icons/CodeBlockActionIcons.tsx | 0 .../webviews}/icons/CodyLogo.tsx | 0 .../webviews}/icons/LLMProviderIcons.tsx | 0 vscode/webviews/index.css | 2 +- .../webviews}/utils/highlight.css | 0 .../src => vscode/webviews}/utils/icons.tsx | 0 68 files changed, 1219 insertions(+), 1519 deletions(-) delete mode 100644 doc/dev/library-development.md delete mode 100644 lib/ui/.gitignore delete mode 100644 lib/ui/.storybook/main.ts delete mode 100644 lib/ui/README.md delete mode 100644 lib/ui/package.json delete mode 100644 lib/ui/src/Chat.module.css delete mode 100644 lib/ui/src/Chat.tsx delete mode 100644 lib/ui/src/globals.d.ts delete mode 100644 lib/ui/src/index.css delete mode 100644 lib/ui/src/index.ts delete mode 100644 lib/ui/tsconfig.json create mode 100644 vscode/webviews/Chat.story.tsx rename {lib/ui/src => vscode/webviews}/chat/BlinkingCursor.module.css (100%) rename {lib/ui/src => vscode/webviews}/chat/BlinkingCursor.tsx (100%) rename {lib/ui/src => vscode/webviews}/chat/CodeBlocks.module.css (100%) rename {lib/ui/src => vscode/webviews}/chat/CodeBlocks.tsx (97%) rename {lib/ui/src => vscode/webviews}/chat/ErrorItem.module.css (100%) rename {lib/ui/src => vscode/webviews}/chat/ErrorItem.tsx (97%) rename {lib/ui/src => vscode/webviews}/chat/PreciseContext.tsx (100%) create mode 100644 vscode/webviews/chat/TextArea.module.css create mode 100644 vscode/webviews/chat/TextArea.tsx rename {lib/ui/src => vscode/webviews}/chat/Transcript.module.css (100%) rename {lib/ui/src => vscode/webviews}/chat/Transcript.story.module.css (100%) rename {lib/ui/src => vscode/webviews}/chat/Transcript.story.tsx (84%) rename {lib/ui/src => vscode/webviews}/chat/Transcript.tsx (96%) rename {lib/ui/src => vscode/webviews}/chat/TranscriptItem.module.css (100%) rename {lib/ui/src => vscode/webviews}/chat/TranscriptItem.tsx (96%) rename {lib/ui/src => vscode/webviews}/chat/actions/TranscriptAction.module.css (100%) rename {lib/ui/src => vscode/webviews}/chat/actions/TranscriptAction.tsx (100%) rename {lib/ui/src => vscode/webviews}/chat/components/ChatActions.module.css (100%) create mode 100644 vscode/webviews/chat/components/ChatActions.story.tsx rename {lib/ui/src => vscode/webviews}/chat/components/ChatActions.tsx (98%) rename {lib/ui/src => vscode/webviews}/chat/components/EnhancedContext.tsx (100%) rename {lib/ui/src => vscode/webviews}/chat/fixtures.ts (100%) rename {lib/ui/src => vscode/webviews}/icons/CodeBlockActionIcons.tsx (100%) rename {lib/ui/src => vscode/webviews}/icons/CodyLogo.tsx (100%) rename {lib/ui/src => vscode/webviews}/icons/LLMProviderIcons.tsx (100%) rename {lib/ui/src => vscode/webviews}/utils/highlight.css (100%) rename {lib/ui/src => vscode/webviews}/utils/icons.tsx (100%) diff --git a/agent/src/esbuild.mjs b/agent/src/esbuild.mjs index 6a33a737f954..7672a9a6e084 100644 --- a/agent/src/esbuild.mjs +++ b/agent/src/esbuild.mjs @@ -20,8 +20,6 @@ import { build } from 'esbuild' // during dev. '@sourcegraph/cody-shared': '@sourcegraph/cody-shared/src/index', '@sourcegraph/cody-shared/src': '@sourcegraph/cody-shared/src', - '@sourcegraph/cody-ui': '@sourcegraph/cody-ui/src/index', - '@sourcegraph/cody-ui/src': '@sourcegraph/cody-ui/src', }, } const res = await build(esbuildOptions) diff --git a/doc/dev/index.md b/doc/dev/index.md index 21ade9078ba9..a730da5056cc 100644 --- a/doc/dev/index.md +++ b/doc/dev/index.md @@ -14,5 +14,4 @@ See [vscode/CONTRIBUTING.md](../../vscode/CONTRIBUTING.md) for more information. ### Other topics -- [Developing the Cody library packages (`@sourcegraph/cody-{shared,ui}`)](library-development.md) - [Quality tools](quality/index.md) diff --git a/doc/dev/library-development.md b/doc/dev/library-development.md deleted file mode 100644 index 73857097967d..000000000000 --- a/doc/dev/library-development.md +++ /dev/null @@ -1,59 +0,0 @@ -# Developing the Cody library packages - -The `cody` repository contains 2 npm packages that can be used by other applications to integrate Cody: `@sourcegraph/cody-shared` and `@sourcegraph/cody-ui`. - -For example, the Sourcegraph web app uses these packages to provide Cody functionality on the web. - -## Publishing new packages - -1. Increment the `version` in `lib/shared/package.json` and `lib/ui/package.json`. -1. Commit and push the version increment. -1. Run `pnpm publish` from the corresponding package root folder (`lib/shared` or `lib/ui`). See instructions in the team password manager for npm authentication. -1. Update consumers to use the new published versions. - -## Local development - -For your local changes to `@sourcegraph/cody-{shared,ui}` to be immediately reflected in your application that consumes those libraries, use [`pnpm link`](https://pnpm.io/cli/link). - -### Linking - -In the consumer package (such as the `sourcegraph` repository's `client/web` directory), _after_ you've run `sg start`, run: - -```shell -pnpm link $CODY_REPO/lib/ui -pnpm link $CODY_REPO/lib/shared -``` - -In the `cody` repository, run: - -```shell -pnpm link $CONSUMER_REPO/node_modules/react -``` - -After each change in the `cody` repository, run: - -```shell -pnpm -C lib/ui run build && pnpm -C lib/shared run build -``` - -Known issues: - -1. When working with the `sourcegraph` repository, if you run the `pnpm link $CODY_REPO/lib/...` commands above _before_ running `sg start`, Bazel will complain. You need to run those commands after `sg start` and any Bazel commands you must run. If you need to get back to a clean slate to run Bazel, just revert the `sourcegraph` repository's `/pnpm-lock.yaml` file. -1. You should be able to just run `pnpm run watch` from the `cody` repository, but that is not currently working. - -### Unlinking - -To return to using the published versions of the `@sourcegraph/cody-{shared,ui}` packages, use [`pnpm unlink`](https://pnpm.io/cli/unlink). - -In the consumer package, run: - -```shell -pnpm unlink $CODY_REPO/lib/ui -pnpm unlink $CODY_REPO/lib/shared -``` - -In the `cody` repository, run: - -```shell -pnpm unlink $CONSUMER_REPO/node_modules/react -``` diff --git a/knip.jsonc b/knip.jsonc index cdfefab43b28..2433c4f06c94 100644 --- a/knip.jsonc +++ b/knip.jsonc @@ -24,9 +24,6 @@ "entry": ["**/__tests__/**/*.ts"] }, "ignore": ["src/vscode-shim.ts"] - }, - "lib/ui": { - "ignore": ["src/utils/icons.tsx"] } }, "ignore": ["**/__mocks__/**", "**/mocks.*"], diff --git a/lib/shared/src/common/platform.ts b/lib/shared/src/common/platform.ts index 7bb5be49d873..95c919f2d809 100644 --- a/lib/shared/src/common/platform.ts +++ b/lib/shared/src/common/platform.ts @@ -12,7 +12,22 @@ export function isWindows(): boolean { return navigator.userAgent.toLowerCase().includes('windows') } - return false // default + return false } -export const isMac = () => process.platform === 'darwin' +/** Reports whether the current OS is macOS. */ +export function isMacOS(): boolean { + // For Node environments (such as VS Code Desktop). + if (typeof process !== 'undefined') { + if (process.platform) { + return process.platform === 'darwin' + } + } + + // For web environments (such as webviews and VS Code Web). + if (typeof navigator === 'object') { + return navigator.userAgent?.includes('Mac') + } + + return false +} diff --git a/lib/shared/src/index.ts b/lib/shared/src/index.ts index 4f48d3e4f4d8..f1719fb14f7b 100644 --- a/lib/shared/src/index.ts +++ b/lib/shared/src/index.ts @@ -70,7 +70,7 @@ export { } from './common/languages' export { renderMarkdown } from './common/markdown' export { posixFilePaths } from './common/path' -export { isWindows } from './common/platform' +export { isWindows, isMacOS } from './common/platform' export { assertFileURI, isFileURI, diff --git a/lib/ui/.gitignore b/lib/ui/.gitignore deleted file mode 100644 index 3191ead19eeb..000000000000 --- a/lib/ui/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -node_modules/ -out/ -dist/ diff --git a/lib/ui/.storybook/main.ts b/lib/ui/.storybook/main.ts deleted file mode 100644 index 576cffe488a9..000000000000 --- a/lib/ui/.storybook/main.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { StorybookConfig } from '@storybook/react-vite' - -import { defineProjectWithDefaults } from '../../../.config/viteShared' - -const config: StorybookConfig = { - stories: ['../src/**/*.story.@(js|jsx|ts|tsx)'], - addons: ['@storybook/addon-links', '@storybook/addon-essentials', '@storybook/addon-interactions'], - framework: { - name: '@storybook/react-vite', - options: {}, - }, - viteFinal: config => defineProjectWithDefaults(__dirname, config), - docs: { - autodocs: 'tag', - }, -} -export default config diff --git a/lib/ui/README.md b/lib/ui/README.md deleted file mode 100644 index d2ab353ac36e..000000000000 --- a/lib/ui/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# Cody UI shared library - -The `@sourcegraph/cody-ui` package contains UI code that is shared among Cody clients. diff --git a/lib/ui/package.json b/lib/ui/package.json deleted file mode 100644 index 0306ad79e959..000000000000 --- a/lib/ui/package.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "name": "@sourcegraph/cody-ui", - "version": "0.0.7", - "description": "Cody UI shared library", - "license": "Apache-2.0", - "repository": { - "type": "git", - "url": "https://github.com/sourcegraph/cody", - "directory": "lib/ui" - }, - "main": "dist/index.js", - "types": "dist/index.d.ts", - "files": ["dist", "src", "!**/*.test.*", "!**/*.story.*", "!dist/**/*.ts?(x)", "dist/**/*.d.ts"], - "sideEffects": false, - "scripts": { - "build": "tsc --build && cp -R src/* dist/", - "test": "vitest", - "storybook": "storybook dev -p 6006 --no-open --no-version-updates --no-release-notes", - "prepublishOnly": "tsc --build --clean && pnpm run build" - }, - "dependencies": { - "@mdi/js": "^7.2.96", - "@sourcegraph/cody-shared": "workspace:*", - "classnames": "^2.3.2", - "vscode-uri": "^3.0.7" - } -} diff --git a/lib/ui/src/Chat.module.css b/lib/ui/src/Chat.module.css deleted file mode 100644 index 2b5b4a21bcd1..000000000000 --- a/lib/ui/src/Chat.module.css +++ /dev/null @@ -1,79 +0,0 @@ -.inner-container { - display: flex; - flex-direction: column; - height: 100%; -} - -.transcript-container { - flex: 1; -} - -.input-row { - position: relative; - display: flex; - flex-direction: column; - gap: 0.25rem; - padding: 0.5rem; -} - -.text-area-container { - position: relative; - width: 100%; - display: flex; - align-items: flex-end; -} - -.chat-input-container { - width: 100%; - height: 100%; - position: relative; -} - -.chat-input { - width: 100%; - height: 100%; - resize: none; -} - -.submit-button { - border: none; - cursor: pointer; - - border-radius: 50%; - height: 2rem; - width: 2rem; - - margin-left: 0.5rem; -} - -.context-button { - color: var(--button-primary-background); - position: absolute; - right: 0.3rem; - bottom: 0.3rem; - fill: currentColor; - background: none; - border: none; - cursor: pointer; -} - -.suggestions { - display: flex; - flex-wrap: wrap; - flex-direction: row; - gap: 0.1rem; - margin-bottom: 0.25rem; - /* This matches the button paddings, so the button text lines up with the input */ - margin-left: -6px; -} - -.abort-button-container { - display: flex; - /* Remove the padding of the form container from the width */ - width: calc(100% - 2rem); - justify-content: center; - align-items: center; - position: absolute; - top: -2.5rem; - z-index: 100; -} diff --git a/lib/ui/src/Chat.tsx b/lib/ui/src/Chat.tsx deleted file mode 100644 index a246c45a9561..000000000000 --- a/lib/ui/src/Chat.tsx +++ /dev/null @@ -1,937 +0,0 @@ -import type React from 'react' -import { useCallback, useMemo, useState } from 'react' - -import classNames from 'classnames' - -import { - type ChatButton, - type ChatInputHistory, - type ChatMessage, - type CodyCommand, - type ContextItem, - type Guardrails, - type ModelProvider, - getAtMentionQuery, - getAtMentionedInputText, - getContextFileDisplayText, - isAtMention, - isAtRange, - isDefined, -} from '@sourcegraph/cody-shared' - -import type { CodeBlockMeta } from './chat/CodeBlocks' -import type { SymbolLinkProps } from './chat/PreciseContext' -import { Transcript } from './chat/Transcript' -import type { TranscriptItemClassNames } from './chat/TranscriptItem' -import type { FileLinkProps } from './chat/components/EnhancedContext' - -import styles from './Chat.module.css' -import { ChatActions } from './chat/components/ChatActions' - -interface ChatProps extends ChatClassNames { - transcript: ChatMessage[] - messageInProgress: ChatMessage | null - messageBeingEdited: number | undefined - setMessageBeingEdited: (index?: number) => void - formInput: string - setFormInput: (input: string) => void - inputHistory: ChatInputHistory[] - setInputHistory: (history: ChatInputHistory[]) => void - onSubmit: ( - text: string, - submitType: WebviewChatSubmitType, - userContextFiles?: Map - ) => void - gettingStartedComponent?: React.FunctionComponent - gettingStartedComponentProps?: any - textAreaComponent: React.FunctionComponent - submitButtonComponent: React.FunctionComponent - fileLinkComponent: React.FunctionComponent - symbolLinkComponent: React.FunctionComponent - helpMarkdown?: string - afterMarkdown?: string - gettingStartedButtons?: ChatButton[] - className?: string - EditButtonContainer?: React.FunctionComponent - FeedbackButtonsContainer?: React.FunctionComponent - feedbackButtonsOnSubmit?: (text: string) => void - copyButtonOnSubmit?: CodeBlockActionsProps['copyButtonOnSubmit'] - insertButtonOnSubmit?: CodeBlockActionsProps['insertButtonOnSubmit'] - needsEmailVerification?: boolean - needsEmailVerificationNotice?: React.FunctionComponent - codyNotEnabledNotice?: React.FunctionComponent - abortMessageInProgressComponent?: React.FunctionComponent<{ - onAbortMessageInProgress: () => void - }> - onAbortMessageInProgress?: () => void - isCodyEnabled: boolean - chatEnabled: boolean - ChatButtonComponent?: React.FunctionComponent - chatCommands?: [string, CodyCommand][] | null - filterChatCommands?: ( - chatCommands: [string, CodyCommand][], - input: string - ) => [string, CodyCommand][] - isTranscriptError?: boolean - contextSelection?: ContextItem[] | null - setContextSelection: (context: ContextItem[] | null) => void - UserContextSelectorComponent?: React.FunctionComponent - chatModels?: ModelProvider[] - EnhancedContextSettings?: React.FunctionComponent<{ - isOpen: boolean - setOpen: (open: boolean) => void - presentationMode: 'consumer' | 'enterprise' - }> - ChatModelDropdownMenu?: React.FunctionComponent - onCurrentChatModelChange?: (model: ModelProvider) => void - userInfo: UserAccountInfo - postMessage?: ApiPostMessage - guardrails?: Guardrails - chatIDHistory: string[] - isWebviewActive: boolean -} - -export interface UserAccountInfo { - isDotComUser: boolean - isCodyProUser: boolean -} - -export type ApiPostMessage = (message: any) => void - -interface ChatClassNames extends TranscriptItemClassNames { - inputRowClassName?: string - chatInputClassName?: string -} - -export interface ChatButtonProps { - label: string - action: string - onClick: (action: string) => void - appearance?: 'primary' | 'secondary' | 'icon' -} - -export interface ChatUITextAreaProps { - className: string - rows: number - isFocusd: boolean - isNewChat: boolean - value: string - required: boolean - chatEnabled: boolean - disabled?: boolean - onInput: React.FormEventHandler - setValue?: (value: string) => void - onKeyDown?: (event: React.KeyboardEvent, caretPosition: number | null) => void - onKeyUp?: (event: React.KeyboardEvent, caretPosition: number | null) => void - onFocus?: (event: React.FocusEvent) => void - chatModels?: ModelProvider[] - messageBeingEdited: number | undefined - inputCaretPosition?: number - isWebviewActive: boolean -} - -export interface ChatUISubmitButtonProps { - type: 'user' | 'user-newchat' | 'edit' - className: string - disabled: boolean - onClick: (event: React.MouseEvent) => void - onAbortMessageInProgress?: () => void -} - -export interface EditButtonProps { - className: string - disabled?: boolean - messageBeingEdited: number | undefined - setMessageBeingEdited: (index?: number) => void -} - -export interface FeedbackButtonsProps { - className: string - disabled?: boolean - feedbackButtonsOnSubmit: (text: string) => void -} - -export interface CodeBlockActionsProps { - copyButtonOnSubmit: (text: string, event?: 'Keydown' | 'Button', metadata?: CodeBlockMeta) => void - insertButtonOnSubmit: (text: string, newFile?: boolean, metadata?: CodeBlockMeta) => void -} - -export interface UserContextSelectorProps { - onSelected: (context: ContextItem, queryEndsWithColon?: boolean) => void - contextSelection?: ContextItem[] - selected?: number - onSubmit: (input: string, inputType: 'user') => void - setSelectedChatContext: (arg: number) => void - contextQuery: string -} - -export type WebviewChatSubmitType = 'user' | 'user-newchat' | 'edit' - -export interface ChatModelDropdownMenuProps { - models: ModelProvider[] - disabled: boolean // Disabled when transcript length > 1 - onCurrentChatModelChange: (model: ModelProvider) => void - userInfo: UserAccountInfo -} - -/** - * The Cody chat interface, with a transcript of all messages and a message form. - */ -export const Chat: React.FunctionComponent = ({ - messageInProgress, - messageBeingEdited, - setMessageBeingEdited, - transcript, - formInput, - setFormInput, - inputHistory, - setInputHistory, - onSubmit, - textAreaComponent: TextArea, - submitButtonComponent: SubmitButton, - fileLinkComponent, - symbolLinkComponent, - helpMarkdown, - afterMarkdown, - gettingStartedButtons, - className, - codeBlocksCopyButtonClassName, - codeBlocksInsertButtonClassName, - transcriptItemClassName, - humanTranscriptItemClassName, - transcriptItemParticipantClassName, - transcriptActionClassName, - inputRowClassName, - chatInputClassName, - EditButtonContainer, - FeedbackButtonsContainer, - feedbackButtonsOnSubmit, - copyButtonOnSubmit, - insertButtonOnSubmit, - needsEmailVerification = false, - codyNotEnabledNotice: CodyNotEnabledNotice, - needsEmailVerificationNotice: NeedsEmailVerificationNotice, - gettingStartedComponent: GettingStartedComponent, - gettingStartedComponentProps = {}, - abortMessageInProgressComponent: AbortMessageInProgressButton, - onAbortMessageInProgress = () => {}, - isCodyEnabled, - ChatButtonComponent, - isTranscriptError, - UserContextSelectorComponent, - contextSelection, - setContextSelection, - chatModels, - ChatModelDropdownMenu, - EnhancedContextSettings, - chatEnabled, - onCurrentChatModelChange, - userInfo, - postMessage, - guardrails, - chatIDHistory, - isWebviewActive, -}) => { - const isMac = isMacOS() - const [inputFocus, setInputFocus] = useState(!messageInProgress?.speaker) - const [inputRows, setInputRows] = useState(1) - - // This is used to keep track of the current position of the text input caret and for updating - // the caret position to the altered text after selecting a context file to insert to the input. - const [inputCaretPosition, setInputCaretPosition] = useState(undefined) - - const [historyIndex, setHistoryIndex] = useState(inputHistory.length) - - // The context files added via the chat input by user - const [chatContextFiles, setChatContextFiles] = useState>(new Map([])) - const [selectedChatContext, setSelectedChatContext] = useState(0) - const [currentChatContextQuery, setCurrentChatContextQuery] = useState(undefined) - - // When New Chat Mode is enabled, all non-edit questions will be asked in a new chat session - // Users can toggle this feature via "shift" + "Meta(Mac)/Control" keys - const [enableNewChatMode, setEnableNewChatMode] = useState(false) - - const lastHumanMessageIndex = useMemo(() => { - if (!transcript?.length) { - return undefined - } - const index = transcript.findLastIndex(msg => msg.speaker === 'human') - - return index - }, [transcript]) - - /** - * Sets the state to edit a message at the given index in the transcript. - * Checks that the index is valid, then gets the display text to set as the - * form input. - * - * An undefined index number means there is no message being edited. - */ - const setEditMessageState = useCallback( - (index?: number): void => { - // When a message is no longer being edited - // we will reset the form input fill to empty state - if (index === undefined && index !== messageBeingEdited) { - setFormInput('') - setInputFocus(true) - } - setMessageBeingEdited(index) - if (index === undefined || index > transcript.length) { - return - } - // Only returns command name if it is the first word in the message - // Attempts to remove markdown links - const messageAtIndex = transcript[index] - const inputText = messageAtIndex?.text - if (inputText) { - setFormInput(inputText) - if (messageAtIndex.contextFiles) { - useOldChatMessageContext(messageAtIndex.contextFiles) - } - } - // move focus back to chatbox - setInputFocus(true) - }, - [messageBeingEdited, setFormInput, setMessageBeingEdited, transcript] - ) - - /** - * Reset current chat view with a new empty chat session. - * - * Calls setEditMessageState() to reset any in-progress edit state. - * Sends a 'reset' command to postMessage to reset the chat on the server. - */ - const onChatResetClick = useCallback( - (eventType: 'keyDown' | 'click' = 'click') => { - setEditMessageState() - postMessage?.({ command: 'reset' }) - postMessage?.({ - command: 'event', - eventName: 'CodyVSCodeExtension:chatActions:reset:executed', - properties: { source: 'chat', eventType }, - }) - }, - [postMessage, setEditMessageState] - ) - - /** - * Resets the context selection and query state. - */ - const resetContextSelection = useCallback( - (eventType?: 'keyDown' | 'click') => { - setSelectedChatContext(0) - setCurrentChatContextQuery(undefined) - setContextSelection(null) - }, - [setContextSelection] - ) - - // Add old context files from the transcript to the map - const useOldChatMessageContext = (oldContextFiles: ContextItem[]) => { - const contextFilesMap = new Map(chatContextFiles) - for (const file of oldContextFiles) { - const fileDisplayText = getContextFileDisplayText(file) - contextFilesMap.set(fileDisplayText, file) - } - setChatContextFiles(contextFilesMap) - } - - /** - * Callback function called when a chat context file is selected from the context selector. - * This updates the chat input with the selected file context. - * - * Allows users to quickly insert file context into the chat input. - */ - const onChatContextSelected = useCallback( - (selected: ContextItem, queryEndsWithColon = false): void => { - const atRangeEndingRegex = /:\d+(-\d+)?$/ - const inputBeforeCaret = formInput.slice(0, inputCaretPosition) - - const fileDisplayText = getContextFileDisplayText(selected, inputBeforeCaret) - if (inputCaretPosition && fileDisplayText) { - const newDisplayInput = getAtMentionedInputText( - fileDisplayText, - formInput, - inputCaretPosition, - queryEndsWithColon - ) - - if (newDisplayInput) { - // Updates contextConfig with the new added context file. - // We will use the newInput as key to check if the file still exists in formInput on submit - const storedFileName = fileDisplayText.replace(atRangeEndingRegex, '') - setChatContextFiles(new Map(chatContextFiles).set(storedFileName, selected)) - setFormInput(newDisplayInput.newInput) - // Move the caret to the end of the newly added file display text, - // including the length of text exisited before the lastAtIndex - // + 1 empty whitespace added after the fileDisplayText - setInputCaretPosition(newDisplayInput.newInputCaretPosition) - } - } - resetContextSelection() // RESET - }, - [ - formInput, - chatContextFiles, - setFormInput, - inputCaretPosition, - resetContextSelection, - // setContextSelection, - ] - ) - - /** - * Callback function to handle at mentions in the chat input. - * - * Checks if the text before the caret in the chat input contains an '@' symbol, - * and if so extracts the text after the last '@' up to the caret position as the - * mention query. - */ - const atMentionInputHandler = useCallback( - (inputValue: string, caretPosition?: number) => { - // If any of these conditions are false, it indicates an invalid state - // where the necessary inputs for processing the at-mention are missing. - if (!postMessage || !inputValue || !caretPosition) { - // Resets the context selection and query state. - resetContextSelection() - return - } - - const mentionQuery = getAtMentionQuery(inputValue, caretPosition) - const query = mentionQuery.replace(/^@/, '') - - // Filters invalid queries and sets context query state accordingly: - // Sets the current chat context query state if a valid mention is detected. - // Otherwise resets the context selection and query state. - if (!isAtMention(mentionQuery) && !isAtRange(mentionQuery)) { - resetContextSelection() - return - } - - setCurrentChatContextQuery(query) - - if (isAtRange(mentionQuery)) { - if (contextSelection?.length) { - setContextSelection([contextSelection[0]]) - return - } - // The actual file query shouldn't contain the range input - postMessage({ command: 'getUserContext', query: query.replace(/:[^ ]*$/, '') }) - return - } - - if (contextSelection?.length) { - // Cover cases where user prefer to type the file without expicitly select it - const isEndWithSpace = query.trimEnd() === currentChatContextQuery - const isAtRange = /:\d+(-\d+)?$/.test(query) - if (isEndWithSpace || isAtRange) { - onChatContextSelected(contextSelection[0]) - return - } - } - - // Posts a getUserContext command to fetch context for the mention query. - postMessage({ command: 'getUserContext', query }) - }, - [ - postMessage, - resetContextSelection, - contextSelection, - setContextSelection, - currentChatContextQuery, - onChatContextSelected, - ] - ) - - const inputHandler = useCallback( - (inputValue: string): void => { - if (contextSelection && inputValue) { - setSelectedChatContext(0) - } - const rowsCount = (inputValue.match(/\n/g)?.length || 0) + 1 - setInputRows(rowsCount > 25 ? 25 : rowsCount) - setFormInput(inputValue) - const lastInput = inputHistory[historyIndex] - const lastText = typeof lastInput === 'string' ? lastInput : lastInput?.inputText - if (inputValue !== lastText) { - setHistoryIndex(inputHistory.length) - } - }, - [contextSelection, setFormInput, inputHistory, historyIndex] - ) - - const submitInput = useCallback( - (input: string, submitType: WebviewChatSubmitType): void => { - if (messageInProgress && submitType !== 'edit') { - return - } - resetContextSelection() - onSubmit(input, submitType, chatContextFiles) - - // Record the chat history with (optional) context files. - const newHistory: ChatInputHistory = { - inputText: input, - inputContextFiles: Array.from(chatContextFiles.values()), - } - setHistoryIndex(inputHistory.length + 1) - setInputHistory([...inputHistory, newHistory]) - - setChatContextFiles(new Map()) - setSelectedChatContext(0) - setFormInput('') - setEditMessageState() - }, - [ - messageInProgress, - onSubmit, - chatContextFiles, - inputHistory, - setInputHistory, - setEditMessageState, - setFormInput, - resetContextSelection, - ] - ) - - const onChatInput = useCallback( - (event: React.KeyboardEvent) => { - const { value, selectionStart, selectionEnd } = event.currentTarget - inputHandler(value) - - const hasSelection = selectionStart !== selectionEnd - const caretPosition = hasSelection ? undefined : selectionStart - setInputCaretPosition(caretPosition) - atMentionInputHandler(value, caretPosition) - }, - [inputHandler, atMentionInputHandler] - ) - - const onChatSubmit = useCallback((): void => { - // Submit edits when there is one being edited - if (messageBeingEdited !== undefined) { - onAbortMessageInProgress() - submitInput(formInput, 'edit') - return - } - - // Submit chat only when input is not empty and not in progress - if (formInput.trim() && !messageInProgress?.speaker) { - const submitType = enableNewChatMode ? 'user-newchat' : 'user' - submitInput(formInput, submitType) - } - }, [ - formInput, - messageBeingEdited, - messageInProgress?.speaker, - enableNewChatMode, - submitInput, - onAbortMessageInProgress, - ]) - - const onChatKeyUp = useCallback( - (event: React.KeyboardEvent): void => { - // Check if the current input has an active selection instead of cursor position - const isSelection = event.currentTarget?.selectionStart !== event.currentTarget?.selectionEnd - setInputCaretPosition(isSelection ? undefined : event.currentTarget?.selectionStart) - - // Captures Escape button clicks - if (event.key === 'Escape') { - // Exits editing mode if a message is being edited - if (messageBeingEdited !== undefined) { - event.preventDefault() - setEditMessageState() - return - } - - // Aborts a message in progress if one exists - if (messageInProgress?.speaker) { - event.preventDefault() - onAbortMessageInProgress() - return - } - } - }, - [messageBeingEdited, setEditMessageState, messageInProgress, onAbortMessageInProgress] - ) - - const onChatKeyDown = useCallback( - (event: React.KeyboardEvent, caretPosition: number | null): void => { - // Left & right arrow to hide the context suggestion popover - if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') { - resetContextSelection() - } - - // Check if the Ctrl key is pressed on Windows/Linux or the Cmd key is pressed on macOS - const isModifierDown = isMac ? event.metaKey : event.ctrlKey - if (isModifierDown) { - // Ctrl/Cmd + / - Clears the chat and starts a new session - if (event.key === '/') { - event.preventDefault() - event.stopPropagation() - onChatResetClick('keyDown') - return - } - // Ctrl/Cmd + K - When not already editing, edits the last human message - if (messageBeingEdited === undefined && event.key === 'k') { - event.preventDefault() - event.stopPropagation() - setEditMessageState(lastHumanMessageIndex) - - postMessage?.({ - command: 'event', - eventName: 'CodyVSCodeExtension:chatActions:editLast:executed', - properties: { source: 'chat', eventType: 'keyDown' }, - }) - return - } - } - - // Ignore alt + c key combination for editor to avoid conflict with cody shortcut - if (event.altKey && event.key === 'c') { - event.preventDefault() - event.stopPropagation() - return - } - - // Allows backspace and delete keystrokes to remove characters - const deleteKeysList = new Set(['Backspace', 'Delete']) - if (deleteKeysList.has(event.key)) { - setSelectedChatContext(0) - return - } - - // Allow navigation/selection with Ctrl(+Shift?)+Arrows - const arrowKeys = new Set(['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight']) - if (event.ctrlKey && arrowKeys.has(event.key)) { - return - } - - // Handles keyboard shortcuts with Ctrl key. - // Checks if the Ctrl key is pressed with a key not in the allow list - // to avoid triggering default browser shortcuts and bubbling the event. - const ctrlKeysAllowList = new Set([ - 'a', - 'c', - 'v', - 'x', - 'y', - 'z', - 'Enter', - 'Shift' /* follow-up */, - ]) - if (event.ctrlKey && !ctrlKeysAllowList.has(event.key)) { - event.preventDefault() - return - } - - // Ignore alt + c key combination for editor to avoid conflict with cody shortcut - const vscodeCodyShortcuts = new Set(['Slash', 'KeyC']) - if (event.altKey && vscodeCodyShortcuts.has(event.code)) { - event.preventDefault() - return - } - - // Handles cycling through context matches on key presses - if (contextSelection?.length) { - if (event.key === 'ArrowUp' || event.key === 'ArrowDown') { - event.preventDefault() - const selectionLength = contextSelection?.length - 1 - const newIndex = - event.key === 'ArrowUp' ? selectedChatContext - 1 : selectedChatContext + 1 - const newMatchIndex = - newIndex < 0 ? selectionLength : newIndex > selectionLength ? 0 : newIndex - setSelectedChatContext(newMatchIndex) - return - } - - // Escape to hide the suggestion popover - if (event.key === 'Escape') { - event.preventDefault() - resetContextSelection() - return - } - - // tab/enter to complete - if (event.key === 'Tab' || event.key === 'Enter') { - event.preventDefault() - const contextIndex = /(^| )@[^ ]*:\d+(-\d+)?$/.test(formInput) - ? 0 - : selectedChatContext - onChatContextSelected(contextSelection[contextIndex]) - return - } - - // Close the popover on space - if (event.key === 'Space') { - resetContextSelection() - } - } - - // Submit input on Enter press (without shift) and - // trim the formInput to make sure input value is not empty. - if ( - event.key === 'Enter' && - !event.shiftKey && - !event.nativeEvent.isComposing && - formInput?.trim() - ) { - event.preventDefault() - onChatSubmit() - return - } - - // TODO (bee) - Update to use Option key instead - // TODO (bee) - remove once updated to use Option key - // Toggles between new chat mode and regular chat mode - if (event.altKey && event.shiftKey && isModifierDown) { - // use as a temporary block for this key combination - event.preventDefault() - setEnableNewChatMode(!enableNewChatMode) - return - } - - // Loop through input history on up arrow press - if (!inputHistory?.length) { - return - } - - // If there's no input or the input matches the current history index, handle cycling through - // history with the cursor keys. - const previousHistoryInput = inputHistory[historyIndex] - const previousHistoryText: string = - typeof previousHistoryInput === 'string' - ? previousHistoryInput - : previousHistoryInput?.inputText - if (formInput === previousHistoryText || !formInput) { - let newIndex: number | undefined - if (event.key === 'ArrowUp' && caretPosition === 0) { - newIndex = historyIndex - 1 < 0 ? inputHistory.length - 1 : historyIndex - 1 - } else if (event.key === 'ArrowDown' && caretPosition === formInput.length) { - if (historyIndex + 1 < inputHistory.length) { - newIndex = historyIndex + 1 - } - } - - if (newIndex !== undefined) { - setHistoryIndex(newIndex) - - const newHistoryInput = inputHistory[newIndex] - if (typeof newHistoryInput === 'string') { - setFormInput(newHistoryInput) - setChatContextFiles(new Map()) - } else { - setFormInput(newHistoryInput.inputText) - // chatContextFiles uses a map but history only stores a simple array. - useOldChatMessageContext(newHistoryInput.inputContextFiles) - } - - postMessage?.({ - command: 'event', - eventName: 'CodyVSCodeExtension:chatInputHistory:executed', - properties: { source: 'chat' }, - }) - } - } - }, - [ - isMac, - messageBeingEdited, - formInput, - contextSelection, - inputHistory, - historyIndex, - onChatResetClick, - setEditMessageState, - lastHumanMessageIndex, - setFormInput, - onChatSubmit, - selectedChatContext, - onChatContextSelected, - enableNewChatMode, - resetContextSelection, - useOldChatMessageContext, - postMessage, - ] - ) - - const transcriptWithWelcome = useMemo( - () => [ - { - speaker: 'assistant', - displayText: welcomeText({ helpMarkdown, afterMarkdown }), - buttons: gettingStartedButtons, - data: 'welcome-text', - }, - ...transcript, - ], - [helpMarkdown, afterMarkdown, gettingStartedButtons, transcript] - ) - - const isGettingStartedComponentVisible = - transcript.length === 0 && GettingStartedComponent !== undefined - - const [isEnhancedContextOpen, setIsEnhancedContextOpen] = useState(false) - - return ( -
- {!isCodyEnabled && CodyNotEnabledNotice ? ( -
- -
- ) : needsEmailVerification && NeedsEmailVerificationNotice ? ( -
- -
- ) : ( - - )} - {isGettingStartedComponentVisible && ( - - )} -
- {messageInProgress && AbortMessageInProgressButton && ( -
- -
- )} - {/* Don't show chat action buttons on empty chat session unless it's a new cha*/} - - 1 && messageBeingEdited !== undefined} - onChatResetClick={onChatResetClick} - onCancelEditClick={() => setEditMessageState()} - onEditLastMessageClick={() => setEditMessageState(lastHumanMessageIndex)} - onRestoreLastChatClick={ - // Display the restore button if there is a previous chat id in current window - // And the current chat window is new - chatIDHistory.length > 1 - ? () => - postMessage?.({ - command: 'restoreHistory', - chatID: chatIDHistory.at(-2), - }) - : undefined - } - /> - -
- {contextSelection && - inputCaretPosition && - UserContextSelectorComponent && - currentChatContextQuery !== undefined && ( - - )} -
-