jupyterlab_ai_code_assistants_extension 1.0.41 → 1.1.13

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/README.md CHANGED
@@ -30,6 +30,7 @@ Chat-panel extensions re-implement the agent loop and trail the real tool. This
30
30
  - **Joint settings page** - one settings section covering all assistants, with a per-assistant toggle (all on by default); toggling takes effect live, no JupyterLab reload
31
31
  - **Three-section side panel** - Favorites, Recent, and All projects, each scrolling independently
32
32
  - **One-click resume** - click a row to jump back into that session in a terminal; an open terminal for the project is reused instead of duplicated
33
+ - **Launcher tiles** - every enabled assistant has a tile in an "AI Assistants" section of the JupyterLab Launcher (after Other, under the extension's own robot-head icon); a click opens the assistant in the file browser's current folder, resuming the folder's conversation when one exists and starting a new one otherwise, through the companion `jupyterlab_basic_terminal_extension` (installed automatically). Disable an assistant and its tile goes; disable them all and the section goes with them
33
34
  - **Your terminal's `claude -c` follows the panel** (Claude) - switching to or launching a conversation makes plain `claude -c` in that project resume that same conversation, including one that has been compacted, which Claude Code otherwise refuses to continue
34
35
  - **Conversation switcher** - a right-click "Switch and Manage Sessions" submenu lists a project's other conversations by name and short id with last-activity time; "Manage Sessions..." opens a searchable popup over the full list with multi-select delete and per-row open and copy-id buttons
35
36
  - **Branch session** - fork the current conversation into a new named session via the right-click menu; each assistant forks its own way (Claude's native `--fork-session`, Codex's `codex fork`, server-side copies for Kimi and Gemini) behind the same menu item
@@ -11,3 +11,17 @@ export declare const switchIcon: LabIcon;
11
11
  export declare const filterIcon: LabIcon;
12
12
  /** Build (or reuse) the LabIcon for a provider's own glyph. */
13
13
  export declare function providerIcon(name: string, svgstr: string): LabIcon;
14
+ /**
15
+ * A provider's tile icon as the Launcher should draw it.
16
+ *
17
+ * The Launcher has no section icon of its own: a category's header is drawn
18
+ * with the FIRST tile's command icon under the `launcherSection` stylesheet
19
+ * preset, and each tile under `launcherCard`. This view of `tile` renders the
20
+ * extension's joint icon under the section preset and `tile` everywhere else,
21
+ * so the header carries no vendor's mark while every tile keeps its own
22
+ * (ACC-LNCH-163).
23
+ *
24
+ * Built the way `LabIcon.bindprops` builds its views - a prototype child of
25
+ * the tile with its own `react` - so `LabIcon.resolve` still recognises it.
26
+ */
27
+ export declare function launcherTileIcon(tile: LabIcon): LabIcon;
package/lib/core/icons.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { LabIcon } from '@jupyterlab/ui-components';
2
+ import * as React from 'react';
2
3
  // Shared panel chrome icons. Provider icons are NOT here - each provider
3
4
  // carries its own SVG on its descriptor and the registry builds the LabIcon
4
5
  // from it, so adding an assistant touches no core file.
@@ -6,11 +7,13 @@ import { LabIcon } from '@jupyterlab/ui-components';
6
7
  // Material-style glyphs matched to the rest of the sidebar so header and menu
7
8
  // icons render at identical size and theme.
8
9
  const ICON_PREFIX = 'jupyterlab_ai_code_assistants_extension';
9
- // The extension's own identity, used by the settings entry - one section for
10
- // all assistants, so it cannot borrow any one assistant's glyph. Material
11
- // "smart_toy" outline.
10
+ // The extension's own identity, used by the settings entry and the Launcher
11
+ // section header - one section for all assistants, so it cannot borrow any one
12
+ // assistant's glyph. A robot head in the Material filled style: antenna, side
13
+ // bolts, eye and mouth cut-outs. One evenodd path, so the cut-outs are holes
14
+ // and the theme's `.jp-icon3[fill]` rule recolours the whole glyph at once.
12
15
  const assistantsSvgStr = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="16" height="16">
13
- <path class="jp-icon3" fill="#616161" d="M20 9V7c0-1.1-.9-2-2-2h-3c0-1.66-1.34-3-3-3S9 3.34 9 5H6c-1.1 0-2 .9-2 2v2c-1.66 0-3 1.34-3 3s1.34 3 3 3v4c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2v-4c1.66 0 3-1.34 3-3s-1.34-3-3-3zm-2 10H6V7h12v12zM9 13.5c-.83 0-1.5-.67-1.5-1.5s.67-1.5 1.5-1.5 1.5.67 1.5 1.5-.67 1.5-1.5 1.5zm7.5-1.5c0 .83-.67 1.5-1.5 1.5s-1.5-.67-1.5-1.5.67-1.5 1.5-1.5 1.5.67 1.5 1.5zM8 15h8v2H8v-2z"/>
16
+ <path class="jp-icon3" fill="#616161" fill-rule="evenodd" d="M12 1.5a1.6 1.6 0 1 1 0 3.2 1.6 1.6 0 0 1 0-3.2ZM11.2 4.7h1.6V7h-1.6ZM7 7h10a3 3 0 0 1 3 3v8a3 3 0 0 1-3 3H7a3 3 0 0 1-3-3v-8a3 3 0 0 1 3-3ZM2 11h2v5H2ZM20 11h2v5h-2ZM9 10.2a1.8 1.8 0 1 0 0 3.6 1.8 1.8 0 0 0 0-3.6ZM15 10.2a1.8 1.8 0 1 0 0 3.6 1.8 1.8 0 0 0 0-3.6ZM8.6 15.6h6.8a.9.9 0 0 1 0 1.8H8.6a.9.9 0 0 1 0-1.8Z"/>
14
17
  </svg>`;
15
18
  export const assistantsIcon = new LabIcon({
16
19
  name: `${ICON_PREFIX}:assistants`,
@@ -114,3 +117,25 @@ export function providerIcon(name, svgstr) {
114
117
  }
115
118
  return icon;
116
119
  }
120
+ /**
121
+ * A provider's tile icon as the Launcher should draw it.
122
+ *
123
+ * The Launcher has no section icon of its own: a category's header is drawn
124
+ * with the FIRST tile's command icon under the `launcherSection` stylesheet
125
+ * preset, and each tile under `launcherCard`. This view of `tile` renders the
126
+ * extension's joint icon under the section preset and `tile` everywhere else,
127
+ * so the header carries no vendor's mark while every tile keeps its own
128
+ * (ACC-LNCH-163).
129
+ *
130
+ * Built the way `LabIcon.bindprops` builds its views - a prototype child of
131
+ * the tile with its own `react` - so `LabIcon.resolve` still recognises it.
132
+ */
133
+ export function launcherTileIcon(tile) {
134
+ const view = Object.create(tile);
135
+ const react = React.forwardRef((props, ref) => React.createElement(props.stylesheet === 'launcherSection'
136
+ ? assistantsIcon.react
137
+ : tile.react, { ...props, ref }));
138
+ // `react` is readonly on the type; the view shadows the tile's own.
139
+ Object.defineProperty(view, 'react', { value: react });
140
+ return view;
141
+ }
@@ -97,9 +97,28 @@ export declare class AssistantSessionsPanel extends Widget {
97
97
  * the + button then did nothing at all, and Open Terminal and Show in File
98
98
  * Browser answered "outside the JupyterLab root" for every row inside it. */
99
99
  setRoot(rootDir: string, deleteToTrash: boolean): void;
100
- /** Absolute path of the file browser's current folder; the server root when
101
- * no file browser is available. */
100
+ /** Absolute path of a folder given as a Contents-API path, defaulting to the
101
+ * file browser's current folder; the server root when neither is available.
102
+ *
103
+ * `path` is passed by the Launcher tile, which carries the folder its click
104
+ * happened in rather than whatever the browser shows now. An empty string is
105
+ * the root, so the default applies only when nothing at all was given. */
102
106
  private _currentFolder;
107
+ /**
108
+ * Start or resume this assistant in `cwd`, and answer with the terminal
109
+ * widget the sibling extension opened - the Launcher replaces its own tab
110
+ * with whatever the command resolves to (ACC-LNCH-157).
111
+ *
112
+ * The tile's whole click path lives here rather than in `src/index.ts`
113
+ * because every input it needs is already the panel's: the root join, the
114
+ * listing fetch, the terminal reuse ladder and the settings-resolved launch
115
+ * mode. Nothing here retries and nothing is scheduled: a guard that refuses
116
+ * shows one notification and launches nothing, and a request that fails
117
+ * writes nothing.
118
+ *
119
+ * `cwd` is a Contents-API path, `''` for the root.
120
+ */
121
+ launchHere(cwd?: string): Promise<any>;
103
122
  /** Start a brand-new conversation in the file browser's current folder. */
104
123
  private _newSession;
105
124
  /**
package/lib/core/panel.js CHANGED
@@ -36,6 +36,11 @@ const BRANCH_WATCH_INTERVAL_MS = 2000;
36
36
  const BRANCH_WATCH_MAX_ATTEMPTS = 90; // ~3 minutes
37
37
  /** Conversations listed inline in a branch submenu before the popup takes over. */
38
38
  const INLINE_BRANCH_LIMIT = 5;
39
+ /** Command id of the sibling extension that spawns a shell-less terminal on a
40
+ * bare argv, with args `{argv, cwd}`. That string IS the coupling: the sibling
41
+ * exports no token, so its absence is something to ask the command registry
42
+ * about at click time rather than a build-time dependency (ACC-LNCH-155). */
43
+ const BASIC_TERMINAL_LAUNCH = 'basic-terminal:launch';
39
44
  export const DEFAULT_PRESENTATION_MODE = 'name';
40
45
  const SECTION_LABELS = {
41
46
  favourites: 'Favorites',
@@ -751,21 +756,131 @@ export class AssistantSessionsPanel extends Widget {
751
756
  this._render();
752
757
  }
753
758
  }
754
- /** Absolute path of the file browser's current folder; the server root when
755
- * no file browser is available. */
756
- _currentFolder() {
759
+ /** Absolute path of a folder given as a Contents-API path, defaulting to the
760
+ * file browser's current folder; the server root when neither is available.
761
+ *
762
+ * `path` is passed by the Launcher tile, which carries the folder its click
763
+ * happened in rather than whatever the browser shows now. An empty string is
764
+ * the root, so the default applies only when nothing at all was given. */
765
+ _currentFolder(path) {
757
766
  var _a, _b, _c;
758
767
  // No roster yet (DEF-132): a relative folder joined to an empty root
759
768
  // would be an absolute path at the filesystem root.
760
769
  if (!this._rootDir) {
761
770
  return '';
762
771
  }
763
- const rel = ((_c = (_b = (_a = this._fileBrowser) === null || _a === void 0 ? void 0 : _a.model) === null || _b === void 0 ? void 0 : _b.path) !== null && _c !== void 0 ? _c : '').replace(/^\/+/, '');
772
+ const rel = ((_c = path !== null && path !== void 0 ? path : (_b = (_a = this._fileBrowser) === null || _a === void 0 ? void 0 : _a.model) === null || _b === void 0 ? void 0 : _b.path) !== null && _c !== void 0 ? _c : '').replace(/^\/+/, '');
764
773
  if (!rel) {
765
774
  return this._rootDir;
766
775
  }
767
776
  return this._rootDir === '/' ? `/${rel}` : `${this._rootDir}/${rel}`;
768
777
  }
778
+ /**
779
+ * Start or resume this assistant in `cwd`, and answer with the terminal
780
+ * widget the sibling extension opened - the Launcher replaces its own tab
781
+ * with whatever the command resolves to (ACC-LNCH-157).
782
+ *
783
+ * The tile's whole click path lives here rather than in `src/index.ts`
784
+ * because every input it needs is already the panel's: the root join, the
785
+ * listing fetch, the terminal reuse ladder and the settings-resolved launch
786
+ * mode. Nothing here retries and nothing is scheduled: a guard that refuses
787
+ * shows one notification and launches nothing, and a request that fails
788
+ * writes nothing.
789
+ *
790
+ * `cwd` is a Contents-API path, `''` for the root.
791
+ */
792
+ async launchHere(cwd) {
793
+ var _a, _b;
794
+ // No roster yet, so the root is unknown. Joining onto an empty one names a
795
+ // folder at the filesystem root, which the server accepts whenever it
796
+ // happens to exist (ACC-LNCH-148, DEF-132).
797
+ if (!this._rootDir) {
798
+ Notification.warning('Waiting for the server root - nothing was launched.', { autoClose: 4000 });
799
+ return undefined;
800
+ }
801
+ // A registered drive (jupyter-fs, S3) is not assumed to map onto the
802
+ // server filesystem, and its local path would resolve against the server's
803
+ // own root - an unrelated directory of the same name (ACC-LNCH-149).
804
+ if (typeof cwd === 'string' &&
805
+ this._app.serviceManager.contents.driveName(cwd) !== '') {
806
+ Notification.warning('The file browser is on a drive that does not map onto the Jupyter ' +
807
+ "server's filesystem, so nothing was launched.", { autoClose: 4000 });
808
+ return undefined;
809
+ }
810
+ if (!this._app.commands.hasCommand(BASIC_TERMINAL_LAUNCH)) {
811
+ Notification.warning('jupyterlab_basic_terminal_extension is not installed, and it owns ' +
812
+ `the \`${BASIC_TERMINAL_LAUNCH}\` command this launch runs on.`, { autoClose: 4000 });
813
+ return undefined;
814
+ }
815
+ const folder = this._currentFolder(cwd);
816
+ let argv;
817
+ try {
818
+ // Every click asks the server for the listing (ACC-LNCH-151). `_sessions`
819
+ // is written by `_fetch` and by the optimistic remove in
820
+ // `_removeProject`, and the poll that calls `_fetch` stops on
821
+ // `onBeforeHide` - so a hidden panel's cache is as old as the moment it
822
+ // was hidden, and reading it resumed a conversation that had since moved
823
+ // or minted a second one for a folder that already had a row.
824
+ await this._fetch();
825
+ const rows = (_a = this._sessions) !== null && _a !== void 0 ? _a : [];
826
+ // A row for this folder means the folder has a conversation, which is
827
+ // what makes this a resume rather than a new session (ACC-LNCH-151).
828
+ const row = (_b = rows.find(s => s.project_path === folder)) !== null && _b !== void 0 ? _b : null;
829
+ if (row) {
830
+ // A terminal already holding that conversation IS the answer: a second
831
+ // process on one history is never what the click meant (ACC-LNCH-152).
832
+ const found = await this._terminals.findForSession(row.session_id);
833
+ if (found) {
834
+ this._terminals.focus(found.widget);
835
+ return found.widget;
836
+ }
837
+ }
838
+ const request = {
839
+ project_path: folder,
840
+ encoded_path: row === null || row === void 0 ? void 0 : row.encoded_path,
841
+ session_id: row === null || row === void 0 ? void 0 : row.session_id,
842
+ // Minted here when the CLI takes an id, so the new conversation is
843
+ // identifiable from its argv on the first poll (ACC-LNCH-153).
844
+ new_session_id: row || !this._descriptor.mintsNewSessionId ? undefined : UUID.uuid4(),
845
+ // No forced mode - the same default the + button launches under
846
+ // (ACC-LNCH-147).
847
+ mode: this._launchMode()
848
+ };
849
+ const answer = await requestProvider(this._descriptor.id, 'launch-argv', this._serverSettings, { method: 'POST', body: JSON.stringify(request) });
850
+ argv = answer.argv;
851
+ }
852
+ catch (err) {
853
+ const message = err instanceof Error ? err.message : String(err);
854
+ // The binary went away after the panel docked. Named, because the
855
+ // symptom otherwise reads as a broken tile.
856
+ if (isResponseStatus(err, 503) && message === 'cli_not_found') {
857
+ Notification.warning(`\`${this._descriptor.cliBinary}\` was not found on the Jupyter ` +
858
+ "server's PATH, so nothing was launched.", { autoClose: 4000 });
859
+ return undefined;
860
+ }
861
+ this._notifyLaunchError('Could not start a session in this folder', err);
862
+ return undefined;
863
+ }
864
+ // The folder is already resolved against the server root, and the sibling
865
+ // takes an absolute path (ACC-LNCH-154).
866
+ //
867
+ // Its own catch, because the cause is a different one: the block above
868
+ // refuses before anything was spawned, this one refuses after the argv was
869
+ // agreed. The Launcher shows a modal "Launcher Error" for any rejection
870
+ // that escapes the command, so the refusal is reported as one notification
871
+ // and the Launcher keeps its tab (ACC-LNCH-157 covers the success path
872
+ // only).
873
+ try {
874
+ return await this._app.commands.execute(BASIC_TERMINAL_LAUNCH, {
875
+ argv,
876
+ cwd: folder
877
+ });
878
+ }
879
+ catch (err) {
880
+ this._notifyLaunchError('Could not open a terminal for this folder', err);
881
+ return undefined;
882
+ }
883
+ }
769
884
  /** Start a brand-new conversation in the file browser's current folder. */
