Skip to content
This repository was archived by the owner on Sep 30, 2024. It is now read-only.

Commit b4e03f4

Browse files
authored
Prompt Library (#63872)
The Prompt Library lets you create, share, and browse chat prompts for use with Cody. Prompts are owned by users or organizations, and site admins can make prompts public so that all users on the instance can see and use them. A prompt is just plain text for now, and you can see a list of prompts in your Prompt Library from within Cody chat (https://github.com/sourcegraph/cody/pull/4903). See https://www.loom.com/share/f3124269300c481ebfcbd0a1e300be1b. Depends on https://github.com/sourcegraph/cody/pull/4903. ![image](https://github.com/user-attachments/assets/d1098809-f7ff-4233-8ecb-9bc53ad4dbb2) ## Test plan Add a prompt on the web. Ensure you can access it from Cody. ## Changelog - The Prompt Library lets you create, share, and browse chat prompts for use with Cody. Prompts are owned by users or organizations, and site admins can make prompts public so that all users on the instance can see and use them. To use a prompt from your Prompt Library in Cody, select it in the **Prompts** dropdown in the Cody chat message field.
1 parent 57de59c commit b4e03f4

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

64 files changed

+6891
-18
lines changed

client/web/BUILD.bazel

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1223,6 +1223,17 @@ ts_project(
12231223
"src/person/PersonLink.tsx",
12241224
"src/platform/context.ts",
12251225
"src/productSubscription/helpers.ts",
1226+
"src/prompts/Area.tsx",
1227+
"src/prompts/DetailPage.tsx",
1228+
"src/prompts/EditPage.tsx",
1229+
"src/prompts/Form.tsx",
1230+
"src/prompts/ListPage.tsx",
1231+
"src/prompts/NewForm.tsx",
1232+
"src/prompts/Page.tsx",
1233+
"src/prompts/PromptIcon.tsx",
1234+
"src/prompts/PromptNameWithOwner.tsx",
1235+
"src/prompts/graphql.ts",
1236+
"src/prompts/util.ts",
12261237
"src/repo/DirectImportRepoAlert.tsx",
12271238
"src/repo/FilePathBreadcrumbs.tsx",
12281239
"src/repo/GitReference.tsx",
@@ -1985,6 +1996,12 @@ ts_project(
19851996
"src/open-in-editor/build-url.test.ts",
19861997
"src/open-in-editor/migrate-legacy-settings.test.ts",
19871998
"src/person/PersonLink.test.tsx",
1999+
"src/prompts/DetailPage.test.tsx",
2000+
"src/prompts/EditPage.test.tsx",
2001+
"src/prompts/Form.test.tsx",
2002+
"src/prompts/ListPage.test.tsx",
2003+
"src/prompts/NewForm.test.tsx",
2004+
"src/prompts/graphql.mocks.ts",
19882005
"src/repo/RepoRevisionSidebarSymbols.test.tsx",
19892006
"src/repo/RepositoriesPopover/RepositoriesPopover.mocks.ts",
19902007
"src/repo/RepositoriesPopover/RepositoriesPopover.test.tsx",
@@ -2403,6 +2420,10 @@ ts_project(
24032420
"src/notebooks/blocks/query/NotebookQueryBlock.story.tsx",
24042421
"src/notebooks/listPage/NotebooksListPage.story.tsx",
24052422
"src/notebooks/notebook/NotebookComponent.story.tsx",
2423+
"src/prompts/EditPage.story.tsx",
2424+
"src/prompts/Form.story.tsx",
2425+
"src/prompts/ListPage.story.tsx",
2426+
"src/prompts/NewForm.story.tsx",
24062427
"src/repo/RepoHeader.story.tsx",
24072428
"src/repo/RepositoriesPopover/RepositoriesPopover.story.tsx",
24082429
"src/repo/RevisionsPopover/RevisionsPopover.story.tsx",

client/web/src/library/itemBadges.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,9 @@ import classNames from 'classnames'
44

55
import { Badge } from '@sourcegraph/wildcard'
66

7-
import { SavedSearchVisibility, type SavedSearchFields } from '../graphql-operations'
7+
import { SavedSearchVisibility, type PromptFields, type SavedSearchFields } from '../graphql-operations'
88

9-
type LibraryItem = SavedSearchFields
9+
type LibraryItem = SavedSearchFields | PromptFields
1010

1111
export const LibraryItemStatusBadge: FunctionComponent<{
1212
item: Pick<LibraryItem, 'draft'>

client/web/src/nav/GlobalNavbar.tsx

Lines changed: 8 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -306,14 +306,18 @@ export const InlineNavigationPanel: FC<InlineNavigationPanelProps> = props => {
306306

307307
const toolsItems = useMemo(() => {
308308
const items: (NavDropdownItem | false)[] = [
309-
// Don't show "Saved Searches" on dotcom yet because it results in a Tools menu with
310-
// only 1 item, which looks weird. Users can still find it in their user menu.
311-
props.authenticatedUser && !isSourcegraphDotCom
309+
props.authenticatedUser
312310
? {
313311
path: PageRoutes.SavedSearches,
314312
content: 'Saved Searches',
315313
}
316314
: false,
315+
window.context?.codyEnabledOnInstance
316+
? {
317+
path: PageRoutes.Prompts,
318+
content: 'Prompt Library',
319+
}
320+
: false,
317321
showSearchContext && { path: PageRoutes.Contexts, content: 'Contexts' },
318322
showSearchNotebook && { path: PageRoutes.Notebooks, content: 'Notebooks' },
319323
// We hardcode the code monitoring path here because PageRoutes.CodeMonitoring is a catch-all
@@ -325,14 +329,7 @@ export const InlineNavigationPanel: FC<InlineNavigationPanelProps> = props => {
325329
},
326330
]
327331
return items.filter<NavDropdownItem>((item): item is NavDropdownItem => !!item)
328-
}, [
329-
props.authenticatedUser,
330-
isSourcegraphDotCom,
331-
showSearchContext,
332-
showSearchNotebook,
333-
showCodeMonitoring,
334-
showSearchJobs,
335-
])
332+
}, [props.authenticatedUser, showSearchContext, showSearchNotebook, showCodeMonitoring, showSearchJobs])
336333
const toolsItem = toolsItems.length > 0 && (
337334
<NavDropdown
338335
key="tools"

client/web/src/nav/new-global-navigation/NewGlobalNavigationBar.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -377,6 +377,11 @@ const SidebarNavigation: FC<SidebarNavigationProps> = props => {
377377
<NavItemLink url={PageRoutes.SavedSearches} onClick={handleNavigationClick}>
378378
Saved Searches
379379
</NavItemLink>
380+
{window.context?.codyEnabledOnInstance && (
381+
<NavItemLink url={PageRoutes.Prompts} onClick={handleNavigationClick}>
382+
Prompt Library
383+
</NavItemLink>
384+
)}
380385
{showSearchContext && (
381386
<NavItemLink url={PageRoutes.Contexts} onClick={handleNavigationClick}>
382387
Contexts

client/web/src/prompts/Area.tsx

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import type { FunctionComponent } from 'react'
2+
3+
import { mdiPlus } from '@mdi/js'
4+
import { Route, Routes } from 'react-router-dom'
5+
6+
import { type AuthenticatedUser } from '@sourcegraph/shared/src/auth'
7+
import type { TelemetryV2Props } from '@sourcegraph/shared/src/telemetry'
8+
import { Button, Icon, Link, PageHeader } from '@sourcegraph/wildcard'
9+
10+
import { AuthenticatedUserOnly } from '../auth/withAuthenticatedUser'
11+
import { NotFoundPage } from '../components/HeroPage'
12+
13+
import { DetailPage } from './DetailPage'
14+
import { EditPage } from './EditPage'
15+
import { ListPage } from './ListPage'
16+
import { NewForm } from './NewForm'
17+
import { PromptPage } from './Page'
18+
19+
/** The prompt area. */
20+
export const Area: FunctionComponent<
21+
{
22+
authenticatedUser: AuthenticatedUser | null
23+
} & TelemetryV2Props
24+
> = ({ authenticatedUser, telemetryRecorder }) => (
25+
<Routes>
26+
<Route
27+
path=""
28+
element={
29+
<PromptPage
30+
title="Prompt Library"
31+
actions={
32+
authenticatedUser && (
33+
<Button to="new" variant="primary" as={Link}>
34+
<Icon aria-hidden={true} svgPath={mdiPlus} /> New prompt
35+
</Button>
36+
)
37+
}
38+
>
39+
<ListPage telemetryRecorder={telemetryRecorder} />
40+
</PromptPage>
41+
}
42+
/>
43+
<Route
44+
path="new"
45+
element={
46+
<AuthenticatedUserOnly authenticatedUser={authenticatedUser}>
47+
<PromptPage title="New prompt" breadcrumbs={<PageHeader.Breadcrumb>New</PageHeader.Breadcrumb>}>
48+
<NewForm telemetryRecorder={telemetryRecorder} />
49+
</PromptPage>
50+
</AuthenticatedUserOnly>
51+
}
52+
/>
53+
<Route
54+
path=":id/edit"
55+
element={
56+
<AuthenticatedUserOnly authenticatedUser={authenticatedUser}>
57+
<EditPage telemetryRecorder={telemetryRecorder} />
58+
</AuthenticatedUserOnly>
59+
}
60+
/>
61+
<Route path=":id" element={<DetailPage telemetryRecorder={telemetryRecorder} />} />
62+
<Route path="*" element={<NotFoundPage pageType="prompt" />} />
63+
</Routes>
64+
)
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
.definition-text {
2+
white-space: pre-wrap;
3+
margin-bottom: 0;
4+
}
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import { screen } from '@testing-library/react'
2+
import { describe, expect, test, vi } from 'vitest'
3+
4+
import { getDocumentNode } from '@sourcegraph/http-client'
5+
import { MockedTestProvider, waitForNextApolloResponse } from '@sourcegraph/shared/src/testing/apollo'
6+
import { renderWithBrandedContext } from '@sourcegraph/wildcard/src/testing'
7+
8+
import { DetailPage } from './DetailPage'
9+
import { promptQuery } from './graphql'
10+
import { MOCK_PROMPT_FIELDS, promptMock } from './graphql.mocks'
11+
12+
const mockTelemetryRecorder = {
13+
recordEvent: vi.fn(),
14+
}
15+
16+
describe('DetailPage', () => {
17+
test('found', async () => {
18+
renderWithBrandedContext(
19+
<MockedTestProvider mocks={[promptMock]}>
20+
<DetailPage telemetryRecorder={mockTelemetryRecorder} />
21+
</MockedTestProvider>,
22+
{ route: '/prompts/1', path: '/prompts/:id' }
23+
)
24+
await waitForNextApolloResponse()
25+
expect(screen.getAllByText('Prompt alice/my-prompt', { exact: false }).at(0)).toBeInTheDocument()
26+
expect(screen.queryByRole('link', { name: 'Edit' })).toBeInTheDocument()
27+
})
28+
29+
test('!viewerCanAdminister', async () => {
30+
renderWithBrandedContext(
31+
<MockedTestProvider
32+
mocks={[
33+
{
34+
request: {
35+
query: getDocumentNode(promptQuery),
36+
variables: { id: '1' },
37+
},
38+
result: {
39+
data: {
40+
node: {
41+
...MOCK_PROMPT_FIELDS,
42+
viewerCanAdminister: false,
43+
},
44+
},
45+
},
46+
},
47+
]}
48+
>
49+
<DetailPage telemetryRecorder={mockTelemetryRecorder} />
50+
</MockedTestProvider>,
51+
{ route: '/prompts/1', path: '/prompts/:id' }
52+
)
53+
await waitForNextApolloResponse()
54+
expect(screen.getAllByText('Prompt alice/my-prompt', { exact: false }).at(0)).toBeInTheDocument()
55+
expect(screen.queryByRole('link', { name: 'Edit' })).not.toBeInTheDocument()
56+
})
57+
58+
test('not found', async () => {
59+
renderWithBrandedContext(
60+
<MockedTestProvider
61+
mocks={[
62+
{
63+
request: {
64+
query: getDocumentNode(promptQuery),
65+
variables: { id: '1' },
66+
},
67+
result: {
68+
data: {
69+
node: null,
70+
},
71+
},
72+
},
73+
]}
74+
>
75+
<DetailPage telemetryRecorder={mockTelemetryRecorder} />
76+
</MockedTestProvider>,
77+
{ route: '/prompts/1', path: '/prompts/:id' }
78+
)
79+
await waitForNextApolloResponse()
80+
expect(screen.getByText('Prompt not found.')).toBeInTheDocument()
81+
})
82+
})
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import { useEffect, type FunctionComponent } from 'react'
2+
3+
import { useLocation, useNavigate, useParams } from 'react-router-dom'
4+
5+
import { Timestamp } from '@sourcegraph/branded/src/components/Timestamp'
6+
import { useQuery } from '@sourcegraph/http-client'
7+
import type { TelemetryV2Props } from '@sourcegraph/shared/src/telemetry'
8+
import { Alert, Button, Container, ErrorAlert, H5, Link, LoadingSpinner, PageHeader, Text } from '@sourcegraph/wildcard'
9+
10+
import type { PromptFields, PromptResult, PromptVariables } from '../graphql-operations'
11+
import { LibraryItemStatusBadge, LibraryItemVisibilityBadge } from '../library/itemBadges'
12+
import { namespaceTelemetryMetadata } from '../namespaces/telemetry'
13+
14+
import { PROMPT_UPDATED_LOCATION_STATE_KEY } from './EditPage'
15+
import { promptQuery } from './graphql'
16+
import { PromptPage } from './Page'
17+
import { urlToEditPrompt } from './util'
18+
19+
import styles from './DetailPage.module.scss'
20+
21+
/**
22+
* Page to show a prompt.
23+
*/
24+
export const DetailPage: FunctionComponent<TelemetryV2Props> = ({ telemetryRecorder }) => {
25+
const { id } = useParams<{ id: string }>()
26+
27+
const { data, loading, error } = useQuery<PromptResult, PromptVariables>(promptQuery, {
28+
variables: { id: id! },
29+
})
30+
const prompt = data?.node?.__typename === 'Prompt' ? data.node : null
31+
32+
// Flash after updating.
33+
const location = useLocation()
34+
const navigate = useNavigate()
35+
const justUpdated = !!location.state?.[PROMPT_UPDATED_LOCATION_STATE_KEY]
36+
useEffect(() => {
37+
if (justUpdated) {
38+
setTimeout(() => navigate({}, { state: {} }), 1000)
39+
}
40+
}, [justUpdated, navigate])
41+
const flash = justUpdated ? 'Saved!' : null
42+
43+
return (
44+
<PromptPage
45+
title={prompt ? `Prompt ${prompt.nameWithOwner}` : 'Prompt'}
46+
actions={
47+
prompt?.viewerCanAdminister && (
48+
<Button to={urlToEditPrompt(prompt)} variant="secondary" as={Link}>
49+
Edit
50+
</Button>
51+
)
52+
}
53+
breadcrumbsNamespace={prompt?.owner}
54+
breadcrumbs={prompt ? <PageHeader.Breadcrumb>{prompt.name}</PageHeader.Breadcrumb> : null}
55+
>
56+
{loading ? (
57+
<LoadingSpinner />
58+
) : error ? (
59+
<ErrorAlert error={error} />
60+
) : !prompt ? (
61+
<Alert variant="danger" as="p">
62+
Prompt not found.
63+
</Alert>
64+
) : (
65+
<>
66+
<Detail prompt={prompt} telemetryRecorder={telemetryRecorder} />
67+
{flash && !loading && (
68+
<Alert variant="success" className="my-3">
69+
{flash}
70+
</Alert>
71+
)}
72+
</>
73+
)}
74+
</PromptPage>
75+
)
76+
}
77+
78+
const Detail: FunctionComponent<TelemetryV2Props & { prompt: PromptFields }> = ({ prompt, telemetryRecorder }) => {
79+
useEffect(() => {
80+
telemetryRecorder.recordEvent('prompts.detail', 'view', {
81+
metadata: namespaceTelemetryMetadata(prompt.owner),
82+
})
83+
}, [telemetryRecorder, prompt.owner])
84+
85+
return (
86+
<>
87+
<Text>
88+
<LibraryItemVisibilityBadge item={prompt} className="mr-1" />
89+
<LibraryItemStatusBadge item={prompt} className="mr-1" />
90+
{prompt.description}
91+
{prompt.description ? ' — ' : ''}
92+
<small>
93+
Last updated <Timestamp date={prompt.updatedAt} noAbout={true} />
94+
{prompt.updatedBy && (
95+
<>
96+
{' '}
97+
by{' '}
98+
<Link to={prompt.updatedBy.url}>
99+
<strong>{prompt.updatedBy.username}</strong>
100+
</Link>
101+
</>
102+
)}
103+
</small>
104+
</Text>
105+
<H5 className="mt-4 mb-2">Prompt template</H5>
106+
<Container>
107+
<Text className={styles.definitionText}>
108+
{prompt.definition.text.trim() === '' ? '(empty)' : prompt.definition.text}
109+
</Text>
110+
</Container>
111+
</>
112+
)
113+
}

0 commit comments

Comments
 (0)