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:
Kelli Rockwell 2023-09-10 15:55:47 -07:00 committed by GitHub
parent 30877e5b14
commit f8ead8534b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 697 additions and 225 deletions

2
.gitignore vendored
View File

@ -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

View File

@ -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",

View File

@ -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:*",

View File

@ -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)

View File

@ -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>
)}

View File

@ -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) {

View File

@ -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
}
}

View File

@ -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
}
}

View File

@ -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 {

View File

@ -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 wont 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>

View File

@ -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
}
}

View File

@ -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>

View File

@ -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">

View File

@ -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}
`

View File

@ -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)

View File

@ -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')} />}

View File

@ -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>(
() => ({

View File

@ -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,

View File

@ -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)

View File

@ -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>

View File

@ -155,7 +155,7 @@ export const routes: RouteObject[] = [
element: (
<LegacyRoute
render={props => (
<CodySidebarStoreProvider>
<CodySidebarStoreProvider authenticatedUser={props.authenticatedUser}>
<RepoContainer {...props} />
</CodySidebarStoreProvider>
)}

View File

@ -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)

View File

@ -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: