jupyterlab_ai_code_assistants_extension 1.2.163 → 1.2.199

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.
@@ -2,8 +2,10 @@ import { Dialog } from '@jupyterlab/apputils';
2
2
  import { Widget } from '@lumino/widgets';
3
3
  /**
4
4
  * The renderer every dialog of this extension is built with. It marks the
5
- * footer, and `style/base.css` scopes its dialog rules - no focus rim on any
6
- * button, the reject buttons (Cancel, and Close in the Open Callback popup) as
5
+ * footer, and `style/base.css` scopes its dialog rules - no focus rim on a
6
+ * button focused by the mouse or by a dialog opened with the mouse (keyboard
7
+ * focus keeps JupyterLab's rim), the reject buttons (Cancel, and Close in the
8
+ * Open Callback popup) as
7
9
  * secondary surface buttons so the fill alone marks the primary - to a dialog
8
10
  * that holds the mark, so JupyterLab's own
9
11
  * dialogs keep their look. A renderer is the one hook that also reaches the
@@ -1,8 +1,10 @@
1
1
  import { Dialog } from '@jupyterlab/apputils';
2
2
  /**
3
3
  * The renderer every dialog of this extension is built with. It marks the
4
- * footer, and `style/base.css` scopes its dialog rules - no focus rim on any
5
- * button, the reject buttons (Cancel, and Close in the Open Callback popup) as
4
+ * footer, and `style/base.css` scopes its dialog rules - no focus rim on a
5
+ * button focused by the mouse or by a dialog opened with the mouse (keyboard
6
+ * focus keeps JupyterLab's rim), the reject buttons (Cancel, and Close in the
7
+ * Open Callback popup) as
6
8
  * secondary surface buttons so the fill alone marks the primary - to a dialog
7
9
  * that holds the mark, so JupyterLab's own
8
10
  * dialogs keep their look. A renderer is the one hook that also reaches the
@@ -28,7 +28,11 @@ export declare function providerIcon(name: string, svgstr: string): LabIcon;
28
28
  * preset, and each tile under `launcherCard`. This view of `tile` renders the
29
29
  * extension's joint icon under the section preset and `tile` everywhere else,
30
30
  * so the header carries no vendor's mark while every tile keeps its own
31
- * (ACC-LNCH-163).
31
+ * (ACC-LNCH-163). Under `launcherCard` it also adds the class
32
+ * `jp-AiAssistantsLauncher-tileGlyph`, which `style/base.css` scales to 75%,
33
+ * so the tile glyph is drawn smaller than JupyterLab's own tile glyphs while
34
+ * the sidebar tab, panel header and menus keep the standard size
35
+ * (ACC-LNCH-215).
32
36
  *
33
37
  * Built the way `LabIcon.bindprops` builds its views - a prototype child of
34
38
  * the tile with its own `react` - so `LabIcon.resolve` still recognises it.
package/lib/core/icons.js CHANGED
@@ -1,4 +1,4 @@
1
- import { LabIcon } from '@jupyterlab/ui-components';
1
+ import { classes, LabIcon } from '@jupyterlab/ui-components';
2
2
  import * as React from 'react';
3
3
  // Shared panel chrome icons. Provider icons are NOT here - each provider
4
4
  // carries its own SVG on its descriptor and the registry builds the LabIcon
@@ -148,7 +148,11 @@ export function providerIcon(name, svgstr) {
148
148
  * preset, and each tile under `launcherCard`. This view of `tile` renders the
149
149
  * extension's joint icon under the section preset and `tile` everywhere else,
150
150
  * so the header carries no vendor's mark while every tile keeps its own
151
- * (ACC-LNCH-163).
151
+ * (ACC-LNCH-163). Under `launcherCard` it also adds the class
152
+ * `jp-AiAssistantsLauncher-tileGlyph`, which `style/base.css` scales to 75%,
153
+ * so the tile glyph is drawn smaller than JupyterLab's own tile glyphs while
154
+ * the sidebar tab, panel header and menus keep the standard size
155
+ * (ACC-LNCH-215).
152
156
  *
153
157
  * Built the way `LabIcon.bindprops` builds its views - a prototype child of
154
158
  * the tile with its own `react` - so `LabIcon.resolve` still recognises it.
@@ -157,7 +161,13 @@ export function launcherTileIcon(tile) {
157
161
  const view = Object.create(tile);
158
162
  const react = React.forwardRef((props, ref) => React.createElement(props.stylesheet === 'launcherSection'
159
163
  ? assistantsIcon.react
160
- : tile.react, { ...props, ref }));
164
+ : tile.react, {
165
+ ...props,
166
+ ...(props.stylesheet === 'launcherCard' && {
167
+ className: classes(props.className, 'jp-AiAssistantsLauncher-tileGlyph')
168
+ }),
169
+ ref
170
+ }));
161
171
  // `react` is readonly on the type; the view shadows the tile's own.
162
172
  Object.defineProperty(view, 'react', { value: react });
163
173
  return view;
package/lib/core/panel.js CHANGED
@@ -1057,10 +1057,7 @@ export class AssistantSessionsPanel extends Widget {
1057
1057
  }, fork.session_id, spinner);
1058
1058
  }
1059
1059
  catch (err) {
1060
- if (isResponseStatus(err, 400)) {
1061
- Notification.error(`${this._descriptor.label} cannot branch this conversation.`, { autoClose: 4000 });
1062
- }
1063
- else if (minted) {
1060
+ if (minted) {
1064
1061
  // The branch was created and only its terminal was refused - the
1065
1062
  // launch preflight's 409 carries words for the user. "Could not
1066
1063
  // branch this session" would be false, and the early return would
@@ -1073,6 +1070,9 @@ export class AssistantSessionsPanel extends Widget {
1073
1070
  this._notifyLaunchError('Branch created; its terminal did not open', err);
1074
1071
  await this._fetch().catch(() => undefined);
1075
1072
  }
1073
+ else if (isResponseStatus(err, 400)) {
1074
+ Notification.error(`${this._descriptor.label} cannot branch this conversation.`, { autoClose: 4000 });
1075
+ }
1076
1076
  else {
1077
1077
  this._showActionError('Could not branch this session - try again.', err);
1078
1078
  }
package/lib/core/popup.js CHANGED
@@ -50,6 +50,9 @@ export function showManageSessionsPopup(options) {
50
50
  // removal is permanent.
51
51
  let armed = false;
52
52
  let armTimer = 0;
53
+ // The arming click's event.timeStamp, which the confirm click is measured
54
+ // against.
55
+ let armedAt = 0;
53
56
  const body = document.createElement('div');
54
57
  body.className = 'jp-AiAssistantsPanel-branchPopup';
55
58
  const search = document.createElement('input');
@@ -348,10 +351,17 @@ export function showManageSessionsPopup(options) {
348
351
  list.removeAttribute('aria-busy');
349
352
  }
350
353
  };
351
- deleteBtn.addEventListener('click', () => {
354
+ deleteBtn.addEventListener('click', event => {
352
355
  if (deleting || selected.size === 0) {
353
356
  return;
354
357
  }
358
+ // The second click of a double-click is not a confirmation (DEF-PANE-336):
359
+ // a multi-click within 600 ms of arming is ignored. A later click confirms
360
+ // whatever its detail - the OS double-click interval can be longer than
361
+ // the user's pause, and the click would be dropped (DEF-PANE-338).
362
+ if (armed && event.detail > 1 && event.timeStamp - armedAt < 600) {
363
+ return;
364
+ }
355
365
  // No confirmation DIALOG here: a second Lumino dialog stacked on this popup
356
366
  // renders detached. The deletion confirms in the footer itself - the first
357
367
  // click arms the button, the second executes, and any selection change or
@@ -360,6 +370,7 @@ export function showManageSessionsPopup(options) {
360
370
  // refused move may remove the conversation permanently or leave it.
361
371
  if (!armed) {
362
372
  armed = true;
373
+ armedAt = event.timeStamp;
363
374
  window.clearTimeout(armTimer);
364
375
  armTimer = window.setTimeout(() => {
365
376
  disarm();
@@ -20,9 +20,10 @@ export declare function endSpinner(token: number): void;
20
20
  * tint pass.
21
21
  *
22
22
  * Split out of the panel because none of it is presentation - it is process
23
- * identity. The panel asks for a conversation and gets a focused terminal
24
- * running exactly that conversation, or a fresh one; nothing in here knows
25
- * about sections, filters or menus.
23
+ * identity. The panel asks for a conversation and gets a terminal running
24
+ * exactly that conversation, or a fresh one, focused unless the user hid the
25
+ * action's spinner dialog; nothing in here knows about sections, filters or
26
+ * menus.
26
27
  */
