Skip to content

Commit 94f3e1b

Browse files
authored
Add code toolbar to Jupyter AI chat (#789)
* add ActiveCellContext component * add code block toolbar in chat * pre-commit * prefer sentence case in copy button * prefer single-char ellipsis
1 parent 20875ad commit 94f3e1b

File tree

11 files changed

+536
-114
lines changed

11 files changed

+536
-114
lines changed

packages/jupyter-ai/src/components/chat.tsx

Lines changed: 53 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import SettingsIcon from '@mui/icons-material/Settings';
55
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
66
import type { Awareness } from 'y-protocols/awareness';
77
import type { IThemeManager } from '@jupyterlab/apputils';
8+
import { IRenderMimeRegistry } from '@jupyterlab/rendermime';
89

910
import { JlThemeProvider } from './jl-theme-provider';
1011
import { ChatMessages } from './chat-messages';
@@ -19,7 +20,10 @@ import { SelectionWatcher } from '../selection-watcher';
1920
import { ChatHandler } from '../chat_handler';
2021
import { CollaboratorsContextProvider } from '../contexts/collaborators-context';
2122
import { IJaiCompletionProvider } from '../tokens';
22-
import { IRenderMimeRegistry } from '@jupyterlab/rendermime';
23+
import {
24+
ActiveCellContextProvider,
25+
ActiveCellManager
26+
} from '../contexts/active-cell-context';
2327
import { ScrollContainer } from './scroll-container';
2428

2529
type ChatBodyProps = {
@@ -188,6 +192,7 @@ export type ChatProps = {
188192
chatView?: ChatView;
189193
completionProvider: IJaiCompletionProvider | null;
190194
openInlineCompleterSettings: () => void;
195+
activeCellManager: ActiveCellManager;
191196
};
192197

193198
enum ChatView {
@@ -202,51 +207,57 @@ export function Chat(props: ChatProps): JSX.Element {
202207
<JlThemeProvider themeManager={props.themeManager}>
203208
<SelectionContextProvider selectionWatcher={props.selectionWatcher}>
204209
<CollaboratorsContextProvider globalAwareness={props.globalAwareness}>
205-
<Box
206-
// root box should not include padding as it offsets the vertical
207-
// scrollbar to the left
208-
sx={{
209-
width: '100%',
210-
height: '100%',
211-
boxSizing: 'border-box',
212-
background: 'var(--jp-layout-color0)',
213-
display: 'flex',
214-
flexDirection: 'column'
215-
}}
210+
<ActiveCellContextProvider
211+
activeCellManager={props.activeCellManager}
216212
>
217-
{/* top bar */}
218-
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
219-
{view !== ChatView.Chat ? (
220-
<IconButton onClick={() => setView(ChatView.Chat)}>
221-
<ArrowBackIcon />
222-
</IconButton>
223-
) : (
224-
<Box />
213+
<Box
214+
// root box should not include padding as it offsets the vertical
215+
// scrollbar to the left
216+
sx={{
217+
width: '100%',
218+
height: '100%',
219+
boxSizing: 'border-box',
220+
background: 'var(--jp-layout-color0)',
221+
display: 'flex',
222+
flexDirection: 'column'
223+
}}
224+
>
225+
{/* top bar */}
226+
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
227+
{view !== ChatView.Chat ? (
228+
<IconButton onClick={() => setView(ChatView.Chat)}>
229+
<ArrowBackIcon />
230+
</IconButton>
231+
) : (
232+
<Box />
233+
)}
234+
{view === ChatView.Chat ? (
235+
<IconButton onClick={() => setView(ChatView.Settings)}>
236+
<SettingsIcon />
237+
</IconButton>
238+
) : (
239+
<Box />
240+
)}
241+
</Box>
242+
{/* body */}
243+
{view === ChatView.Chat && (
244+
<ChatBody
245+
chatHandler={props.chatHandler}
246+
setChatView={setView}
247+
rmRegistry={props.rmRegistry}
248+
/>
225249
)}
226-
{view === ChatView.Chat ? (
227-
<IconButton onClick={() => setView(ChatView.Settings)}>
228-
<SettingsIcon />
229-
</IconButton>
230-
) : (
231-
<Box />
250+
{view === ChatView.Settings && (
251+
<ChatSettings
252+
rmRegistry={props.rmRegistry}
253+
completionProvider={props.completionProvider}
254+
openInlineCompleterSettings={
255+
props.openInlineCompleterSettings
256+
}
257+
/>
232258
)}
233259
</Box>
234-
{/* body */}
235-
{view === ChatView.Chat && (
236-
<ChatBody
237-
chatHandler={props.chatHandler}
238-
setChatView={setView}
239-
rmRegistry={props.rmRegistry}
240-
/>
241-
)}
242-
{view === ChatView.Settings && (
243-
<ChatSettings
244-
rmRegistry={props.rmRegistry}
245-
completionProvider={props.completionProvider}
246-
openInlineCompleterSettings={props.openInlineCompleterSettings}
247-
/>
248-
)}
249-
</Box>
260+
</ActiveCellContextProvider>
250261
</CollaboratorsContextProvider>
251262
</SelectionContextProvider>
252263
</JlThemeProvider>
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import React from 'react';
2+
import { Box } from '@mui/material';
3+
import { addAboveIcon, addBelowIcon } from '@jupyterlab/ui-components';
4+
5+
import { CopyButton } from './copy-button';
6+
import { replaceCellIcon } from '../../icons';
7+
8+
import {
9+
ActiveCellManager,
10+
useActiveCellContext
11+
} from '../../contexts/active-cell-context';
12+
import { TooltippedIconButton } from '../mui-extras/tooltipped-icon-button';
13+
14+
export type CodeToolbarProps = {
15+
/**
16+
* The content of the Markdown code block this component is attached to.
17+
*/
18+
content: string;
19+
};
20+
21+
export function CodeToolbar(props: CodeToolbarProps): JSX.Element {
22+
const [activeCellExists, activeCellManager] = useActiveCellContext();
23+
const sharedToolbarButtonProps = {
24+
content: props.content,
25+
activeCellManager,
26+
activeCellExists
27+
};
28+
29+
return (
30+
<Box
31+
sx={{
32+
display: 'flex',
33+
justifyContent: 'flex-end',
34+
alignItems: 'center',
35+
padding: '6px 2px',
36+
marginBottom: '1em',
37+
border: '1px solid var(--jp-cell-editor-border-color)',
38+
borderTop: 'none'
39+
}}
40+
>
41+
<InsertAboveButton {...sharedToolbarButtonProps} />
42+
<InsertBelowButton {...sharedToolbarButtonProps} />
43+
<ReplaceButton {...sharedToolbarButtonProps} />
44+
<CopyButton value={props.content} />
45+
</Box>
46+
);
47+
}
48+
49+
type ToolbarButtonProps = {
50+
content: string;
51+
activeCellExists: boolean;
52+
activeCellManager: ActiveCellManager;
53+
};
54+
55+
function InsertAboveButton(props: ToolbarButtonProps) {
56+
const tooltip = props.activeCellExists
57+
? 'Insert above active cell'
58+
: 'Insert above active cell (no active cell)';
59+
60+
return (
61+
<TooltippedIconButton
62+
tooltip={tooltip}
63+
onClick={() => props.activeCellManager.insertAbove(props.content)}
64+
disabled={!props.activeCellExists}
65+
>
66+
<addAboveIcon.react height="16px" width="16px" />
67+
</TooltippedIconButton>
68+
);
69+
}
70+
71+
function InsertBelowButton(props: ToolbarButtonProps) {
72+
const tooltip = props.activeCellExists
73+
? 'Insert below active cell'
74+
: 'Insert below active cell (no active cell)';
75+
76+
return (
77+
<TooltippedIconButton
78+
tooltip={tooltip}
79+
disabled={!props.activeCellExists}
80+
onClick={() => props.activeCellManager.insertBelow(props.content)}
81+
>
82+
<addBelowIcon.react height="16px" width="16px" />
83+
</TooltippedIconButton>
84+
);
85+
}
86+
87+
function ReplaceButton(props: ToolbarButtonProps) {
88+
const tooltip = props.activeCellExists
89+
? 'Replace active cell'
90+
: 'Replace active cell (no active cell)';
91+
92+
return (
93+
<TooltippedIconButton
94+
tooltip={tooltip}
95+
disabled={!props.activeCellExists}
96+
onClick={() => props.activeCellManager.replace(props.content)}
97+
>
98+
<replaceCellIcon.react height="16px" width="16px" />
99+
</TooltippedIconButton>
100+
);
101+
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import React, { useState, useCallback, useRef } from 'react';
2+
3+
import { copyIcon } from '@jupyterlab/ui-components';
4+
5+
import { TooltippedIconButton } from '../mui-extras/tooltipped-icon-button';
6+
7+
enum CopyStatus {
8+
None,
9+
Copying,
10+
Copied
11+
}
12+
13+
const COPYBTN_TEXT_BY_STATUS: Record<CopyStatus, string> = {
14+
[CopyStatus.None]: 'Copy to clipboard',
15+
[CopyStatus.Copying]: 'Copying…',
16+
[CopyStatus.Copied]: 'Copied!'
17+
};
18+
19+
type CopyButtonProps = {
20+
value: string;
21+
};
22+
23+
export function CopyButton(props: CopyButtonProps): JSX.Element {
24+
const [copyStatus, setCopyStatus] = useState<CopyStatus>(CopyStatus.None);
25+
const timeoutId = useRef<number | null>(null);
26+
27+
const copy = useCallback(async () => {
28+
// ignore if we are already copying
29+
if (copyStatus === CopyStatus.Copying) {
30+
return;
31+
}
32+
33+
try {
34+
await navigator.clipboard.writeText(props.value);
35+
} catch (err) {
36+
console.error('Failed to copy text: ', err);
37+
setCopyStatus(CopyStatus.None);
38+
return;
39+
}
40+
41+
setCopyStatus(CopyStatus.Copied);
42+
if (timeoutId.current) {
43+
clearTimeout(timeoutId.current);
44+
}
45+
timeoutId.current = setTimeout(() => setCopyStatus(CopyStatus.None), 1000);
46+
}, [copyStatus, props.value]);
47+
48+
return (
49+
<TooltippedIconButton
50+
tooltip={COPYBTN_TEXT_BY_STATUS[copyStatus]}
51+
placement="top"
52+
onClick={copy}
53+
aria-label="Copy to clipboard"
54+
>
55+
<copyIcon.react height="16px" width="16px" />
56+
</TooltippedIconButton>
57+
);
58+
}

packages/jupyter-ai/src/components/copy-button.tsx

Lines changed: 0 additions & 50 deletions
This file was deleted.

0 commit comments

Comments
 (0)