@jupyter/chat 0.20.0-alpha.2 → 0.20.0-alpha.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jupyter/chat",
3
- "version": "0.20.0-alpha.2",
3
+ "version": "0.20.0-alpha.3",
4
4
  "description": "A package that provides UI components that can be used to create a chat in a Jupyterlab extension.",
5
5
  "keywords": [
6
6
  "jupyter",
@@ -0,0 +1,440 @@
1
+ /*
2
+ * Copyright (c) Jupyter Development Team.
3
+ * Distributed under the terms of the Modified BSD License.
4
+ */
5
+
6
+ import { Button } from '@jupyter/react-components';
7
+ import { closeIcon, ReactWidget } from '@jupyterlab/ui-components';
8
+ import { Message } from '@lumino/messaging';
9
+ import React, { useEffect, useRef } from 'react';
10
+
11
+ const POPUP_CLASS = 'jp-chat-selector-popup';
12
+ const POPUP_LIST_CLASS = 'jp-chat-selector-popup-list';
13
+ const POPUP_ITEM_CLASS = 'jp-chat-selector-popup-item';
14
+ const POPUP_ITEM_ACTIVE_CLASS = 'jp-chat-selector-popup-item-active';
15
+ const POPUP_ITEM_LABEL_CLASS = 'jp-chat-selector-popup-item-label';
16
+ const POPUP_EMPTY_CLASS = 'jp-chat-selector-popup-empty';
17
+
18
+ /**
19
+ * A popup widget for selecting a chat from a filtered list.
20
+ */
21
+ export class ChatSelectorPopup extends ReactWidget {
22
+ constructor(options: ChatSelectorPopup.IOptions) {
23
+ super();
24
+ this.addClass(POPUP_CLASS);
25
+ this._chatNames = options.chatNames;
26
+ this._onSelect = options.onSelect;
27
+ this._onClose = options.onClose;
28
+ this._anchor = options.anchor ?? null;
29
+
30
+ // Start hidden
31
+ this.hide();
32
+
33
+ // Initialize filtered chats
34
+ this._updateFilteredChats();
35
+ }
36
+
37
+ /**
38
+ * Getter/setter of the anchor element.
39
+ */
40
+ get anchor(): HTMLElement | null {
41
+ return this._anchor;
42
+ }
43
+ set anchor(element: HTMLElement | null) {
44
+ this._anchor = element;
45
+ }
46
+
47
+ /**
48
+ * Update the list of available chats.
49
+ */
50
+ updateChats(chatNames: string[]): void {
51
+ this._chatNames = chatNames;
52
+ this._updateFilteredChats();
53
+ this.update();
54
+ }
55
+
56
+ /**
57
+ * Update the list of loaded models.
58
+ */
59
+ setLoadedModels(loadedModels: string[]): void {
60
+ this._loadedModels = new Set(loadedModels);
61
+ this._updateFilteredChats();
62
+ this.update();
63
+ }
64
+
65
+ /**
66
+ * Set the currently displayed chat.
67
+ */
68
+ setCurrentChat(chatName: string | null): void {
69
+ this._currentChat = chatName;
70
+ this.update();
71
+ }
72
+
73
+ /**
74
+ * Set the search query and filter the list.
75
+ */
76
+ setQuery(query: string): void {
77
+ this._query = query;
78
+ this._updateFilteredChats();
79
+ // When filtering, select first in filtered list
80
+ if (query.trim()) {
81
+ this._selectedName =
82
+ this._filteredChats.length > 0 ? this._filteredChats[0] : null;
83
+ }
84
+ this.update();
85
+ }
86
+
87
+ /**
88
+ * Get the currently selected chat value.
89
+ */
90
+ getSelectedValue(): string | null {
91
+ return this._selectedName;
92
+ }
93
+
94
+ /**
95
+ * Move selection down.
96
+ */
97
+ selectNext(): void {
98
+ if (this._filteredChats.length === 0) {
99
+ return;
100
+ }
101
+
102
+ const currentIndex = this._filteredChats.findIndex(
103
+ name => name === this._selectedName
104
+ );
105
+
106
+ // If not found or at the end, wrap to first or move down
107
+ if (currentIndex === -1) {
108
+ // No selection yet, select first
109
+ this._selectedName = this._filteredChats[0];
110
+ } else if (currentIndex < this._filteredChats.length - 1) {
111
+ // Move to next
112
+ this._selectedName = this._filteredChats[currentIndex + 1];
113
+ }
114
+ this.update();
115
+ }
116
+
117
+ /**
118
+ * Move selection up.
119
+ */
120
+ selectPrevious(): void {
121
+ if (this._filteredChats.length === 0) {
122
+ return;
123
+ }
124
+
125
+ const currentIndex = this._filteredChats.findIndex(
126
+ name => name === this._selectedName
127
+ );
128
+
129
+ // If not found, select last; otherwise move up
130
+ if (currentIndex === -1) {
131
+ // No selection yet, select last
132
+ this._selectedName = this._filteredChats[this._filteredChats.length - 1];
133
+ } else if (currentIndex > 0) {
134
+ // Move to previous
135
+ this._selectedName = this._filteredChats[currentIndex - 1];
136
+ }
137
+ this.update();
138
+ }
139
+
140
+ /**
141
+ * Show the popup and position it.
142
+ */
143
+ show(): void {
144
+ let needsUpdate = false;
145
+
146
+ if (this._filteredChats.length > 0) {
147
+ const oldSelection = this._selectedName;
148
+ // If there's a current chat and no query, select it
149
+ if (this._currentChat && !this._query.trim()) {
150
+ this._selectedName = this._currentChat;
151
+ } else if (!this._selectedName) {
152
+ // Otherwise select first chat if nothing is selected
153
+ this._selectedName = this._filteredChats[0];
154
+ }
155
+ needsUpdate = oldSelection !== this._selectedName;
156
+ }
157
+
158
+ super.show();
159
+ this._positionPopup();
160
+
161
+ // Only update if selection changed
162
+ if (needsUpdate) {
163
+ this.update();
164
+ }
165
+ }
166
+
167
+ /**
168
+ * Hide the popup.
169
+ */
170
+ hide(): void {
171
+ this._anchorRect = null;
172
+ super.hide();
173
+ }
174
+
175
+ render(): JSX.Element {
176
+ return (
177
+ <ChatSelectorList
178
+ names={this._filteredChats}
179
+ selectedName={this._selectedName}
180
+ loadedModels={this._loadedModels}
181
+ onSelect={this._handleItemClick}
182
+ onUpdateSelectedName={this._handleUpdateSelectedName}
183
+ onClose={this._handleClose}
184
+ />
185
+ );
186
+ }
187
+
188
+ protected onAfterShow(msg: Message): void {
189
+ super.onAfterShow(msg);
190
+ document.addEventListener('pointerdown', this._handleOutsideClick, true);
191
+ window.addEventListener('resize', this._checkPosition);
192
+ }
193
+
194
+ protected onAfterHide(msg: Message): void {
195
+ document.removeEventListener('pointerdown', this._handleOutsideClick, true);
196
+ window.removeEventListener('resize', this._checkPosition);
197
+ super.onAfterHide(msg);
198
+ }
199
+
200
+ /**
201
+ * Check if the popup should move (anchor has moved).
202
+ */
203
+ private _checkPosition = (): void => {
204
+ if (!this._anchor) {
205
+ return;
206
+ }
207
+ const rect = this._anchor.getBoundingClientRect();
208
+ if (
209
+ rect.bottom !== this._anchorRect?.bottom ||
210
+ rect.left !== this._anchorRect.left ||
211
+ rect.width !== this._anchorRect.width
212
+ ) {
213
+ this._positionPopup();
214
+ }
215
+ };
216
+
217
+ /**
218
+ * Position the popup below the search element.
219
+ */
220
+ private _positionPopup(): void {
221
+ if (!this._anchor) {
222
+ return;
223
+ }
224
+
225
+ this._anchorRect = this._anchor.getBoundingClientRect();
226
+
227
+ const rect = this.node.getBoundingClientRect();
228
+ const margin = 8;
229
+
230
+ let left = this._anchorRect.left;
231
+ if (this._anchorRect.left + rect.width > window.innerWidth - margin) {
232
+ left = window.innerWidth - margin - rect.width;
233
+ }
234
+
235
+ let top = this._anchorRect.bottom;
236
+ if (this._anchorRect.bottom + rect.height > window.innerHeight - margin) {
237
+ top = window.innerHeight - margin - rect.height;
238
+ }
239
+
240
+ this.node.style.minWidth = `${this._anchorRect.width}px`;
241
+ this.node.style.top = `${top}px`;
242
+ this.node.style.left = `${left}px`;
243
+ }
244
+
245
+ /**
246
+ * Update the filtered and sorted chats based on current state.
247
+ */
248
+ private _updateFilteredChats(): void {
249
+ let filteredChats = Array.from(
250
+ new Set([...this._chatNames, ...this._loadedModels])
251
+ );
252
+
253
+ // Filter by query if present
254
+ if (this._query.trim()) {
255
+ const queryLower = this._query.toLowerCase();
256
+ filteredChats = filteredChats.filter(name =>
257
+ name.toLowerCase().includes(queryLower)
258
+ );
259
+ }
260
+
261
+ // Separate into loaded and non-loaded
262
+ const loadedChats: string[] = [];
263
+ const nonLoadedChats: string[] = [];
264
+
265
+ filteredChats.forEach(name => {
266
+ if (this._loadedModels.has(name)) {
267
+ loadedChats.push(name);
268
+ } else {
269
+ nonLoadedChats.push(name);
270
+ }
271
+ });
272
+
273
+ // Sort each group alphabetically by name
274
+ loadedChats.sort();
275
+ nonLoadedChats.sort();
276
+
277
+ // Combine: loaded first, then non-loaded
278
+ this._filteredChats = [...loadedChats, ...nonLoadedChats];
279
+ }
280
+
281
+ private _handleItemClick = (name: string): void => {
282
+ if (this._onSelect) {
283
+ this._onSelect(name);
284
+ }
285
+ };
286
+
287
+ private _handleUpdateSelectedName = (name: string): void => {
288
+ this._selectedName = name;
289
+ };
290
+
291
+ private _handleClose = (name: string): void => {
292
+ if (this._onClose) {
293
+ this._onClose(name);
294
+ }
295
+ };
296
+
297
+ private _handleOutsideClick = (event: MouseEvent): void => {
298
+ if (this.isHidden) {
299
+ return;
300
+ }
301
+
302
+ const target = event.target as HTMLElement;
303
+ if (
304
+ !this.node.contains(target) &&
305
+ this._anchor &&
306
+ !this._anchor.contains(target)
307
+ ) {
308
+ this.hide();
309
+ }
310
+ };
311
+
312
+ private _chatNames: string[] = [];
313
+ private _loadedModels: Set<string> = new Set();
314
+ private _currentChat: string | null = null;
315
+ private _query: string = '';
316
+ private _selectedName: string | null = null;
317
+ private _filteredChats: string[] = [];
318
+ private _onSelect?: (name: string) => void;
319
+ private _onClose?: (name: string) => void;
320
+ private _anchor: HTMLElement | null = null;
321
+ private _anchorRect: DOMRect | null = null;
322
+ }
323
+
324
+ /**
325
+ * Namespace for ChatSelectorPopup.
326
+ */
327
+ export namespace ChatSelectorPopup {
328
+ export interface IOptions {
329
+ /**
330
+ * Object mapping display names to values used to identify/open chats.
331
+ */
332
+ chatNames: string[];
333
+ /**
334
+ * Callback when a chat is selected.
335
+ */
336
+ onSelect?: (name: string) => void;
337
+ /**
338
+ * Callback when a chat is closed/disposed.
339
+ */
340
+ onClose?: (name: string) => void;
341
+ /**
342
+ * The element to anchor the popup to.
343
+ */
344
+ anchor?: HTMLElement;
345
+ }
346
+ }
347
+
348
+ /**
349
+ * Props for the ChatSelectorList component.
350
+ */
351
+ interface IChatSelectorListProps {
352
+ names: string[];
353
+ selectedName: string | null;
354
+ loadedModels: Set<string>;
355
+ onSelect: (name: string) => void;
356
+ onUpdateSelectedName: (name: string) => void;
357
+ onClose: (names: string) => void;
358
+ }
359
+
360
+ /**
361
+ * React component for rendering the chat list.
362
+ */
363
+ function ChatSelectorList({
364
+ names,
365
+ selectedName,
366
+ loadedModels,
367
+ onSelect,
368
+ onUpdateSelectedName,
369
+ onClose
370
+ }: IChatSelectorListProps): JSX.Element {
371
+ const listRef = useRef<HTMLUListElement>(null);
372
+
373
+ // Scroll selected item into view
374
+ useEffect(() => {
375
+ if (listRef.current && selectedName) {
376
+ const selectedItem = listRef.current.querySelector(
377
+ `[data-chat-name="${CSS.escape(selectedName)}"]`
378
+ );
379
+ if (selectedItem) {
380
+ selectedItem.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
381
+ }
382
+ }
383
+ }, [selectedName]);
384
+
385
+ const handleCloseClick = (event: React.MouseEvent, name: string): void => {
386
+ event.stopPropagation();
387
+ onClose(name);
388
+ };
389
+
390
+ if (names.length === 0) {
391
+ return <div className={POPUP_EMPTY_CLASS}>No chats found</div>;
392
+ }
393
+
394
+ return (
395
+ <ul ref={listRef} className={POPUP_LIST_CLASS}>
396
+ {names.map(name => {
397
+ const isLoaded = loadedModels.has(name);
398
+ return (
399
+ <li
400
+ key={name}
401
+ data-chat-name={name}
402
+ className={`${POPUP_ITEM_CLASS} ${
403
+ name === selectedName ? POPUP_ITEM_ACTIVE_CLASS : ''
404
+ }`}
405
+ onClick={() => onSelect(name)}
406
+ onMouseEnter={() => onUpdateSelectedName(name)}
407
+ >
408
+ <div className="jp-chat-selector-popup-item-content">
409
+ <div className="jp-chat-selector-popup-item-text">
410
+ <div className={POPUP_ITEM_LABEL_CLASS}>
411
+ <span
412
+ className="jp-chat-selector-popup-item-name"
413
+ title={name}
414
+ >
415
+ {name}
416
+ </span>
417
+ {isLoaded && (
418
+ <span className="jp-chat-selector-popup-item-indicator">
419
+
420
+ </span>
421
+ )}
422
+ </div>
423
+ </div>
424
+ {isLoaded && (
425
+ <Button
426
+ onClick={e => handleCloseClick(e, name)}
427
+ appearance="stealth"
428
+ title="Close and dispose this chat"
429
+ className="jp-chat-selector-popup-item-close"
430
+ >
431
+ <closeIcon.react tag={null} />
432
+ </Button>
433
+ )}
434
+ </div>
435
+ </li>
436
+ );
437
+ })}
438
+ </ul>
439
+ );
440
+ }
@@ -47,6 +47,7 @@ export class ChatWidget extends ReactWidget {
47
47
 
48
48
  this._chatOptions = options;
49
49
  this.id = `jupyter-chat::widget::${options.model.name}`;
50
+ this.addClass('jp-chat-widget');
50
51
  this.node.addEventListener('click', (event: MouseEvent) => {
51
52
  const target = event.target as HTMLElement;
52
53
  if (this.node.contains(document.activeElement)) {
@@ -4,6 +4,7 @@
4
4
  */
5
5
 
6
6
  export * from './chat-error';
7
+ export * from './chat-selector-popup';
7
8
  export * from './chat-sidebar';
8
9
  export * from './chat-widget';
9
10
  export * from './multichat-panel';