27
28
  export declare class TerminalManager {
28
29
  constructor(options: TerminalManager.IOptions);
@@ -52,9 +53,13 @@ export declare class TerminalManager {
52
53
  * `spinner` is the token of the click this launch belongs to. The launch
53
54
  * joins that click's spinner instead of beginning its own, so the user sees
54
55
  * one dialog from the click to the terminal, and a click the user hid stays
55
- * hidden.
56
+ * hidden and its terminal opens behind the current tab.
56
57
  */
57
58
  launch(request: ILaunchRequest, tagSessionId?: string, spinner?: number): Promise<any>;
59
+ /** Show the current widget's tab again. Opening a terminal selects its tab,
60
+ * so for a hidden action the tab the user was on goes back in front
61
+ * (DEF-PANE-339). */
62
+ private _showCurrentTab;
58
63
  /**
59
64
  * Bring a terminal tab to the front AND hand it keyboard focus, so typing
60
65
  * starts without a second click. `activateById` only reveals the tab; the
@@ -68,8 +73,12 @@ export declare class TerminalManager {
68
73
  * under it ends up without the keyboard. Closing emits `disposed` before
69
74
  * that return, and the activation the tab then receives is processed after
70
75
  * it, so the terminal is the last to take the focus.
76
+ *
77
+ * `spinner` is the token of the action that asked. When the user hid that
78
+ * action's dialog the terminal is left where it is, and so is a terminal
79
+ * waiting for the dialog the user then hides (DEF-PANE-339).
71
80
  */
72
- focus(widget: any): void;
81
+ focus(widget: any, spinner?: number): void;
73
82
  /** Find an open terminal running exactly this conversation in this project,
74
83
  * or null.
75
84
  *
@@ -75,7 +75,8 @@ function titleSpinner(dialog, title) {
75
75
  *
76
76
  * Hide does not abort the request - it only takes the app-wide scrim away, so
77
77
  * a stalled action cannot leave the user staring at a modal with no exit. If
78
- * the action later succeeds the terminal simply appears. */
78
+ * the action later succeeds the terminal opens as a tab behind the current
79
+ * one (DEF-PANE-339). */
79
80
  function showLaunchSpinner(title, trans) {
80
81
  const body = new Widget();
81
82
  body.node.className = 'jp-AiAssistantsPanel-launchOverlay';
@@ -87,10 +88,13 @@ function showLaunchSpinner(title, trans) {
87
88
  title,
88
89
  body,
89
90
  // `Hide`, not `Cancel`. The button takes the scrim away and nothing else -
90
- // the request is already on the wire and the terminal will still open and
91
- // take focus. A button named Cancel that does not cancel is worse than no
92
- // button.
91
+ // the request is already on the wire and the terminal will still open, as
92
+ // a tab behind the current one that does not take the keyboard. A button
93
+ // named Cancel that does not cancel is worse than no button.
93
94
  buttons: [Dialog.okButton({ label: trans.__('Hide') })],
95
+ // Hide is the only exit: the second click of a double-click on the row
96
+ // lands on the backdrop, and that is not a choice to hide (DEF-PANE-340).
97
+ hasClose: false,
94
98
  renderer: spinnerRenderer
95
99
  });
96
100
  // Scopes the compact layout in base.css to this one dialog.
@@ -110,6 +114,12 @@ function showLaunchSpinner(title, trans) {
110
114
  let spinnerDialog = null;
111
115
  let spinnerSeq = 0;
112
116
  const spinnerTitles = new Map();
117
+ /** Whether the user hid the dialog of `token`, an action still running. Hide
118
+ * disposes the dialog, and an action begun after it raises a fresh dialog that
119
+ * does not stand for the hidden one (DEF-PANE-339). */
120
+ function spinnerHidden(token) {
121
+ return (!spinnerDialog || spinnerDialog.isDisposed || !spinnerTitles.has(token));
122
+ }
113
123
  /** Show the spinner dialog for one action, or retitle the one already up so it
114
124
  * names the newest action. Pass the token to `endSpinner` when the action
115
125
  * settles. Hide disposes the dialog for the actions then running: they stay
@@ -146,9 +156,10 @@ export function endSpinner(token) {
146
156
  * tint pass.
147
157
  *
148
158
  * Split out of the panel because none of it is presentation - it is process
149
- * identity. The panel asks for a conversation and gets a focused terminal
150
- * running exactly that conversation, or a fresh one; nothing in here knows
151
- * about sections, filters or menus.
159
+ * identity. The panel asks for a conversation and gets a terminal running
160
+ * exactly that conversation, or a fresh one, focused unless the user hid the
161
+ * action's spinner dialog; nothing in here knows about sections, filters or
162
+ * menus.
152
163
  */
153
164
  export class TerminalManager {
154
165
  constructor(options) {
@@ -259,7 +270,7 @@ export class TerminalManager {
259
270
  // when it carries the wanted conversation.
260
271
  const cached = this._byProject.get(session.project_path);
261
272
  if (cached && !cached.widget.isDisposed && cached.sessionId === wanted) {
262
- this.focus(cached.widget);
273
+ this.focus(cached.widget, spinner);
263
274
  return;
264
275
  }
265
276
  // 2. Walk every live terminal, asking the server what each is running in
@@ -273,7 +284,7 @@ export class TerminalManager {
273
284
  sessionId: (_a = found.runningId) !== null && _a !== void 0 ? _a : undefined
274
285
  });
275
286
  this._wireDisposal(session.project_path, found.widget);
276
- this.focus(found.widget);
287
+ this.focus(found.widget, spinner);
277
288
  return;
278
289
  }
279
290
  // 3. Fresh launch. The server picks the verb (resume, attach, bare) at
@@ -294,7 +305,7 @@ export class TerminalManager {
294
305
  * `spinner` is the token of the click this launch belongs to. The launch
295
306
  * joins that click's spinner instead of beginning its own, so the user sees
296
307
  * one dialog from the click to the terminal, and a click the user hid stays
297
- * hidden.
308
+ * hidden and its terminal opens behind the current tab.
298
309
  */
299
310
  async launch(request, tagSessionId, spinner) {
300
311
  const own = spinner === undefined
@@ -302,16 +313,43 @@ export class TerminalManager {
302
313
  : undefined;
303
314
  try {
304
315
  const launched = await requestProvider(this._descriptor.id, 'launch', this._serverSettings, { method: 'POST', body: JSON.stringify(request) });
305
- const widget = await this._app.commands.execute('terminal:open', {
306
- name: launched.terminal_name
307
- });
316
+ const token = spinner !== null && spinner !== void 0 ? spinner : own;
317
+ const name = launched.terminal_name;
318
+ // `terminal:open` shows the new terminal's tab and moves the focus into
319
+ // it before it returns. When the user has hidden this action's dialog,
320
+ // that focus is stopped before the shell sees it, as JupyterLab's Dialog
321
+ // stops focus that leaves it, so the user's widget stays the current one.
322
+ // The focus goes back to the element it left once the user's tab is
323
+ // shown again (DEF-PANE-339).
324
+ let left = null;
325
+ const keep = (event) => {
326
+ const target = event.target;
327
+ if (spinnerHidden(token) &&
328
+ this._liveTerminals().some(w => { var _a, _b; return ((_b = (_a = w.content) === null || _a === void 0 ? void 0 : _a.session) === null || _b === void 0 ? void 0 : _b.name) === name && w.node.contains(target); })) {
329
+ event.stopPropagation();
330
+ left = event.relatedTarget;
331
+ target.blur();
332
+ }
333
+ };
334
+ document.addEventListener('focus', keep, true);
335
+ let widget;
336
+ try {
337
+ widget = await this._app.commands.execute('terminal:open', { name });
338
+ }
339
+ finally {
340
+ document.removeEventListener('focus', keep, true);
341
+ }
342
+ if (spinnerHidden(token)) {
343
+ this._showCurrentTab();
344
+ left === null || left === void 0 ? void 0 : left.focus();
345
+ }
308
346
  if (widget === null || widget === void 0 ? void 0 : widget.id) {
309
347
  this._byProject.set(request.project_path, {
310
348
  widget,
311
349
  sessionId: tagSessionId
312
350
  });
313
351
  this._wireDisposal(request.project_path, widget);
314
- this.focus(widget);
352
+ this.focus(widget, token);
315
353
  }
316
354
  return widget !== null && widget !== void 0 ? widget : null;
317
355
  }
@@ -321,6 +359,17 @@ export class TerminalManager {
321
359
  }
322
360
  }
323
361
  }
362
+ /** Show the current widget's tab again. Opening a terminal selects its tab,
363
+ * so for a hidden action the tab the user was on goes back in front
364
+ * (DEF-PANE-339). */
365
+ _showCurrentTab() {
366
+ const shell = this._app.shell;
367
+ const current = shell.currentWidget;
368
+ const bar = current && shell.getMainAreaTabBar(current);
369
+ if (bar) {
370
+ bar.currentTitle = current.title;
371
+ }
372
+ }
324
373
  /**
325
374
  * Bring a terminal tab to the front AND hand it keyboard focus, so typing
326
375
  * starts without a second click. `activateById` only reveals the tab; the
@@ -334,13 +383,31 @@ export class TerminalManager {
334
383
  * under it ends up without the keyboard. Closing emits `disposed` before
335
384
  * that return, and the activation the tab then receives is processed after
336
385
  * it, so the terminal is the last to take the focus.
386
+ *
387
+ * `spinner` is the token of the action that asked. When the user hid that
388
+ * action's dialog the terminal is left where it is, and so is a terminal
389
+ * waiting for the dialog the user then hides (DEF-PANE-339).
337
390
  */
338
- focus(widget) {
391
+ focus(widget, spinner) {
339
392
  if (!widget || widget.isDisposed) {
340
393
  return;
341
394
  }
395
+ if (spinner !== undefined && spinnerHidden(spinner)) {
396
+ return;
397
+ }
342
398
  if (spinnerDialog && !spinnerDialog.isDisposed) {
343
- spinnerDialog.disposed.connect(() => this.focus(widget));
399
+ spinnerDialog.disposed.connect(() => {
400
+ // The dialog closes on its own only with its last action. Closed with
401
+ // an action still running, the user hid it: the terminal is not
402
+ // activated, and its tab, shown behind the dialog, goes back behind
403
+ // the user's tab.
404
+ if (spinnerTitles.size > 0) {
405
+ this._showCurrentTab();
406
+ }
407
+ else {
408
+ this.focus(widget);
409
+ }
410
+ });
344
411
  return;
345
412
  }
346
413
  this._app.shell.activateById(widget.id);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jupyterlab_ai_code_assistants_extension",
3
- "version": "1.2.163",
3
+ "version": "1.2.199",
4
4
  "description": "A full launcher and manager for every AI code assistant in JupyterLab - Claude Code, Codex, Kimi, Gemini and DeepSeek. Start, resume, fork, switch, and clean up CLI sessions from a side panel per assistant - one click lands you in the right terminal with the assistant already running, no duplicate tabs, no UUID hunting.",
5
5
  "keywords": [
6
6
  "jupyter",
@@ -169,5 +169,8 @@
169
169
  "selector-no-vendor-prefix": null,
170
170
  "value-no-vendor-prefix": null
171
171
  }
172
+ },
173
+ "allowScripts": {
174
+ "@fortawesome/fontawesome-free": true
172
175
  }
173
176
  }
@@ -3,8 +3,10 @@
3
3
  * icons (DEF-PANE-332).
4
4
  *
5
5
  * The Launcher card, the sidebar tab and the menus draw every icon in one box
6
- * size, so how big a glyph looks there is fixed by the SVG alone: the share of
7
- * the viewBox its drawn paths cover. The reference is measured from
6
+ * size, so how big a glyph looks there is fixed by the SVG: the share of the
7
+ * viewBox its drawn paths cover. In the Launcher card the assistant svg is then
8
+ * scaled to 0.75 (ACC-LNCH-215, style/base.css), so the tile glyph is three
9
+ * quarters of that share. The reference is measured from
8
10
  * JupyterLab's terminal icon, the Terminal card of the same Launcher, rather
9
11
  * than written down, and each provider glyph is measured the same way.
10
12
  */
@@ -30,8 +30,10 @@ import { PROVIDERS } from '../providers';
30
30
 
31
31
  type Preset = 'launcherSection' | 'launcherCard';
32
32
 
33
- function markup(icon: LabIcon, stylesheet: Preset): string {
34
- return renderToStaticMarkup(React.createElement(icon.react, { stylesheet }));
33
+ function markup(icon: LabIcon, stylesheet: Preset, className?: string): string {
34
+ return renderToStaticMarkup(
35
+ React.createElement(icon.react, { stylesheet, className })
36
+ );
35
37
  }
36
38
 
37
39
  const tiles = PROVIDERS.map(module => [
@@ -55,7 +57,11 @@ describe('ACC-LNCH-163 - the section header draws the joint icon', () => {
55
57
 
56
58
  it.each(tiles)('%s: its own glyph on the tile', (_id, tile) => {
57
59
  const view = launcherTileIcon(tile);
58
- expect(markup(view, 'launcherCard')).toBe(markup(tile, 'launcherCard'));
60
+ // The tile's glyph carries the class style/base.css scales to 75%
61
+ // (ACC-LNCH-215).
62
+ expect(markup(view, 'launcherCard')).toBe(
63
+ markup(tile, 'launcherCard', 'jp-AiAssistantsLauncher-tileGlyph')
64
+ );
59
65
  expect(markup(view, 'launcherCard')).not.toBe(
60
66
  markup(assistantsIcon, 'launcherCard')
61
67
  );
@@ -585,83 +585,92 @@ describe('click-time spinner dialog (DEF-PANE-318, DEF-PANE-327-BUSY-MODAL-1)',
585
585
  });
586
586
 
587
587
  describe('a branch whose terminal launch was refused after the mint', () => {
588
- it('says the branch was created and still lists it when the launch is refused', async () => {
589
- // The DeepSeek preflight refuses the follow-up launch with a 409 whose
590
- // body is words, but by then the branch POST has already returned 200 with
591
- // the minted id - the row exists server-side. The user is told that, and
592
- // the refresh that surfaces the row still runs; "Could not branch this
593
- // session" would report the whole action as failed and leave the branch
594
- // invisible until the next poll.
595
- const target = makePanel({ ...DESCRIPTOR, forkStrategy: 'server-copy' });
596
- const parent = session({ name: 'analysis', name_source: 'session' });
597
- const child = session({
598
- name: 'analysis_1',
599
- session_id: 'child',
600
- encoded_path: 'enc-child'
601
- });
602
- const refusal =
603
- 'the TUI profile is not mounted; run dsh plugin --profile tui add @deepseek-harness-tui/dsh-tui';
604
- (target as any)._terminals.launch = jest
605
- .fn()
606
- .mockRejectedValue(
607
- Object.assign(new Error(refusal), { response: { status: 409 } })
588
+ it.each<[number, string]>([
589
+ [
590
+ 409,
591
+ 'the TUI profile is not mounted; run dsh plugin --profile tui add @deepseek-harness-tui/dsh-tui'
592
+ ],
593
+ // DEF-PANE-337: the launch preflight's 400 for a project folder that is
594
+ // gone is not the branch POST's 400, and must not read as one.
595
+ [400, 'invalid_project_path']
596
+ ])(
597
+ 'says the branch was created and still lists it when the launch is refused with %i',
598
+ async (status, refusal) => {
599
+ // The DeepSeek preflight refuses the follow-up launch with a 409 whose
600
+ // body is words, but by then the branch POST has already returned 200 with
601
+ // the minted id - the row exists server-side. The user is told that, and
602
+ // the refresh that surfaces the row still runs; "Could not branch this
603
+ // session" would report the whole action as failed and leave the branch
604
+ // invisible until the next poll.
605
+ const target = makePanel({ ...DESCRIPTOR, forkStrategy: 'server-copy' });
606
+ const parent = session({ name: 'analysis', name_source: 'session' });
607
+ const child = session({
608
+ name: 'analysis_1',
609
+ session_id: 'child',
610
+ encoded_path: 'enc-child'
611
+ });
612
+ (target as any)._terminals.launch = jest
613
+ .fn()
614
+ .mockRejectedValue(
615
+ Object.assign(new Error(refusal), { response: { status } })
616
+ );
617
+ (target as any)._activeSession = parent;
618
+ request.mockImplementation((_id: string, path: string) =>
619
+ Promise.resolve(
620
+ path === 'branch'
621
+ ? { session_id: 'child' }
622
+ : path === 'sessions'
623
+ ? { sessions: [parent, child] }
624
+ : {}
625
+ )
608
626
  );
609
- (target as any)._activeSession = parent;
610
- request.mockImplementation((_id: string, path: string) =>
611
- Promise.resolve(
612
- path === 'branch'
613
- ? { session_id: 'child' }
614
- : path === 'sessions'
615
- ? { sessions: [parent, child] }
616
- : {}
617
- )
618
- );
619
- const errored = jest
620
- .spyOn(Notification, 'error')
621
- .mockImplementation(() => '');
622
- try {
623
- await (target as any)._branchSession();
627
+ const errored = jest
628
+ .spyOn(Notification, 'error')
629
+ .mockImplementation(() => '');
630
+ try {
631
+ await (target as any)._branchSession();
624
632
 
625
- // The refused launch still closes the click-time spinner dialog.
626
- await flush();
627
- expect(document.querySelector('.jp-Dialog')).toBeNull();
633
+ // The refused launch still closes the click-time spinner dialog.
634
+ await flush();
635
+ expect(document.querySelector('.jp-Dialog')).toBeNull();
628
636
 
629
- // What the user is told: the branch was created, its terminal did not
630
- // open, and the refusal's own words - neither sentence that blames the
631
- // branch itself.
632
- expect(errored).toHaveBeenCalledTimes(1);
633
- const told = String(errored.mock.calls[0][0]);
634
- expect(told).toContain('Branch created; its terminal did not open');
635
- expect(told).toContain(refusal);
636
- expect(told).not.toContain('cannot branch this conversation');
637
- expect(told).not.toContain('Could not branch this session');
638
-
639
- // The toast budget, by arithmetic rather than by eye: JupyterLab shows
640
- // a toast's first 140 characters and clips the rest, and a store may
641
- // raise a message of up to 97 (tests/test_provider_stores.py holds the
642
- // DeepSeek refusal to that), so the panel's copy plus ": " gets
643
- // 140 - 2 - 97 = 41 characters. This reddens when a reworded copy
644
- // grows past the budget - the 55-character "The branch was created,
645
- // but its terminal could not open" only showed the cut in a browser
646
- // toast, with the store's command sliced mid-name.
647
- const copy = told.slice(0, told.indexOf(refusal) - 2);
648
- expect(copy.length + 2 + 97).toBeLessThanOrEqual(140);
649
-
650
- // The list was re-read, so the new branch is on screen rather than
651
- // waiting for the next poll.
652
- expect(
653
- (target as any)._sessions.map((s: ISession) => s.session_id)
654
- ).toContain('child');
655
- const names = Array.from(
656
- target.node.querySelectorAll('.jp-AiAssistantsPanel-nameText')
657
- ).map(node => node.textContent);
658
- expect(names).toContain('analysis_1');
659
- } finally {
660
- errored.mockRestore();
661
- target.dispose();
662
- request.mockImplementation(() => Promise.resolve({}));
637
+ // What the user is told: the branch was created, its terminal did not
638
+ // open, and the refusal's own words - neither sentence that blames the
639
+ // branch itself.
640
+ expect(errored).toHaveBeenCalledTimes(1);
641
+ const told = String(errored.mock.calls[0][0]);
642
+ expect(told).toContain('Branch created; its terminal did not open');
643
+ expect(told).toContain(refusal);
644
+ expect(told).not.toContain('cannot branch this conversation');
645
+ expect(told).not.toContain('Could not branch this session');
646
+
647
+ // The toast budget, by arithmetic rather than by eye: JupyterLab shows
648
+ // a toast's first 140 characters and clips the rest, and a store may
649
+ // raise a message of up to 97 (tests/test_provider_stores.py holds the
650
+ // DeepSeek refusal to that), so the panel's copy plus ": " gets
651
+ // 140 - 2 - 97 = 41 characters. This reddens when a reworded copy
652
+ // grows past the budget - the 55-character "The branch was created,
653
+ // but its terminal could not open" only showed the cut in a browser
654
+ // toast, with the store's command sliced mid-name.
655
+ const copy = told.slice(0, told.indexOf(refusal) - 2);
656
+ expect(copy.length + 2 + 97).toBeLessThanOrEqual(140);
657
+
658
+ // The list was re-read, so the new branch is on screen rather than
659
+ // waiting for the next poll.
660
+ expect(
661
+ (target as any)._sessions.map((s: ISession) => s.session_id)
662
+ ).toContain('child');
663
+ const names = Array.from(
664
+ target.node.querySelectorAll('.jp-AiAssistantsPanel-nameText')
665
+ ).map(node => node.textContent);
666
+ expect(names).toContain('analysis_1');
667
+ } finally {
668
+ errored.mockRestore();
669
+ target.dispose();
670
+ request.mockImplementation(() => Promise.resolve({}));
671
+ }
663
672
  }
664
- });
673
+ );
665
674
  });
666
675
 
667
676
  describe('DEF-46 - the row tooltip names only a mode the row launches with', () => {
@@ -276,4 +276,51 @@ describe('Manage Sessions popup permanent Delete', () => {
276
276
  expect(del.textContent).toBe('Delete (1)');
277
277
  expect(onDelete).not.toHaveBeenCalled();
278
278
  });
279
+
280
+ it('DEF-PANE-336 - a double-click arms and does not delete', async () => {
281
+ checkbox(rows()[1]).click();
282
+ const del = deleteButton();
283
+ const click = (detail: number): void => {
284
+ del.dispatchEvent(new MouseEvent('click', { bubbles: true, detail }));
285
+ };
286
+
287
+ // The two clicks a double-click dispatches: detail 1, then detail 2.
288
+ click(1);
289
+ click(2);
290
+ await Promise.resolve();
291
+ expect(onDelete).not.toHaveBeenCalled();
292
+ expect(del.classList.contains('jp-mod-armed')).toBe(true);
293
+
294
+ // A separate click confirms.
295
+ click(1);
296
+ await Promise.resolve();
297
+ expect(onDelete).toHaveBeenCalledTimes(1);
298
+ expect(onDelete).toHaveBeenCalledWith([FIRST]);
299
+ });
300
+
301
+ it('DEF-PANE-338-CONFIRM-WINDOW - a multi-click 600 ms after arming confirms', async () => {
302
+ checkbox(rows()[1]).click();
303
+ const del = deleteButton();
304
+ // The arm window is measured on the event clock, so each click carries
305
+ // its own timeStamp.
306
+ const click = (detail: number, timeStamp: number): void => {
307
+ const event = new MouseEvent('click', { bubbles: true, detail });
308
+ Object.defineProperty(event, 'timeStamp', { value: timeStamp });
309
+ del.dispatchEvent(event);
310
+ };
311
+
312
+ // Within 600 ms of arming, a detail-2 click is the second click of a
313
+ // double-click.
314
+ click(1, 1000);
315
+ click(2, 1500);
316
+ await Promise.resolve();
317
+ expect(onDelete).not.toHaveBeenCalled();
318
+ expect(del.classList.contains('jp-mod-armed')).toBe(true);
319
+
320
+ // Past 600 ms, a click the OS still counts into the double-click confirms.
321
+ click(2, 1700);
322
+ await Promise.resolve();
323
+ expect(onDelete).toHaveBeenCalledTimes(1);
324
+ expect(onDelete).toHaveBeenCalledWith([FIRST]);
325
+ });
279
326
  });
@@ -373,4 +373,120 @@ describe('one spinner dialog (DEF-PANE-327-BUSY-MODAL-1)', () => {
373
373
  endSpinner(hidden);
374
374
  }
375
375
  });
376
+
377
+ it('activates a terminal waiting on the dialog only when the dialog closes on its own (DEF-PANE-339)', async () => {
378
+ manager = makeManager();
379
+ const activated = jest.spyOn((manager as any)._app.shell, 'activateById');
380
+
381
+ // Another action keeps the dialog up after this launch has ended, so its
382
+ // terminal waits for the dialog to close. The user hides the dialog.
383
+ const other = beginSpinner('Branching Testbed session', trans);
384
+ try {
385
+ await manager.launch(
386
+ { project_path: PROJECT, session_id: CURRENT },
387
+ CURRENT
388
+ );
389
+ await flush();
390
+ expect(spinners()).toHaveLength(1);
391
+ spinners()[0]
392
+ .querySelector<HTMLElement>('.jp-Dialog-footer .jp-Dialog-button')!
393
+ .click();
394
+ await flush();
395
+ expect(spinners()).toHaveLength(0);
396
+ expect(activated).not.toHaveBeenCalled();
397
+ } finally {
398
+ endSpinner(other);
399
+ }
400
+
401
+ // Without Hide the dialog closes with the last action and the terminal
402
+ // is activated.
403
+ const next = beginSpinner('Branching Testbed session', trans);
404
+ let opened: any;
405
+ try {
406
+ opened = await manager.launch(
407
+ { project_path: PROJECT, session_id: CURRENT },
408
+ CURRENT
409
+ );
410
+ await flush();
411
+ expect(activated).not.toHaveBeenCalled();
412
+ } finally {
413
+ endSpinner(next);
414
+ }
415
+ await flush();
416
+ expect(activated).toHaveBeenCalledWith(opened.id);
417
+ });
418
+
419
+ it("leaves a hidden action's terminal alone when a later action's dialog closes (DEF-PANE-339)", async () => {
420
+ manager = makeManager();
421
+ const activated = jest.spyOn((manager as any)._app.shell, 'activateById');
422
+
423
+ // The user hides the dialog, then a later action raises a fresh one that
424
+ // stands for the later action alone.
425
+ const hidden = beginSpinner('Opening Testbed', trans);
426
+ let later: number | undefined;
427
+ try {
428
+ await flush();
429
+ spinners()[0]
430
+ .querySelector<HTMLElement>('.jp-Dialog-footer .jp-Dialog-button')!
431
+ .click();
432
+ later = beginSpinner('Branching Testbed session', trans);
433
+ await flush();
434
+ expect(spinners()).toHaveLength(1);
435
+
436
+ // The hidden action's terminal opens while the later dialog is up, and
437
+ // that dialog then closes on its own: the terminal is not activated.
438
+ await manager.launch(
439
+ { project_path: PROJECT, session_id: CURRENT },
440
+ CURRENT,
441
+ hidden
442
+ );
443
+ endSpinner(later);
444
+ await flush();
445
+ expect(spinners()).toHaveLength(0);
446
+ expect(activated).not.toHaveBeenCalled();
447
+ } finally {
448
+ if (later !== undefined) {
449
+ endSpinner(later);
450
+ }
451
+ endSpinner(hidden);
452
+ }
453
+ });
454
+
455
+ it('keeps the dialog up through the second click of a double-click, and the terminal then takes the keyboard (DEF-PANE-340)', async () => {
456
+ manager = makeManager();
457
+ const activated = jest.spyOn((manager as any)._app.shell, 'activateById');
458
+ const answer = request.getMockImplementation()!;
459
+ let release!: () => void;
460
+ const held = new Promise<void>(resolve => {
461
+ release = resolve;
462
+ });
463
+ request.mockImplementation(((...args: Parameters<typeof answer>) =>
464
+ args[1] === 'launch'
465
+ ? held.then(() => answer(...args))
466
+ : answer(...args)) as any);
467
+ const call = manager.launch(
468
+ { project_path: PROJECT, session_id: CURRENT },
469
+ CURRENT
470
+ );
471
+ let opened: any;
472
+ try {
473
+ await flush();
474
+ expect(spinners()).toHaveLength(1);
475
+ // The second click of a double-click on the row lands on the backdrop,
476
+ // outside the dialog's content.
477
+ for (const type of ['mousedown', 'click']) {
478
+ spinners()[0].dispatchEvent(
479
+ new MouseEvent(type, { bubbles: true, cancelable: true, detail: 2 })
480
+ );
481
+ }
482
+ await flush();
483
+ expect(spinners()).toHaveLength(1);
484
+ } finally {
485
+ release();
486
+ opened = await call;
487
+ }
488
+ await flush();
489
+ expect(spinners()).toHaveLength(0);
490
+ expect(activated).toHaveBeenCalledWith(opened.id);
491
+ });
376
492
  });
@@ -3,8 +3,10 @@ import { Widget } from '@lumino/widgets';
3
3
 
4
4
  /**
5
5
  * The renderer every dialog of this extension is built with. It marks the
6
- * footer, and `style/base.css` scopes its dialog rules - no focus rim on any
7
- * button, the reject buttons (Cancel, and Close in the Open Callback popup) as
6
+ * footer, and `style/base.css` scopes its dialog rules - no focus rim on a
7
+ * button focused by the mouse or by a dialog opened with the mouse (keyboard
8
+ * focus keeps JupyterLab's rim), the reject buttons (Cancel, and Close in the
9
+ * Open Callback popup) as
8
10
  * secondary surface buttons so the fill alone marks the primary - to a dialog
9
11
  * that holds the mark, so JupyterLab's own
10
12
  * dialogs keep their look. A renderer is the one hook that also reaches the
package/src/core/icons.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { LabIcon } from '@jupyterlab/ui-components';
1
+ import { classes, LabIcon } from '@jupyterlab/ui-components';
2
2
  import * as React from 'react';
3
3
 
4
4
  // Shared panel chrome icons. Provider icons are NOT here - each provider
@@ -175,7 +175,11 @@ export function providerIcon(name: string, svgstr: string): LabIcon {
175
175
  * preset, and each tile under `launcherCard`. This view of `tile` renders the
176
176
  * extension's joint icon under the section preset and `tile` everywhere else,
177
177
  * so the header carries no vendor's mark while every tile keeps its own
178
- * (ACC-LNCH-163).
178
+ * (ACC-LNCH-163). Under `launcherCard` it also adds the class
179
+ * `jp-AiAssistantsLauncher-tileGlyph`, which `style/base.css` scales to 75%,
180
+ * so the tile glyph is drawn smaller than JupyterLab's own tile glyphs while
181
+ * the sidebar tab, panel header and menus keep the standard size
182
+ * (ACC-LNCH-215).
179
183
  *
180
184
  * Built the way `LabIcon.bindprops` builds its views - a prototype child of
181
185
  * the tile with its own `react` - so `LabIcon.resolve` still recognises it.
@@ -188,7 +192,16 @@ export function launcherTileIcon(tile: LabIcon): LabIcon {
188
192
  props.stylesheet === 'launcherSection'
189
193
  ? assistantsIcon.react
190
194
  : tile.react,
191
- { ...props, ref }
195
+ {
196
+ ...props,
197
+ ...(props.stylesheet === 'launcherCard' && {
198
+ className: classes(
199
+ props.className,
200
+ 'jp-AiAssistantsLauncher-tileGlyph'
201
+ )
202
+ }),
203
+ ref
204
+ }
192
205
  )
193
206
  );
194
207
  // `react` is readonly on the type; the view shadows the tile's own.
package/src/core/panel.ts CHANGED
@@ -1254,12 +1254,7 @@ export class AssistantSessionsPanel extends Widget {
1254
1254
  spinner
1255
1255
  );
1256
1256
  } catch (err) {
1257
- if (isResponseStatus(err, 400)) {
1258
- Notification.error(
1259
- `${this._descriptor.label} cannot branch this conversation.`,
1260
- { autoClose: 4000 }
1261
- );
1262
- } else if (minted) {
1257
+ if (minted) {
1263
1258
  // The branch was created and only its terminal was refused - the
1264
1259
  // launch preflight's 409 carries words for the user. "Could not
1265
1260
  // branch this session" would be false, and the early return would
@@ -1274,6 +1269,11 @@ export class AssistantSessionsPanel extends Widget {
1274
1269
  err
1275
1270
  );
1276
1271
  await this._fetch().catch(() => undefined);
1272
+ } else if (isResponseStatus(err, 400)) {
1273
+ Notification.error(
1274
+ `${this._descriptor.label} cannot branch this conversation.`,
1275
+ { autoClose: 4000 }
1276
+ );
1277
1277
  } else {
1278
1278
  this._showActionError(
1279
1279
  'Could not branch this session - try again.',
package/src/core/popup.ts CHANGED
@@ -116,6 +116,9 @@ export function showManageSessionsPopup(
116
116
  // removal is permanent.
117
117
  let armed = false;
118
118
  let armTimer = 0;
119
+ // The arming click's event.timeStamp, which the confirm click is measured
120
+ // against.
121
+ let armedAt = 0;
119
122
 
120
123
  const body = document.createElement('div');
121
124
  body.className = 'jp-AiAssistantsPanel-branchPopup';
@@ -437,10 +440,17 @@ export function showManageSessionsPopup(
437
440
  }
438
441
  };
439
442
 
440
- deleteBtn.addEventListener('click', () => {
443
+ deleteBtn.addEventListener('click', event => {
441
444
  if (deleting || selected.size === 0) {
442
445
  return;
443
446
  }
447
+ // The second click of a double-click is not a confirmation (DEF-PANE-336):
448
+ // a multi-click within 600 ms of arming is ignored. A later click confirms
449
+ // whatever its detail - the OS double-click interval can be longer than
450
+ // the user's pause, and the click would be dropped (DEF-PANE-338).
451
+ if (armed && event.detail > 1 && event.timeStamp - armedAt < 600) {
452
+ return;
453
+ }
444
454
  // No confirmation DIALOG here: a second Lumino dialog stacked on this popup
445
455
  // renders detached. The deletion confirms in the footer itself - the first
446
456
  // click arms the button, the second executes, and any selection change or
@@ -449,6 +459,7 @@ export function showManageSessionsPopup(
449
459
  // refused move may remove the conversation permanently or leave it.
450
460
  if (!armed) {
451
461
  armed = true;
462
+ armedAt = event.timeStamp;
452
463
  window.clearTimeout(armTimer);
453
464
  armTimer = window.setTimeout(() => {
454
465
  disarm();
@@ -1,4 +1,4 @@
1
- import { JupyterFrontEnd } from '@jupyterlab/application';
1
+ import { ILabShell, JupyterFrontEnd } from '@jupyterlab/application';
2
2
  import { Dialog, Notification } from '@jupyterlab/apputils';
3
3
  import { TranslationBundle, nullTranslator } from '@jupyterlab/translation';
4
4
  import { ServerConnection } from '@jupyterlab/services';
@@ -103,7 +103,8 @@ function titleSpinner(dialog: Dialog<unknown>, title: string): void {
103
103
  *
104
104
  * Hide does not abort the request - it only takes the app-wide scrim away, so
105
105
  * a stalled action cannot leave the user staring at a modal with no exit. If
106
- * the action later succeeds the terminal simply appears. */
106
+ * the action later succeeds the terminal opens as a tab behind the current
107
+ * one (DEF-PANE-339). */
107
108
  function showLaunchSpinner(
108
109
  title: string,
109
110
  trans: TranslationBundle
@@ -120,10 +121,13 @@ function showLaunchSpinner(
120
121
  title,
121
122
  body,
122
123
  // `Hide`, not `Cancel`. The button takes the scrim away and nothing else -
123
- // the request is already on the wire and the terminal will still open and
124
- // take focus. A button named Cancel that does not cancel is worse than no
125
- // button.
124
+ // the request is already on the wire and the terminal will still open, as
125
+ // a tab behind the current one that does not take the keyboard. A button
126
+ // named Cancel that does not cancel is worse than no button.
126
127
  buttons: [Dialog.okButton({ label: trans.__('Hide') })],
128
+ // Hide is the only exit: the second click of a double-click on the row
129
+ // lands on the backdrop, and that is not a choice to hide (DEF-PANE-340).
130
+ hasClose: false,
127
131
  renderer: spinnerRenderer
128
132
  });
129
133
  // Scopes the compact layout in base.css to this one dialog.
@@ -145,6 +149,15 @@ let spinnerDialog: Dialog<unknown> | null = null;
145
149
  let spinnerSeq = 0;
146
150
  const spinnerTitles = new Map<number, string>();
147
151
 
152
+ /** Whether the user hid the dialog of `token`, an action still running. Hide
153
+ * disposes the dialog, and an action begun after it raises a fresh dialog that
154
+ * does not stand for the hidden one (DEF-PANE-339). */
155
+ function spinnerHidden(token: number): boolean {
156
+ return (
157
+ !spinnerDialog || spinnerDialog.isDisposed || !spinnerTitles.has(token)
158
+ );
159
+ }
160
+
148
161
  /** Show the spinner dialog for one action, or retitle the one already up so it
149
162
  * names the newest action. Pass the token to `endSpinner` when the action
150
163
  * settles. Hide disposes the dialog for the actions then running: they stay
@@ -182,9 +195,10 @@ export function endSpinner(token: number): void {
182
195
  * tint pass.
183
196
  *
184
197
  * Split out of the panel because none of it is presentation - it is process
185
- * identity. The panel asks for a conversation and gets a focused terminal
186
- * running exactly that conversation, or a fresh one; nothing in here knows
187
- * about sections, filters or menus.
198
+ * identity. The panel asks for a conversation and gets a terminal running
199
+ * exactly that conversation, or a fresh one, focused unless the user hid the
200
+ * action's spinner dialog; nothing in here knows about sections, filters or
201
+ * menus.
188
202
  */
189
203
  export class TerminalManager {
190
204
  constructor(options: TerminalManager.IOptions) {
@@ -274,7 +288,7 @@ export class TerminalManager {
274
288
  // when it carries the wanted conversation.
275
289
  const cached = this._byProject.get(session.project_path);
276
290
  if (cached && !cached.widget.isDisposed && cached.sessionId === wanted) {
277
- this.focus(cached.widget);
291
+ this.focus(cached.widget, spinner);
278
292
  return;
279
293
  }
280
294
 
@@ -289,7 +303,7 @@ export class TerminalManager {
289
303
  sessionId: found.runningId ?? undefined
290
304
  });
291
305
  this._wireDisposal(session.project_path, found.widget);
292
- this.focus(found.widget);
306
+ this.focus(found.widget, spinner);
293
307
  return;
294
308
  }
295
309
 
@@ -316,7 +330,7 @@ export class TerminalManager {
316
330
  * `spinner` is the token of the click this launch belongs to. The launch
317
331
  * joins that click's spinner instead of beginning its own, so the user sees
318
332
  * one dialog from the click to the terminal, and a click the user hid stays
319
- * hidden.
333
+ * hidden and its terminal opens behind the current tab.
320
334
  */
321
335
  async launch(
322
336
  request: ILaunchRequest,
@@ -337,16 +351,46 @@ export class TerminalManager {
337
351
  this._serverSettings,
338
352
  { method: 'POST', body: JSON.stringify(request) }
339
353
  );
340
- const widget: any = await this._app.commands.execute('terminal:open', {
341
- name: launched.terminal_name
342
- });
354
+ const token = spinner ?? own!;
355
+ const name = launched.terminal_name;
356
+ // `terminal:open` shows the new terminal's tab and moves the focus into
357
+ // it before it returns. When the user has hidden this action's dialog,
358
+ // that focus is stopped before the shell sees it, as JupyterLab's Dialog
359
+ // stops focus that leaves it, so the user's widget stays the current one.
360
+ // The focus goes back to the element it left once the user's tab is
361
+ // shown again (DEF-PANE-339).
362
+ let left = null as HTMLElement | null;
363
+ const keep = (event: FocusEvent): void => {
364
+ const target = event.target as HTMLElement;
365
+ if (
366
+ spinnerHidden(token) &&
367
+ this._liveTerminals().some(
368
+ w => w.content?.session?.name === name && w.node.contains(target)
369
+ )
370
+ ) {
371
+ event.stopPropagation();
372
+ left = event.relatedTarget as HTMLElement | null;
373
+ target.blur();
374
+ }
375
+ };
376
+ document.addEventListener('focus', keep, true);
377
+ let widget: any;
378
+ try {
379
+ widget = await this._app.commands.execute('terminal:open', { name });
380
+ } finally {
381
+ document.removeEventListener('focus', keep, true);
382
+ }
383
+ if (spinnerHidden(token)) {
384
+ this._showCurrentTab();
385
+ left?.focus();
386
+ }
343
387
  if (widget?.id) {
344
388
  this._byProject.set(request.project_path, {
345
389
  widget,
346
390
  sessionId: tagSessionId
347
391
  });
348
392
  this._wireDisposal(request.project_path, widget);
349
- this.focus(widget);
393
+ this.focus(widget, token);
350
394
  }
351
395
  return widget ?? null;
352
396
  } finally {
@@ -356,6 +400,18 @@ export class TerminalManager {
356
400
  }
357
401
  }
358
402
 
403
+ /** Show the current widget's tab again. Opening a terminal selects its tab,
404
+ * so for a hidden action the tab the user was on goes back in front
405
+ * (DEF-PANE-339). */
406
+ private _showCurrentTab(): void {
407
+ const shell = this._app.shell as ILabShell;
408
+ const current = shell.currentWidget;
409
+ const bar = current && shell.getMainAreaTabBar(current);
410
+ if (bar) {
411
+ bar.currentTitle = current.title;
412
+ }
413
+ }
414
+
359
415
  /**
360
416
  * Bring a terminal tab to the front AND hand it keyboard focus, so typing
361
417
  * starts without a second click. `activateById` only reveals the tab; the
@@ -369,13 +425,30 @@ export class TerminalManager {
369
425
  * under it ends up without the keyboard. Closing emits `disposed` before
370
426
  * that return, and the activation the tab then receives is processed after
371
427
  * it, so the terminal is the last to take the focus.
428
+ *
429
+ * `spinner` is the token of the action that asked. When the user hid that
430
+ * action's dialog the terminal is left where it is, and so is a terminal
431
+ * waiting for the dialog the user then hides (DEF-PANE-339).
372
432
  */
373
- focus(widget: any): void {
433
+ focus(widget: any, spinner?: number): void {
374
434
  if (!widget || widget.isDisposed) {
375
435
  return;
376
436
  }
437
+ if (spinner !== undefined && spinnerHidden(spinner)) {
438
+ return;
439
+ }
377
440
  if (spinnerDialog && !spinnerDialog.isDisposed) {
378
- spinnerDialog.disposed.connect(() => this.focus(widget));
441
+ spinnerDialog.disposed.connect(() => {
442
+ // The dialog closes on its own only with its last action. Closed with
443
+ // an action still running, the user hid it: the terminal is not
444
+ // activated, and its tab, shown behind the dialog, goes back behind
445
+ // the user's tab.
446
+ if (spinnerTitles.size > 0) {
447
+ this._showCurrentTab();
448
+ } else {
449
+ this.focus(widget);
450
+ }
451
+ });
379
452
  return;
380
453
  }
381
454
  this._app.shell.activateById(widget.id);
package/style/base.css CHANGED
@@ -616,17 +616,23 @@ body[data-jp-theme-light='true'] .jp-AiAssistantsPanel-dotHalo {
616
616
  border-width: 3px;
617
617
  }
618
618
 
619
- /* No focus rim on any button of this extension's dialogs: the primary action
620
- is marked by its fill (jp-mod-accept). JupyterLab draws the rim from
619
+ /* A focus rim on a button of this extension's dialogs only when the focus
620
+ came from the keyboard; the primary action is marked by its fill
621
+ (jp-mod-accept). JupyterLab draws the rim from
621
622
  `button.jp-Dialog-button.jp-mod-styled.jp-mod-accept:focus` and its warn and
622
623
  reject siblings in apputils' dialog.css - on :focus, not :focus-visible -
623
624
  and a Dialog focuses its default button when it attaches, so the rim showed
624
- on a dialog opened with the mouse too. Scoped through the footer mark of
625
- the extension's dialog renderer (src/core/dialogs.ts); the second selector
626
- outranks those four-class rules. */
627
- .jp-Dialog:has(.jp-AiAssistantsPanel-dialogFooter) button:focus,
625
+ on a dialog opened with the mouse too (DEF-PANE-328-FOCUS-RIM). The rule
626
+ removes it where the browser does not mark the focus :focus-visible: after
627
+ a mouse click, and on the default button of a dialog opened with the
628
+ mouse. Keyboard focus keeps JupyterLab's rim (ACC-PANE-217-FOCUS-OUTLINE).
629
+ Scoped through the footer mark of the extension's dialog renderer
630
+ (src/core/dialogs.ts); the second selector outranks those four-class
631
+ rules. */
628
632
  .jp-Dialog:has(.jp-AiAssistantsPanel-dialogFooter)
629
- button.jp-Dialog-button.jp-mod-styled:focus {
633
+ button:focus:not(:focus-visible),
634
+ .jp-Dialog:has(.jp-AiAssistantsPanel-dialogFooter)
635
+ button.jp-Dialog-button.jp-mod-styled:focus:not(:focus-visible) {
630
636
  outline: none;
631
637
  }
632
638
 
@@ -1126,3 +1132,10 @@ body[data-jp-theme-light='true'] .jp-AiAssistantsPanel-dotHalo {
1126
1132
  .jp-AiAssistantsCallback-status[data-level='error'] {
1127
1133
  color: var(--jp-error-color1);
1128
1134
  }
1135
+
1136
+ /* Launcher tile glyph at 75% of JupyterLab's own tile glyph (ACC-LNCH-215).
1137
+ A transform, so the 52px icon box and the card layout stay as they are and
1138
+ the svg shrinks about its own centre. */
1139
+ .jp-LauncherCard-icon .jp-AiAssistantsLauncher-tileGlyph > svg {
1140
+ transform: scale(0.75);
1141
+ }