mirror of
https://github.com/sourcegraph/sourcegraph.git
synced 2026-02-06 12:51:55 +00:00
cody web: provide suggestions in context selector (#56039)
* Add note to gitignore about cody directory inclusion * Update docstring about user-history usage * Re-index user history entries by user ID * Pass authenticated user ID to useUserHistory hook * Pass `authenticatedUser` through Cody chat UI component props * Refactor scope selector queries to use fragments * Hook up repo selector popover to query 10 most recently visited repos * Change visibility of `UserHistory.loadEntries()` * Pass authenticated user through a couple more components * Refactor suggested repos query to grab 10 by name and first 10 embedded * Construct suggested repos list from user history, transcript history, and defaults * Display suggestions, cleanup popover height/display * Initialize new transcript with previous scope * Type fixups * Some language cleanup * Add docstring for user history hook * Refactor repo suggestions out to a custom hook * Re-index Cody transcript history entries by user ID * Don't show suggestions section at all if there are none * Update for autoload case * Update cody-shared dependency * Fix placeholder spacing * DRY up suggestions rendering, improve spacing * Use abbreviated repo name in header * Bazel configure * Update editor implementations for cody-shared package * Add extra top padding to section headers * Vertically align radio buttons in rows * Update unindexed warning copy, link color * Hide context explanation when repos are selected * Remove "Great"
This commit is contained in:
parent
30877e5b14
commit
f8ead8534b
2
.gitignore
vendored
2
.gitignore
vendored
@ -205,4 +205,6 @@ result-*
|
||||
# Windows
|
||||
/cmd/sourcegraph/__debug_bin.exe
|
||||
|
||||
# For locally building and testing shared packages from the sourcegraph/cody repository on
|
||||
# the web client.
|
||||
/cody
|
||||
|
||||
@ -237,6 +237,7 @@ ts_project(
|
||||
"src/cody/components/ScopeSelector/ScopeSelector.tsx",
|
||||
"src/cody/components/ScopeSelector/backend.ts",
|
||||
"src/cody/components/ScopeSelector/index.tsx",
|
||||
"src/cody/components/ScopeSelector/useRepoSuggestions.ts",
|
||||
"src/cody/isCodyEnabled.tsx",
|
||||
"src/cody/search/CodySearchPage.tsx",
|
||||
"src/cody/search/api.ts",
|
||||
|
||||
@ -43,7 +43,7 @@
|
||||
"@sourcegraph/branded": "workspace:*",
|
||||
"@sourcegraph/client-api": "workspace:*",
|
||||
"@sourcegraph/codeintellify": "workspace:*",
|
||||
"@sourcegraph/cody-shared": "^0.0.5",
|
||||
"@sourcegraph/cody-shared": "^0.0.6",
|
||||
"@sourcegraph/cody-ui": "^0.0.7",
|
||||
"@sourcegraph/common": "workspace:*",
|
||||
"@sourcegraph/http-client": "workspace:*",
|
||||
|
||||
@ -87,7 +87,7 @@ export const LegacyLayout: FC<LegacyLayoutProps> = props => {
|
||||
const isSetupWizardPage = location.pathname.startsWith(PageRoutes.SetupWizard)
|
||||
|
||||
const [isFuzzyFinderVisible, setFuzzyFinderVisible] = useState(false)
|
||||
const userHistory = useUserHistory(isRepositoryRelatedPage)
|
||||
const userHistory = useUserHistory(props.authenticatedUser?.id, isRepositoryRelatedPage)
|
||||
|
||||
const communitySearchContextPaths = communitySearchContextsRoutes.map(route => route.path)
|
||||
const isCommunitySearchContextPage = communitySearchContextPaths.includes(location.pathname)
|
||||
|
||||
@ -99,6 +99,7 @@ export const CodyChatPage: React.FunctionComponent<CodyChatPageProps> = ({
|
||||
const navigate = useNavigate()
|
||||
|
||||
const codyChatStore = useCodyChat({
|
||||
userID: authenticatedUser?.id,
|
||||
onTranscriptHistoryLoad,
|
||||
autoLoadTranscriptFromHistory: false,
|
||||
autoLoadScopeWithRepositories: isSourcegraphApp,
|
||||
@ -377,7 +378,12 @@ export const CodyChatPage: React.FunctionComponent<CodyChatPageProps> = ({
|
||||
New chat
|
||||
</Button>
|
||||
</div>
|
||||
<ChatUI codyChatStore={codyChatStore} isSourcegraphApp={true} isCodyChatPage={true} />
|
||||
<ChatUI
|
||||
codyChatStore={codyChatStore}
|
||||
isSourcegraphApp={true}
|
||||
isCodyChatPage={true}
|
||||
authenticatedUser={authenticatedUser}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{showMobileHistory && (
|
||||
@ -457,7 +463,11 @@ export const CodyChatPage: React.FunctionComponent<CodyChatPageProps> = ({
|
||||
deleteHistoryItem={deleteHistoryItem}
|
||||
/>
|
||||
) : (
|
||||
<ChatUI codyChatStore={codyChatStore} isCodyChatPage={true} />
|
||||
<ChatUI
|
||||
codyChatStore={codyChatStore}
|
||||
isCodyChatPage={true}
|
||||
authenticatedUser={authenticatedUser}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -22,6 +22,7 @@ import {
|
||||
type FeedbackButtonsProps,
|
||||
} from '@sourcegraph/cody-ui/dist/Chat'
|
||||
import type { FileLinkProps } from '@sourcegraph/cody-ui/dist/chat/ContextFiles'
|
||||
import type { AuthenticatedUser } from '@sourcegraph/shared/src/auth'
|
||||
import { Button, Icon, TextArea, Link, Tooltip, Alert, Text, H2 } from '@sourcegraph/wildcard'
|
||||
|
||||
import { eventLogger } from '../../../tracking/eventLogger'
|
||||
@ -44,9 +45,15 @@ interface IChatUIProps {
|
||||
codyChatStore: CodyChatStore
|
||||
isSourcegraphApp?: boolean
|
||||
isCodyChatPage?: boolean
|
||||
authenticatedUser: AuthenticatedUser | null
|
||||
}
|
||||
|
||||
export const ChatUI: React.FC<IChatUIProps> = ({ codyChatStore, isSourcegraphApp, isCodyChatPage }): JSX.Element => {
|
||||
export const ChatUI: React.FC<IChatUIProps> = ({
|
||||
codyChatStore,
|
||||
isSourcegraphApp,
|
||||
isCodyChatPage,
|
||||
authenticatedUser,
|
||||
}): JSX.Element => {
|
||||
const {
|
||||
submitMessage,
|
||||
editMessage,
|
||||
@ -90,7 +97,9 @@ export const ChatUI: React.FC<IChatUIProps> = ({ codyChatStore, isSourcegraphApp
|
||||
fetchRepositoryNames,
|
||||
isSourcegraphApp,
|
||||
logTranscriptEvent,
|
||||
transcriptHistory,
|
||||
className: 'mt-2',
|
||||
authenticatedUser,
|
||||
}),
|
||||
[
|
||||
scope,
|
||||
@ -100,12 +109,14 @@ export const ChatUI: React.FC<IChatUIProps> = ({ codyChatStore, isSourcegraphApp
|
||||
fetchRepositoryNames,
|
||||
isSourcegraphApp,
|
||||
logTranscriptEvent,
|
||||
transcriptHistory,
|
||||
authenticatedUser,
|
||||
]
|
||||
)
|
||||
|
||||
const gettingStartedComponentProps = useMemo(
|
||||
() => ({ ...scopeSelectorProps, logTranscriptEvent, isCodyChatPage }),
|
||||
[scopeSelectorProps, logTranscriptEvent, isCodyChatPage]
|
||||
() => ({ ...scopeSelectorProps, logTranscriptEvent, isCodyChatPage, authenticatedUser }),
|
||||
[scopeSelectorProps, isCodyChatPage, logTranscriptEvent, authenticatedUser]
|
||||
)
|
||||
|
||||
if (!loaded) {
|
||||
|
||||
@ -99,6 +99,10 @@ export class CodeMirrorEditor implements Editor {
|
||||
return null
|
||||
}
|
||||
|
||||
public getActiveTextEditorSelectionOrVisibleContent(): ActiveTextEditorSelection | null {
|
||||
return this.getActiveTextEditorSelectionOrEntireFile()
|
||||
}
|
||||
|
||||
public getActiveTextEditorVisibleContent(): ActiveTextEditorVisibleContent | null {
|
||||
const editor = this.editor
|
||||
if (editor) {
|
||||
@ -153,4 +157,14 @@ export class CodeMirrorEditor implements Editor {
|
||||
// Not implemented.
|
||||
return Promise.resolve(undefined)
|
||||
}
|
||||
|
||||
public getActiveInlineChatTextEditor(): ActiveTextEditor | null {
|
||||
// Not implemented.
|
||||
return null
|
||||
}
|
||||
|
||||
public getActiveInlineChatSelection(): ActiveTextEditorSelection | null {
|
||||
// Not implemented.
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
@ -42,6 +42,10 @@ export class FileContentEditor implements Editor {
|
||||
}
|
||||
}
|
||||
|
||||
public getActiveTextEditorSelectionOrVisibleContent(): ActiveTextEditorSelection | null {
|
||||
return this.getActiveTextEditorSelectionOrEntireFile()
|
||||
}
|
||||
|
||||
public getActiveTextEditorVisibleContent(): ActiveTextEditorVisibleContent | null {
|
||||
return {
|
||||
...this.editor,
|
||||
@ -85,4 +89,14 @@ export class FileContentEditor implements Editor {
|
||||
// Not implemented.
|
||||
return Promise.resolve(undefined)
|
||||
}
|
||||
|
||||
public getActiveInlineChatTextEditor(): ActiveTextEditor | null {
|
||||
// Not implemented.
|
||||
return null
|
||||
}
|
||||
|
||||
public getActiveInlineChatSelection(): ActiveTextEditorSelection | null {
|
||||
// Not implemented.
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
@ -62,10 +62,20 @@
|
||||
padding-left: var(--form-check-input-gutter);
|
||||
}
|
||||
|
||||
&-warning {
|
||||
margin-top: 0.75rem;
|
||||
&-warning,
|
||||
&-warning-link {
|
||||
margin-top: 0.5rem;
|
||||
margin-bottom: 0;
|
||||
color: var(--warning-3);
|
||||
}
|
||||
|
||||
&-warning-link {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
&-warning-link:hover {
|
||||
color: var(--warning);
|
||||
}
|
||||
}
|
||||
|
||||
.divider {
|
||||
|
||||
@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
|
||||
import classNames from 'classnames'
|
||||
|
||||
import type { AuthenticatedUser } from '@sourcegraph/shared/src/auth'
|
||||
import { H4, H5, RadioButton, Text, Button, Grid, Icon, Link } from '@sourcegraph/wildcard'
|
||||
|
||||
import { CodyColorIcon, CodySpeechBubbleIcon } from '../chat/CodyPageIcon'
|
||||
@ -22,6 +23,7 @@ export const GettingStarted: React.FC<
|
||||
CodyChatStore,
|
||||
| 'scope'
|
||||
| 'logTranscriptEvent'
|
||||
| 'transcriptHistory'
|
||||
| 'setScope'
|
||||
| 'toggleIncludeInferredRepository'
|
||||
| 'toggleIncludeInferredFile'
|
||||
@ -30,8 +32,9 @@ export const GettingStarted: React.FC<
|
||||
isSourcegraphApp?: boolean
|
||||
isCodyChatPage?: boolean
|
||||
submitInput: (input: string, submitType: 'user' | 'suggestion' | 'example') => void
|
||||
authenticatedUser: AuthenticatedUser | null
|
||||
}
|
||||
> = ({ isCodyChatPage, submitInput, ...scopeSelectorProps }) => {
|
||||
> = ({ isCodyChatPage, submitInput, authenticatedUser, ...scopeSelectorProps }) => {
|
||||
const [conversationScope, setConversationScope] = useState<ConversationScope>(
|
||||
!isCodyChatPage || scopeSelectorProps.scope.repositories.length > 0 ? 'repo' : 'general'
|
||||
)
|
||||
@ -77,9 +80,9 @@ export const GettingStarted: React.FC<
|
||||
const content: { title: string; examples: { label?: string; text: string }[] } = useMemo(() => {
|
||||
if (conversationScope === 'repo') {
|
||||
return {
|
||||
title: `Great examples to start with${
|
||||
title: `Examples to start with${
|
||||
scopeSelectorProps.scope.repositories.length === 1
|
||||
? ` for ${scopeSelectorProps.scope.repositories[0]}`
|
||||
? ` for ${scopeSelectorProps.scope.repositories[0].split('/').slice(-2).join('/')}`
|
||||
: ''
|
||||
}`,
|
||||
examples: [
|
||||
@ -101,10 +104,10 @@ export const GettingStarted: React.FC<
|
||||
examples: [
|
||||
{
|
||||
label: 'Algorithms',
|
||||
text: 'How the QuickSort algorithm works with an implementation in Python?',
|
||||
text: 'Can you explain how the QuickSort algorithm works in Python?',
|
||||
},
|
||||
{
|
||||
label: 'Good Practice',
|
||||
label: 'Best practices',
|
||||
text: "I'm working on a large-scale web application using React. What are some best practices or design patterns I should be aware of to maintain code readability and performance?",
|
||||
},
|
||||
{
|
||||
@ -121,11 +124,22 @@ export const GettingStarted: React.FC<
|
||||
return null
|
||||
}
|
||||
|
||||
const unindexedCount = repos.filter(repo => !isRepoIndexed(repo)).length
|
||||
const warningText =
|
||||
repos.length === 1
|
||||
? 'The selected repository is not indexed for Cody and is missing embeddings.'
|
||||
: `${unindexedCount} of ${repos.length} selected repositories are not indexed for Cody and are missing embeddings.`
|
||||
|
||||
return (
|
||||
<Text size="small" className={styles.scopeSelectorWarning}>
|
||||
{repos.length === 1 ? 'This repo is' : 'Some repos are'} not indexed for Cody. This may affect the
|
||||
quality of the answers. Learn more about this{' '}
|
||||
<Link to="/help/cody/explanations/code_graph_context#embeddings">in the docs</Link>.
|
||||
{warningText} This may affect the quality of the answers. To enable indexing, see the{' '}
|
||||
<Link
|
||||
className={styles.scopeSelectorWarningLink}
|
||||
to="/help/cody/explanations/code_graph_context#embeddings"
|
||||
>
|
||||
embeddings documentation
|
||||
</Link>
|
||||
.
|
||||
</Text>
|
||||
)
|
||||
},
|
||||
@ -165,11 +179,11 @@ export const GettingStarted: React.FC<
|
||||
name="general"
|
||||
label={
|
||||
<Text as="span" size="small">
|
||||
General Coding Knowledge
|
||||
General knowledge
|
||||
</Text>
|
||||
}
|
||||
value="general"
|
||||
wrapperClassName="d-flex align-items-baseline"
|
||||
wrapperClassName="d-flex align-items-center"
|
||||
checked={conversationScope === 'general'}
|
||||
onChange={event => setConversationScope(event.target.value as ConversationScope)}
|
||||
/>
|
||||
@ -180,11 +194,11 @@ export const GettingStarted: React.FC<
|
||||
name="repo"
|
||||
label={
|
||||
<Text as="span" size="small">
|
||||
My Repos:
|
||||
Specific repositories:
|
||||
</Text>
|
||||
}
|
||||
value="repo"
|
||||
wrapperClassName="d-flex align-items-baseline mb-1"
|
||||
wrapperClassName="d-flex align-items-center mb-1"
|
||||
checked={conversationScope === 'repo'}
|
||||
onChange={event => setConversationScope(event.target.value as ConversationScope)}
|
||||
/>
|
||||
@ -193,24 +207,29 @@ export const GettingStarted: React.FC<
|
||||
{...scopeSelectorProps}
|
||||
renderHint={renderRepoIndexingWarning}
|
||||
encourageOverlap={true}
|
||||
authenticatedUser={authenticatedUser}
|
||||
/>
|
||||
</div>
|
||||
<hr className={styles.divider} />
|
||||
|
||||
<Text size="small" className={classNames('text-muted', styles.hintTitle)}>
|
||||
Why context is important?
|
||||
</Text>
|
||||
<Text size="small" className={classNames('text-muted', styles.hintText)}>
|
||||
Without providing a specific repo as context, Cody won’t be able to answer with
|
||||
relevant knowledge about your project.
|
||||
</Text>
|
||||
{scopeSelectorProps.scope.repositories.length === 0 && (
|
||||
<>
|
||||
<hr className={styles.divider} />
|
||||
<Text size="small" className={classNames('text-muted', styles.hintTitle)}>
|
||||
Why is context important?
|
||||
</Text>
|
||||
<Text size="small" className={classNames('text-muted', styles.hintText)}>
|
||||
Without providing relevant repo(s) for context, Cody won't be able to answer
|
||||
questions specific to your project.
|
||||
</Text>
|
||||
|
||||
<Text size="small" className="mb-0 text-muted">
|
||||
<Text as="span" weight="bold">
|
||||
Tip:
|
||||
</Text>{' '}
|
||||
The context selector is always available at the bottom of the screen
|
||||
</Text>
|
||||
<Text size="small" className="mb-0 text-muted">
|
||||
<Text as="span" weight="bold">
|
||||
Tip:
|
||||
</Text>{' '}
|
||||
The context selector is always available at the bottom of the screen
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
|
||||
@ -29,6 +29,10 @@ export class RepoContainerEditor implements Editor {
|
||||
return null
|
||||
}
|
||||
|
||||
public getActiveTextEditorSelectionOrVisibleContent(): ActiveTextEditorSelection | null {
|
||||
return null
|
||||
}
|
||||
|
||||
public getActiveTextEditorVisibleContent(): ActiveTextEditorVisibleContent | null {
|
||||
return null
|
||||
}
|
||||
@ -69,4 +73,14 @@ export class RepoContainerEditor implements Editor {
|
||||
// Not implemented.
|
||||
return Promise.resolve(undefined)
|
||||
}
|
||||
|
||||
public getActiveInlineChatTextEditor(): ActiveTextEditor | null {
|
||||
// Not implemented.
|
||||
return null
|
||||
}
|
||||
|
||||
public getActiveInlineChatSelection(): ActiveTextEditorSelection | null {
|
||||
// Not implemented.
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
@ -14,7 +14,9 @@ import {
|
||||
} from '@mdi/js'
|
||||
import classNames from 'classnames'
|
||||
|
||||
import type { TranscriptJSON } from '@sourcegraph/cody-shared/dist/chat/transcript'
|
||||
import { useLazyQuery } from '@sourcegraph/http-client'
|
||||
import type { AuthenticatedUser } from '@sourcegraph/shared/src/auth'
|
||||
import { useTemporarySetting } from '@sourcegraph/shared/src/settings/temporary'
|
||||
import {
|
||||
Icon,
|
||||
@ -38,6 +40,7 @@ import { ExternalRepositoryIcon } from '../../../site-admin/components/ExternalR
|
||||
|
||||
import { ReposSelectorSearchQuery } from './backend'
|
||||
import { Callout } from './Callout'
|
||||
import { useRepoSuggestions } from './useRepoSuggestions'
|
||||
|
||||
import styles from './ScopeSelector.module.scss'
|
||||
|
||||
@ -73,6 +76,8 @@ export const RepositoriesSelectorPopover: React.FC<{
|
||||
// Whether to encourage the popover to overlap its trigger if necessary, rather than
|
||||
// collapsing or flipping position.
|
||||
encourageOverlap?: boolean
|
||||
transcriptHistory: TranscriptJSON[]
|
||||
authenticatedUser: AuthenticatedUser | null
|
||||
}> = React.memo(function RepositoriesSelectorPopoverContent({
|
||||
inferredRepository,
|
||||
inferredFilePath,
|
||||
@ -85,6 +90,8 @@ export const RepositoriesSelectorPopover: React.FC<{
|
||||
toggleIncludeInferredRepository,
|
||||
toggleIncludeInferredFile,
|
||||
encourageOverlap = false,
|
||||
transcriptHistory,
|
||||
authenticatedUser,
|
||||
}) {
|
||||
const [isPopoverOpen, setIsPopoverOpen] = useState(false)
|
||||
const [searchText, setSearchText] = useState('')
|
||||
@ -95,6 +102,10 @@ export const RepositoriesSelectorPopover: React.FC<{
|
||||
ReposSelectorSearchVariables
|
||||
>(ReposSelectorSearchQuery, {})
|
||||
|
||||
const suggestions = useRepoSuggestions(transcriptHistory, authenticatedUser, {
|
||||
omitSuggestions: additionalRepositories,
|
||||
})
|
||||
|
||||
const searchResults = useMemo(() => searchResultsData?.repositories.nodes || [], [searchResultsData])
|
||||
|
||||
const onSearch = useCallback(
|
||||
@ -113,12 +124,12 @@ export const RepositoriesSelectorPopover: React.FC<{
|
||||
if (searchTextDebounced) {
|
||||
/* eslint-disable no-console */
|
||||
searchRepositories({
|
||||
variables: { query: searchTextDebounced, includeJobs: !!window.context.currentUser?.siteAdmin },
|
||||
variables: { query: searchTextDebounced, includeJobs: !!authenticatedUser?.siteAdmin },
|
||||
pollInterval: 5000,
|
||||
}).catch(console.error)
|
||||
/* eslint-enable no-console */
|
||||
}
|
||||
}, [searchTextDebounced, searchRepositories])
|
||||
}, [searchTextDebounced, searchRepositories, authenticatedUser?.siteAdmin])
|
||||
|
||||
const [isCalloutDismissed = true, setIsCalloutDismissed] = useTemporarySetting(
|
||||
'cody.contextCallout.dismissed',
|
||||
@ -207,42 +218,76 @@ export const RepositoriesSelectorPopover: React.FC<{
|
||||
styles.repositorySelectorContent
|
||||
)}
|
||||
>
|
||||
{!searchText && (
|
||||
<>
|
||||
<div className="d-flex justify-content-between p-2 border-bottom mb-1">
|
||||
<Text className={classNames('m-0', styles.header)}>Chat Context</Text>
|
||||
{resetScope && scopeChanged && (
|
||||
<Button
|
||||
onClick={resetScope}
|
||||
variant="icon"
|
||||
aria-label="Reset scope"
|
||||
title="Reset scope"
|
||||
className={styles.header}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className={classNames('d-flex flex-column', styles.contextItemsContainer)}>
|
||||
{inferredRepository && (
|
||||
<div className="d-flex flex-column">
|
||||
{inferredFilePath && (
|
||||
<>
|
||||
{!searchText && (
|
||||
<>
|
||||
<div className="d-flex justify-content-between p-2 border-bottom">
|
||||
<Text className={classNames('m-0', styles.header)}>Chat Context</Text>
|
||||
{resetScope && scopeChanged && (
|
||||
<Button
|
||||
onClick={resetScope}
|
||||
variant="icon"
|
||||
aria-label="Reset scope"
|
||||
title="Reset scope"
|
||||
className={styles.header}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className={classNames('d-flex flex-column', styles.contextItemsContainer)}>
|
||||
{inferredRepository && (
|
||||
<div className="d-flex flex-column py-1">
|
||||
{inferredFilePath && (
|
||||
<button
|
||||
type="button"
|
||||
className={classNames(
|
||||
'd-flex justify-content-between flex-row text-truncate pl-2 pr-3 py-1',
|
||||
styles.repositoryListItem,
|
||||
{
|
||||
[styles.notIncludedInContext]: !includeInferredFile,
|
||||
}
|
||||
)}
|
||||
onClick={toggleIncludeInferredFile}
|
||||
>
|
||||
<div className="d-flex align-items-center text-truncate">
|
||||
<Icon
|
||||
aria-hidden={true}
|
||||
className={classNames('mr-1 text-muted', {
|
||||
[styles.visibilityHidden]: !includeInferredFile,
|
||||
})}
|
||||
svgPath={mdiCheck}
|
||||
/>
|
||||
<ExternalRepositoryIcon
|
||||
externalRepo={inferredRepository.externalRepository}
|
||||
className={styles.repoIcon}
|
||||
/>
|
||||
<span className="text-truncate">
|
||||
{getFileName(inferredFilePath)}
|
||||
</span>
|
||||
</div>
|
||||
<EmbeddingExistsIcon
|
||||
repo={inferredRepository}
|
||||
authenticatedUser={authenticatedUser}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className={classNames(
|
||||
'd-flex justify-content-between flex-row text-truncate px-2 py-1 mt-1',
|
||||
'd-flex justify-content-between flex-row text-truncate pl-2 pr-3 py-1',
|
||||
styles.repositoryListItem,
|
||||
{
|
||||
[styles.notIncludedInContext]: !includeInferredFile,
|
||||
[styles.notIncludedInContext]: !includeInferredRepository,
|
||||
}
|
||||
)}
|
||||
onClick={toggleIncludeInferredFile}
|
||||
onClick={toggleIncludeInferredRepository}
|
||||
>
|
||||
<div className="d-flex align-items-center text-truncate">
|
||||
<Icon
|
||||
aria-hidden={true}
|
||||
className={classNames('mr-1 text-muted', {
|
||||
[styles.visibilityHidden]: !includeInferredFile,
|
||||
[styles.visibilityHidden]: !includeInferredRepository,
|
||||
})}
|
||||
svgPath={mdiCheck}
|
||||
/>
|
||||
@ -251,110 +296,117 @@ export const RepositoriesSelectorPopover: React.FC<{
|
||||
className={styles.repoIcon}
|
||||
/>
|
||||
<span className="text-truncate">
|
||||
{getFileName(inferredFilePath)}
|
||||
{getRepoName(inferredRepository.name)}
|
||||
</span>
|
||||
</div>
|
||||
<EmbeddingExistsIcon repo={inferredRepository} />
|
||||
<EmbeddingExistsIcon
|
||||
repo={inferredRepository}
|
||||
authenticatedUser={authenticatedUser}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className={classNames(
|
||||
'd-flex justify-content-between flex-row text-truncate px-2 py-1',
|
||||
styles.repositoryListItem,
|
||||
{
|
||||
[styles.notIncludedInContext]: !includeInferredRepository,
|
||||
}
|
||||
)}
|
||||
onClick={toggleIncludeInferredRepository}
|
||||
>
|
||||
<div className="d-flex align-items-center text-truncate">
|
||||
<Icon
|
||||
aria-hidden={true}
|
||||
className={classNames('mr-1 text-muted', {
|
||||
[styles.visibilityHidden]: !includeInferredRepository,
|
||||
})}
|
||||
svgPath={mdiCheck}
|
||||
/>
|
||||
<ExternalRepositoryIcon
|
||||
externalRepo={inferredRepository.externalRepository}
|
||||
className={styles.repoIcon}
|
||||
/>
|
||||
<span className="text-truncate">
|
||||
{getRepoName(inferredRepository.name)}
|
||||
</div>
|
||||
)}
|
||||
{!!additionalRepositories.length && (
|
||||
<div className="d-flex flex-column mb-1">
|
||||
<Text
|
||||
className={classNames(
|
||||
'm-0 pl-2 pr-3 pb-1 pt-2 text-muted d-flex justify-content-between',
|
||||
styles.subHeader
|
||||
)}
|
||||
>
|
||||
<span className="small">
|
||||
{inferredRepository
|
||||
? 'Additional repositories'
|
||||
: 'Repositories'}
|
||||
</span>
|
||||
</div>
|
||||
<EmbeddingExistsIcon repo={inferredRepository} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{!!additionalRepositories.length && (
|
||||
<div className="d-flex flex-column">
|
||||
<Text
|
||||
className={classNames(
|
||||
'mb-0 px-2 py-1 text-muted d-flex justify-content-between',
|
||||
styles.subHeader,
|
||||
{
|
||||
'mt-1': inferredRepository || inferredFilePath,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<span className="small">
|
||||
{inferredRepository ? 'Additional repositories' : 'Repositories'}
|
||||
</span>
|
||||
<span className="small">{additionalRepositories.length}/10</span>
|
||||
<span className="small">
|
||||
{additionalRepositories.length}/{MAX_ADDITIONAL_REPOSITORIES}
|
||||
</span>
|
||||
</Text>
|
||||
{additionalRepositories.map(repository => (
|
||||
<AdditionalRepositoriesListItem
|
||||
key={repository.id}
|
||||
repository={repository}
|
||||
removeRepository={removeRepository}
|
||||
authenticatedUser={authenticatedUser}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!inferredRepository && !inferredFilePath && !additionalRepositories.length && (
|
||||
<Text size="small" className="m-0 px-4 py-2 my-1 text-center text-muted">
|
||||
Add up to {MAX_ADDITIONAL_REPOSITORIES} repositories for Cody to
|
||||
reference when providing answers.
|
||||
</Text>
|
||||
{additionalRepositories.map(repository => (
|
||||
<AdditionalRepositoriesListItem
|
||||
)}
|
||||
|
||||
{!!suggestions.length && (
|
||||
<div className="d-flex flex-column">
|
||||
<Text
|
||||
className={classNames(
|
||||
'mb-0 pl-2 pr-3 pb-1 pt-2 text-muted d-flex justify-content-between border-top',
|
||||
styles.subHeader
|
||||
)}
|
||||
>
|
||||
<span className="small">Suggestions</span>
|
||||
</Text>
|
||||
<div
|
||||
className={classNames(
|
||||
'd-flex flex-column',
|
||||
styles.contextItemsContainer
|
||||
)}
|
||||
>
|
||||
{suggestions.map(repository => (
|
||||
<SearchResultsListItem
|
||||
additionalRepositories={[]}
|
||||
key={repository.id}
|
||||
repository={repository}
|
||||
searchText=""
|
||||
addRepository={addRepository}
|
||||
removeRepository={removeRepository}
|
||||
authenticatedUser={authenticatedUser}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{searchText && (
|
||||
<>
|
||||
<div className="d-flex justify-content-between p-2 border-bottom mb-1">
|
||||
<Text className={classNames('m-0', styles.header)}>
|
||||
{additionalRepositoriesLeft
|
||||
? `Add up to ${additionalRepositoriesLeft} additional repositories`
|
||||
: 'Maximum additional repositories added'}
|
||||
</Text>
|
||||
</div>
|
||||
<div className={classNames('d-flex flex-column', styles.contextItemsContainer)}>
|
||||
{searchResults.length ? (
|
||||
searchResults.map(repository => (
|
||||
<SearchResultsListItem
|
||||
additionalRepositories={additionalRepositories}
|
||||
key={repository.id}
|
||||
repository={repository}
|
||||
searchText={searchText}
|
||||
addRepository={addRepository}
|
||||
removeRepository={removeRepository}
|
||||
authenticatedUser={authenticatedUser}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!inferredRepository && !inferredFilePath && !additionalRepositories.length && (
|
||||
<div className="d-flex align-items-center justify-content-center flex-column p-4 mt-4">
|
||||
<Text size="small" className="m-0 text-center text-muted">
|
||||
Add up to 10 repositories for Cody to reference when providing answers.
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{searchText && (
|
||||
<>
|
||||
<div className="d-flex justify-content-between p-2 border-bottom mb-1">
|
||||
<Text className={classNames('m-0', styles.header)}>
|
||||
{additionalRepositoriesLeft
|
||||
? `Add up to ${additionalRepositoriesLeft} additional repositories`
|
||||
: 'Maximum additional repositories added'}
|
||||
</Text>
|
||||
</div>
|
||||
<div className={classNames('d-flex flex-column', styles.contextItemsContainer)}>
|
||||
{searchResults.length ? (
|
||||
searchResults.map(repository => (
|
||||
<SearchResultsListItem
|
||||
additionalRepositories={additionalRepositories}
|
||||
key={repository.id}
|
||||
repository={repository}
|
||||
searchText={searchText}
|
||||
addRepository={addRepository}
|
||||
removeRepository={removeRepository}
|
||||
/>
|
||||
))
|
||||
) : !loadingSearchResults ? (
|
||||
<div className="d-flex align-items-center justify-content-center flex-column p-4 mt-4">
|
||||
<Text size="small" className="m-0 d-flex text-center">
|
||||
No matching repositories found
|
||||
</Text>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
))
|
||||
) : !loadingSearchResults ? (
|
||||
<div className="d-flex align-items-center justify-content-center flex-column p-4 mt-4">
|
||||
<Text size="small" className="m-0 d-flex text-center">
|
||||
No matching repositories found
|
||||
</Text>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
<div className={classNames('relative p-2 border-top mt-auto', styles.inputContainer)}>
|
||||
<Input
|
||||
role="combobox"
|
||||
@ -398,7 +450,8 @@ export const RepositoriesSelectorPopover: React.FC<{
|
||||
const AdditionalRepositoriesListItem: React.FC<{
|
||||
repository: IRepo
|
||||
removeRepository: (repoName: string) => void
|
||||
}> = React.memo(function RepositoryListItemContent({ repository, removeRepository }) {
|
||||
authenticatedUser: AuthenticatedUser | null
|
||||
}> = React.memo(function RepositoryListItemContent({ repository, removeRepository, authenticatedUser }) {
|
||||
const onClick = useCallback(() => {
|
||||
removeRepository(repository.name)
|
||||
}, [repository, removeRepository])
|
||||
@ -407,7 +460,7 @@ const AdditionalRepositoriesListItem: React.FC<{
|
||||
<button
|
||||
type="button"
|
||||
className={classNames(
|
||||
'd-flex justify-content-between flex-row text-truncate px-2 py-1 mb-1',
|
||||
'd-flex justify-content-between flex-row text-truncate pl-2 pr-3 py-1 mb-1',
|
||||
styles.repositoryListItem
|
||||
)}
|
||||
onClick={onClick}
|
||||
@ -421,7 +474,7 @@ const AdditionalRepositoriesListItem: React.FC<{
|
||||
<ExternalRepositoryIcon externalRepo={repository.externalRepository} className={styles.repoIcon} />
|
||||
<span className="text-truncate">{getRepoName(repository.name)}</span>
|
||||
</div>
|
||||
<EmbeddingExistsIcon repo={repository} />
|
||||
<EmbeddingExistsIcon repo={repository} authenticatedUser={authenticatedUser} />
|
||||
</button>
|
||||
)
|
||||
})
|
||||
@ -432,12 +485,14 @@ const SearchResultsListItem: React.FC<{
|
||||
searchText: string
|
||||
addRepository: (repoName: string) => void
|
||||
removeRepository: (repoName: string) => void
|
||||
authenticatedUser: AuthenticatedUser | null
|
||||
}> = React.memo(function RepositoryListItemContent({
|
||||
additionalRepositories,
|
||||
repository,
|
||||
searchText,
|
||||
addRepository,
|
||||
removeRepository,
|
||||
authenticatedUser,
|
||||
}) {
|
||||
const selected = useMemo(
|
||||
() => !!additionalRepositories.find(({ name }) => name === repository.name),
|
||||
@ -458,7 +513,7 @@ const SearchResultsListItem: React.FC<{
|
||||
<button
|
||||
type="button"
|
||||
className={classNames(
|
||||
'd-flex justify-content-between flex-row text-truncate px-2 py-1 mb-1',
|
||||
'd-flex justify-content-between flex-row text-truncate pl-2 pr-3 py-1 mb-1',
|
||||
styles.repositoryListItem,
|
||||
{ [styles.disabledSearchResult]: disabled }
|
||||
)}
|
||||
@ -474,7 +529,7 @@ const SearchResultsListItem: React.FC<{
|
||||
<ExternalRepositoryIcon externalRepo={repository.externalRepository} className={styles.repoIcon} />
|
||||
{getTintedText(getRepoName(repository.name), searchText)}
|
||||
</div>
|
||||
<EmbeddingExistsIcon repo={repository} />
|
||||
<EmbeddingExistsIcon repo={repository} authenticatedUser={authenticatedUser} />
|
||||
</button>
|
||||
)
|
||||
})
|
||||
@ -594,12 +649,13 @@ export const isRepoIndexed = (repo: IRepo): boolean => getEmbeddingStatus(repo).
|
||||
|
||||
const EmbeddingExistsIcon: React.FC<{
|
||||
repo: IRepo
|
||||
}> = React.memo(function EmbeddingExistsIconContent({ repo }) {
|
||||
authenticatedUser: AuthenticatedUser | null
|
||||
}> = React.memo(function EmbeddingExistsIconContent({ repo, authenticatedUser }) {
|
||||
const { tooltip, icon, className } = getEmbeddingStatus(repo)
|
||||
|
||||
return (
|
||||
<Tooltip content={tooltip}>
|
||||
{window.context.currentUser?.siteAdmin ? (
|
||||
{authenticatedUser?.siteAdmin ? (
|
||||
<Link to="/site-admin/embeddings" className="text-body" onClick={event => event.stopPropagation()}>
|
||||
<Icon aria-hidden={true} className={classNames(styles.icon, className)} svgPath={icon} />
|
||||
</Link>
|
||||
|
||||
@ -2,8 +2,10 @@ import React, { useEffect, useMemo, useCallback } from 'react'
|
||||
|
||||
import classNames from 'classnames'
|
||||
|
||||
import type { TranscriptJSON } from '@sourcegraph/cody-shared/dist/chat/transcript'
|
||||
import type { CodyClientScope } from '@sourcegraph/cody-shared/dist/chat/useClient'
|
||||
import { useLazyQuery } from '@sourcegraph/http-client'
|
||||
import type { AuthenticatedUser } from '@sourcegraph/shared/src/auth'
|
||||
import { Text } from '@sourcegraph/wildcard'
|
||||
|
||||
import type { ReposStatusResult, ReposStatusVariables } from '../../../graphql-operations'
|
||||
@ -22,11 +24,13 @@ export interface ScopeSelectorProps {
|
||||
fetchRepositoryNames: (count: number) => Promise<string[]>
|
||||
isSourcegraphApp?: boolean
|
||||
logTranscriptEvent: (eventLabel: string, eventProperties?: { [key: string]: any }) => void
|
||||
transcriptHistory: TranscriptJSON[]
|
||||
className?: string
|
||||
renderHint?: (repos: IRepo[]) => React.ReactNode
|
||||
// Whether to encourage the selector popover to overlap its trigger if necessary,
|
||||
// rather than collapsing or flipping position.
|
||||
encourageOverlap?: boolean
|
||||
authenticatedUser: AuthenticatedUser | null
|
||||
}
|
||||
|
||||
export const ScopeSelector: React.FC<ScopeSelectorProps> = React.memo(function ScopeSelectorComponent({
|
||||
@ -37,9 +41,11 @@ export const ScopeSelector: React.FC<ScopeSelectorProps> = React.memo(function S
|
||||
fetchRepositoryNames,
|
||||
isSourcegraphApp,
|
||||
logTranscriptEvent,
|
||||
transcriptHistory,
|
||||
className,
|
||||
renderHint,
|
||||
encourageOverlap,
|
||||
authenticatedUser,
|
||||
}) {
|
||||
const [loadReposStatus, { data: newReposStatusData, previousData: previousReposStatusData }] = useLazyQuery<
|
||||
ReposStatusResult,
|
||||
@ -62,10 +68,10 @@ export const ScopeSelector: React.FC<ScopeSelectorProps> = React.memo(function S
|
||||
}
|
||||
|
||||
loadReposStatus({
|
||||
variables: { repoNames, first: repoNames.length, includeJobs: !!window.context.currentUser?.siteAdmin },
|
||||
variables: { repoNames, first: repoNames.length, includeJobs: !!authenticatedUser?.siteAdmin },
|
||||
pollInterval: 2000,
|
||||
}).catch(() => null)
|
||||
}, [activeEditor, scope.repositories, loadReposStatus])
|
||||
}, [activeEditor, scope.repositories, loadReposStatus, authenticatedUser?.siteAdmin])
|
||||
|
||||
const allRepositories = useMemo(() => reposStatusData?.repositories.nodes || [], [reposStatusData])
|
||||
|
||||
@ -134,6 +140,8 @@ export const ScopeSelector: React.FC<ScopeSelectorProps> = React.memo(function S
|
||||
toggleIncludeInferredRepository={toggleIncludeInferredRepository}
|
||||
toggleIncludeInferredFile={toggleIncludeInferredFile}
|
||||
encourageOverlap={encourageOverlap}
|
||||
transcriptHistory={transcriptHistory}
|
||||
authenticatedUser={authenticatedUser}
|
||||
/>
|
||||
{scope.includeInferredFile && activeEditor?.filePath && (
|
||||
<Text size="small" className="ml-2 mb-0 align-self-center">
|
||||
|
||||
@ -1,47 +1,87 @@
|
||||
import { gql } from '@sourcegraph/http-client'
|
||||
|
||||
const REPO_FIELDS = gql`
|
||||
fragment ContextSelectorRepoFields on Repository {
|
||||
id
|
||||
name
|
||||
embeddingExists
|
||||
externalRepository {
|
||||
id
|
||||
serviceType
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const EMBEDDING_JOB_FIELDS = gql`
|
||||
fragment ContextSelectorEmbeddingJobFields on RepoEmbeddingJob {
|
||||
id
|
||||
state
|
||||
failureMessage
|
||||
}
|
||||
`
|
||||
|
||||
export const ReposSelectorSearchQuery = gql`
|
||||
query ReposSelectorSearch($query: String!, $includeJobs: Boolean!) {
|
||||
repositories(query: $query, first: 10) {
|
||||
repositories(query: $query, first: 15) {
|
||||
nodes {
|
||||
id
|
||||
name
|
||||
embeddingExists
|
||||
externalRepository {
|
||||
id
|
||||
serviceType
|
||||
}
|
||||
...ContextSelectorRepoFields
|
||||
embeddingJobs(first: 1) @include(if: $includeJobs) {
|
||||
nodes {
|
||||
id
|
||||
state
|
||||
failureMessage
|
||||
...ContextSelectorEmbeddingJobFields
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
${REPO_FIELDS}
|
||||
${EMBEDDING_JOB_FIELDS}
|
||||
`
|
||||
|
||||
export const SuggestedReposQuery = gql`
|
||||
query SuggestedRepos($names: [String!]!, $numResults: Int!, $includeJobs: Boolean!) {
|
||||
byName: repositories(names: $names, first: $numResults) {
|
||||
nodes {
|
||||
...ContextSelectorRepoFields
|
||||
embeddingJobs(first: 1) @include(if: $includeJobs) {
|
||||
nodes {
|
||||
...ContextSelectorEmbeddingJobFields
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
# We also grab the first $numResults embedded repos available on the site
|
||||
# to show in suggestions as a backup.
|
||||
firstN: repositories(first: $numResults, embedded: true) {
|
||||
nodes {
|
||||
...ContextSelectorRepoFields
|
||||
embeddingJobs(first: 1) @include(if: $includeJobs) {
|
||||
nodes {
|
||||
...ContextSelectorEmbeddingJobFields
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
${REPO_FIELDS}
|
||||
${EMBEDDING_JOB_FIELDS}
|
||||
`
|
||||
|
||||
export const ReposStatusQuery = gql`
|
||||
query ReposStatus($repoNames: [String!]!, $first: Int!, $includeJobs: Boolean!) {
|
||||
repositories(names: $repoNames, first: $first) {
|
||||
nodes {
|
||||
id
|
||||
name
|
||||
embeddingExists
|
||||
externalRepository {
|
||||
id
|
||||
serviceType
|
||||
}
|
||||
...ContextSelectorRepoFields
|
||||
embeddingJobs(first: 1) @include(if: $includeJobs) {
|
||||
nodes {
|
||||
id
|
||||
state
|
||||
failureMessage
|
||||
...ContextSelectorEmbeddingJobFields
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
${REPO_FIELDS}
|
||||
${EMBEDDING_JOB_FIELDS}
|
||||
`
|
||||
|
||||
@ -0,0 +1,179 @@
|
||||
import { useMemo } from 'react'
|
||||
|
||||
import type { TranscriptJSON } from '@sourcegraph/cody-shared/dist/chat/transcript'
|
||||
import { useQuery } from '@sourcegraph/http-client'
|
||||
import type { AuthenticatedUser } from '@sourcegraph/shared/src/auth'
|
||||
|
||||
import { useUserHistory } from '../../../components/useUserHistory'
|
||||
import type {
|
||||
ContextSelectorRepoFields,
|
||||
SuggestedReposResult,
|
||||
SuggestedReposVariables,
|
||||
} from '../../../graphql-operations'
|
||||
|
||||
import { SuggestedReposQuery } from './backend'
|
||||
|
||||
interface UseRepoSuggestionsOpts {
|
||||
numSuggestions: number
|
||||
fallbackSuggestions: string[]
|
||||
omitSuggestions: { name: string }[]
|
||||
}
|
||||
|
||||
const DEFAULT_OPTS: UseRepoSuggestionsOpts = {
|
||||
numSuggestions: 10,
|
||||
fallbackSuggestions: [
|
||||
// This is mostly relevant for dotcom but could fill in for any instance which
|
||||
// indexes GitHub OSS repositories. If the repositories are not indexed on the
|
||||
// instance, they will not return any results from the search API and thus will
|
||||
// not be included in the final list of suggestions.
|
||||
//
|
||||
// NOTE: The actual 10 most popular OSS repositories on GitHub are typically just
|
||||
// plaintext resource collections like awesome lists or how-to-program tutorials, which
|
||||
// are not likely to be as interesting to Cody users. Instead, we hardcode a list of
|
||||
// repositories that are among the top 100 that contain actual source code.
|
||||
'github.com/sourcegraph/sourcegraph',
|
||||
'github.com/freeCodeCamp/freeCodeCamp',
|
||||
'github.com/facebook/react',
|
||||
'github.com/tensorflow/tensorflow',
|
||||
'github.com/torvalds/linux',
|
||||
'github.com/microsoft/vscode',
|
||||
'github.com/flutter/flutter',
|
||||
'github.com/golang/go',
|
||||
'github.com/d3/d3',
|
||||
'github.com/kubernetes/kubernetes',
|
||||
],
|
||||
omitSuggestions: [],
|
||||
}
|
||||
|
||||
/**
|
||||
* useRepoSuggestions is a custom hook that generates repository suggestions for the
|
||||
* context scope selector.
|
||||
*
|
||||
* The suggestions are based on the current user's chat transcript history, search
|
||||
* browsing history, embedded repositories available on their instance, and any fallbacks
|
||||
* configured in the options.
|
||||
*
|
||||
* The number of suggestions can be configured with `opts.numSuggestions`. The default is 10.
|
||||
*
|
||||
* Fallback suggestions can be configured with `opts.fallbackSuggestions`. The default is
|
||||
* a list of 10 of the most popular OSS repositories on GitHub.
|
||||
*
|
||||
* Repositories can be omitted from the suggestions (for example, repositories that are
|
||||
* already added to the context scope) by passing them as `opts.omitSuggestions.`
|
||||
*
|
||||
* @param transcriptHistory the current user's chat transcript history from the store
|
||||
* @param authenticatedUser the current authenticated user
|
||||
* @param opts any options for further configuring the suggestions
|
||||
* @returns a list of repository suggestions as `ContextSelectorRepoFields` objects
|
||||
*/
|
||||
export const useRepoSuggestions = (
|
||||
transcriptHistory: TranscriptJSON[],
|
||||
authenticatedUser: AuthenticatedUser | null = null,
|
||||
opts?: Partial<UseRepoSuggestionsOpts>
|
||||
): ContextSelectorRepoFields[] => {
|
||||
const { numSuggestions, fallbackSuggestions, omitSuggestions } = { ...DEFAULT_OPTS, ...opts }
|
||||
|
||||
const userHistory = useUserHistory(authenticatedUser?.id, false)
|
||||
const suggestedRepoNames: string[] = useMemo(() => {
|
||||
const flattenedTranscriptHistoryEntries = transcriptHistory
|
||||
.map(item => {
|
||||
const { scope, lastInteractionTimestamp } = item
|
||||
return (
|
||||
// Return a new item for each repository in the scope.
|
||||
scope?.repositories.map(name => ({
|
||||
// Parse a date from the last interaction timestamp.
|
||||
lastAccessed: new Date(lastInteractionTimestamp),
|
||||
name,
|
||||
})) || []
|
||||
)
|
||||
})
|
||||
.flat()
|
||||
// Remove duplicates.
|
||||
.filter(removeDupes)
|
||||
// We only need up to the first numSuggestions.
|
||||
.slice(0, numSuggestions)
|
||||
|
||||
const userHistoryEntries =
|
||||
userHistory
|
||||
.loadEntries()
|
||||
.map(item => ({
|
||||
name: item.repoName,
|
||||
// Parse a date from the last acessed timestamp.
|
||||
lastAccessed: new Date(item.lastAccessed),
|
||||
}))
|
||||
// We only need up to the first numSuggestions.
|
||||
.slice(0, numSuggestions) || []
|
||||
|
||||
// We also can take a list of up to numSuggestions fallback repos to
|
||||
// fill in if we have fewer than numSuggestions to actually suggest.
|
||||
const fallbackRepos = fallbackSuggestions
|
||||
.map(name => ({
|
||||
name,
|
||||
// We order by most recently accessed; these should always be ranked last.
|
||||
lastAccessed: new Date(0),
|
||||
}))
|
||||
.slice(0, numSuggestions)
|
||||
|
||||
// Merge the lists.
|
||||
const merged = [...flattenedTranscriptHistoryEntries, ...userHistoryEntries, ...fallbackRepos]
|
||||
// Sort by most recently accessed.
|
||||
.sort((a, b) => b.lastAccessed.getTime() - a.lastAccessed.getTime())
|
||||
// Remove duplicates.
|
||||
.filter(removeDupes)
|
||||
// Take the most recent numSuggestions.
|
||||
.slice(0, numSuggestions)
|
||||
|
||||
// Return just the names.
|
||||
return merged.map(({ name }) => name)
|
||||
}, [transcriptHistory, userHistory, numSuggestions, fallbackSuggestions])
|
||||
|
||||
// Query for the suggested repositories.
|
||||
const { data: suggestedReposData } = useQuery<SuggestedReposResult, SuggestedReposVariables>(SuggestedReposQuery, {
|
||||
variables: {
|
||||
names: suggestedRepoNames,
|
||||
numResults: numSuggestions,
|
||||
includeJobs: !!authenticatedUser?.siteAdmin,
|
||||
},
|
||||
fetchPolicy: 'cache-first',
|
||||
})
|
||||
|
||||
// Filter out and reorder the suggested repository results.
|
||||
const suggestions: ContextSelectorRepoFields[] = useMemo(() => {
|
||||
if (!suggestedReposData) {
|
||||
return []
|
||||
}
|
||||
|
||||
const nodes = [...suggestedReposData.byName.nodes]
|
||||
// The order of the by-name repos returned by the search API will not match the
|
||||
// order of suggestions we intend to display (the ordering of suggestedRepoNames),
|
||||
// since the default ordering of the search API is alphabetical. Thus, we reorder
|
||||
// them to match the initial ordering of suggestedRepoNames.
|
||||
const sortedByNameNodes = nodes.sort(
|
||||
(a, b) => suggestedRepoNames.indexOf(a.name) - suggestedRepoNames.indexOf(b.name)
|
||||
)
|
||||
// Make sure we have a full numSuggestions to display in the
|
||||
// suggestions. We'll prioritize the repositories we looked up by name, and then
|
||||
// fill in the rest from the first 10 embedded repositories returned by the search
|
||||
// API.
|
||||
return (
|
||||
[...sortedByNameNodes, ...suggestedReposData.firstN.nodes]
|
||||
// Remove any duplicates.
|
||||
.filter(removeDupes)
|
||||
// Take the first numSuggestions.
|
||||
.slice(0, numSuggestions)
|
||||
// Finally, filter out repositories that are should be omitted.
|
||||
.filter(suggestion => !omitSuggestions.find(toOmit => toOmit.name === suggestion.name))
|
||||
)
|
||||
}, [suggestedReposData, suggestedRepoNames, omitSuggestions, numSuggestions])
|
||||
|
||||
return suggestions
|
||||
}
|
||||
|
||||
/**
|
||||
* removeDupes is an `Array.filter` predicate function which removes duplicate entries
|
||||
* from an array of objects based on the `name` property. It filters out any entries which
|
||||
* are not the first occurrence with a given `name`, which means it will preserve the
|
||||
* earliest occurrence of each.
|
||||
*/
|
||||
const removeDupes = (first: { name: string }, index: number, self: { name: string }[]): boolean =>
|
||||
index === self.findIndex(entry => entry.name === first.name)
|
||||
@ -4,6 +4,7 @@ import { mdiClose, mdiHistory, mdiPlus, mdiDelete } from '@mdi/js'
|
||||
import classNames from 'classnames'
|
||||
|
||||
import { CodyLogo } from '@sourcegraph/cody-ui/dist/icons/CodyLogo'
|
||||
import type { AuthenticatedUser } from '@sourcegraph/shared/src/auth'
|
||||
import { Button, Icon, Tooltip, Badge } from '@sourcegraph/wildcard'
|
||||
|
||||
import { ChatUI, ScrollDownButton } from '../components/ChatUI'
|
||||
@ -17,9 +18,10 @@ export const SCROLL_THRESHOLD = 100
|
||||
|
||||
interface CodySidebarProps {
|
||||
onClose?: () => void
|
||||
authenticatedUser: AuthenticatedUser | null
|
||||
}
|
||||
|
||||
export const CodySidebar: React.FC<CodySidebarProps> = ({ onClose }) => {
|
||||
export const CodySidebar: React.FC<CodySidebarProps> = ({ onClose, authenticatedUser }) => {
|
||||
const codySidebarStore = useCodySidebar()
|
||||
const {
|
||||
initializeNewChat,
|
||||
@ -143,7 +145,7 @@ export const CodySidebar: React.FC<CodySidebarProps> = ({ onClose }) => {
|
||||
deleteHistoryItem={deleteHistoryItem}
|
||||
/>
|
||||
) : (
|
||||
<ChatUI codyChatStore={codySidebarStore} />
|
||||
<ChatUI codyChatStore={codySidebarStore} authenticatedUser={authenticatedUser} />
|
||||
)}
|
||||
</div>
|
||||
{showScrollDownButton && <ScrollDownButton onClick={() => scrollToBottom('smooth')} />}
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import React, { useContext, useState, useCallback, useMemo } from 'react'
|
||||
|
||||
import type { AuthenticatedUser } from '@sourcegraph/shared/src/auth'
|
||||
import { useTemporarySetting } from '@sourcegraph/shared/src/settings/temporary'
|
||||
|
||||
import { useCodyChat, type CodyChatStore, codyChatStoreMock } from '../useCodyChat'
|
||||
@ -25,9 +26,10 @@ const CodySidebarContext = React.createContext<CodySidebarStore | null>({
|
||||
|
||||
interface ICodySidebarStoreProviderProps {
|
||||
children?: React.ReactNode
|
||||
authenticatedUser: AuthenticatedUser | null
|
||||
}
|
||||
|
||||
export const CodySidebarStoreProvider: React.FC<ICodySidebarStoreProviderProps> = ({ children }) => {
|
||||
export const CodySidebarStoreProvider: React.FC<ICodySidebarStoreProviderProps> = ({ authenticatedUser, children }) => {
|
||||
const [isSidebarOpen, setIsSidebarOpenState] = useTemporarySetting('cody.showSidebar', false)
|
||||
const [inputNeedsFocus, setInputNeedsFocus] = useState(false)
|
||||
const { setSidebarSize } = useSidebarSize()
|
||||
@ -46,7 +48,7 @@ export const CodySidebarStoreProvider: React.FC<ICodySidebarStoreProviderProps>
|
||||
|
||||
const onEvent = useCallback(() => setIsSidebarOpen(true), [setIsSidebarOpen])
|
||||
|
||||
const codyChatStore = useCodyChat({ onEvent })
|
||||
const codyChatStore = useCodyChat({ userID: authenticatedUser?.id, onEvent })
|
||||
|
||||
const state = useMemo<CodySidebarStore>(
|
||||
() => ({
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react'
|
||||
|
||||
import { noop } from 'lodash'
|
||||
|
||||
import {
|
||||
Transcript,
|
||||
type TranscriptJSON,
|
||||
@ -13,6 +15,7 @@ import {
|
||||
type CodyClientEvent,
|
||||
} from '@sourcegraph/cody-shared/dist/chat/useClient'
|
||||
import { NoopEditor } from '@sourcegraph/cody-shared/dist/editor'
|
||||
import type { Scalars } from '@sourcegraph/shared/src/graphql-operations'
|
||||
import { useLocalStorage } from '@sourcegraph/wildcard'
|
||||
|
||||
import { eventLogger } from '../tracking/eventLogger'
|
||||
@ -31,7 +34,6 @@ export interface CodyChatStore
|
||||
| 'messageInProgress'
|
||||
| 'submitMessage'
|
||||
| 'editMessage'
|
||||
| 'initializeNewChat'
|
||||
| 'executeRecipe'
|
||||
| 'scope'
|
||||
| 'setScope'
|
||||
@ -47,6 +49,7 @@ export interface CodyChatStore
|
||||
deleteHistoryItem: (id: string) => void
|
||||
loadTranscriptFromHistory: (id: string) => Promise<void>
|
||||
logTranscriptEvent: (eventLabel: string, eventProperties?: { [key: string]: any }) => void
|
||||
initializeNewChat: () => void
|
||||
}
|
||||
|
||||
export const codyChatStoreMock: CodyChatStore = {
|
||||
@ -79,6 +82,7 @@ export const codyChatStoreMock: CodyChatStore = {
|
||||
}
|
||||
|
||||
interface CodyChatProps {
|
||||
userID?: Scalars['ID']
|
||||
scope?: CodyClientScope
|
||||
config?: CodyClientConfig
|
||||
onEvent?: (event: CodyClientEvent) => void
|
||||
@ -95,6 +99,7 @@ const CODY_TRANSCRIPT_HISTORY_KEY = 'cody.chat.history'
|
||||
const SAVE_MAX_TRANSCRIPT_HISTORY = 20
|
||||
|
||||
export const useCodyChat = ({
|
||||
userID = 'anonymous',
|
||||
scope: initialScope,
|
||||
config: initialConfig,
|
||||
onEvent,
|
||||
@ -103,8 +108,13 @@ export const useCodyChat = ({
|
||||
autoLoadScopeWithRepositories = false,
|
||||
}: CodyChatProps): CodyChatStore => {
|
||||
const [loadedTranscriptFromHistory, setLoadedTranscriptFromHistory] = useState(false)
|
||||
// Read old transcript history from local storage, if any exists. We will use this to
|
||||
// preserve the history as we migrate to a new key that is differentiated by user.
|
||||
const oldJSON = window.localStorage.getItem(CODY_TRANSCRIPT_HISTORY_KEY)
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
const [transcriptHistoryInternal, setTranscriptHistoryState] = useLocalStorage<TranscriptJSON[]>(
|
||||
CODY_TRANSCRIPT_HISTORY_KEY,
|
||||
// Users have distinct transcript histories, so we use the user ID as a key.
|
||||
`${CODY_TRANSCRIPT_HISTORY_KEY}:${userID}`,
|
||||
[]
|
||||
)
|
||||
const transcriptHistory = useMemo(() => transcriptHistoryInternal || [], [transcriptHistoryInternal])
|
||||
@ -135,6 +145,7 @@ export const useCodyChat = ({
|
||||
customHeaders: window.context.xhrHeaders,
|
||||
debugEnable: false,
|
||||
needsEmailVerification: isEmailVerificationNeededForCody(),
|
||||
experimentalLocalSymbols: false,
|
||||
},
|
||||
scope: initialScope,
|
||||
onEvent,
|
||||
@ -330,25 +341,30 @@ export const useCodyChat = ({
|
||||
return null
|
||||
}
|
||||
|
||||
const newTranscript = initializeNewChatInternal()
|
||||
const newTranscript = initializeNewChatInternal(scope)
|
||||
|
||||
if (newTranscript) {
|
||||
pushTranscriptToHistory(newTranscript).catch(() => null)
|
||||
if (!newTranscript) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (autoLoadScopeWithRepositories) {
|
||||
fetchRepositoryNames(10)
|
||||
.then(repositories => {
|
||||
const updatedScope = {
|
||||
includeInferredRepository: true,
|
||||
includeInferredFile: true,
|
||||
repositories,
|
||||
editor: scope.editor,
|
||||
}
|
||||
setScopeInternal(updatedScope)
|
||||
updateTranscriptInHistory(newTranscript, updatedScope).catch(() => null)
|
||||
})
|
||||
.catch(() => null)
|
||||
}
|
||||
pushTranscriptToHistory(newTranscript).catch(noop)
|
||||
|
||||
// If we couldn't populate the scope with repositories from the last chat
|
||||
// conversation and `autoLoadScopeWithRepositories` is enabled, then we fetch 10
|
||||
// to set in the scope.
|
||||
if (scope.repositories.length === 0 && autoLoadScopeWithRepositories) {
|
||||
fetchRepositoryNames(10)
|
||||
.then(repositories => {
|
||||
const updatedScope = {
|
||||
includeInferredRepository: true,
|
||||
includeInferredFile: true,
|
||||
repositories,
|
||||
editor: scope.editor,
|
||||
}
|
||||
setScopeInternal(updatedScope)
|
||||
updateTranscriptInHistory(newTranscript, updatedScope).catch(noop)
|
||||
})
|
||||
.catch(noop)
|
||||
}
|
||||
|
||||
logTranscriptEvent(EventName.CODY_CHAT_INITIALIZED)
|
||||
@ -385,6 +401,18 @@ export const useCodyChat = ({
|
||||
// Autoload the latest transcript from history once it is loaded. Initially the transcript is null.
|
||||
useEffect(() => {
|
||||
if (!loadedTranscriptFromHistory && transcript === null) {
|
||||
// User transcript history entries were previously stored in a single array in
|
||||
// local storage under the generic key `cody.chat.history`, which is not
|
||||
// differentiated by user. The first time we run this effect, we take any old
|
||||
// entries and write them to the new user-differentiated key, and then delete
|
||||
// the old key. When the effect runs again in the future, it will thus only
|
||||
// run the code that comes after this block.
|
||||
if (oldJSON) {
|
||||
setTranscriptHistoryState(JSON.parse(oldJSON))
|
||||
window.localStorage.removeItem(CODY_TRANSCRIPT_HISTORY_KEY)
|
||||
return
|
||||
}
|
||||
|
||||
const history = sortSliceTranscriptHistory([...transcriptHistory])
|
||||
|
||||
if (autoLoadTranscriptFromHistory) {
|
||||
@ -411,6 +439,7 @@ export const useCodyChat = ({
|
||||
}
|
||||
}, [
|
||||
transcriptHistory,
|
||||
oldJSON,
|
||||
loadedTranscriptFromHistory,
|
||||
transcript,
|
||||
autoLoadTranscriptFromHistory,
|
||||
|
||||
@ -3,6 +3,7 @@ import { useEffect, useMemo } from 'react'
|
||||
import type * as H from 'history'
|
||||
import { useLocation } from 'react-router-dom'
|
||||
|
||||
import type { Scalars } from '../graphql-operations'
|
||||
import { parseBrowserRepoURL } from '../util/url'
|
||||
|
||||
export interface UserHistoryEntry {
|
||||
@ -17,32 +18,53 @@ const LOCAL_STORAGE_KEY = 'user-history'
|
||||
const MAX_LOCAL_STORAGE_COUNT = 100
|
||||
|
||||
/**
|
||||
* Collects all browser history events and stores which repos/files are visited
|
||||
* in local storage. In the future, we should consider storing this history
|
||||
* remotely in temporary settings (or similar). The history is used to
|
||||
* personalize ranking in the fuzzy finder, but could theorically power other
|
||||
* features like improve ranking in the search bar suggestions.
|
||||
* Collects all browser history events and stores which repos/files are visited in local
|
||||
* storage. The history is used to personalize ranking in the fuzzy finder and populate
|
||||
* suggestions in the Cody context selector.
|
||||
*
|
||||
* In the future, we should consider storing this history remotely in temporary settings
|
||||
* and use them to power other features like improving ranking in the search bar
|
||||
* suggestions.
|
||||
*/
|
||||
export class UserHistory {
|
||||
private userID: Scalars['ID'] = 'anonymous'
|
||||
private repos: Map<string, Map<string, number>> = new Map()
|
||||
private storage = window.localStorage
|
||||
constructor() {
|
||||
constructor(userID: Scalars['ID'] = 'anonymous') {
|
||||
this.userID = userID
|
||||
this.migrateOldEntries()
|
||||
for (const entry of this.loadEntries()) {
|
||||
this.onEntry(entry)
|
||||
}
|
||||
}
|
||||
private storageKey(userID: Scalars['ID']): string {
|
||||
return `${LOCAL_STORAGE_KEY}:${userID}`
|
||||
}
|
||||
// User history entries were previously stored in a single array in local storage
|
||||
// under the generic key `user-history`, which is not differentiated by user. The
|
||||
// first time we reinitialize UserHistory and this method runs, we take any old
|
||||
// entries and write them to the new user-differentiated key, and then delete the old
|
||||
// key. When the method runs again in the future, it will thus be a no-op.
|
||||
private migrateOldEntries(): void {
|
||||
const oldJSON = this.storage.getItem(LOCAL_STORAGE_KEY)
|
||||
if (!oldJSON) {
|
||||
return
|
||||
}
|
||||
this.storage.setItem(this.storageKey(this.userID), oldJSON)
|
||||
this.storage.removeItem(LOCAL_STORAGE_KEY)
|
||||
}
|
||||
private saveEntries(entries: UserHistoryEntry[]): void {
|
||||
entries.sort((a, b) => b.lastAccessed - a.lastAccessed)
|
||||
const truncated = entries.slice(0, MAX_LOCAL_STORAGE_COUNT)
|
||||
this.storage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(truncated))
|
||||
this.storage.setItem(this.storageKey(this.userID), JSON.stringify(truncated))
|
||||
for (let index = MAX_LOCAL_STORAGE_COUNT; index < entries.length; index++) {
|
||||
// Synchronize persisted entries with in-memory entries so that
|
||||
// reloading the page doesn't change which entries are available.
|
||||
this.deleteEntry(entries[index])
|
||||
}
|
||||
}
|
||||
private loadEntries(): UserHistoryEntry[] {
|
||||
return JSON.parse(this.storage.getItem(LOCAL_STORAGE_KEY) ?? '[]')
|
||||
public loadEntries(): UserHistoryEntry[] {
|
||||
return JSON.parse(this.storage.getItem(this.storageKey(this.userID)) ?? '[]')
|
||||
}
|
||||
private deleteEntry(entry: UserHistoryEntry): void {
|
||||
if (!entry.filePath) {
|
||||
@ -104,9 +126,30 @@ export class UserHistory {
|
||||
}
|
||||
}
|
||||
|
||||
export function useUserHistory(isRepositoryRelatedPage: boolean): UserHistory {
|
||||
/**
|
||||
* useUserHistory is a custom hook that collects browser history events for the current
|
||||
* user and stores visited repos and files in local storage.
|
||||
*
|
||||
* It takes in the user ID of the current user and whether the current page is
|
||||
* repository-related. On repository pages, it parses the location to extract the repo
|
||||
* name and file path. It then updates the history entry for that repo/file with the
|
||||
* current timestamp.
|
||||
*
|
||||
* The returned `UserHistory` instance provides methods to get the list of visited repos,
|
||||
* and lookup the last accessed timestamp for a repo or file.
|
||||
*
|
||||
* The repo history is persisted to local storage and can be used to personalize and
|
||||
* improve the search experience for the user.
|
||||
*
|
||||
* @param userID the ID of the currently-authenticated user, or undefined if the user is
|
||||
* anonymous
|
||||
* @param isRepositoryRelatedPage whether the component rendering this hook is on a page
|
||||
* that is related to a repository (e.g. a code view page) and should be tracked
|
||||
* @returns a `UserHistory` instance
|
||||
*/
|
||||
export function useUserHistory(userID: Scalars['ID'] | undefined, isRepositoryRelatedPage: boolean): UserHistory {
|
||||
const location = useLocation()
|
||||
const userHistory = useMemo(() => new UserHistory(), [])
|
||||
const userHistory = useMemo(() => new UserHistory(userID), [userID])
|
||||
useEffect(() => {
|
||||
if (isRepositoryRelatedPage) {
|
||||
userHistory.onLocation(location)
|
||||
|
||||
@ -510,7 +510,10 @@ export const RepoContainer: FC<RepoContainerProps> = props => {
|
||||
storageKey="size-cache-cody-sidebar"
|
||||
onResize={setCodySidebarSize}
|
||||
>
|
||||
<CodySidebar onClose={() => setIsCodySidebarOpen(false)} />
|
||||
<CodySidebar
|
||||
onClose={() => setIsCodySidebarOpen(false)}
|
||||
authenticatedUser={props.authenticatedUser}
|
||||
/>
|
||||
</Panel>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -155,7 +155,7 @@ export const routes: RouteObject[] = [
|
||||
element: (
|
||||
<LegacyRoute
|
||||
render={props => (
|
||||
<CodySidebarStoreProvider>
|
||||
<CodySidebarStoreProvider authenticatedUser={props.authenticatedUser}>
|
||||
<RepoContainer {...props} />
|
||||
</CodySidebarStoreProvider>
|
||||
)}
|
||||
|
||||
@ -86,7 +86,7 @@ export const Layout: React.FC<LegacyLayoutProps> = props => {
|
||||
const isSetupWizardPage = location.pathname.startsWith(PageRoutes.SetupWizard)
|
||||
|
||||
const [isFuzzyFinderVisible, setFuzzyFinderVisible] = useState(false)
|
||||
const userHistory = useUserHistory(isRepositoryRelatedPage)
|
||||
const userHistory = useUserHistory(props.authenticatedUser?.id, isRepositoryRelatedPage)
|
||||
|
||||
const communitySearchContextPaths = communitySearchContextsRoutes.map(route => route.path)
|
||||
const isCommunitySearchContextPage = communitySearchContextPaths.includes(location.pathname)
|
||||
|
||||
@ -1581,8 +1581,8 @@ importers:
|
||||
specifier: workspace:*
|
||||
version: link:../codeintellify
|
||||
'@sourcegraph/cody-shared':
|
||||
specifier: ^0.0.5
|
||||
version: 0.0.5
|
||||
specifier: ^0.0.6
|
||||
version: 0.0.6
|
||||
'@sourcegraph/cody-ui':
|
||||
specifier: ^0.0.7
|
||||
version: 0.0.7
|
||||
@ -9358,6 +9358,21 @@ packages:
|
||||
- encoding
|
||||
dev: false
|
||||
|
||||
/@sourcegraph/cody-shared@0.0.6:
|
||||
resolution: {integrity: sha512-Lnc/E8ooH3O2FuvvqLM2T+Vy3a0fPrFh1o4LsTYvMBdekcTz7g2ENyRcEEMwqcbn83TlnO5OqYIPqq/2DJFKLg==}
|
||||
dependencies:
|
||||
'@microsoft/fetch-event-source': 2.0.1
|
||||
dompurify: 3.0.4
|
||||
highlight.js: 10.7.3
|
||||
isomorphic-fetch: 3.0.0
|
||||
lodash: 4.17.21
|
||||
marked: 4.0.16
|
||||
vscode-uri: 3.0.7
|
||||
x2js: 3.4.4
|
||||
transitivePeerDependencies:
|
||||
- encoding
|
||||
dev: false
|
||||
|
||||
/@sourcegraph/cody-ui@0.0.7:
|
||||
resolution: {integrity: sha512-JhPcZppe01jMAWSUqYeCfGoy0Eq6W0+wgeAHjxBADDXmeNlswODgyTtNA4OBD5/8Rw6bYvsxEjnV2u9iWRCsZw==}
|
||||
dependencies:
|
||||
|
||||
Loading…
Reference in New Issue
Block a user