770
885
  async _newSession(force) {
771
886
  var _a;
@@ -205,6 +205,14 @@ export interface ILaunchRequest {
205
205
  export interface ILaunchResponse {
206
206
  terminal_name: string;
207
207
  }
208
+ /** `POST providers/<id>/launch-argv`: the command line a launch WOULD run,
209
+ * for a caller that owns the spawn itself. Same body as {@link ILaunchRequest}
210
+ * and the same server-side bookkeeping as `launch`; the difference is that
211
+ * nothing is spawned, so the argv can be handed to another extension's
212
+ * terminal (ACC-LNCH-154). */
213
+ export interface ILaunchArgvResponse {
214
+ argv: string[];
215
+ }
208
216
  /** What a terminal is running, as the server resolved it from `/proc`. */
209
217
  export interface ITerminalProbeResponse {
210
218
  terminal_name: string;
package/lib/index.js CHANGED
@@ -1,9 +1,11 @@
1
1
  import { ILabShell, ILayoutRestorer } from '@jupyterlab/application';
2
2
  import { Notification } from '@jupyterlab/apputils';
3
3
  import { IDefaultFileBrowser } from '@jupyterlab/filebrowser';
4
+ import { ILauncher } from '@jupyterlab/launcher';
4
5
  import { ISettingRegistry } from '@jupyterlab/settingregistry';
5
6
  import { ITerminalTracker } from '@jupyterlab/terminal';
6
7
  import { IColourfulTabs } from 'jupyterlab_colourful_tab_extension';
8
+ import { launcherTileIcon, providerIcon } from './core/icons';
7
9
  import { DEFAULT_RECENT_LIMIT } from './core/limits';
8
10
  import { AssistantSessionsPanel, DEFAULT_PRESENTATION_MODE, commandId, panelWidgetId } from './core/panel';
9
11
  import { LOG_PREFIX, ProviderRegistry } from './core/registry';
@@ -17,6 +19,14 @@ const PANEL_RANK = 600;
17
19
  /** How often the server is re-asked which assistants are available, so a CLI
18
20
  * installed while JupyterLab runs surfaces without a reload. */
19
21
  const STATUS_INTERVAL_MS = 60000;
22
+ /** Launcher section every assistant tile lands in. */
23
+ const LAUNCHER_CATEGORY = 'AI Assistants';
24
+ /** Where that section sits. The Launcher ranks Notebook 0, Console 20 and
25
+ * Other 100, and the smallest rank among a category's items decides where the
26
+ * category goes - so ONE value on every tile, above Other's, puts the
27
+ * assistants after Other and keeps them there whichever tile is added first;
28
+ * unranked third-party categories (rank Infinity) still follow (ACC-LNCH-156). */
29
+ const LAUNCHER_CATEGORY_RANK = 200;
20
30
  const plugin = {
21
31
  id: PLUGIN_ID,
22
32
  description: 'One side panel per AI code assistant, over a shared provider registry: sessions, favourites, branching and one-click resume in a terminal.',
@@ -27,9 +37,10 @@ const plugin = {
27
37
  ISettingRegistry,
28
38
  ITerminalTracker,
29
39
  IDefaultFileBrowser,
30
- IColourfulTabs
40
+ IColourfulTabs,
41
+ ILauncher
31
42
  ],
32
- activate: async (app, labShell, restorer, settingRegistry, terminalTracker, fileBrowser, colourfulTabs) => {
43
+ activate: async (app, labShell, restorer, settingRegistry, terminalTracker, fileBrowser, colourfulTabs, launcher) => {
33
44
  console.log('JupyterLab extension jupyterlab_ai_code_assistants_extension is activated!');
34
45
  // A duplicate id throws here, at registration, rather than surfacing later
35
46
  // as two panels quietly fighting over one widget id.
@@ -135,7 +146,7 @@ const plugin = {
135
146
  labShell.add(panel, sidebar, { rank: PANEL_RANK });
136
147
  };
137
148
  const start = (id) => {
138
- var _a;
149
+ var _a, _b;
139
150
  const module = registry.get(id);
140
151
  if (!module || live.has(id)) {
141
152
  return;
@@ -163,7 +174,29 @@ const plugin = {
163
174
  label: `Refresh ${module.descriptor.panelTitle}`,
164
175
  execute: () => panel.refresh()
165
176
  });
166
- live.set(id, { panel, command });
177
+ // The Launcher tile's command. It is registered whether or not a
178
+ // launcher exists, because the tile is the only caller but the command
179
+ // is what carries the label, the icon and the click path.
180
+ const launchCommandId = commandId(id, 'launch-here');
181
+ const launchCommand = app.commands.addCommand(launchCommandId, {
182
+ label: module.descriptor.label,
183
+ caption: `Start or resume ${module.descriptor.label} in the current folder`,
184
+ // The section header borrows this icon too; the view draws the
185
+ // joint icon there and the provider's own on the tile.
186
+ icon: launcherTileIcon(providerIcon(module.descriptor.iconName, module.descriptor.iconSvg)),
187
+ // The Launcher puts the file browser's folder in `cwd` on every
188
+ // click; the panel resolves it against the server root.
189
+ execute: args => panel.launchHere(args.cwd)
190
+ });
191
+ // Tile order follows the barrel, so the Launcher section reads in the
192
+ // sidebar's order (ACC-LNCH-158).
193
+ const tile = (_b = launcher === null || launcher === void 0 ? void 0 : launcher.add({
194
+ command: launchCommandId,
195
+ category: LAUNCHER_CATEGORY,
196
+ rank: registry.ids.indexOf(id),
197
+ categoryRank: LAUNCHER_CATEGORY_RANK
198
+ })) !== null && _b !== void 0 ? _b : null;
199
+ live.set(id, { panel, command, launchCommand, tile });
167
200
  }
168
201
  catch (err) {
169
202
  // One provider failing to start must leave the others working.
@@ -171,6 +204,7 @@ const plugin = {
171
204
  }
172
205
  };
173
206
  const stop = (id) => {
207
+ var _a;
174
208
  const entry = live.get(id);
175
209
  if (!entry) {
176
210
  return;
@@ -179,6 +213,10 @@ const plugin = {
179
213
  // Disposing stops the polling and clears this provider's tab tints; the
180
214
  // terminals it launched keep running, untouched.
181
215
  entry.command.dispose();
216
+ // Tile before the command it runs, so the Launcher never re-renders an
217
+ // item whose command has already gone.
218
+ (_a = entry.tile) === null || _a === void 0 ? void 0 : _a.dispose();
219
+ entry.launchCommand.dispose();
182
220
  entry.panel.dispose();
183
221
  };
184
222
  /** Bring the docked panels in line with settings and CLI availability.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jupyterlab_ai_code_assistants_extension",
3
- "version": "1.0.41",
3
+ "version": "1.1.13",
4
4
  "description": "Jupyterlab extension to replace individual jupyterlab_*_code_extension and integrate into one, that offers joint capability, joint settings page and individual right toolbar panels per code assistant, with the ability in settings to decide which code assistant support is on (default - all are enabled)",
5
5
  "keywords": [
6
6
  "jupyter",
@@ -62,6 +62,7 @@
62
62
  "@jupyterlab/apputils": "^4.0.0",
63
63
  "@jupyterlab/coreutils": "^6.0.0",
64
64
  "@jupyterlab/filebrowser": "^4.0.0",
65
+ "@jupyterlab/launcher": "^4.6.0",
65
66
  "@jupyterlab/services": "^7.0.0",
66
67
  "@jupyterlab/settingregistry": "^4.0.0",
67
68
  "@jupyterlab/terminal": "^4.0.0",
@@ -71,7 +72,8 @@
71
72
  "@lumino/disposable": "^2.0.0",
72
73
  "@lumino/messaging": "^2.0.0",
73
74
  "@lumino/widgets": "^2.0.0",
74
- "jupyterlab_colourful_tab_extension": "^1.0.19"
75
+ "jupyterlab_colourful_tab_extension": "^1.0.19",
76
+ "react": "^18.2.0"
75
77
  },
76
78
  "devDependencies": {
77
79
  "@eslint/js": "^9.0.0",
@@ -80,6 +82,7 @@
80
82
  "@jupyterlab/testutils": "^4.0.0",
81
83
  "@types/jest": "^29.2.0",
82
84
  "@types/json-schema": "^7.0.11",
85
+ "@types/react-dom": "^18.2.0",
83
86
  "eslint": "^9.0.0",
84
87
  "eslint-config-prettier": "^9.0.0",
85
88
  "eslint-plugin-prettier": "^5.0.0",
@@ -87,6 +90,7 @@
87
90
  "jest": "^29.2.0",
88
91
  "npm-run-all2": "^7.0.1",
89
92
  "prettier": "^3.0.0",
93
+ "react-dom": "^18.2.0",
90
94
  "rimraf": "^5.0.1",
91
95
  "stylelint": "^15.10.1",
92
96
  "stylelint-config-recommended": "^13.0.0",
@@ -1,5 +1,6 @@
1
1
  /**
2
- * No assistant is named in `src/core/`.
2
+ * No assistant is named in `src/core/` or in the composition root
3
+ * `src/index.ts` (ACC-LNCH-150).
3
4
  *
4
5
  * The architecture rests on it: adding or removing an assistant must touch one
5
6
  * provider module and one barrel line, never a core file. A single `if (id ===
@@ -112,13 +113,21 @@ function coreFiles(): string[] {
112
113
  .map(name => path.join(CORE_DIR, name));
113
114
  }
114
115
 
115
- describe('the core names no assistant', () => {
116
+ describe('the core and the composition root name no assistant', () => {
116
117
  it('has core files to scan at all', () => {
117
118
  // Guards against the scan passing because it found nothing to read.
118
119
  expect(coreFiles().length).toBeGreaterThan(3);
119
120
  });
120
121
 
121
- it.each(coreFiles().map(file => [path.basename(file), file]))(
122
+ // ACC-LNCH-150 names the composition root as well as `src/core/`, so
123
+ // `index.ts` joins this scan only; `coreFiles()` stays as it is because the
124
+ // registry test below forbids the providers import `index.ts` must make.
125
+ it.each(
126
+ [...coreFiles(), path.resolve(__dirname, '..', 'index.ts')].map(file => [
127
+ path.basename(file),
128
+ file
129
+ ])
130
+ )(
122
131
  '%s carries no assistant name outside comments',
123
132
  (_name: string, file: string) => {
124
133
  const code = fs.readFileSync(file, 'utf8');
@@ -40,9 +40,11 @@ jest.mock('../core/request', () => ({
40
40
  import { CommandRegistry } from '@lumino/commands';
41
41
  import { Signal } from '@lumino/signaling';
42
42
 
43
- import { AssistantSessionsPanel } from '../core/panel';
43
+ import { providerIcon } from '../core/icons';
44
+ import { AssistantSessionsPanel, commandId } from '../core/panel';
44
45
  import { requestAPI } from '../core/request';
45
46
  import plugin from '../index';
47
+ import { PROVIDERS } from '../providers';
46
48
 
47
49
  const request = requestAPI as jest.Mock;
48
50
 
@@ -125,3 +127,171 @@ describe('DEF-GUARD-139 - reconcile hands a late roster to a docked panel', () =
125
127
  docked.forEach(w => w.dispose());
126
128
  });
127
129
  });
130
+
131
+ describe('ACC-LNCH-143..146 - the Launcher tile has the panel lifecycle', () => {
132
+ /** A roster in which every assistant is present, or every one but `absent`. */
133
+ const roster = (absent?: string): unknown => ({
134
+ root_dir: '/srv/lab',
135
+ delete_to_trash: true,
136
+ providers: PROVIDERS.map(module => ({
137
+ id: module.descriptor.id,
138
+ available: module.descriptor.id !== absent
139
+ }))
140
+ });
141
+
142
+ interface IActivation {
143
+ app: any;
144
+ docked: AssistantSessionsPanel[];
145
+ /** Every `ILauncher.add` call, in order. */
146
+ added: any[];
147
+ /** Commands whose tile has been disposed. */
148
+ disposed: string[];
149
+ /** Replace the roster the next probe answers with. */
150
+ setRoster: (next: unknown) => void;
151
+ }
152
+
153
+ /** Activate the plugin with no settings registry - every assistant enabled
154
+ * by default - against a launcher double, or none at all. */
155
+ async function activate(withLauncher = true): Promise<IActivation> {
156
+ let current = roster();
157
+ request.mockImplementation((path: string) =>
158
+ path === 'status'
159
+ ? Promise.resolve(current)
160
+ : Promise.reject(new Error('no other route'))
161
+ );
162
+ const added: any[] = [];
163
+ const disposed: string[] = [];
164
+ const docked: AssistantSessionsPanel[] = [];
165
+ const launcher = withLauncher
166
+ ? ({
167
+ add: (options: any) => {
168
+ added.push(options);
169
+ return { dispose: () => disposed.push(options.command) };
170
+ }
171
+ } as any)
172
+ : null;
173
+ const app = {
174
+ serviceManager: { serverSettings: {} },
175
+ commands: new CommandRegistry(),
176
+ hasPlugin: () => false
177
+ } as any;
178
+ const labShell = {
179
+ add: (widget: AssistantSessionsPanel) => docked.push(widget)
180
+ } as any;
181
+ await (plugin.activate as any)(
182
+ app,
183
+ labShell,
184
+ null,
185
+ null,
186
+ null,
187
+ null,
188
+ null,
189
+ launcher
190
+ );
191
+ await flush();
192
+ return {
193
+ app,
194
+ docked,
195
+ added,
196
+ disposed,
197
+ setRoster: (next: unknown) => {
198
+ current = next;
199
+ }
200
+ };
201
+ }
202
+
203
+ it('adds one tile per docked assistant, labelled and iconed from its descriptor', async () => {
204
+ const live = await activate();
205
+ expect(live.added).toHaveLength(PROVIDERS.length);
206
+ for (const module of PROVIDERS) {
207
+ const id = commandId(module.descriptor.id, 'launch-here');
208
+ const tile = live.added.find(item => item.command === id);
209
+ expect(tile).toBeDefined();
210
+ expect(tile.category).toBe('AI Assistants');
211
+ expect(live.app.commands.label(id)).toBe(module.descriptor.label);
212
+ // A `launcherTileIcon` view of the provider's own icon - the header
213
+ // draws the joint icon through it (launcher-icon.spec.ts).
214
+ expect(Object.getPrototypeOf(live.app.commands.icon(id))).toBe(
215
+ providerIcon(module.descriptor.iconName, module.descriptor.iconSvg)
216
+ );
217
+ }
218
+ live.docked.forEach(widget => widget.dispose());
219
+ });
220
+
221
+ it('disposes the tile and its command when the panel stops', async () => {
222
+ const live = await activate();
223
+ const id = commandId(PROVIDERS[0].descriptor.id, 'launch-here');
224
+ expect(live.app.commands.hasCommand(id)).toBe(true);
225
+
226
+ // The server now reports that assistant's binary gone, which is the same
227
+ // decision that undocks its panel - there is no second one for the tile.
228
+ live.setRoster(roster(PROVIDERS[0].descriptor.id));
229
+ window.dispatchEvent(new Event('online'));
230
+ await flush();
231
+
232
+ expect(live.disposed).toEqual([id]);
233
+ expect(live.app.commands.hasCommand(id)).toBe(false);
234
+ expect(live.docked[0].isDisposed).toBe(true);
235
+ live.docked.forEach(widget => widget.dispose());
236
+ });
237
+
238
+ it('gives an assistant whose binary is absent neither a panel nor a tile', async () => {
239
+ const absent = PROVIDERS[1].descriptor.id;
240
+ request.mockImplementation((path: string) =>
241
+ path === 'status'
242
+ ? Promise.resolve(roster(absent))
243
+ : Promise.reject(new Error('no other route'))
244
+ );
245
+ const added: any[] = [];
246
+ const docked: AssistantSessionsPanel[] = [];
247
+ await (plugin.activate as any)(
248
+ {
249
+ serviceManager: { serverSettings: {} },
250
+ commands: new CommandRegistry(),
251
+ hasPlugin: () => false
252
+ } as any,
253
+ { add: (widget: AssistantSessionsPanel) => docked.push(widget) } as any,
254
+ null,
255
+ null,
256
+ null,
257
+ null,
258
+ null,
259
+ {
260
+ add: (options: any) => {
261
+ added.push(options);
262
+ return { dispose: () => undefined };
263
+ }
264
+ } as any
265
+ );
266
+ await flush();
267
+
268
+ expect(docked.map(widget => widget.node.dataset.provider)).not.toContain(
269
+ absent
270
+ );
271
+ expect(added.map(item => item.command)).not.toContain(
272
+ commandId(absent, 'launch-here')
273
+ );
274
+ expect(added).toHaveLength(PROVIDERS.length - 1);
275
+ docked.forEach(widget => widget.dispose());
276
+ });
277
+
278
+ it('docks the panels and adds nothing when JupyterLab has no launcher', async () => {
279
+ // `start` swallows what a provider throws, so "nothing threw" is read off
280
+ // the line it logs rather than off the activation resolving.
281
+ const logged = jest
282
+ .spyOn(console, 'error')
283
+ .mockImplementation(() => undefined);
284
+ const live = await activate(false);
285
+ expect(logged).not.toHaveBeenCalled();
286
+ logged.mockRestore();
287
+ expect(live.docked).toHaveLength(PROVIDERS.length);
288
+ expect(live.added).toEqual([]);
289
+ // The command is still registered - only the tile depends on the launcher.
290
+ expect(
291
+ live.app.commands.hasCommand(
292
+ commandId(PROVIDERS[0].descriptor.id, 'launch-here')
293
+ )
294
+ ).toBe(true);
295
+ live.docked.forEach(widget => widget.dispose());
296
+ });
297
+ });