jupyterlab_ai_code_assistants_extension 1.0.39 → 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
+ }
@@ -89,17 +89,36 @@ export declare class AssistantSessionsPanel extends Widget {
89
89
  * JupyterLab's move-to-trash setting. Settable because a panel docked before
90
90
  * the server has answered its first status probe is built without either
91
91
  * (DEF-132); the action paths read them lazily, so a late value applies on
92
- * the next click, while rows already rendered keep absolute paths until the
93
- * next poll.
92
+ * the next click, and a root arriving after rows were drawn redraws them
93
+ * once (DEF-136).
94
94
  *
95
95
  * Trailing slashes go, but never the root itself: serving JupyterLab at
96
96
  * `/` used to normalise to the empty string, which reads as "no root" -
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',
@@ -508,6 +513,18 @@ export class AssistantSessionsPanel extends Widget {
508
513
  _showError(err) {
509
514
  const message = err instanceof Error ? err.message : String(err);
510
515
  this._logError(message);
516
+ // DEF-138: the server ANSWERED, and its answer is that this assistant's
517
+ // binary is not on the PATH the Jupyter server runs with. Blaming the
518
+ // server names the wrong cause - the panel is docked only because no
519
+ // roster has arrived yet, and the roster is what removes it (DEF-132).
520
+ // The error code is matched as well as the status, because a 503 raised
521
+ // by a proxy or a stopped single-user server is NOT this: its body never
522
+ // parses to `{"error": "cli_not_found"}`, so it keeps the copy below.
523
+ if (isResponseStatus(err, 503) && message === 'cli_not_found') {
524
+ this._setInlineError(`\`${this._descriptor.cliBinary}\` was not found on the Jupyter ` +
525
+ `server's PATH.`);
526
+ return;
527
+ }
511
528
  const copy = isRequestTimeout(err)
512
529
  ? 'The server is not answering - retrying.'
513
530
  : 'Could not reach the server - retrying.';
@@ -714,8 +731,8 @@ export class AssistantSessionsPanel extends Widget {
714
731
  * JupyterLab's move-to-trash setting. Settable because a panel docked before
715
732
  * the server has answered its first status probe is built without either
716
733
  * (DEF-132); the action paths read them lazily, so a late value applies on
717
- * the next click, while rows already rendered keep absolute paths until the
718
- * next poll.
734
+ * the next click, and a root arriving after rows were drawn redraws them
735
+ * once (DEF-136).
719
736
  *
720
737
  * Trailing slashes go, but never the root itself: serving JupyterLab at
721
738
  * `/` used to normalise to the empty string, which reads as "no root" -
@@ -724,25 +741,146 @@ export class AssistantSessionsPanel extends Widget {
724
741
  setRoot(rootDir, deleteToTrash) {
725
742
  var _a;
726
743
  const root = (rootDir || '').replace(/\/+$/, '');
727
- this._rootDir = root || (rootDir ? '/' : '');
744
+ const next = root || (rootDir ? '/' : '');
745
+ const changed = next !== this._rootDir;
746
+ this._rootDir = next;
728
747
  this._deleteToTrash = deleteToTrash;
729
748
  (_a = this._renameNewButton) === null || _a === void 0 ? void 0 : _a.call(this);
749
+ // Guarded on the VALUE, not on the call: `reconcile` re-sends the same
750
+ // root every status tick, so a changed-guard redraws exactly once, on the
751
+ // '' -> root transition. `_sessions` stands in for "the shell is built and
752
+ // rows are on screen" - it is null until the first `_fetch` answers, which
753
+ // is what keeps the constructor's own `setRoot` from reaching `_render`
754
+ // before `_buildShell` has created `_bodyEl`.
755
+ if (changed && this._sessions !== null) {
756
+ this._render();
757
+ }
730
758
  }
731
- /** Absolute path of the file browser's current folder; the server root when
732
- * no file browser is available. */
733
- _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) {
734
766
  var _a, _b, _c;
735
767
  // No roster yet (DEF-132): a relative folder joined to an empty root
736
768
  // would be an absolute path at the filesystem root.
737
769
  if (!this._rootDir) {
738
770
  return '';
739
771
  }
740
- 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(/^\/+/, '');
741
773
  if (!rel) {
742
774
  return this._rootDir;
743
775
  }
744
776
  return this._rootDir === '/' ? `/${rel}` : `${this._rootDir}/${rel}`;
745
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
+ }
746
884
  /** Start a brand-new conversation in the file browser's current folder. */
747
885
  async _newSession(force) {
748
886
  var _a;
@@ -1770,7 +1908,11 @@ export class AssistantSessionsPanel extends Widget {
1770
1908
  // absolute filesystem path, so it goes through _pathUnderRoot.
1771
1909
  const rel = this._pathUnderRoot(s.project_path);
1772
1910
  if (rel === null) {
1773
- Notification.warning('Folder is outside the JupyterLab root - cannot open a terminal there.', { autoClose: 4000 });
1911
+ // A root the server has not sent yet is not a folder outside it
1912
+ // (DEF-134).
1913
+ Notification.warning(this._rootDir
1914
+ ? 'Folder is outside the JupyterLab root - cannot open a terminal there.'
1915
+ : 'Waiting for the server root - cannot open a terminal yet.', { autoClose: 4000 });
1774
1916
  return;
1775
1917
  }
1776
1918
  this._app.commands
@@ -1788,7 +1930,11 @@ export class AssistantSessionsPanel extends Widget {
1788
1930
  }
1789
1931
  const rel = this._pathUnderRoot(s.project_path);
1790
1932
  if (rel === null) {
1791
- Notification.warning('Folder is outside the JupyterLab root - the file browser cannot show it.', { autoClose: 4000 });
1933
+ // A root the server has not sent yet is not a folder outside it
1934
+ // (DEF-134).
1935
+ Notification.warning(this._rootDir
1936
+ ? 'Folder is outside the JupyterLab root - the file browser cannot show it.'
1937
+ : 'Waiting for the server root - the file browser cannot show it yet.', { autoClose: 4000 });
1792
1938
  return;
1793
1939
  }
1794
1940
  this._app.commands
@@ -56,6 +56,7 @@ export declare function isRequestTimeout(err: unknown): boolean;
56
56
  * @returns The response body interpreted as JSON
57
57
  * @throws RequestTimeoutError when the deadline passes before the server
58
58
  * answers
59
+ * @throws Error when a 200 carries a body this helper cannot parse
59
60
  */
60
61
  export declare function requestAPI<T>(endPoint: string, serverSettings: ServerConnection.ISettings, init?: RequestInit): Promise<T>;
61
62
  /** Build a provider-scoped path: `providers/<id>/<path>`. The id is encoded so
@@ -63,6 +63,7 @@ export function isRequestTimeout(err) {
63
63
  * @returns The response body interpreted as JSON
64
64
  * @throws RequestTimeoutError when the deadline passes before the server
65
65
  * answers
66
+ * @throws Error when a 200 carries a body this helper cannot parse
66
67
  */
67
68
  export async function requestAPI(endPoint, serverSettings, init = {}) {
68
69
  var _a, _b;
@@ -111,7 +112,24 @@ export async function requestAPI(endPoint, serverSettings, init = {}) {
111
112
  data = JSON.parse(data);
112
113
  }
113
114
  catch (_c) {
114
- console.log(`${LOG_PREFIX} Not a JSON response body.`, response);
115
+ if (response.ok) {
116
+ // DEF-118: every handler in `core/routes.py` finishes `json.dumps`, so
117
+ // a 200 this helper cannot parse did not come from this server - it is
118
+ // an SSO or proxy interstitial served in its place. Returning the raw
119
+ // string let every caller read `undefined` off it: `probeStatus` wrote
120
+ // a roster with no `providers` and `reconcile` undocked all four
121
+ // panels, `_fetch` rendered an empty session list, and
122
+ // `ColourStore.load` replaced the cache with an empty map. Refusing it
123
+ // makes the probe FAIL, and a failed probe writes nothing (DEF-132),
124
+ // so the last known roster stands.
125
+ throw new Error(`Response to ${endPoint} was not JSON (HTTP ${response.status})`);
126
+ }
127
+ // Not ok: the `ResponseError` below carries this body as its message, so
128
+ // this side is no longer a silent path and a proxy's error page here is
129
+ // noise (DEF-116). This is the conditional DEF-118 recorded - `debug`
130
+ // only on the refusal side; the 200 side is now louder than the `warn`
131
+ // it asked for.
132
+ console.debug(`${LOG_PREFIX} Not a JSON response body.`, response);
115
133
  }
116
134
  }
117
135
  if (!response.ok) {
@@ -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.
@@ -226,6 +264,17 @@ const plugin = {
226
264
  // gets a notification, so the COMMON failure was the quiet one.
227
265
  Notification.warning(`${label} is enabled but \`${binary}\` was not found on the Jupyter server's PATH, so its panel is not shown.`, { autoClose: 8000 });
228
266
  }
267
+ // The latch is per ABSENCE, not per page load. Once the server reports
268
+ // the binary present again, a later disappearance gets its own warning:
269
+ // without this, one transient absence - a CLI upgrade unlinking the
270
+ // binary for a moment, or an obsolete roster from an overlapping probe -
271
+ // permanently suppresses the real one (DEF-125). It cannot make the
272
+ // warning repeat while the state is unchanged, which is what the
273
+ // once-per-id latch above exists to prevent: the two conditions are
274
+ // mutually exclusive.
275
+ if (probe !== null && probe.available !== false) {
276
+ warnedUnavailable.delete(id);
277
+ }
229
278
  if (enabled && available) {
230
279
  if (live.has(id)) {
231
280
  const panel = live.get(id).panel;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jupyterlab_ai_code_assistants_extension",
3
- "version": "1.0.39",
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');