Skip to content

Commit 08b371d

Browse files
brichetdlqqqgithub-actions[bot]
authored
Code toolbar (#67)
* Copy the code cell toolbar from jupyter-ai Co-authored-by: david qiu <[email protected]> * Use notebook tracker to update cell status, and enable toolbar button only if the notebook is visible * Include the active cell manager to the model, and remove the context * Add the toolbar to the collaborative chat * Automatic application of license header * Add configuration to disable the toolbar * Use the configChanged signal * Add the toolbar to websocket chat * update lock file * set activeCell to null when disposed * Add tests * Automatic application of license header * Try to fix ui tests --------- Co-authored-by: david qiu <[email protected]> Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
1 parent 043bc38 commit 08b371d

28 files changed

+1126
-178
lines changed

.github/workflows/build.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,7 @@ jobs:
125125
- name: Execute integration tests
126126
working-directory: packages/jupyterlab-${{ matrix.extension }}-chat/ui-tests
127127
run: |
128-
jlpm playwright test
128+
jlpm playwright test --retries=2
129129
130130
- name: Upload Playwright Test report
131131
if: always()

packages/jupyter-chat/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,9 @@
5555
"@emotion/react": "^11.10.5",
5656
"@emotion/styled": "^11.10.5",
5757
"@jupyter/react-components": "^0.15.2",
58+
"@jupyterlab/application": "^4.2.0",
5859
"@jupyterlab/apputils": "^4.3.0",
60+
"@jupyterlab/notebook": "^4.2.0",
5961
"@jupyterlab/rendermime": "^4.2.0",
6062
"@jupyterlab/ui-components": "^4.2.0",
6163
"@lumino/commands": "^2.0.0",
Lines changed: 318 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,318 @@
1+
/*
2+
* Copyright (c) Jupyter Development Team.
3+
* Distributed under the terms of the Modified BSD License.
4+
*/
5+
6+
import { JupyterFrontEnd, LabShell } from '@jupyterlab/application';
7+
import { Cell, ICellModel } from '@jupyterlab/cells';
8+
import { IChangedArgs } from '@jupyterlab/coreutils';
9+
import { INotebookTracker, NotebookActions } from '@jupyterlab/notebook';
10+
import { IError as CellError } from '@jupyterlab/nbformat';
11+
import { ISignal, Signal } from '@lumino/signaling';
12+
13+
type CellContent = {
14+
type: string;
15+
source: string;
16+
};
17+
18+
type CellWithErrorContent = {
19+
type: 'code';
20+
source: string;
21+
error: {
22+
name: string;
23+
value: string;
24+
traceback: string[];
25+
};
26+
};
27+
28+
export interface IActiveCellManager {
29+
/**
30+
* Whether the notebook is available and an active cell exists.
31+
*/
32+
readonly available: boolean;
33+
/**
34+
* The `CellError` output within the active cell, if any.
35+
*/
36+
readonly activeCellError: CellError | null;
37+
/**
38+
* A signal emitting when the active cell changed.
39+
*/
40+
readonly availabilityChanged: ISignal<this, boolean>;
41+
/**
42+
* A signal emitting when the error state of the active cell changed.
43+
*/
44+
readonly activeCellErrorChanged: ISignal<this, CellError | null>;
45+
/**
46+
* Returns an `ActiveCellContent` object that describes the current active
47+
* cell. If no active cell exists, this method returns `null`.
48+
*
49+
* When called with `withError = true`, this method returns `null` if the
50+
* active cell does not have an error output. Otherwise it returns an
51+
* `ActiveCellContentWithError` object that describes both the active cell and
52+
* the error output.
53+
*/
54+
getContent(withError: boolean): CellContent | CellWithErrorContent | null;
55+
/**
56+
* Inserts `content` in a new cell above the active cell.
57+
*/
58+
insertAbove(content: string): void;
59+
/**
60+
* Inserts `content` in a new cell below the active cell.
61+
*/
62+
insertBelow(content: string): void;
63+
/**
64+
* Replaces the contents of the active cell.
65+
*/
66+
replace(content: string): Promise<void>;
67+
}
68+
69+
/**
70+
* The active cell manager namespace.
71+
*/
72+
export namespace ActiveCellManager {
73+
/**
74+
* The constructor options.
75+
*/
76+
export interface IOptions {
77+
/**
78+
* The notebook tracker.
79+
*/
80+
tracker: INotebookTracker;
81+
/**
82+
* The current shell of the application.
83+
*/
84+
shell: JupyterFrontEnd.IShell;
85+
}
86+
}
87+
88+
/**
89+
* A manager that maintains a reference to the current active notebook cell in
90+
* the main panel (if any), and provides methods for inserting or appending
91+
* content to the active cell.
92+
*
93+
* The current active cell should be obtained by listening to the
94+
* `activeCellChanged` signal.
95+
*/
96+
export class ActiveCellManager implements IActiveCellManager {
97+
constructor(options: ActiveCellManager.IOptions) {
98+
this._notebookTracker = options.tracker;
99+
this._notebookTracker.activeCellChanged.connect(this._onActiveCellChanged);
100+
options.shell.currentChanged?.connect(this._onMainAreaChanged);
101+
if (options.shell instanceof LabShell) {
102+
options.shell.layoutModified?.connect(this._onMainAreaChanged);
103+
}
104+
this._onMainAreaChanged();
105+
}
106+
107+
/**
108+
* Whether the notebook is available and an active cell exists.
109+
*/
110+
get available(): boolean {
111+
return this._available;
112+
}
113+
114+
/**
115+
* The `CellError` output within the active cell, if any.
116+
*/
117+
get activeCellError(): CellError | null {
118+
return this._activeCellError;
119+
}
120+
121+
/**
122+
* A signal emitting when the active cell changed.
123+
*/
124+
get availabilityChanged(): ISignal<this, boolean> {
125+
return this._availabilityChanged;
126+
}
127+
128+
/**
129+
* A signal emitting when the error state of the active cell changed.
130+
*/
131+
get activeCellErrorChanged(): ISignal<this, CellError | null> {
132+
return this._activeCellErrorChanged;
133+
}
134+
135+
/**
136+
* Returns an `ActiveCellContent` object that describes the current active
137+
* cell. If no active cell exists, this method returns `null`.
138+
*
139+
* When called with `withError = true`, this method returns `null` if the
140+
* active cell does not have an error output. Otherwise it returns an
141+
* `ActiveCellContentWithError` object that describes both the active cell and
142+
* the error output.
143+
*/
144+
getContent(withError: false): CellContent | null;
145+
getContent(withError: true): CellWithErrorContent | null;
146+
getContent(withError = false): CellContent | CellWithErrorContent | null {
147+
const sharedModel = this._notebookTracker.activeCell?.model.sharedModel;
148+
if (!sharedModel) {
149+
return null;
150+
}
151+
152+
// case where withError = false
153+
if (!withError) {
154+
return {
155+
type: sharedModel.cell_type,
156+
source: sharedModel.getSource()
157+
};
158+
}
159+
160+
// case where withError = true
161+
const error = this._activeCellError;
162+
if (error) {
163+
return {
164+
type: 'code',
165+
source: sharedModel.getSource(),
166+
error: {
167+
name: error.ename,
168+
value: error.evalue,
169+
traceback: error.traceback
170+
}
171+
};
172+
}
173+
174+
return null;
175+
}
176+
177+
/**
178+
* Inserts `content` in a new cell above the active cell.
179+
*/
180+
insertAbove(content: string): void {
181+
const notebookPanel = this._notebookTracker.currentWidget;
182+
if (!notebookPanel || !notebookPanel.isVisible) {
183+
return;
184+
}
185+
186+
// create a new cell above the active cell and mark new cell as active
187+
NotebookActions.insertAbove(notebookPanel.content);
188+
// replace content of this new active cell
189+
this.replace(content);
190+
}
191+
192+
/**
193+
* Inserts `content` in a new cell below the active cell.
194+
*/
195+
insertBelow(content: string): void {
196+
const notebookPanel = this._notebookTracker.currentWidget;
197+
if (!notebookPanel || !notebookPanel.isVisible) {
198+
return;
199+
}
200+
201+
// create a new cell below the active cell and mark new cell as active
202+
NotebookActions.insertBelow(notebookPanel.content);
203+
// replace content of this new active cell
204+
this.replace(content);
205+
}
206+
207+
/**
208+
* Replaces the contents of the active cell.
209+
*/
210+
async replace(content: string): Promise<void> {
211+
const notebookPanel = this._notebookTracker.currentWidget;
212+
if (!notebookPanel || !notebookPanel.isVisible) {
213+
return;
214+
}
215+
// get reference to active cell directly from Notebook API. this avoids the
216+
// possibility of acting on an out-of-date reference.
217+
const activeCell = this._notebookTracker.activeCell;
218+
if (!activeCell) {
219+
return;
220+
}
221+
222+
// wait for editor to be ready
223+
await activeCell.ready;
224+
225+
// replace the content of the active cell
226+
/**
227+
* NOTE: calling this method sometimes emits an error to the browser console:
228+
*
229+
* ```
230+
* Error: Calls to EditorView.update are not allowed while an update is in progress
231+
* ```
232+
*
233+
* However, there seems to be no impact on the behavior/stability of the
234+
* JupyterLab application after this error is logged. Furthermore, this is
235+
* the official API for setting the content of a cell in JupyterLab 4,
236+
* meaning that this is likely unavoidable.
237+
*/
238+
activeCell.editor?.model.sharedModel.setSource(content);
239+
}
240+
241+
private _onMainAreaChanged = () => {
242+
const value = this._notebookTracker.currentWidget?.isVisible ?? false;
243+
if (value !== this._notebookVisible) {
244+
this._notebookVisible = value;
245+
this._available = !!this._activeCell && this._notebookVisible;
246+
this._availabilityChanged.emit(this._available);
247+
}
248+
};
249+
250+
/**
251+
* Handle the change of active notebook cell.
252+
*/
253+
private _onActiveCellChanged = (
254+
_: INotebookTracker,
255+
activeCell: Cell<ICellModel> | null
256+
): void => {
257+
if (this._activeCell !== activeCell) {
258+
this._activeCell?.model.stateChanged.disconnect(this._cellStateChange);
259+
this._activeCell = activeCell;
260+
261+
activeCell?.ready.then(() => {
262+
this._activeCell?.model.stateChanged.connect(this._cellStateChange);
263+
this._available = !!this._activeCell && this._notebookVisible;
264+
this._availabilityChanged.emit(this._available);
265+
this._activeCell?.disposed.connect(() => {
266+
this._activeCell = null;
267+
});
268+
});
269+
}
270+
};
271+
272+
/**
273+
* Handle the change of the active cell state.
274+
*/
275+
private _cellStateChange = (
276+
_: ICellModel,
277+
change: IChangedArgs<boolean, boolean, any>
278+
): void => {
279+
if (change.name === 'executionCount') {
280+
const currSharedModel = this._activeCell?.model.sharedModel;
281+
const prevActiveCellError = this._activeCellError;
282+
let currActiveCellError: CellError | null = null;
283+
if (currSharedModel && 'outputs' in currSharedModel) {
284+
currActiveCellError =
285+
currSharedModel.outputs.find<CellError>(
286+
(output): output is CellError => output.output_type === 'error'
287+
) || null;
288+
}
289+
290+
// for some reason, the `CellError` object is not referentially stable,
291+
// meaning that this condition always evaluates to `true` and the
292+
// `activeCellErrorChanged` signal is emitted every 200ms, even when the
293+
// error output is unchanged. this is why we have to rely on
294+
// `execution_count` to track changes to the error output.
295+
if (prevActiveCellError !== currActiveCellError) {
296+
this._activeCellError = currActiveCellError;
297+
this._activeCellErrorChanged.emit(this._activeCellError);
298+
}
299+
}
300+
};
301+
302+
/**
303+
* The notebook tracker.
304+
*/
305+
private _notebookTracker: INotebookTracker;
306+
/**
307+
* Whether the current notebook panel is visible or not.
308+
*/
309+
private _notebookVisible: boolean = false;
310+
/**
311+
* The active cell.
312+
*/
313+
private _activeCell: Cell | null = null;
314+
private _available: boolean = false;
315+
private _activeCellError: CellError | null = null;
316+
private _availabilityChanged = new Signal<this, boolean>(this);
317+
private _activeCellErrorChanged = new Signal<this, CellError | null>(this);
318+
}

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -398,6 +398,7 @@ export function ChatMessage(props: ChatMessageProps): JSX.Element {
398398
<RendermimeMarkdown
399399
rmRegistry={rmRegistry}
400400
markdownStr={message.body}
401+
model={model}
401402
edit={canEdit ? () => setEdit(true) : undefined}
402403
delete={canDelete ? () => deleteMessage(message.id) : undefined}
403404
/>

0 commit comments

Comments
 (0)