Re-write Cody Web Client (#52498)

Sorry for the big PR, but there was no other way. 

This PR almost entirely re-writes the Cody web client and state
management. The old state management was powered by zustand and not
native react states. It then internally integrated the chat client,
which was non-reactive. The old chat client was just builder function,
and it was required to re-create the client with every single change.
The old client then also used callbacks to update the state at the
parent level, and the state was partially duplicated. Basically, it
needed a makeover.

- The new client is reactive and uses native react states.
- The new chat store allows having separate chat states for the sidebar
and standalone chat.
- The states are not partially duplicated at two places anymore.
- It also prepares for multi-repo scope.
- It introduces URL-based routing for /chat.
- It disables the editor widget when a message is in progress.
- It also prepares for editing any message and not just the last one.
- It also fixes the re-rendering of the whole chat on input change.

Future PRs:
- update Codebase context and context fetcher clients to support new
CodyClientScope aka multi-repo context
- implement cody scope selector UI
- save the scope with each interaction and the current scope with the
transcript.
- load the current scope from the transcript on load.
- show scope with each human message.
- show scope selector with edit message form.
- Allow editing of any message and not just the last.

## Test plan

- visit /cody on web and check if messages, history and all other
actions are working as expected.
- Check same for vs code & app.
This commit is contained in:
Naman Kumar 2023-05-31 01:53:54 +05:30 committed by GitHub
parent f21f53bcd2
commit e49ff43d6f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
34 changed files with 1394 additions and 854 deletions

View File

@ -52,6 +52,7 @@ ts_project(
"src/chat/transcript/index.ts",
"src/chat/transcript/interaction.ts",
"src/chat/transcript/messages.ts",
"src/chat/useClient.ts",
"src/chat/viewHelpers.ts",
"src/codebase-context/index.ts",
"src/codebase-context/messages.ts",
@ -92,10 +93,12 @@ ts_project(
"//:node_modules/@types/isomorphic-fetch",
"//:node_modules/@types/marked",
"//:node_modules/@types/node",
"//:node_modules/@types/react",
"//:node_modules/@types/vscode", #keep
"//:node_modules/@vscode",
"//:node_modules/isomorphic-fetch",
"//:node_modules/marked",
"//:node_modules/react",
],
)

View File

@ -39,6 +39,7 @@ export interface Client {
recipeId: RecipeID,
options?: {
prefilledOptions?: PrefilledOptions
humanChatInput?: string
}
) => Promise<void>
reset: () => void

View File

@ -91,6 +91,13 @@ export class Transcript {
this.interactions.pop()
}
public removeInteractionsSince(id: string): void {
const index = this.interactions.findIndex(({ timestamp }) => timestamp === id)
if (index >= 0) {
this.interactions = this.interactions.slice(0, index)
}
}
public addAssistantResponse(text: string, displayText?: string): void {
this.getLastInteraction()?.setAssistantMessage({
speaker: 'assistant',
@ -158,6 +165,14 @@ export class Transcript {
}
}
public toJSONEmpty(): TranscriptJSON {
return {
id: this.id,
interactions: [],
lastInteractionTimestamp: this.lastInteractionTimestamp,
}
}
public reset(): void {
this.interactions = []
this.internalID = new Date().toISOString()

View File

@ -0,0 +1,334 @@
import { useState, useCallback, useMemo } from 'react'
import { CodebaseContext } from '../codebase-context'
import { ConfigurationWithAccessToken } from '../configuration'
import { Editor, NoopEditor } from '../editor'
import { PrefilledOptions, withPreselectedOptions } from '../editor/withPreselectedOptions'
import { SourcegraphEmbeddingsSearchClient } from '../embeddings/client'
import { SourcegraphIntentDetectorClient } from '../intent-detector/client'
import { SourcegraphBrowserCompletionsClient } from '../sourcegraph-api/completions/browserClient'
import { SourcegraphGraphQLAPIClient } from '../sourcegraph-api/graphql'
import { isError } from '../utils'
import { BotResponseMultiplexer } from './bot-response-multiplexer'
import { ChatClient } from './chat'
import { ChatContextStatus } from './context'
import { getPreamble } from './preamble'
import { getRecipe } from './recipes/browser-recipes'
import { RecipeID } from './recipes/recipe'
import { Transcript } from './transcript'
import { ChatMessage } from './transcript/messages'
import { reformatBotMessage } from './viewHelpers'
export type CodyClientConfig = Pick<
ConfigurationWithAccessToken,
'serverEndpoint' | 'useContext' | 'accessToken' | 'customHeaders'
> & { debugEnable: boolean; needsEmailVerification: boolean }
export interface CodyClientScope {
type: 'Automatic' | 'None' | 'Repositories'
repositories: string[]
editor: Editor
}
export type CodyClientEvent = 'submit' | 'initializedNewChat' | 'error'
export interface CodyClient {
readonly transcript: Transcript | null
readonly chatMessages: ChatMessage[]
readonly messageInProgress: ChatMessage | null
readonly isMessageInProgress: boolean
readonly scope: CodyClientScope
readonly config: CodyClientConfig
readonly legacyChatContext: ChatContextStatus
setTranscript: (transcript: Transcript) => Promise<void>
setScope: (scope: CodyClientScope) => void
setConfig: (config: CodyClientConfig) => void
submitMessage: (humanChatInput: string, scope?: CodyClientScope) => Promise<Transcript | null>
editMessage: (
humanChatInput: string,
messageId?: string | undefined,
scope?: CodyClientScope
) => Promise<Transcript | null>
initializeNewChat: () => Transcript | null
executeRecipe: (
recipeId: RecipeID,
options?: {
prefilledOptions?: PrefilledOptions
humanChatInput?: string
scope?: CodyClientScope
}
) => Promise<Transcript | null>
setEditorScope: (editor: Editor) => void
}
interface CodyClientProps {
config: CodyClientConfig
scope?: CodyClientScope
initialTranscript?: Transcript | null
onEvent?: (event: CodyClientEvent) => void
}
export const useClient = ({
config: initialConfig,
initialTranscript = null,
scope: initialScope = {
type: 'None',
repositories: [],
editor: new NoopEditor(),
},
onEvent,
}: CodyClientProps): CodyClient => {
const [transcript, setTranscriptState] = useState<Transcript | null>(initialTranscript)
const [chatMessages, setChatMessagesState] = useState<ChatMessage[]>([])
const [isMessageInProgress, setIsMessageInProgressState] = useState<boolean>(false)
const messageInProgress: ChatMessage | null = useMemo(() => {
if (isMessageInProgress) {
const lastMessage = chatMessages[chatMessages.length - 1]
if (lastMessage?.speaker === 'assistant') {
return lastMessage
}
}
return null
}, [chatMessages, isMessageInProgress])
const setTranscript = useCallback(async (transcript: Transcript): Promise<void> => {
const messages = await transcript.toChatPromise()
setIsMessageInProgressState(false)
setTranscriptState(transcript)
setChatMessagesState(messages)
}, [])
const [config, setConfig] = useState<CodyClientConfig>(initialConfig)
const initializeNewChat = useCallback((): Transcript | null => {
if (config.needsEmailVerification) {
return transcript
}
const newTranscript = new Transcript()
setIsMessageInProgressState(false)
setTranscriptState(newTranscript)
setChatMessagesState(newTranscript.toChat())
onEvent?.('initializedNewChat')
return newTranscript
}, [onEvent, config.needsEmailVerification, transcript])
const { graphqlClient, chatClient, intentDetector } = useMemo(() => {
const completionsClient = new SourcegraphBrowserCompletionsClient(config)
const chatClient = new ChatClient(completionsClient)
const graphqlClient = new SourcegraphGraphQLAPIClient(config)
const intentDetector = new SourcegraphIntentDetectorClient(graphqlClient)
return { graphqlClient, chatClient, intentDetector }
}, [config])
const [scope, setScopeState] = useState<CodyClientScope>(initialScope)
const setScope = useCallback((scope: CodyClientScope) => {
setScopeState(scope)
}, [])
const setEditorScope = useCallback((editor: Editor) => {
setScopeState(scope => ({ ...scope, editor }))
}, [])
// TODO(naman): temporarily set codebase to the first repository in the list until multi-repo context is implemented throughout.
const codebase: string | null = useMemo(() => scope.repositories[0] || null, [scope])
const codebaseId: Promise<string | null> = useMemo(async () => {
if (codebase === null) {
return null
}
const id = (await graphqlClient.getRepoIdIfEmbeddingExists(codebase)) || null
if (isError(id)) {
console.error(
`Cody could not access the '${codebase}' repository on your Sourcegraph instance. Details: ${id.message}`
)
return null
}
return id
}, [codebase, graphqlClient])
const executeRecipe = useCallback(
async (
recipeId: RecipeID,
options?: {
prefilledOptions?: PrefilledOptions
humanChatInput?: string
// TODO(naman): accept scope with execute recipe
scope?: CodyClientScope
}
): Promise<Transcript | null> => {
const recipe = getRecipe(recipeId)
if (!recipe || transcript === null || isMessageInProgress || config.needsEmailVerification) {
return Promise.resolve(null)
}
const repoId = await codebaseId
const embeddingsSearch = repoId ? new SourcegraphEmbeddingsSearchClient(graphqlClient, repoId) : null
const codebaseContext = new CodebaseContext(config, codebase || undefined, embeddingsSearch, null)
const { humanChatInput = '', prefilledOptions } = options ?? {}
// TODO(naman): save scope with each interaction
const interaction = await recipe.getInteraction(humanChatInput, {
editor: prefilledOptions ? withPreselectedOptions(scope.editor, prefilledOptions) : scope.editor,
intentDetector,
codebaseContext,
responseMultiplexer: new BotResponseMultiplexer(),
firstInteraction: transcript.isEmpty,
})
if (!interaction) {
return Promise.resolve(null)
}
transcript.addInteraction(interaction)
setChatMessagesState(transcript.toChat())
setIsMessageInProgressState(true)
onEvent?.('submit')
const prompt = await transcript.toPrompt(getPreamble(codebase || undefined))
const responsePrefix = interaction.getAssistantMessage().prefix ?? ''
let rawText = ''
return new Promise(resolve => {
chatClient.chat(prompt, {
onChange(_rawText) {
rawText = _rawText
const text = reformatBotMessage(rawText, responsePrefix)
transcript.addAssistantResponse(text)
setChatMessagesState(transcript.toChat())
},
onComplete() {
const text = reformatBotMessage(rawText, responsePrefix)
transcript.addAssistantResponse(text)
transcript
.toChatPromise()
.then(messages => {
setChatMessagesState(messages)
setIsMessageInProgressState(false)
})
.catch(() => null)
resolve(transcript)
},
onError(error) {
// Display error message as assistant response
transcript.addErrorAsAssistantResponse(
`<div class="cody-chat-error"><span>Request failed: </span>${error}</div>`
)
console.error(`Completion request failed: ${error}`)
transcript
.toChatPromise()
.then(messages => {
setChatMessagesState(messages)
setIsMessageInProgressState(false)
})
.catch(() => null)
onEvent?.('error')
resolve(transcript)
},
})
})
},
[
config,
scope,
codebase,
codebaseId,
graphqlClient,
transcript,
intentDetector,
chatClient,
isMessageInProgress,
onEvent,
]
)
const submitMessage = useCallback(
async (humanChatInput: string, scope?: CodyClientScope): Promise<Transcript | null> =>
executeRecipe('chat-question', { humanChatInput, scope }),
[executeRecipe]
)
// TODO(naman): load message scope from the interaction
const editMessage = useCallback(
async (
humanChatInput: string,
messageId?: string | undefined,
scope?: CodyClientScope
): Promise<Transcript | null> => {
if (!transcript) {
return transcript
}
const timestamp = messageId || transcript.getLastInteraction()?.timestamp || new Date().toISOString()
transcript.removeInteractionsSince(timestamp)
setChatMessagesState(transcript.toChat())
return submitMessage(humanChatInput, scope)
},
[transcript, submitMessage]
)
// TODO(naman): usage of `chatContext` in Chat UI component will be replaced by `scope`. Remove this once done.
const legacyChatContext = useMemo<ChatContextStatus>(
() => ({
codebase: codebase || undefined,
filePath: scope.editor.getActiveTextEditorSelectionOrEntireFile()?.fileName,
connection: true,
}),
[codebase, scope]
)
const returningChatMessages = useMemo(
() => (messageInProgress ? chatMessages.slice(0, -1) : chatMessages),
[chatMessages, messageInProgress]
)
return useMemo(
() => ({
transcript,
chatMessages: returningChatMessages,
isMessageInProgress,
messageInProgress,
setTranscript,
scope,
setScope,
setEditorScope,
config,
setConfig,
executeRecipe,
submitMessage,
initializeNewChat,
editMessage,
legacyChatContext,
}),
[
transcript,
returningChatMessages,
isMessageInProgress,
messageInProgress,
setTranscript,
scope,
setScope,
setEditorScope,
config,
setConfig,
executeRecipe,
submitMessage,
initializeNewChat,
editMessage,
legacyChatContext,
]
)
}

View File

@ -52,3 +52,41 @@ export interface Editor {
showWarningMessage(message: string): Promise<void>
showInputBox(prompt?: string): Promise<string | undefined>
}
export class NoopEditor implements Editor {
public getWorkspaceRootPath(): string | null {
return null
}
public getActiveTextEditor(): ActiveTextEditor | null {
return null
}
public getActiveTextEditorSelection(): ActiveTextEditorSelection | null {
return null
}
public getActiveTextEditorSelectionOrEntireFile(): ActiveTextEditorSelection | null {
return null
}
public getActiveTextEditorVisibleContent(): ActiveTextEditorVisibleContent | null {
return null
}
public replaceSelection(_fileName: string, _selectedText: string, _replacement: string): Promise<void> {
return Promise.resolve()
}
public showQuickPick(_labels: string[]): Promise<string | undefined> {
return Promise.resolve(undefined)
}
public showWarningMessage(_message: string): Promise<void> {
return Promise.resolve()
}
public showInputBox(_prompt?: string): Promise<string | undefined> {
return Promise.resolve(undefined)
}
}

View File

@ -100,7 +100,9 @@ export class SourcegraphGraphQLAPIClient {
private config: Pick<ConfigurationWithAccessToken, 'serverEndpoint' | 'accessToken' | 'customHeaders'>
) {}
public onConfigurationChange(newConfig: typeof this.config): void {
public onConfigurationChange(
newConfig: Pick<ConfigurationWithAccessToken, 'serverEndpoint' | 'accessToken' | 'customHeaders'>
): void {
this.config = newConfig
}

View File

@ -157,6 +157,13 @@ export const Chat: React.FunctionComponent<ChatProps> = ({
},
[inputHistory, messageInProgress, onSubmit, setInputHistory, setSuggestions]
)
const onChatInput = useCallback(
({ target }: React.SyntheticEvent) => {
const { value } = target as HTMLInputElement
inputHandler(value)
},
[inputHandler]
)
const onChatSubmit = useCallback((): void => {
// Submit chat only when input is not empty and not in progress
@ -268,10 +275,7 @@ export const Chat: React.FunctionComponent<ChatProps> = ({
autoFocus={true}
required={true}
disabled={needsEmailVerification}
onInput={({ target }) => {
const { value } = target as HTMLInputElement
inputHandler(value)
}}
onInput={onChatInput}
onKeyDown={onChatKeyDown}
/>
<SubmitButton

View File

@ -114,12 +114,12 @@ function getSelectedTextWithin(element: HTMLElement | null): string | null {
return null
}
export const CodeBlocks: React.FunctionComponent<CodeBlocksProps> = ({
export const CodeBlocks: React.FunctionComponent<CodeBlocksProps> = React.memo(function CodeBlocksContent({
displayText,
copyButtonClassName,
insertButtonClassName,
CopyButtonProps,
}) => {
}) {
const rootRef = useRef<HTMLDivElement>(null)
useEffect(() => {
@ -145,4 +145,4 @@ export const CodeBlocks: React.FunctionComponent<CodeBlocksProps> = ({
() => <div ref={rootRef} dangerouslySetInnerHTML={{ __html: renderCodyMarkdown(displayText) }} />,
[displayText]
)
}
})

View File

@ -17,17 +17,19 @@ export const ContextFiles: React.FunctionComponent<{
contextFiles: ContextFile[]
fileLinkComponent: React.FunctionComponent<FileLinkProps>
className?: string
}> = ({ contextFiles, fileLinkComponent: FileLink, className }) => (
<TranscriptAction
title={{ verb: 'Read', object: `${contextFiles.length} ${pluralize('file', contextFiles.length)}` }}
steps={[
{ verb: 'Searched', object: 'entire codebase for relevant files', icon: mdiMagnify },
...contextFiles.map(file => ({
verb: '',
object: <FileLink path={file.fileName} repoName={file.repoName} revision={file.revision} />,
icon: mdiFileDocumentOutline,
})),
]}
className={className}
/>
)
}> = React.memo(function ContextFilesContent({ contextFiles, fileLinkComponent: FileLink, className }) {
return (
<TranscriptAction
title={{ verb: 'Read', object: `${contextFiles.length} ${pluralize('file', contextFiles.length)}` }}
steps={[
{ verb: 'Searched', object: 'entire codebase for relevant files', icon: mdiMagnify },
...contextFiles.map(file => ({
verb: '',
object: <FileLink path={file.fileName} repoName={file.repoName} revision={file.revision} />,
icon: mdiFileDocumentOutline,
})),
]}
className={className}
/>
)
})

View File

@ -33,7 +33,7 @@ export const Transcript: React.FunctionComponent<
copyButtonOnSubmit?: CopyButtonProps['copyButtonOnSubmit']
submitButtonComponent?: React.FunctionComponent<ChatUISubmitButtonProps>
} & TranscriptItemClassNames
> = ({
> = React.memo(function TranscriptContent({
transcript,
messageInProgress,
messageBeingEdited,
@ -54,7 +54,7 @@ export const Transcript: React.FunctionComponent<
copyButtonOnSubmit,
submitButtonComponent,
chatInputClassName,
}) => {
}) {
const transcriptContainerRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (transcriptContainerRef.current) {
@ -140,4 +140,4 @@ export const Transcript: React.FunctionComponent<
)}
</div>
)
}
})

View File

@ -52,7 +52,7 @@ export const TranscriptItem: React.FunctionComponent<
copyButtonOnSubmit?: CopyButtonProps['copyButtonOnSubmit']
submitButtonComponent?: React.FunctionComponent<ChatUISubmitButtonProps>
} & TranscriptItemClassNames
> = ({
> = React.memo(function TranscriptItemContent({
message,
inProgress,
beingEdited,
@ -74,7 +74,7 @@ export const TranscriptItem: React.FunctionComponent<
copyButtonOnSubmit,
submitButtonComponent: SubmitButton,
chatInputClassName,
}) => {
}) {
const [formInput, setFormInput] = useState<string>(message.displayText ?? '')
const textarea =
TextArea && beingEdited && editButtonOnSubmit && SubmitButton ? (
@ -181,4 +181,4 @@ export const TranscriptItem: React.FunctionComponent<
</div>
</div>
)
}
})

View File

@ -16,7 +16,7 @@ const warning =
export const ChatInputContext: React.FunctionComponent<{
contextStatus: ChatContextStatus
className?: string
}> = ({ contextStatus, className }) => {
}> = React.memo(function ChatInputContextContent({ contextStatus, className }) {
const items: Pick<React.ComponentProps<typeof ContextItem>, 'icon' | 'text' | 'tooltip'>[] = useMemo(
() =>
[
@ -66,7 +66,7 @@ export const ChatInputContext: React.FunctionComponent<{
)}
</div>
)
}
})
const ContextItem: React.FunctionComponent<{ icon: string; text: string; tooltip?: string; as: 'li' }> = ({
icon,

View File

@ -208,11 +208,12 @@ ts_project(
"src/cody/search/api.ts",
"src/cody/search/translateToQuery.ts",
"src/cody/sidebar/CodySidebar.tsx",
"src/cody/sidebar/Provider.tsx",
"src/cody/sidebar/index.tsx",
"src/cody/stores/chat.ts",
"src/cody/stores/editor.ts",
"src/cody/stores/sidebar.ts",
"src/cody/sidebar/useSidebarSize.tsx",
"src/cody/useCodyChat.tsx",
"src/cody/useIsCodyEnabled.tsx",
"src/cody/useSizebarSize.tsx",
"src/cody/widgets/CodyRecipesWidget.tsx",
"src/cody/widgets/components/Recipe.tsx",
"src/cody/widgets/components/RecipeAction.tsx",

View File

@ -1,3 +1,5 @@
import { useEffect } from 'react'
import { gql, useQuery } from '@sourcegraph/http-client'
import { useTemporarySetting } from '@sourcegraph/shared/src/settings/temporary'
import { Button, Link, Select, Text, useLocalStorage } from '@sourcegraph/wildcard'
@ -7,14 +9,11 @@ import { HeroPage } from '../components/HeroPage'
import { GetReposForCodyResult, GetReposForCodyVariables } from '../graphql-operations'
import { CodyLogo } from './components/CodyLogo'
import { CodySidebar } from './sidebar/CodySidebar'
import { useChatStore } from './stores/chat'
import { useIsCodyEnabled } from './useIsCodyEnabled'
import { CodySidebar } from './sidebar'
import { useCodySidebar, CodySidebarStoreProvider } from './sidebar/Provider'
import styles from './CodyStandalonePage.module.scss'
const noop = (): void => {}
const REPOS_QUERY = gql`
query GetReposForCody {
repositories(first: 1000) {
@ -26,21 +25,6 @@ const REPOS_QUERY = gql`
}
`
export const CodyStandalonePage: React.FunctionComponent<{}> = () => {
// eslint-disable-next-line no-restricted-syntax
const [appSetupFinished] = useLocalStorage('app.setup.finished', false)
const { chat, needsEmailVerification } = useIsCodyEnabled()
const isCodyEnabled = appSetupFinished && chat && !needsEmailVerification
const disabledReason: CodyDisabledReason = !appSetupFinished
? 'setupNotCompleted'
: !chat
? 'accountNotConnected'
: 'emailNotVerified'
return isCodyEnabled ? <CodyChat /> : <CodyDisabledNotice reason={disabledReason} />
}
type CodyDisabledReason = 'setupNotCompleted' | 'accountNotConnected' | 'emailNotVerified'
const reasonBodies: Record<CodyDisabledReason, () => React.ReactNode> = {
@ -95,13 +79,25 @@ const CodyDisabledNotice: React.FunctionComponent<{ reason: CodyDisabledReason }
/>
)
const CodyChat: React.FunctionComponent<{}> = () => {
const { data } = useQuery<GetReposForCodyResult, GetReposForCodyVariables>(REPOS_QUERY, {})
const CodyStandalonePageContext: React.FC<{ repos: GetReposForCodyResult['repositories']['nodes'] }> = ({ repos }) => {
// eslint-disable-next-line no-restricted-syntax
const [appSetupFinished] = useLocalStorage('app.setup.finished', false)
const [selectedRepo, setSelectedRepo] = useTemporarySetting('app.codyStandalonePage.selectedRepo', '')
useChatStore({ codebase: selectedRepo || '', setIsCodySidebarOpen: noop })
const repos = data?.repositories.nodes ?? []
const { scope, setScope, loaded, isCodyEnabled } = useCodySidebar()
const enabled = appSetupFinished && isCodyEnabled.chat && !isCodyEnabled.needsEmailVerification
const disabledReason: CodyDisabledReason = !appSetupFinished
? 'setupNotCompleted'
: !isCodyEnabled.chat
? 'accountNotConnected'
: 'emailNotVerified'
useEffect(() => {
if (loaded && scope.type === 'Automatic' && !scope.repositories.find(name => name === selectedRepo)) {
setScope({ ...scope, repositories: selectedRepo ? [selectedRepo] : [] })
}
}, [loaded, scope, selectedRepo, setScope])
const repoSelector = (
<Select
@ -118,7 +114,7 @@ const CodyChat: React.FunctionComponent<{}> = () => {
<option value="" disabled={true}>
Select a repo
</option>
{repos.map(({ name }) => (
{repos.map(({ name }: { name: string }) => (
<option key={name} value={name}>
{name}
</option>
@ -126,9 +122,21 @@ const CodyChat: React.FunctionComponent<{}> = () => {
</Select>
)
return (
return enabled ? (
<div className="d-flex flex-column w-100">
<CodySidebar titleContent={repoSelector} />
</div>
) : (
<CodyDisabledNotice reason={disabledReason} />
)
}
export const CodyStandalonePage: React.FunctionComponent<{}> = () => {
const { data } = useQuery<GetReposForCodyResult, GetReposForCodyVariables>(REPOS_QUERY, {})
return (
<CodySidebarStoreProvider>
<CodyStandalonePageContext repos={data?.repositories?.nodes || []} />
</CodySidebarStoreProvider>
)
}

View File

@ -2,6 +2,7 @@ import React, { useEffect, useState } from 'react'
import { mdiClose, mdiCogOutline, mdiDelete, mdiDotsVertical, mdiOpenInNew, mdiPlus, mdiChevronRight } from '@mdi/js'
import classNames from 'classnames'
import { useLocation, useNavigate } from 'react-router-dom'
import { AuthenticatedUser } from '@sourcegraph/shared/src/auth'
import { useTemporarySetting } from '@sourcegraph/shared/src/settings/temporary'
@ -30,8 +31,7 @@ import { eventLogger } from '../../tracking/eventLogger'
import { EventName } from '../../util/constants'
import { ChatUI } from '../components/ChatUI'
import { HistoryList } from '../components/HistoryList'
import { useChatStore } from '../stores/chat'
import { useIsCodyEnabled } from '../useIsCodyEnabled'
import { CodyChatStore, useCodyChat } from '../useCodyChat'
import { CodyColorIcon } from './CodyPageIcon'
@ -44,9 +44,55 @@ interface CodyChatPageProps {
const onDownloadVSCodeClick = (): void => eventLogger.log(EventName.CODY_CHAT_DOWNLOAD_VSCODE)
const onTryOnPublicCodeClick = (): void => eventLogger.log(EventName.CODY_CHAT_TRY_ON_PUBLIC_CODE)
const transcriptIdFromUrl = (pathname: string): string | undefined => {
const serializedID = pathname.split('/').pop()
if (!serializedID) {
return
}
try {
return atob(serializedID)
} catch {
return
}
}
const onTranscriptHistoryLoad = (
loadTranscriptFromHistory: CodyChatStore['loadTranscriptFromHistory'],
transcriptHistory: CodyChatStore['transcriptHistory'],
initializeNewChat: CodyChatStore['initializeNewChat']
): void => {
if (transcriptHistory.length > 0) {
const transcriptId = transcriptIdFromUrl(window.location.pathname)
if (transcriptId && transcriptHistory.find(({ id }) => id === transcriptId)) {
loadTranscriptFromHistory(transcriptId).catch(() => null)
} else {
loadTranscriptFromHistory(transcriptHistory[0].id).catch(() => null)
}
} else {
initializeNewChat()
}
}
export const CodyChatPage: React.FunctionComponent<CodyChatPageProps> = ({ authenticatedUser }) => {
const { reset, clearHistory } = useChatStore({ codebase: '' })
const codyEnabled = useIsCodyEnabled()
const { pathname } = useLocation()
const navigate = useNavigate()
const codyChatStore = useCodyChat({
onTranscriptHistoryLoad,
autoLoadTranscriptFromHistory: false,
})
const {
initializeNewChat,
clearHistory,
isCodyEnabled,
loaded,
transcript,
transcriptHistory,
loadTranscriptFromHistory,
deleteHistoryItem,
} = codyChatStore
const [showVSCodeCTA] = useState<boolean>(Math.random() < 0.5 || true)
const [isCTADismissed = true, setIsCTADismissed] = useTemporarySetting('cody.chatPageCta.dismissed', false)
const onCTADismiss = (): void => setIsCTADismissed(true)
@ -55,7 +101,24 @@ export const CodyChatPage: React.FunctionComponent<CodyChatPageProps> = ({ authe
eventLogger.logPageView('CodyChat')
}, [])
if (!codyEnabled.chat) {
const transcriptId = transcript?.id
useEffect(() => {
if (!loaded || !transcriptId) {
return
}
const idFromUrl = transcriptIdFromUrl(pathname)
if (transcriptId !== idFromUrl) {
navigate(`/cody/${btoa(transcriptId)}`)
}
}, [transcriptId, loaded, pathname, navigate])
if (!loaded) {
return null
}
if (!isCodyEnabled.chat) {
return (
<Page className="overflow-hidden">
<PageTitle title="Cody AI Chat" />
@ -70,7 +133,7 @@ export const CodyChatPage: React.FunctionComponent<CodyChatPageProps> = ({ authe
<PageHeader
actions={
<div className="d-flex">
<Button variant="primary" onClick={reset}>
<Button variant="primary" onClick={initializeNewChat}>
<Icon aria-hidden={true} svgPath={mdiPlus} />
New chat
</Button>
@ -125,7 +188,13 @@ export const CodyChatPage: React.FunctionComponent<CodyChatPageProps> = ({ authe
</Menu>
</div>
<div className={classNames('h-100 mb-4', styles.sidebar)}>
<HistoryList truncateMessageLength={60} />
<HistoryList
currentTranscript={transcript}
transcriptHistory={transcriptHistory}
truncateMessageLength={60}
loadTranscriptFromHistory={loadTranscriptFromHistory}
deleteHistoryItem={deleteHistoryItem}
/>
</div>
{!isCTADismissed &&
(showVSCodeCTA ? (
@ -208,7 +277,7 @@ export const CodyChatPage: React.FunctionComponent<CodyChatPageProps> = ({ authe
</div>
<div className={classNames('d-flex flex-column col-sm-9 h-100', styles.chatMainWrapper)}>
<ChatUI />
<ChatUI codyChatStore={codyChatStore} />
</div>
</div>
</Page>

View File

@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import React, { useCallback, useEffect, useRef, useState } from 'react'
import { mdiClose, mdiSend, mdiArrowDown, mdiPencil, mdiThumbUp, mdiThumbDown, mdiCheck } from '@mdi/js'
import classNames from 'classnames'
@ -17,9 +17,8 @@ import { Button, Icon, TextArea, Link, Tooltip, Alert, Text, H2 } from '@sourceg
import { eventLogger } from '../../../tracking/eventLogger'
import { CodyPageIcon } from '../../chat/CodyPageIcon'
import { useChatStoreState } from '../../stores/chat'
import { useCodySidebarStore } from '../../stores/sidebar'
import { useIsCodyEnabled } from '../../useIsCodyEnabled'
import { useCodySidebar } from '../../sidebar/Provider'
import { CodyChatStore } from '../../useCodyChat'
import styles from './ChatUi.module.scss'
@ -27,17 +26,22 @@ export const SCROLL_THRESHOLD = 100
const onFeedbackSubmit = (feedback: string): void => eventLogger.log(`web:cody:feedbackSubmit:${feedback}`)
export const ChatUI = (): JSX.Element => {
interface IChatUIProps {
codyChatStore: CodyChatStore
}
export const ChatUI: React.FC<IChatUIProps> = ({ codyChatStore }): JSX.Element => {
const {
submitMessage,
editMessage,
messageInProgress,
chatMessages,
transcript,
getChatContext,
transcriptId,
transcriptHistory,
} = useChatStoreState()
const { needsEmailVerification } = useIsCodyEnabled()
loaded,
isCodyEnabled,
legacyChatContext,
} = codyChatStore
const [formInput, setFormInput] = useState('')
const [inputHistory, setInputHistory] = useState<string[] | []>(() =>
@ -49,19 +53,26 @@ export const ChatUI = (): JSX.Element => {
)
const [messageBeingEdited, setMessageBeingEdited] = useState<boolean>(false)
const onSubmit = useCallback((text: string) => submitMessage(text), [submitMessage])
const onEdit = useCallback((text: string) => editMessage(text), [editMessage])
if (!loaded) {
return <></>
}
return (
<Chat
key={transcriptId}
key={transcript?.id}
messageInProgress={messageInProgress}
messageBeingEdited={messageBeingEdited}
setMessageBeingEdited={setMessageBeingEdited}
transcript={transcript}
transcript={chatMessages}
formInput={formInput}
setFormInput={setFormInput}
inputHistory={inputHistory}
setInputHistory={setInputHistory}
onSubmit={submitMessage}
contextStatus={getChatContext()}
onSubmit={onSubmit}
contextStatus={legacyChatContext}
submitButtonComponent={SubmitButton}
fileLinkComponent={FileLink}
className={styles.container}
@ -72,47 +83,57 @@ export const ChatUI = (): JSX.Element => {
inputRowClassName={styles.inputRow}
chatInputClassName={styles.chatInput}
EditButtonContainer={EditButton}
editButtonOnSubmit={editMessage}
editButtonOnSubmit={onEdit}
textAreaComponent={AutoResizableTextArea}
codeBlocksCopyButtonClassName={styles.codeBlocksCopyButton}
transcriptActionClassName={styles.transcriptAction}
FeedbackButtonsContainer={FeedbackButtons}
feedbackButtonsOnSubmit={onFeedbackSubmit}
needsEmailVerification={needsEmailVerification}
needsEmailVerification={isCodyEnabled.needsEmailVerification}
needsEmailVerificationNotice={NeedsEmailVerificationNotice}
/>
)
}
export const ScrollDownButton = ({ onClick }: { onClick: () => void }): JSX.Element => (
<div className={styles.scrollButtonWrapper}>
<Button className={styles.scrollButton} onClick={onClick}>
<Icon aria-label="Scroll down" svgPath={mdiArrowDown} />
</Button>
</div>
)
export const ScrollDownButton = React.memo(function ScrollDownButtonContent({
onClick,
}: {
onClick: () => void
}): JSX.Element {
return (
<div className={styles.scrollButtonWrapper}>
<Button className={styles.scrollButton} onClick={onClick}>
<Icon aria-label="Scroll down" svgPath={mdiArrowDown} />
</Button>
</div>
)
})
export const EditButton: React.FunctionComponent<EditButtonProps> = ({
export const EditButton: React.FunctionComponent<EditButtonProps> = React.memo(function EditButtonContent({
className,
messageBeingEdited,
setMessageBeingEdited,
}) => (
<div className={className}>
<button
className={classNames(className, styles.editButton)}
type="button"
onClick={() => setMessageBeingEdited(!messageBeingEdited)}
>
{messageBeingEdited ? (
<Icon aria-label="Close" svgPath={mdiClose} />
) : (
<Icon aria-label="Edit" svgPath={mdiPencil} />
)}
</button>
</div>
)
}) {
return (
<div className={className}>
<button
className={classNames(className, styles.editButton)}
type="button"
onClick={() => setMessageBeingEdited(!messageBeingEdited)}
>
{messageBeingEdited ? (
<Icon aria-label="Close" svgPath={mdiClose} />
) : (
<Icon aria-label="Edit" svgPath={mdiPencil} />
)}
</button>
</div>
)
})
const FeedbackButtons: React.FunctionComponent<FeedbackButtonsProps> = ({ feedbackButtonsOnSubmit }) => {
const FeedbackButtons: React.FunctionComponent<FeedbackButtonsProps> = React.memo(function FeedbackButtonsContent({
feedbackButtonsOnSubmit,
}) {
const [feedbackSubmitted, setFeedbackSubmitted] = useState(false)
const onFeedbackBtnSubmit = useCallback(
@ -151,95 +172,117 @@ const FeedbackButtons: React.FunctionComponent<FeedbackButtonsProps> = ({ feedba
)}
</div>
)
}
})
export const SubmitButton: React.FunctionComponent<ChatUISubmitButtonProps> = ({ className, disabled, onClick }) => (
<button className={classNames(className, styles.submitButton)} type="submit" disabled={disabled} onClick={onClick}>
<Icon aria-label="Submit" svgPath={mdiSend} />
</button>
)
export const SubmitButton: React.FunctionComponent<ChatUISubmitButtonProps> = React.memo(function SubmitButtonContent({
className,
disabled,
onClick,
}) {
return (
<button
className={classNames(className, styles.submitButton)}
type="submit"
disabled={disabled}
onClick={onClick}
>
<Icon aria-label="Submit" svgPath={mdiSend} />
</button>
)
})
export const FileLink: React.FunctionComponent<FileLinkProps> = ({ path, repoName, revision }) =>
repoName ? <Link to={`/${repoName}${revision ? `@${revision}` : ''}/-/blob/${path}`}>{path}</Link> : <>{path}</>
export const FileLink: React.FunctionComponent<FileLinkProps> = React.memo(function FileLinkContent({
path,
repoName,
revision,
}) {
return repoName ? (
<Link to={`/${repoName}${revision ? `@${revision}` : ''}/-/blob/${path}`}>{path}</Link>
) : (
<>{path}</>
)
})
interface AutoResizableTextAreaProps extends ChatUITextAreaProps {}
export const AutoResizableTextArea: React.FC<AutoResizableTextAreaProps> = ({
value,
onInput,
onKeyDown,
className,
disabled = false,
}) => {
const { inputNeedsFocus, setFocusProvided } = useCodySidebarStore()
const { needsEmailVerification } = useIsCodyEnabled()
const textAreaRef = useRef<HTMLTextAreaElement>(null)
const { width = 0 } = useResizeObserver({ ref: textAreaRef })
const adjustTextAreaHeight = useCallback((): void => {
if (textAreaRef.current) {
textAreaRef.current.style.height = '0px'
const scrollHeight = textAreaRef.current.scrollHeight
textAreaRef.current.style.height = `${scrollHeight}px`
// Hide scroll if the textArea isn't overflowing.
textAreaRef.current.style.overflowY = scrollHeight < 200 ? 'hidden' : 'auto'
export const AutoResizableTextArea: React.FC<AutoResizableTextAreaProps> = React.memo(
function AutoResizableTextAreaContent({ value, onInput, onKeyDown, className, disabled = false }) {
const { inputNeedsFocus, setFocusProvided, isCodyEnabled } = useCodySidebar() || {
inputNeedsFocus: false,
setFocusProvided: () => null,
}
}, [])
const textAreaRef = useRef<HTMLTextAreaElement>(null)
const { width = 0 } = useResizeObserver({ ref: textAreaRef })
const handleChange = (): void => {
adjustTextAreaHeight()
const adjustTextAreaHeight = useCallback((): void => {
if (textAreaRef.current) {
textAreaRef.current.style.height = '0px'
const scrollHeight = textAreaRef.current.scrollHeight
textAreaRef.current.style.height = `${scrollHeight}px`
// Hide scroll if the textArea isn't overflowing.
textAreaRef.current.style.overflowY = scrollHeight < 200 ? 'hidden' : 'auto'
}
}, [])
const handleChange = (): void => {
adjustTextAreaHeight()
}
useEffect(() => {
if (inputNeedsFocus && textAreaRef.current) {
textAreaRef.current.focus()
setFocusProvided()
}
}, [inputNeedsFocus, setFocusProvided])
useEffect(() => {
adjustTextAreaHeight()
}, [adjustTextAreaHeight, value, width])
const handleKeyDown = (event: React.KeyboardEvent<HTMLElement>): void => {
if (onKeyDown) {
onKeyDown(event, textAreaRef.current?.selectionStart ?? null)
}
}
return (
<Tooltip content={isCodyEnabled.needsEmailVerification ? 'Verify your email to use Cody.' : ''}>
<TextArea
ref={textAreaRef}
className={className}
value={value}
onChange={handleChange}
rows={1}
autoFocus={false}
required={true}
onKeyDown={handleKeyDown}
onInput={onInput}
disabled={disabled}
/>
</Tooltip>
)
}
)
const NeedsEmailVerificationNotice: React.FunctionComponent = React.memo(
function NeedsEmailVerificationNoticeContent() {
return (
<div className="p-3">
<H2 className={classNames('d-flex gap-1 align-items-center mb-3', styles.codyMessageHeader)}>
<CodyPageIcon /> Cody
</H2>
<Alert variant="warning">
<Text className="mb-0">Verify email</Text>
<Text className="mb-0">
Using Cody requires a verified email.{' '}
<Link to={`${window.context.currentUser?.settingsURL}/emails`} target="_blank" rel="noreferrer">
Resend email verification
</Link>
.
</Text>
</Alert>
</div>
)
}
useEffect(() => {
if (inputNeedsFocus && textAreaRef.current) {
textAreaRef.current.focus()
setFocusProvided()
}
}, [inputNeedsFocus, setFocusProvided])
useEffect(() => {
adjustTextAreaHeight()
}, [adjustTextAreaHeight, value, width])
const handleKeyDown = (event: React.KeyboardEvent<HTMLElement>): void => {
if (onKeyDown) {
onKeyDown(event, textAreaRef.current?.selectionStart ?? null)
}
}
return (
<Tooltip content={needsEmailVerification ? 'Verify your email to use Cody.' : ''}>
<TextArea
ref={textAreaRef}
className={className}
value={value}
onChange={handleChange}
rows={1}
autoFocus={false}
required={true}
onKeyDown={handleKeyDown}
onInput={onInput}
disabled={disabled}
/>
</Tooltip>
)
}
const NeedsEmailVerificationNotice: React.FunctionComponent = () => (
<div className="p-3">
<H2 className={classNames('d-flex gap-1 align-items-center mb-3', styles.codyMessageHeader)}>
<CodyPageIcon /> Cody
</H2>
<Alert variant="warning">
<Text className="mb-0">Verify email</Text>
<Text className="mb-0">
Using Cody requires a verified email.{' '}
<Link to={`${window.context.currentUser?.settingsURL}/emails`} target="_blank" rel="noreferrer">
Resend email verification
</Link>
.
</Text>
</Alert>
</div>
)

View File

@ -1,3 +1,5 @@
import type { EditorView } from '@codemirror/view'
import {
ActiveTextEditor,
ActiveTextEditorSelection,
@ -5,20 +7,26 @@ import {
Editor,
} from '@sourcegraph/cody-shared/src/editor'
import { EditorStore } from '../stores/editor'
export interface EditorStore {
filename: string
repo: string
revision: string
content: string
view: EditorView
}
export class CodeMirrorEditor implements Editor {
private editorStoreRef: React.MutableRefObject<EditorStore>
constructor(editorStoreRef: React.MutableRefObject<EditorStore>) {
this.editorStoreRef = editorStoreRef
private editor?: EditorStore | null
constructor(editor?: EditorStore | null) {
this.editor = editor
}
public get repoName(): string | undefined {
return this.editorStoreRef.current.editor?.repo
return this.editor?.repo
}
public get revision(): string | undefined {
return this.editorStoreRef.current.editor?.revision
return this.editor?.revision
}
public getWorkspaceRootPath(): string | null {
@ -26,16 +34,20 @@ export class CodeMirrorEditor implements Editor {
}
public getActiveTextEditor(): ActiveTextEditor | null {
const editor = this.editorStoreRef.current.editor
if (editor === null) {
return null
const editor = this.editor
if (editor) {
return {
content: editor.content,
filePath: editor.filename,
repoName: this.repoName,
revision: this.revision,
}
}
return { content: editor.content, filePath: editor.filename, repoName: this.repoName, revision: this.revision }
return null
}
public getActiveTextEditorSelection(): ActiveTextEditorSelection | null {
const editor = this.editorStoreRef.current.editor
const editor = this.editor
if (!editor || editor.view.state.selection.main.empty) {
return null
@ -63,41 +75,40 @@ export class CodeMirrorEditor implements Editor {
}
public getActiveTextEditorSelectionOrEntireFile(): ActiveTextEditorSelection | null {
const editor = this.editorStoreRef.current.editor
if (editor === null) {
return null
if (this.editor) {
const selection = this.getActiveTextEditorSelection()
if (selection) {
return selection
}
return {
fileName: this.editor.filename,
repoName: this.repoName,
revision: this.revision,
precedingText: '',
selectedText: this.editor.content,
followingText: '',
}
}
const selection = this.getActiveTextEditorSelection()
if (selection) {
return selection
}
return {
fileName: editor.filename,
repoName: this.repoName,
revision: this.revision,
precedingText: '',
selectedText: editor.content,
followingText: '',
}
return null
}
public getActiveTextEditorVisibleContent(): ActiveTextEditorVisibleContent | null {
const editor = this.editorStoreRef.current.editor
if (editor === null) {
return null
const editor = this.editor
if (editor) {
const { from, to } = editor.view.viewport
const content = editor.view?.state.sliceDoc(from, to)
return {
fileName: editor.filename,
repoName: this.repoName,
revision: this.revision,
content,
}
}
const { from, to } = editor.view.viewport
const content = editor.view?.state.sliceDoc(from, to)
return {
fileName: editor.filename,
repoName: this.repoName,
revision: this.revision,
content,
}
return null
}
public replaceSelection(_fileName: string, _selectedText: string, _replacement: string): Promise<void> {

View File

@ -4,66 +4,63 @@ import { mdiDelete } from '@mdi/js'
import classNames from 'classnames'
import { Timestamp } from '@sourcegraph/branded/src/components/Timestamp'
import { TranscriptJSON } from '@sourcegraph/cody-shared/src/chat/transcript'
import { Transcript, TranscriptJSON } from '@sourcegraph/cody-shared/src/chat/transcript'
import { Text, Icon, Tooltip } from '@sourcegraph/wildcard'
import { safeTimestampToDate, useChatStoreState } from '../../stores/chat'
import { CodyChatStore, safeTimestampToDate } from '../../useCodyChat'
import styles from './HistoryList.module.scss'
interface HistoryListProps {
transcriptHistory: TranscriptJSON[]
currentTranscript: Transcript | null
loadTranscriptFromHistory: CodyChatStore['loadTranscriptFromHistory']
deleteHistoryItem: CodyChatStore['deleteHistoryItem']
truncateMessageLength?: number
onSelect?: (id: string) => void
itemClassName?: string
}
export const HistoryList: React.FunctionComponent<HistoryListProps> = ({
currentTranscript,
transcriptHistory,
truncateMessageLength,
onSelect,
loadTranscriptFromHistory,
deleteHistoryItem,
itemClassName,
}) => {
const { transcriptHistory } = useChatStoreState()
const transcripts = useMemo(
() =>
transcriptHistory.sort(
(a, b) =>
-1 *
((safeTimestampToDate(a.lastInteractionTimestamp) as any) -
(safeTimestampToDate(b.lastInteractionTimestamp) as any))
),
[transcriptHistory]
)
return transcriptHistory.length === 0 ? (
}) =>
transcriptHistory.length === 0 ? (
<Text className="p-2 pb-0 text-muted text-center">No chats yet</Text>
) : (
<div className="p-0 d-flex flex-column">
{transcripts.map(transcript => (
{transcriptHistory.map(transcript => (
<HistoryListItem
key={transcript.id}
currentTranscript={currentTranscript}
transcript={transcript}
onSelect={onSelect}
className={itemClassName}
truncateMessageLength={truncateMessageLength}
loadTranscriptFromHistory={loadTranscriptFromHistory}
deleteHistoryItem={deleteHistoryItem}
/>
))}
</div>
)
}
const HistoryListItem: React.FunctionComponent<{
currentTranscript: Transcript | null
transcript: TranscriptJSON
loadTranscriptFromHistory: CodyChatStore['loadTranscriptFromHistory']
deleteHistoryItem: CodyChatStore['deleteHistoryItem']
truncateMessageLength?: number
onSelect?: (id: string) => void
className?: string
}> = ({
currentTranscript,
transcript: { id, interactions, lastInteractionTimestamp },
truncateMessageLength = 80,
onSelect,
loadTranscriptFromHistory,
deleteHistoryItem,
className,
}) => {
const { loadTranscriptFromHistory, transcriptId, deleteHistoryItem } = useChatStoreState()
const lastMessage = useMemo(() => {
let message = null
@ -102,18 +99,12 @@ const HistoryListItem: React.FunctionComponent<{
'text-left',
styles.historyItem,
{
[styles.selected]: transcriptId === id,
[styles.selected]: currentTranscript?.id === id,
},
className
)}
onClick={(): any => {
onSelect?.(id)
return loadTranscriptFromHistory(id)
}}
onKeyDown={(): any => {
onSelect?.(id)
return loadTranscriptFromHistory(id)
}}
onClick={() => loadTranscriptFromHistory(id)}
onKeyDown={() => loadTranscriptFromHistory(id)}
>
<div className="d-flex align-items-center mb-1 justify-content-between w-100">
<Text className="mb-1 text-muted" size="small">

View File

@ -7,7 +7,8 @@ import { Button, Icon, Tooltip, Badge } from '@sourcegraph/wildcard'
import { ChatUI, ScrollDownButton } from '../components/ChatUI'
import { HistoryList } from '../components/HistoryList'
import { useChatStoreState } from '../stores/chat'
import { useCodySidebar } from './Provider'
import styles from './CodySidebar.module.scss'
@ -19,7 +20,18 @@ interface CodySidebarProps {
}
export const CodySidebar: React.FC<CodySidebarProps> = ({ onClose, titleContent }) => {
const { reset, transcript, messageInProgress, clearHistory } = useChatStoreState()
const codySidebarStore = useCodySidebar()
const {
initializeNewChat,
transcript,
messageInProgress,
clearHistory,
loaded,
isCodyEnabled,
transcriptHistory,
deleteHistoryItem,
loadTranscriptFromHistory,
} = codySidebarStore
const codySidebarRef = useRef<HTMLDivElement>(null)
const [showHistory, setShowHistory] = useState(false)
@ -45,14 +57,10 @@ export const CodySidebar: React.FC<CodySidebarProps> = ({ onClose, titleContent
}
}
const onReset = useCallback(async () => {
await reset()
const onReset = useCallback(() => {
initializeNewChat()
setShowHistory(false)
}, [reset, setShowHistory])
const closeHistory = useCallback(() => {
setShowHistory(false)
}, [setShowHistory])
}, [initializeNewChat, setShowHistory])
useEffect(() => {
const sidebar = codySidebarRef.current
@ -61,6 +69,18 @@ export const CodySidebar: React.FC<CodySidebarProps> = ({ onClose, titleContent
}
}, [transcript, shouldScrollToBottom, messageInProgress])
const onHistoryItemSelect = useCallback(
async (id: string) => {
await loadTranscriptFromHistory(id)
setShowHistory(false)
},
[loadTranscriptFromHistory, setShowHistory]
)
if (!(loaded && isCodyEnabled.sidebar)) {
return null
}
return (
<div className={styles.mainWrapper}>
<div className={styles.codySidebar} ref={codySidebarRef} onScroll={handleScroll}>
@ -115,7 +135,18 @@ export const CodySidebar: React.FC<CodySidebarProps> = ({ onClose, titleContent
</div>
</div>
{showHistory ? <HistoryList onSelect={closeHistory} itemClassName="rounded-0" /> : <ChatUI />}
{showHistory ? (
<HistoryList
itemClassName="rounded-0"
currentTranscript={transcript}
transcriptHistory={transcriptHistory}
truncateMessageLength={60}
loadTranscriptFromHistory={onHistoryItemSelect}
deleteHistoryItem={deleteHistoryItem}
/>
) : (
<ChatUI codyChatStore={codySidebarStore} />
)}
</div>
{showScrollDownButton && <ScrollDownButton onClick={() => scrollToBottom('smooth')} />}
</div>

View File

@ -0,0 +1,80 @@
import React, { useContext, useState, useCallback, useMemo } from 'react'
import { useTemporarySetting } from '@sourcegraph/shared/src/settings/temporary'
import { CodeMirrorEditor } from '../components/CodeMirrorEditor'
import { useCodyChat, CodyChatStore, CodyClientScope, codyChatStoreMock } from '../useCodyChat'
import { useSidebarSize } from './useSidebarSize'
interface CodySidebarStore extends CodyChatStore {
readonly isSidebarOpen: boolean
readonly inputNeedsFocus: boolean
readonly sidebarSize: number
setIsSidebarOpen: (isOpen: boolean) => void
setFocusProvided: () => void
setSidebarSize: (size: number) => void
}
const CodySidebarContext = React.createContext<CodySidebarStore | null>({
...codyChatStoreMock,
isSidebarOpen: false,
inputNeedsFocus: false,
sidebarSize: 0,
setSidebarSize: () => {},
setIsSidebarOpen: () => {},
setFocusProvided: () => {},
})
interface ICodySidebarStoreProviderProps {
children?: React.ReactNode
}
const defaultScope: CodyClientScope = {
type: 'Automatic',
repositories: [],
editor: new CodeMirrorEditor(),
}
export const CodySidebarStoreProvider: React.FC<ICodySidebarStoreProviderProps> = ({ children }) => {
const [isSidebarOpen, setIsSidebarOpenState] = useTemporarySetting('cody.showSidebar', false)
const [inputNeedsFocus, setInputNeedsFocus] = useState(false)
const { sidebarSize, setSidebarSize } = useSidebarSize()
const setFocusProvided = useCallback(() => {
setInputNeedsFocus(false)
}, [setInputNeedsFocus])
const setIsSidebarOpen = useCallback(
(open: boolean) => {
setIsSidebarOpenState(open)
setInputNeedsFocus(true)
},
[setIsSidebarOpenState, setInputNeedsFocus]
)
const onEvent = useCallback(() => setIsSidebarOpen(true), [setIsSidebarOpen])
const codyChatStore = useCodyChat({ onEvent, scope: defaultScope })
const state = useMemo<CodySidebarStore>(
() => ({
...codyChatStore,
isSidebarOpen: isSidebarOpen ?? false,
inputNeedsFocus,
sidebarSize: isSidebarOpen && codyChatStore.isCodyEnabled.sidebar ? sidebarSize : 0,
setIsSidebarOpen,
setFocusProvided,
setSidebarSize,
}),
[codyChatStore, isSidebarOpen, sidebarSize, setIsSidebarOpen, setFocusProvided, setSidebarSize, inputNeedsFocus]
)
// dirty fix because CodyRecipesWidget is rendered inside a different React DOM tree.
const global = window as any
global.codySidebarStore = state
return <CodySidebarContext.Provider value={state}>{children}</CodySidebarContext.Provider>
}
export const useCodySidebar = (): CodySidebarStore => useContext(CodySidebarContext) as CodySidebarStore

View File

@ -0,0 +1,6 @@
import create from 'zustand'
export const useSidebarSize = create<{ sidebarSize: number; setSidebarSize: (size: number) => void }>(set => ({
sidebarSize: 0,
setSidebarSize: (size: number) => set({ sidebarSize: size }),
}))

View File

@ -1,437 +0,0 @@
/* eslint-disable no-void */
import { useCallback, useEffect, useMemo, useRef } from 'react'
import { isEqual } from 'lodash'
import create from 'zustand'
import { Client, createClient, ClientInit, Transcript, TranscriptJSON } from '@sourcegraph/cody-shared/src/chat/client'
import { ChatContextStatus } from '@sourcegraph/cody-shared/src/chat/context'
import { RecipeID } from '@sourcegraph/cody-shared/src/chat/recipes/recipe'
import { ChatMessage } from '@sourcegraph/cody-shared/src/chat/transcript/messages'
import { PrefilledOptions } from '@sourcegraph/cody-shared/src/editor/withPreselectedOptions'
import { isErrorLike } from '@sourcegraph/common'
import { eventLogger } from '../../tracking/eventLogger'
import { EventName } from '../../util/constants'
import { CodeMirrorEditor } from '../components/CodeMirrorEditor'
import { useIsCodyEnabled, isEmailVerificationNeeded } from '../useIsCodyEnabled'
import { EditorStore, useEditorStore } from './editor'
interface CodyChatStore {
readonly client: Client | null
readonly config: ClientInit['config'] | null
readonly editor: CodeMirrorEditor | null
readonly messageInProgress: ChatMessage | null
readonly transcript: ChatMessage[]
readonly transcriptHistory: TranscriptJSON[]
readonly transcriptId: string | null
// private, not used outside of this module
onEvent: ((eventName: 'submit' | 'reset' | 'error') => void) | null
initializeClient: (
config: Required<ClientInit['config']>,
editorStore: React.MutableRefObject<EditorStore>,
onEvent: (eventName: 'submit' | 'reset' | 'error') => void
) => Promise<void>
submitMessage: (text: string) => void
editMessage: (text: string) => void
executeRecipe: (
recipeId: RecipeID,
options?: {
prefilledOptions?: PrefilledOptions
}
) => Promise<void>
reset: () => Promise<void>
getChatContext: () => ChatContextStatus
loadTranscriptFromHistory: (id: string) => Promise<void>
clearHistory: () => void
deleteHistoryItem: (id: string) => void
}
const CODY_TRANSCRIPT_HISTORY_KEY = 'cody:transcript-history'
const CODY_CURRENT_TRANSCRIPT_ID_KEY = 'cody:current-transcript-id'
const SAVE_MAX_TRANSCRIPT_HISTORY = 20
export const safeTimestampToDate = (timestamp: string = ''): Date => {
if (isNaN(new Date(timestamp) as any)) {
return new Date()
}
return new Date(timestamp)
}
const sortSliceTranscriptHistory = (transcriptHistory: TranscriptJSON[]): TranscriptJSON[] =>
transcriptHistory
.sort(
(a, b) =>
(safeTimestampToDate(a.lastInteractionTimestamp) as any) -
(safeTimestampToDate(b.lastInteractionTimestamp) as any)
)
.map(transcript => (transcript.id ? transcript : { ...transcript, id: Transcript.fromJSON(transcript).id }))
.slice(0, SAVE_MAX_TRANSCRIPT_HISTORY)
export const useChatStoreState = create<CodyChatStore>((set, get): CodyChatStore => {
const needsEmailVerification = isEmailVerificationNeeded()
const fetchTranscriptHistory = (): TranscriptJSON[] => {
try {
const json = JSON.parse(
window.localStorage.getItem(CODY_TRANSCRIPT_HISTORY_KEY) || '[]'
) as TranscriptJSON[]
if (!Array.isArray(json)) {
return []
}
const sorted = sortSliceTranscriptHistory(json)
saveTranscriptHistory(sorted)
return sorted
} catch {
return []
}
}
const saveTranscriptHistory = (transcriptHistory: TranscriptJSON[]): void => {
const sorted = sortSliceTranscriptHistory(transcriptHistory)
window.localStorage.setItem(CODY_TRANSCRIPT_HISTORY_KEY, JSON.stringify(sorted))
set({ transcriptHistory: sorted })
}
const fetchCurrentTranscriptId = (): string | null =>
window.localStorage.getItem(CODY_CURRENT_TRANSCRIPT_ID_KEY) || null
const setCurrentTranscriptId = (id: string | null): void => {
window.localStorage.setItem(CODY_CURRENT_TRANSCRIPT_ID_KEY, id || '')
set({ transcriptId: id })
}
const clearHistory = (): void => {
if (needsEmailVerification) {
return
}
const { client, onEvent } = get()
saveTranscriptHistory([])
if (client && !isErrorLike(client)) {
onEvent?.('reset')
void client.reset()
}
}
const deleteHistoryItem = (id: string): void => {
if (needsEmailVerification) {
return
}
const { transcriptId } = get()
const transcriptHistory = fetchTranscriptHistory()
saveTranscriptHistory(transcriptHistory.filter(transcript => transcript.id !== id))
if (transcriptId === id) {
setCurrentTranscriptId(null)
set({ transcript: [] })
}
}
const submitMessage = (text: string): void => {
const { client, onEvent, getChatContext } = get()
if (needsEmailVerification) {
onEvent?.('submit')
return
}
if (client && !isErrorLike(client)) {
const { codebase, filePath } = getChatContext()
eventLogger.log(EventName.CODY_SIDEBAR_SUBMIT, {
repo: codebase,
path: filePath,
text,
})
onEvent?.('submit')
void client.submitMessage(text)
}
}
const editMessage = (text: string): void => {
const { client, onEvent, getChatContext } = get()
if (needsEmailVerification) {
onEvent?.('submit')
return
}
if (client && !isErrorLike(client)) {
const { codebase, filePath } = getChatContext()
eventLogger.log(EventName.CODY_SIDEBAR_EDIT, {
repo: codebase,
path: filePath,
text,
})
onEvent?.('submit')
client.transcript.removeLastInteraction()
void client.submitMessage(text)
}
}
const executeRecipe = async (
recipeId: RecipeID,
options?: {
prefilledOptions?: PrefilledOptions
}
): Promise<void> => {
const { client, getChatContext, onEvent } = get()
if (needsEmailVerification) {
onEvent?.('submit')
return
}
if (client && !isErrorLike(client)) {
const { codebase, filePath } = getChatContext()
eventLogger.log(EventName.CODY_SIDEBAR_RECIPE, { repo: codebase, path: filePath, recipeId })
onEvent?.('submit')
await client.executeRecipe(recipeId, options)
eventLogger.log(EventName.CODY_SIDEBAR_RECIPE_EXECUTED, { repo: codebase, path: filePath, recipeId })
}
return Promise.resolve()
}
const reset = async (): Promise<void> => {
const { client: oldClient, config, editor, onEvent } = get()
if (!config || !editor) {
return
}
if (needsEmailVerification) {
onEvent?.('submit')
return
}
if (oldClient && !isErrorLike(oldClient)) {
oldClient.reset()
}
const transcriptHistory = fetchTranscriptHistory()
const transcript = new Transcript()
const messages = transcript.toChat()
saveTranscriptHistory([...transcriptHistory, await transcript.toJSON()])
try {
const client = await createClient({
config: { ...config, customHeaders: window.context.xhrHeaders },
editor,
setMessageInProgress,
initialTranscript: transcript,
setTranscript: (transcript: Transcript) => void setTranscript(transcript),
})
setCurrentTranscriptId(transcript.id)
set({ client, transcript: messages })
await setTranscript(transcript)
onEvent?.('reset')
} catch (error) {
onEvent?.('error')
set({ client: error })
}
}
const setTranscript = async (transcript: Transcript): Promise<void> => {
const { client } = get()
if (!client || isErrorLike(client)) {
return
}
const messages = transcript.toChat()
if (client.isMessageInProgress) {
messages.pop()
}
setCurrentTranscriptId(transcript.id)
set({ transcript: messages })
// find the transcript in history and update it
const transcriptHistory = fetchTranscriptHistory()
const transcriptJSONIndex = transcriptHistory.findIndex(({ id }) => id === transcript.id)
if (transcriptJSONIndex !== -1) {
transcriptHistory[transcriptJSONIndex] = await transcript.toJSON()
}
saveTranscriptHistory(transcriptHistory)
}
const setMessageInProgress = (message: ChatMessage | null): void => set({ messageInProgress: message })
const initializeClient = async (
config: Required<ClientInit['config']>,
editorStateRef: React.MutableRefObject<EditorStore>,
onEvent: (eventName: 'submit' | 'reset' | 'error') => void
): Promise<void> => {
const editor = new CodeMirrorEditor(editorStateRef)
const transcriptHistory = fetchTranscriptHistory()
const initialTranscript = ((): Transcript => {
try {
const currentTranscriptId = fetchCurrentTranscriptId()
const transcriptJSON =
transcriptHistory.find(({ id }) => id === currentTranscriptId) ||
transcriptHistory[transcriptHistory.length - 1]
const transcript = Transcript.fromJSON(transcriptJSON)
return transcript
} catch {
const newTranscript = new Transcript()
void newTranscript.toJSON().then(transcriptJSON => saveTranscriptHistory([transcriptJSON]))
return newTranscript
}
})()
set({
config,
editor,
onEvent,
transcript: await initialTranscript.toChatPromise(),
transcriptId: initialTranscript.id,
transcriptHistory,
})
try {
const client = await createClient({
config: { ...config, customHeaders: window.context.xhrHeaders },
editor,
setMessageInProgress,
initialTranscript,
setTranscript: (transcript: Transcript) => void setTranscript(transcript),
})
set({ client })
} catch (error) {
eventLogger.log(EventName.CODY_SIDEBAR_CLIENT_ERROR, { repo: config?.codebase })
onEvent('error')
set({ client: error })
}
}
const getChatContext = (): ChatContextStatus => {
const { config, editor, client } = get()
return {
codebase: config?.codebase,
filePath: editor?.getActiveTextEditorSelectionOrEntireFile()?.fileName,
supportsKeyword: false,
mode: config?.useContext,
connection: client?.codebaseContext.checkEmbeddingsConnection(),
}
}
const loadTranscriptFromHistory = async (id: string): Promise<void> => {
const { client: oldClient, config, editor, onEvent } = get()
if (!config || !editor) {
return
}
if (oldClient && !isErrorLike(oldClient)) {
oldClient.reset()
}
const transcriptHistory = fetchTranscriptHistory()
const transcriptJSONFromHistory = transcriptHistory.find(json => json.id === id)
if (!transcriptJSONFromHistory) {
return
}
const transcript = Transcript.fromJSON(transcriptJSONFromHistory)
const messages = await transcript.toChatPromise()
try {
const client = await createClient({
config: { ...config, customHeaders: window.context.xhrHeaders },
editor,
setMessageInProgress,
initialTranscript: transcript,
setTranscript: (transcript: Transcript) => void setTranscript(transcript),
})
set({ client, transcript: messages })
await setTranscript(transcript)
} catch (error) {
eventLogger.log(EventName.CODY_SIDEBAR_CLIENT_ERROR, { repo: config?.codebase })
onEvent?.('error')
set({ client: error })
}
}
return {
client: null,
editor: null,
messageInProgress: null,
config: null,
transcript: [],
transcriptHistory: fetchTranscriptHistory(),
onEvent: null,
transcriptId: null,
initializeClient,
submitMessage,
editMessage,
executeRecipe,
reset,
getChatContext,
loadTranscriptFromHistory,
clearHistory,
deleteHistoryItem,
}
})
export const useChatStore = ({
codebase,
setIsCodySidebarOpen = () => undefined,
}: {
codebase: string
setIsCodySidebarOpen?: (state: boolean | undefined) => void
}): CodyChatStore => {
const store = useChatStoreState()
const enabled = useIsCodyEnabled()
const onEvent = useCallback(
(eventName: 'submit' | 'reset' | 'error') => {
if (eventName === 'submit') {
setIsCodySidebarOpen(true)
}
},
[setIsCodySidebarOpen]
)
// We use a ref here so that a change in the editor state does not need a recreation of the
// client config.
const editorStore = useEditorStore()
const editorStateRef = useRef(editorStore)
useEffect(() => {
editorStateRef.current = editorStore
}, [editorStore])
// TODO(naman): change useContext to `blended` after adding keyboard context
const config = useMemo<Required<ClientInit['config']>>(
() => ({
serverEndpoint: window.location.origin,
useContext: 'embeddings',
codebase,
accessToken: null,
customHeaders: window.context.xhrHeaders,
}),
[codebase]
)
const { initializeClient, config: currentConfig } = store
useEffect(() => {
if (!(enabled.chat || enabled.sidebar) || isEqual(config, currentConfig)) {
return
}
void initializeClient(config, editorStateRef, onEvent)
}, [config, initializeClient, currentConfig, editorStateRef, onEvent, enabled.chat, enabled.sidebar])
return store
}

View File

@ -1,14 +0,0 @@
import type { EditorView } from '@codemirror/view'
import create from 'zustand'
export interface EditorStore {
editor: null | {
filename: string
repo: string
revision: string
content: string
view: EditorView
}
}
export const useEditorStore = create<EditorStore>((): EditorStore => ({ editor: null }))

View File

@ -1,65 +0,0 @@
import { useCallback } from 'react'
import create from 'zustand'
import { useTemporarySetting } from '@sourcegraph/shared/src/settings/temporary'
import { useIsCodyEnabled } from '../useIsCodyEnabled'
interface CodySidebarSizeStore {
size: number
onResize: (size: number) => void
}
const useCodySidebarSizeStore = create<CodySidebarSizeStore>(
(set): CodySidebarSizeStore => ({
size: 0,
onResize(size: number) {
set({ size })
},
})
)
interface CodySidebarOpen {
isOpen: boolean | undefined
inputNeedsFocus: boolean
setFocusProvided: () => void
setIsOpen: (newValue: boolean | ((previousValue: boolean | undefined) => boolean | undefined) | undefined) => void
}
let inputNeedsFocus = false
// By omitting returning the current size, we don't have to re-render users of this hook (e.g. the
// RepoContainer) on every resize event.
export function useCodySidebarStore(): Omit<CodySidebarSizeStore, 'size'> & CodySidebarOpen {
const [isOpen, setIsOpen] = useTemporarySetting('cody.showSidebar', false)
const onResize = useCodySidebarSizeStore(store => store.onResize)
const setFocusProvided = useCallback(() => {
inputNeedsFocus = false
}, [])
const setSidebarIsOpen = useCallback(
(...args: Parameters<typeof setIsOpen>) => {
setIsOpen(...args)
inputNeedsFocus = true
},
[setIsOpen]
)
return {
onResize,
isOpen,
inputNeedsFocus,
setFocusProvided,
setIsOpen: setSidebarIsOpen,
}
}
export function useCodySidebarSize(): number {
const size = useCodySidebarSizeStore(store => store.size)
const { isOpen } = useCodySidebarStore()
const enabled = useIsCodyEnabled()
return isOpen && enabled.sidebar ? size : 0
}

View File

@ -0,0 +1,332 @@
import { useState, useEffect, useCallback, useMemo } from 'react'
import { Transcript, TranscriptJSON } from '@sourcegraph/cody-shared/src/chat/transcript'
import {
useClient,
CodyClient,
CodyClientScope,
CodyClientConfig,
CodyClientEvent,
} from '@sourcegraph/cody-shared/src/chat/useClient'
import { useLocalStorage } from '@sourcegraph/wildcard'
import { CodeMirrorEditor } from './components/CodeMirrorEditor'
import { useIsCodyEnabled, IsCodyEnabled, notEnabled } from './useIsCodyEnabled'
export type { CodyClientScope } from '@sourcegraph/cody-shared/src/chat/useClient'
export interface CodyChatStore
extends Pick<
CodyClient,
| 'transcript'
| 'chatMessages'
| 'isMessageInProgress'
| 'messageInProgress'
| 'submitMessage'
| 'editMessage'
| 'initializeNewChat'
| 'executeRecipe'
| 'scope'
| 'setScope'
| 'setEditorScope'
| 'legacyChatContext'
> {
readonly transcriptHistory: TranscriptJSON[]
readonly loaded: boolean
readonly isCodyEnabled: IsCodyEnabled
clearHistory: () => void
deleteHistoryItem: (id: string) => void
loadTranscriptFromHistory: (id: string) => Promise<void>
}
export const codyChatStoreMock: CodyChatStore = {
transcript: null,
chatMessages: [],
isMessageInProgress: false,
messageInProgress: null,
submitMessage: () => Promise.resolve(null),
editMessage: () => Promise.resolve(null),
initializeNewChat: () => null,
executeRecipe: () => Promise.resolve(null),
scope: { type: 'Automatic', repositories: [], editor: new CodeMirrorEditor() },
setScope: () => {},
setEditorScope: () => {},
legacyChatContext: {},
transcriptHistory: [],
loaded: true,
isCodyEnabled: notEnabled,
clearHistory: () => {},
deleteHistoryItem: () => {},
loadTranscriptFromHistory: () => Promise.resolve(),
}
interface CodyChatProps {
scope?: CodyClientScope
config?: CodyClientConfig
onEvent?: (event: CodyClientEvent) => void
onTranscriptHistoryLoad?: (
loadTranscriptFromHistory: (id: string) => Promise<void>,
transcriptHistory: TranscriptJSON[],
initializeNewChat: CodyClient['initializeNewChat']
) => void
autoLoadTranscriptFromHistory?: boolean
}
const CODY_TRANSCRIPT_HISTORY_KEY = 'cody.chat.history'
const SAVE_MAX_TRANSCRIPT_HISTORY = 20
export const useCodyChat = ({
scope: initialScope,
config: initialConfig,
onEvent,
onTranscriptHistoryLoad,
autoLoadTranscriptFromHistory = true,
}: CodyChatProps): CodyChatStore => {
const isCodyEnabled = useIsCodyEnabled()
const [loadedTranscriptFromHistory, setLoadedTranscriptFromHistory] = useState(false)
const [transcriptHistoryInternal, setTranscriptHistoryState] = useLocalStorage<TranscriptJSON[]>(
CODY_TRANSCRIPT_HISTORY_KEY,
[]
)
const transcriptHistory = useMemo(() => transcriptHistoryInternal || [], [transcriptHistoryInternal])
const {
transcript,
isMessageInProgress,
messageInProgress,
chatMessages,
scope,
setScope,
setEditorScope,
setTranscript,
legacyChatContext,
initializeNewChat: initializeNewChatInternal,
submitMessage: submitMessageInternal,
editMessage: editMessageInternal,
executeRecipe: executeRecipeInternal,
...client
} = useClient({
config: initialConfig || {
serverEndpoint: window.location.origin,
useContext: 'embeddings',
accessToken: null,
customHeaders: window.context.xhrHeaders,
debugEnable: false,
needsEmailVerification: isCodyEnabled.needsEmailVerification,
},
scope: initialScope,
onEvent,
})
const loadTranscriptFromHistory = useCallback(
async (id: string) => {
if (transcript?.id === id) {
return
}
const transcriptToLoad = transcriptHistory.find(item => item.id === id)
if (transcriptToLoad) {
await setTranscript(Transcript.fromJSON(transcriptToLoad))
}
},
[transcriptHistory, transcript?.id, setTranscript]
)
const clearHistory = useCallback(() => {
if (client.config.needsEmailVerification) {
return
}
const newTranscript = initializeNewChatInternal()
if (newTranscript) {
setTranscriptHistoryState([newTranscript.toJSONEmpty()])
} else {
setTranscriptHistoryState([])
}
}, [client.config.needsEmailVerification, initializeNewChatInternal, setTranscriptHistoryState])
const deleteHistoryItem = useCallback(
(id: string): void => {
if (client.config.needsEmailVerification) {
return
}
setTranscriptHistoryState((history: TranscriptJSON[]) => {
const updatedHistory = [...history.filter(transcript => transcript.id !== id)]
if (transcript?.id === id) {
if (updatedHistory.length === 0) {
const newTranscript = initializeNewChatInternal()
if (newTranscript) {
updatedHistory.push(newTranscript.toJSONEmpty())
}
} else {
setTranscript(Transcript.fromJSON(updatedHistory[0])).catch(() => null)
}
}
return sortSliceTranscriptHistory(updatedHistory)
})
},
[
setTranscript,
client.config.needsEmailVerification,
initializeNewChatInternal,
transcript?.id,
setTranscriptHistoryState,
]
)
const updateTranscriptInHistory = useCallback(
async (transcript: Transcript) => {
const transcriptJSON = await transcript.toJSON()
setTranscriptHistoryState((history: TranscriptJSON[]) => {
const index = history.findIndex(item => item.id === transcript.id)
if (index >= 0) {
history[index] = transcriptJSON
}
return [...history]
})
},
[setTranscriptHistoryState]
)
const pushTranscriptToHistory = useCallback(
async (transcript: Transcript) => {
const transcriptJSON = await transcript.toJSON()
setTranscriptHistoryState((history: TranscriptJSON[] = []) =>
sortSliceTranscriptHistory([transcriptJSON, ...history])
)
},
[setTranscriptHistoryState]
)
const submitMessage = useCallback<typeof submitMessageInternal>(
async (humanInputText, scope): Promise<Transcript | null> => {
const transcript = await submitMessageInternal(humanInputText, scope)
if (transcript) {
await updateTranscriptInHistory(transcript)
}
return transcript
},
[submitMessageInternal, updateTranscriptInHistory]
)
const editMessage = useCallback<typeof editMessageInternal>(
async (humanInputText, messageId?, scope?): Promise<Transcript | null> => {
const transcript = await editMessageInternal(humanInputText, messageId, scope)
if (transcript) {
await updateTranscriptInHistory(transcript)
}
return transcript
},
[editMessageInternal, updateTranscriptInHistory]
)
const initializeNewChat = useCallback((): Transcript | null => {
const transcript = initializeNewChatInternal()
if (transcript) {
pushTranscriptToHistory(transcript).catch(() => null)
}
return transcript
}, [initializeNewChatInternal, pushTranscriptToHistory])
const executeRecipe = useCallback<typeof executeRecipeInternal>(
async (recipeId, options): Promise<Transcript | null> => {
const transcript = await executeRecipeInternal(recipeId, options)
if (transcript) {
await updateTranscriptInHistory(transcript)
}
return transcript
},
[executeRecipeInternal, updateTranscriptInHistory]
)
const loaded = useMemo(
() => loadedTranscriptFromHistory && isCodyEnabled.loaded,
[loadedTranscriptFromHistory, isCodyEnabled.loaded]
)
// Autoload the latest transcript from history once it is loaded. Initially the transcript is null.
useEffect(() => {
if (!loadedTranscriptFromHistory && transcript === null) {
const history = sortSliceTranscriptHistory([...transcriptHistory])
if (autoLoadTranscriptFromHistory) {
if (history.length > 0) {
setTranscript(Transcript.fromJSON(history[0])).catch(() => null)
} else {
const newTranscript = new Transcript()
history.push({ interactions: [], id: newTranscript.id, lastInteractionTimestamp: newTranscript.id })
setTranscript(newTranscript)
.then(() => setTranscriptHistoryState(history))
.catch(() => null)
}
}
// usefull to load transcript from any other source like url.
onTranscriptHistoryLoad?.(loadTranscriptFromHistory, history, initializeNewChat)
setLoadedTranscriptFromHistory(true)
}
}, [
transcriptHistory,
loadedTranscriptFromHistory,
transcript,
autoLoadTranscriptFromHistory,
onTranscriptHistoryLoad,
setTranscript,
setTranscriptHistoryState,
loadTranscriptFromHistory,
initializeNewChat,
])
return {
loaded,
isCodyEnabled,
transcript,
transcriptHistory,
chatMessages,
messageInProgress,
isMessageInProgress,
submitMessage,
editMessage,
initializeNewChat,
clearHistory,
deleteHistoryItem,
executeRecipe,
scope,
setScope,
setEditorScope,
loadTranscriptFromHistory,
legacyChatContext,
}
}
export const safeTimestampToDate = (timestamp: string = ''): Date => {
if (isNaN(new Date(timestamp) as any)) {
return new Date()
}
return new Date(timestamp)
}
const sortSliceTranscriptHistory = (transcriptHistory: TranscriptJSON[]): TranscriptJSON[] =>
transcriptHistory
.sort(
(a, b) =>
(safeTimestampToDate(b.lastInteractionTimestamp) as any) -
(safeTimestampToDate(a.lastInteractionTimestamp) as any)
)
.map(transcript => (transcript.id ? transcript : { ...transcript, id: Transcript.fromJSON(transcript).id }))
.slice(0, SAVE_MAX_TRANSCRIPT_HISTORY)

View File

@ -1,6 +1,9 @@
import { useMemo } from 'react'
import { useFeatureFlag } from '../featureFlags/useFeatureFlag'
const notEnabled = {
export const notEnabled = {
loaded: true,
chat: false,
sidebar: false,
search: false,
@ -8,7 +11,8 @@ const notEnabled = {
needsEmailVerification: false,
}
interface IsCodyEnabled {
export interface IsCodyEnabled {
loaded: boolean
chat: boolean
sidebar: boolean
search: boolean
@ -20,26 +24,50 @@ export const isEmailVerificationNeeded = (): boolean =>
window.context?.codyRequiresVerifiedEmail && !window.context?.currentUser?.hasVerifiedEmail
export const useIsCodyEnabled = (): IsCodyEnabled => {
const [chatEnabled] = useFeatureFlag('cody-web-chat')
const [searchEnabled] = useFeatureFlag('cody-web-search')
const [sidebarEnabled] = useFeatureFlag('cody-web-sidebar')
const [editorRecipesEnabled] = useFeatureFlag('cody-web-editor-recipes')
let [allEnabled] = useFeatureFlag('cody-web-all')
const [chatEnabled, chatEnabledStatus] = useFeatureFlag('cody-web-chat')
const [searchEnabled, searchEnabledStatus] = useFeatureFlag('cody-web-search')
const [sidebarEnabled, sidebarEnabledStatus] = useFeatureFlag('cody-web-sidebar')
const [editorRecipesEnabled, editorRecipesEnabledStatus] = useFeatureFlag('cody-web-editor-recipes')
let [allEnabled, allEnabledStatus] = useFeatureFlag('cody-web-all')
if (!window.context?.codyEnabled) {
return notEnabled
}
if (window.context.sourcegraphAppMode) {
if (window.context?.sourcegraphAppMode) {
// If the user is using the Sourcegraph app, all features are enabled
// as long as the user has a connected Sourcegraph.com account.
allEnabled = true
}
return {
chat: chatEnabled || allEnabled,
sidebar: sidebarEnabled || allEnabled,
search: searchEnabled || allEnabled,
editorRecipes: (editorRecipesEnabled && sidebarEnabled) || allEnabled,
needsEmailVerification: isEmailVerificationNeeded(),
const enabled = useMemo(
() => ({
loaded:
window.context?.sourcegraphAppMode ||
(chatEnabledStatus === 'loaded' &&
searchEnabledStatus === 'loaded' &&
sidebarEnabledStatus === 'loaded' &&
editorRecipesEnabledStatus === 'loaded' &&
allEnabledStatus === 'loaded'),
chat: chatEnabled || allEnabled,
sidebar: sidebarEnabled || allEnabled,
search: searchEnabled || allEnabled,
editorRecipes: (editorRecipesEnabled && sidebarEnabled) || allEnabled,
needsEmailVerification: isEmailVerificationNeeded(),
}),
[
chatEnabled,
sidebarEnabled,
searchEnabled,
editorRecipesEnabled,
allEnabled,
chatEnabledStatus,
searchEnabledStatus,
sidebarEnabledStatus,
editorRecipesEnabledStatus,
allEnabledStatus,
]
)
if (!window.context?.codyEnabled) {
return notEnabled
}
return enabled
}

View File

View File

@ -6,25 +6,51 @@ import { mdiCardBulletedOutline, mdiDotsVertical, mdiProgressPencil, mdiShuffleV
import { TranslateToLanguage } from '@sourcegraph/cody-shared/src/chat/recipes/translate'
import { useChatStoreState } from '../stores/chat'
import { useCodySidebar } from '../sidebar/Provider'
import { Recipe } from './components/Recipe'
import { RecipeAction } from './components/RecipeAction'
import { Recipes } from './components/Recipes'
export const CodyRecipesWidget: React.FC<{}> = () => {
const { executeRecipe } = useChatStoreState()
// dirty fix becasue it is rendered under a separate React DOM tree.
const codySidebarStore = (window as any).codySidebarStore as ReturnType<typeof useCodySidebar>
if (!codySidebarStore) {
return null
}
const { executeRecipe, isMessageInProgress, loaded } = codySidebarStore
if (!loaded) {
return null
}
return (
<Recipes>
<Recipe title="Explain" icon={mdiCardBulletedOutline}>
<RecipeAction title="Detailed" onClick={() => void executeRecipe('explain-code-detailed')} />
<RecipeAction title="High level" onClick={() => void executeRecipe('explain-code-high-level')} />
<RecipeAction
title="Detailed"
onClick={() => void executeRecipe('explain-code-detailed')}
disabled={isMessageInProgress}
/>
<RecipeAction
title="High level"
onClick={() => void executeRecipe('explain-code-high-level')}
disabled={isMessageInProgress}
/>
</Recipe>
<Recipe title="Generate" icon={mdiProgressPencil}>
<RecipeAction title="A unit test" onClick={() => void executeRecipe('generate-unit-test')} />
<RecipeAction title="A docstring" onClick={() => void executeRecipe('generate-docstring')} />
<RecipeAction
title="A unit test"
onClick={() => void executeRecipe('generate-unit-test')}
disabled={isMessageInProgress}
/>
<RecipeAction
title="A docstring"
onClick={() => void executeRecipe('generate-docstring')}
disabled={isMessageInProgress}
/>
</Recipe>
<Recipe title="Transpile" icon={mdiShuffleVariant}>
@ -32,6 +58,7 @@ export const CodyRecipesWidget: React.FC<{}> = () => {
<RecipeAction
key={language}
title={language}
disabled={isMessageInProgress}
onClick={() =>
void executeRecipe('translate-to-language', {
prefilledOptions: [[TranslateToLanguage.options, language]],
@ -44,9 +71,14 @@ export const CodyRecipesWidget: React.FC<{}> = () => {
<Recipe icon={mdiDotsVertical}>
<RecipeAction
title="Improve variable names"
disabled={isMessageInProgress}
onClick={() => void executeRecipe('improve-variable-names')}
/>
<RecipeAction title="Smell code" onClick={() => void executeRecipe('find-code-smells')} />
<RecipeAction
title="Smell code"
onClick={() => void executeRecipe('find-code-smells')}
disabled={isMessageInProgress}
/>
</Recipe>
</Recipes>
)

View File

@ -7,10 +7,11 @@ import styles from './Recipes.module.scss'
export interface RecipeActionProps {
title: string
onClick: () => void
disabled?: boolean
}
export const RecipeAction = ({ title, onClick }: RecipeActionProps): JSX.Element => (
<MenuItem className={classNames(styles.recipeMenuWrapper)} onSelect={onClick}>
export const RecipeAction = ({ title, onClick, disabled }: RecipeActionProps): JSX.Element => (
<MenuItem className={classNames(styles.recipeMenuWrapper)} onSelect={onClick} disabled={disabled}>
{title}
</MenuItem>
)

View File

@ -112,7 +112,7 @@ export const enterpriseRoutes: RouteObject[] = [
element: <LegacyRoute render={props => <CodySearchPage {...props} />} />,
},
{
path: EnterprisePageRoutes.Cody,
path: EnterprisePageRoutes.Cody + '/*',
element: <LegacyRoute render={props => <CodyChatPage {...props} />} />,
},
{

View File

@ -33,9 +33,7 @@ import { AuthenticatedUser } from '../auth'
import { BatchChangesProps } from '../batches'
import { CodeIntelligenceProps } from '../codeintel'
import { CodySidebar } from '../cody/sidebar'
import { useChatStore } from '../cody/stores/chat'
import { useCodySidebarStore } from '../cody/stores/sidebar'
import { useIsCodyEnabled } from '../cody/useIsCodyEnabled'
import { useCodySidebar } from '../cody/sidebar/Provider'
import { BreadcrumbSetters, BreadcrumbsProps } from '../components/Breadcrumbs'
import { RouteError } from '../components/ErrorBoundary'
import { HeroPage } from '../components/HeroPage'
@ -155,7 +153,22 @@ export const RepoContainer: FC<RepoContainerProps> = props => {
location.pathname + location.search + location.hash
)
const codyEnabled = useIsCodyEnabled()
const {
isCodyEnabled,
sidebarSize: codySidebarSize,
isSidebarOpen: isCodySidebarOpen,
setIsSidebarOpen: setIsCodySidebarOpen,
setSidebarSize: setCodySidebarSize,
loaded: codyLoaded,
scope,
setScope,
} = useCodySidebar()
useEffect(() => {
if (codyLoaded && scope.type === 'Automatic' && !scope.repositories.find((name: string) => name === repoName)) {
setScope({ ...scope, repositories: [...scope.repositories, repoName] })
}
}, [scope, repoName, setScope, codyLoaded])
const resolvedRevisionOrError = useObservable(
useMemo(
@ -193,13 +206,6 @@ export const RepoContainer: FC<RepoContainerProps> = props => {
)
const focusCodyShortcut = useKeyboardShortcut('focusCody')
const {
isOpen: isCodySidebarOpen,
setIsOpen: setIsCodySidebarOpen,
onResize: onCodySidebarResize,
} = useCodySidebarStore()
// TODO: This hook call is used to initialize the chat store with the right repo name.
useChatStore({ codebase: repoName, setIsCodySidebarOpen })
/**
* A long time ago, we fetched `repo` in a separate GraphQL query.
@ -351,7 +357,7 @@ export const RepoContainer: FC<RepoContainerProps> = props => {
return (
<>
{codyEnabled.sidebar &&
{isCodyEnabled.sidebar &&
focusCodyShortcut?.keybindings.map((keybinding, index) => (
<Shortcut
key={index}
@ -375,7 +381,7 @@ export const RepoContainer: FC<RepoContainerProps> = props => {
telemetryService={props.telemetryService}
/>
{codyEnabled.sidebar ? (
{isCodyEnabled.sidebar ? (
<RepoHeaderContributionPortal
position="right"
priority={1}
@ -491,16 +497,16 @@ export const RepoContainer: FC<RepoContainerProps> = props => {
</Suspense>
</div>
{codyEnabled.sidebar && isCodySidebarOpen && (
{isCodyEnabled.sidebar && isCodySidebarOpen && (
<Panel
className="cody-sidebar-panel"
position="right"
ariaLabel="Cody sidebar"
maxSize={CODY_SIDEBAR_SIZES.max}
minSize={CODY_SIDEBAR_SIZES.min}
defaultSize={CODY_SIDEBAR_SIZES.default}
defaultSize={codySidebarSize || CODY_SIDEBAR_SIZES.default}
storageKey="size-cache-cody-sidebar"
onResize={onCodySidebarResize}
onResize={setCodySidebarSize}
>
<CodySidebar onClose={() => setIsCodySidebarOpen(false)} />
</Panel>

View File

@ -26,8 +26,8 @@ import { useIsLightTheme } from '@sourcegraph/shared/src/theme'
import { AbsoluteRepoFile, ModeSpec, parseQueryAndHash } from '@sourcegraph/shared/src/util/url'
import { useLocalStorage } from '@sourcegraph/wildcard'
import { useEditorStore } from '../../cody/stores/editor'
import { useIsCodyEnabled } from '../../cody/useIsCodyEnabled'
import { CodeMirrorEditor } from '../../cody/components/CodeMirrorEditor'
import { useCodySidebar } from '../../cody/sidebar/Provider'
import { useFeatureFlag } from '../../featureFlags/useFeatureFlag'
import { ExternalLinkFields, Scalars } from '../../graphql-operations'
import { BlameHunkData } from '../blame/useBlameHunks'
@ -273,7 +273,8 @@ export const CodeMirrorBlob: React.FunctionComponent<BlobProps> = props => {
[customHistoryAction]
)
const codyEnabled = useIsCodyEnabled()
// Added fallback to take care of ReferencesPanel/Simple storybook
const { isCodyEnabled, setEditorScope } = useCodySidebar()
const extensions = useMemo(
() => [
@ -291,7 +292,7 @@ export const CodeMirrorBlob: React.FunctionComponent<BlobProps> = props => {
}),
scipSnapshot(blobInfo.snapshotData),
codeFoldingExtension(),
codyEnabled.editorRecipes ? codyWidgetExtension() : [],
isCodyEnabled.editorRecipes ? codyWidgetExtension() : [],
navigateToLineOnAnyClick ? navigateToLineOnAnyClickExtension : tokenSelectionExtension(),
syntaxHighlight.of(blobInfo),
languageSupport.of(blobInfo),
@ -321,7 +322,7 @@ export const CodeMirrorBlob: React.FunctionComponent<BlobProps> = props => {
// further below. However, they are still needed here because we need to
// set initial values when we re-initialize the editor.
// eslint-disable-next-line react-hooks/exhaustive-deps
[onSelection, blobInfo, extensionsController]
[onSelection, blobInfo, extensionsController, isCodyEnabled]
)
const editorRef = useRef<EditorView | null>(null)
@ -439,19 +440,27 @@ export const CodeMirrorBlob: React.FunctionComponent<BlobProps> = props => {
// like Cody to know what file and range a user is looking at.
useEffect(() => {
const view = editorRef.current
useEditorStore.setState({
editor: view
? {
view,
repo: props.blobInfo.repoName,
revision: props.blobInfo.revision,
filename: props.blobInfo.filePath,
content: props.blobInfo.content,
}
: null,
})
return () => useEditorStore.setState({ editor: null })
}, [props.blobInfo.content, props.blobInfo.filePath, props.blobInfo.repoName, props.blobInfo.revision])
setEditorScope(
new CodeMirrorEditor(
view
? {
view,
repo: props.blobInfo.repoName,
revision: props.blobInfo.revision,
filename: props.blobInfo.filePath,
content: props.blobInfo.content,
}
: undefined
)
)
return () => setEditorScope(new CodeMirrorEditor())
}, [
props.blobInfo.content,
props.blobInfo.filePath,
props.blobInfo.repoName,
props.blobInfo.revision,
setEditorScope,
])
return (
<>

View File

@ -34,6 +34,7 @@ const SurveyPage = lazyComponent(() => import('./marketing/page/SurveyPage'), 'S
const RepoContainer = lazyComponent(() => import('./repo/RepoContainer'), 'RepoContainer')
const TeamsArea = lazyComponent(() => import('./team/TeamsArea'), 'TeamsArea')
const CodyStandalonePage = lazyComponent(() => import('./cody/CodyStandalonePage'), 'CodyStandalonePage')
const CodySidebarStoreProvider = lazyComponent(() => import('./cody/sidebar/Provider'), 'CodySidebarStoreProvider')
// Force a hard reload so that we delegate to the serverside HTTP handler for a route.
const PassThroughToServer: React.FC = () => {
@ -149,7 +150,15 @@ export const routes: RouteObject[] = [
...communitySearchContextsRoutes,
{
path: PageRoutes.RepoContainer,
element: <LegacyRoute render={props => <RepoContainer {...props} />} />,
element: (
<LegacyRoute
render={props => (
<CodySidebarStoreProvider>
<RepoContainer {...props} />
</CodySidebarStoreProvider>
)}
/>
),
// In RR6, the useMatches hook will only give you the location that is matched
// by the path rule and not the path rule instead. Since we need to be able to
// detect if we're inside the repo container reliably inside the Layout, we

View File

@ -33,7 +33,7 @@ import { useTemporarySetting } from '@sourcegraph/shared/src/settings/temporary/
import { buildSearchURLQuery, toPrettyBlobURL } from '@sourcegraph/shared/src/util/url'
import { Button, Link, TextArea, Icon, H2, H3, Text, createLinkUrl, useMatchMedia } from '@sourcegraph/wildcard'
import { useCodySidebarSize } from '../cody/stores/sidebar'
import { useSidebarSize } from '../cody/sidebar/useSidebarSize'
import { BlockInput } from '../notebooks'
import { createNotebook } from '../notebooks/backend'
import { blockToGQLInput } from '../notebooks/serialize'
@ -383,7 +383,7 @@ export const Notepad: React.FunctionComponent<React.PropsWithChildren<NotepadPro
// HACK: This is temporary fix for the overlapping Notepad icon until we either disable notepad
// or move Cody to the top level and mount the Notepad entrypoint inside it
const codySidebarWidth = useCodySidebarSize()
const { sidebarSize: codySidebarWidth } = useSidebarSize()
return (
<aside