mirror of
https://github.com/sourcegraph/sourcegraph.git
synced 2026-02-06 18:51:59 +00:00
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:
parent
f21f53bcd2
commit
e49ff43d6f
3
client/cody-shared/BUILD.bazel
generated
3
client/cody-shared/BUILD.bazel
generated
@ -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",
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@ -39,6 +39,7 @@ export interface Client {
|
||||
recipeId: RecipeID,
|
||||
options?: {
|
||||
prefilledOptions?: PrefilledOptions
|
||||
humanChatInput?: string
|
||||
}
|
||||
) => Promise<void>
|
||||
reset: () => void
|
||||
|
||||
@ -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()
|
||||
|
||||
334
client/cody-shared/src/chat/useClient.ts
Normal file
334
client/cody-shared/src/chat/useClient.ts
Normal 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,
|
||||
]
|
||||
)
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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]
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
@ -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,
|
||||
|
||||
7
client/web/BUILD.bazel
generated
7
client/web/BUILD.bazel
generated
@ -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",
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
)
|
||||
|
||||
@ -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> {
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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>
|
||||
|
||||
80
client/web/src/cody/sidebar/Provider.tsx
Normal file
80
client/web/src/cody/sidebar/Provider.tsx
Normal 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
|
||||
6
client/web/src/cody/sidebar/useSidebarSize.tsx
Normal file
6
client/web/src/cody/sidebar/useSidebarSize.tsx
Normal 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 }),
|
||||
}))
|
||||
@ -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
|
||||
}
|
||||
@ -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 }))
|
||||
@ -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
|
||||
}
|
||||
332
client/web/src/cody/useCodyChat.tsx
Normal file
332
client/web/src/cody/useCodyChat.tsx
Normal 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)
|
||||
@ -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
|
||||
}
|
||||
|
||||
0
client/web/src/cody/useSizebarSize.tsx
Normal file
0
client/web/src/cody/useSizebarSize.tsx
Normal 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>
|
||||
)
|
||||
|
||||
@ -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>
|
||||
)
|
||||
|
||||
@ -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} />} />,
|
||||
},
|
||||
{
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 (
|
||||
<>
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user