@plmbr/notebook-intelligence 5.0.0

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.
Files changed (137) hide show
  1. package/LICENSE +674 -0
  2. package/README.md +412 -0
  3. package/lib/api.d.ts +288 -0
  4. package/lib/api.js +927 -0
  5. package/lib/cell-output-bundle.d.ts +25 -0
  6. package/lib/cell-output-bundle.js +129 -0
  7. package/lib/cell-output-toolbar.d.ts +26 -0
  8. package/lib/cell-output-toolbar.js +188 -0
  9. package/lib/chat-progress-feedback.d.ts +3 -0
  10. package/lib/chat-progress-feedback.js +27 -0
  11. package/lib/chat-sidebar.d.ts +92 -0
  12. package/lib/chat-sidebar.js +3452 -0
  13. package/lib/command-ids.d.ts +39 -0
  14. package/lib/command-ids.js +44 -0
  15. package/lib/components/ask-user-question.d.ts +2 -0
  16. package/lib/components/ask-user-question.js +85 -0
  17. package/lib/components/checkbox.d.ts +2 -0
  18. package/lib/components/checkbox.js +30 -0
  19. package/lib/components/claude-mcp-panel.d.ts +2 -0
  20. package/lib/components/claude-mcp-panel.js +275 -0
  21. package/lib/components/claude-mcp-paste.d.ts +7 -0
  22. package/lib/components/claude-mcp-paste.js +104 -0
  23. package/lib/components/claude-session-picker.d.ts +8 -0
  24. package/lib/components/claude-session-picker.js +127 -0
  25. package/lib/components/form-dialog.d.ts +25 -0
  26. package/lib/components/form-dialog.js +35 -0
  27. package/lib/components/launcher-picker.d.ts +6 -0
  28. package/lib/components/launcher-picker.js +135 -0
  29. package/lib/components/mcp-util.d.ts +2 -0
  30. package/lib/components/mcp-util.js +37 -0
  31. package/lib/components/notebook-generation-popover.d.ts +7 -0
  32. package/lib/components/notebook-generation-popover.js +60 -0
  33. package/lib/components/pill.d.ts +2 -0
  34. package/lib/components/pill.js +5 -0
  35. package/lib/components/plugins-panel.d.ts +3 -0
  36. package/lib/components/plugins-panel.js +466 -0
  37. package/lib/components/settings-panel.d.ts +11 -0
  38. package/lib/components/settings-panel.js +742 -0
  39. package/lib/components/skills-panel.d.ts +2 -0
  40. package/lib/components/skills-panel.js +1264 -0
  41. package/lib/handler.d.ts +8 -0
  42. package/lib/handler.js +36 -0
  43. package/lib/icons.d.ts +45 -0
  44. package/lib/icons.js +54 -0
  45. package/lib/index.d.ts +8 -0
  46. package/lib/index.js +2079 -0
  47. package/lib/markdown-renderer.d.ts +10 -0
  48. package/lib/markdown-renderer.js +64 -0
  49. package/lib/notebook-generation-toolbar.d.ts +16 -0
  50. package/lib/notebook-generation-toolbar.js +197 -0
  51. package/lib/notebook-generation.d.ts +8 -0
  52. package/lib/notebook-generation.js +12 -0
  53. package/lib/open-file-refresh-watcher-env.d.ts +4 -0
  54. package/lib/open-file-refresh-watcher-env.js +33 -0
  55. package/lib/open-file-refresh-watcher.d.ts +97 -0
  56. package/lib/open-file-refresh-watcher.js +190 -0
  57. package/lib/shell-utils.d.ts +6 -0
  58. package/lib/shell-utils.js +9 -0
  59. package/lib/task-target-notebook.d.ts +2 -0
  60. package/lib/task-target-notebook.js +28 -0
  61. package/lib/terminal-drag-format.d.ts +9 -0
  62. package/lib/terminal-drag-format.js +23 -0
  63. package/lib/terminal-drag.d.ts +12 -0
  64. package/lib/terminal-drag.js +268 -0
  65. package/lib/tokens.d.ts +149 -0
  66. package/lib/tokens.js +88 -0
  67. package/lib/tour/tour-anchors.d.ts +18 -0
  68. package/lib/tour/tour-anchors.js +18 -0
  69. package/lib/tour/tour-config.d.ts +66 -0
  70. package/lib/tour/tour-config.js +99 -0
  71. package/lib/tour/tour-defaults.json +58 -0
  72. package/lib/tour/tour-events.d.ts +19 -0
  73. package/lib/tour/tour-events.js +30 -0
  74. package/lib/tour/tour-overlay.d.ts +6 -0
  75. package/lib/tour/tour-overlay.js +350 -0
  76. package/lib/tour/tour-state.d.ts +20 -0
  77. package/lib/tour/tour-state.js +81 -0
  78. package/lib/tour/tour-steps.d.ts +33 -0
  79. package/lib/tour/tour-steps.js +216 -0
  80. package/lib/utils.d.ts +53 -0
  81. package/lib/utils.js +385 -0
  82. package/package.json +258 -0
  83. package/schema/plugin.json +42 -0
  84. package/src/api.ts +1424 -0
  85. package/src/cell-output-bundle.ts +176 -0
  86. package/src/cell-output-toolbar.ts +232 -0
  87. package/src/chat-progress-feedback.ts +35 -0
  88. package/src/chat-sidebar.tsx +5147 -0
  89. package/src/command-ids.ts +67 -0
  90. package/src/components/ask-user-question.tsx +151 -0
  91. package/src/components/checkbox.tsx +62 -0
  92. package/src/components/claude-mcp-panel.tsx +543 -0
  93. package/src/components/claude-mcp-paste.ts +132 -0
  94. package/src/components/claude-session-picker.tsx +214 -0
  95. package/src/components/form-dialog.tsx +75 -0
  96. package/src/components/launcher-picker.tsx +237 -0
  97. package/src/components/mcp-util.ts +53 -0
  98. package/src/components/notebook-generation-popover.tsx +127 -0
  99. package/src/components/pill.tsx +15 -0
  100. package/src/components/plugins-panel.tsx +774 -0
  101. package/src/components/settings-panel.tsx +1631 -0
  102. package/src/components/skills-panel.tsx +2084 -0
  103. package/src/handler.ts +51 -0
  104. package/src/icons.ts +71 -0
  105. package/src/index.ts +2583 -0
  106. package/src/markdown-renderer.tsx +153 -0
  107. package/src/notebook-generation-toolbar.tsx +281 -0
  108. package/src/notebook-generation.ts +23 -0
  109. package/src/open-file-refresh-watcher-env.ts +52 -0
  110. package/src/open-file-refresh-watcher.ts +260 -0
  111. package/src/shell-utils.ts +10 -0
  112. package/src/svg.d.ts +4 -0
  113. package/src/task-target-notebook.ts +37 -0
  114. package/src/terminal-drag-format.ts +29 -0
  115. package/src/terminal-drag.ts +382 -0
  116. package/src/tokens.ts +171 -0
  117. package/src/tour/tour-anchors.ts +21 -0
  118. package/src/tour/tour-config.ts +160 -0
  119. package/src/tour/tour-events.ts +34 -0
  120. package/src/tour/tour-overlay.tsx +474 -0
  121. package/src/tour/tour-state.ts +87 -0
  122. package/src/tour/tour-steps.ts +281 -0
  123. package/src/utils.ts +455 -0
  124. package/style/base.css +3238 -0
  125. package/style/icons/cell-toolbar-bug.svg +5 -0
  126. package/style/icons/cell-toolbar-chat.svg +5 -0
  127. package/style/icons/cell-toolbar-sparkle.svg +5 -0
  128. package/style/icons/claude.svg +1 -0
  129. package/style/icons/copilot-warning.svg +1 -0
  130. package/style/icons/copilot.svg +1 -0
  131. package/style/icons/copy.svg +1 -0
  132. package/style/icons/openai.svg +1 -0
  133. package/style/icons/opencode.svg +1 -0
  134. package/style/icons/sparkles-warning.svg +5 -0
  135. package/style/icons/sparkles.svg +1 -0
  136. package/style/index.css +1 -0
  137. package/style/index.js +1 -0
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Admin tour-copy overrides — frontend half.
3
+ *
4
+ * The backend reads a YAML/JSON file (path from `NBI_TOUR_CONFIG_PATH`)
5
+ * and ships the validated overrides via the capabilities response. This
6
+ * module overlays them on the built-in `ALL_TOUR_STEPS` so the same
7
+ * tour code runs against either the default copy or an admin-supplied
8
+ * customization, without rebuilding the extension.
9
+ *
10
+ * Defense in depth: the backend already validated shape and length;
11
+ * the frontend re-applies the same caps here so a backend bug or
12
+ * future server-side regression can't crash the picker layout.
13
+ */
14
+ import { ITourStep } from './tour-steps';
15
+ export interface ITourStepOverride {
16
+ title?: string;
17
+ description?: string;
18
+ enabled?: boolean;
19
+ description_singular?: string;
20
+ description_plural?: string;
21
+ }
22
+ export interface ITourUiOverride {
23
+ skip?: string;
24
+ next?: string;
25
+ back?: string;
26
+ done?: string;
27
+ }
28
+ export interface ITourCommandOverride {
29
+ label?: string;
30
+ }
31
+ export interface ITourOverrides {
32
+ steps?: Record<string, ITourStepOverride>;
33
+ ui?: ITourUiOverride;
34
+ command?: ITourCommandOverride;
35
+ }
36
+ /**
37
+ * Apply admin-supplied overrides to the canonical step list. Returns a
38
+ * new array; the input is not mutated.
39
+ *
40
+ * Override semantics:
41
+ * - `enabled: false` drops the step entirely (filtered out here, in
42
+ * addition to whatever `requires()` says).
43
+ * - `title` / `description` replace the corresponding fields when set.
44
+ * - For `launcher-tiles`, the dynamic thunk continues to run; the
45
+ * templates feed into it through the per-step override dict so the
46
+ * thunk can use them at resolve time.
47
+ */
48
+ export declare function applyTourOverrides(steps: readonly ITourStep[], overrides: ITourOverrides | undefined): ITourStep[];
49
+ /**
50
+ * Pull the per-step override for the launcher-tiles step's templates
51
+ * (if any). Used by the launcher-tiles description thunk.
52
+ */
53
+ export declare function launcherTileTemplates(overrides: ITourOverrides | undefined): {
54
+ singular?: string;
55
+ plural?: string;
56
+ };
57
+ /**
58
+ * Resolve a UI button label: admin override if set, otherwise the
59
+ * default supplied by the caller.
60
+ */
61
+ export declare function uiLabel(overrides: ITourOverrides | undefined, key: keyof ITourUiOverride, fallback: string): string;
62
+ /**
63
+ * Resolve the command-palette label for the tour command: admin
64
+ * override if set, otherwise the default supplied by the caller.
65
+ */
66
+ export declare function commandLabel(overrides: ITourOverrides | undefined, fallback: string): string;
@@ -0,0 +1,99 @@
1
+ // Copyright (c) Mehmet Bektas <mbektasgh@outlook.com>
2
+ // Per-field length caps. Mirror notebook_intelligence/tour_config.py.
3
+ const MAX_TITLE_CHARS = 80;
4
+ const MAX_DESCRIPTION_CHARS = 400;
5
+ const MAX_BUTTON_LABEL_CHARS = 24;
6
+ const MAX_COMMAND_LABEL_CHARS = 40;
7
+ function clamp(value, limit) {
8
+ return value.length <= limit ? value : value.slice(0, limit);
9
+ }
10
+ /**
11
+ * Apply admin-supplied overrides to the canonical step list. Returns a
12
+ * new array; the input is not mutated.
13
+ *
14
+ * Override semantics:
15
+ * - `enabled: false` drops the step entirely (filtered out here, in
16
+ * addition to whatever `requires()` says).
17
+ * - `title` / `description` replace the corresponding fields when set.
18
+ * - For `launcher-tiles`, the dynamic thunk continues to run; the
19
+ * templates feed into it through the per-step override dict so the
20
+ * thunk can use them at resolve time.
21
+ */
22
+ export function applyTourOverrides(steps, overrides) {
23
+ if (!overrides || !overrides.steps) {
24
+ return steps.map(step => ({ ...step }));
25
+ }
26
+ const stepOverrides = overrides.steps;
27
+ const result = [];
28
+ for (const step of steps) {
29
+ const override = stepOverrides[step.id];
30
+ if (!override) {
31
+ result.push({ ...step });
32
+ continue;
33
+ }
34
+ if (override.enabled === false) {
35
+ continue;
36
+ }
37
+ const next = { ...step };
38
+ // Reject empty strings: an empty title would leave the dialog's
39
+ // aria-labelledby target with no accessible name, and an empty
40
+ // description would leave aria-describedby pointing at nothing.
41
+ if (typeof override.title === 'string' && override.title.length > 0) {
42
+ next.title = clamp(override.title, MAX_TITLE_CHARS);
43
+ }
44
+ if (typeof override.description === 'string' &&
45
+ override.description.length > 0 &&
46
+ // The launcher-tiles thunk is overridden via templates, not the
47
+ // plain `description` field; the backend validator rejects it
48
+ // outright, but ignore it here too in case an older payload slips
49
+ // through.
50
+ step.id !== 'launcher-tiles') {
51
+ next.description = clamp(override.description, MAX_DESCRIPTION_CHARS);
52
+ }
53
+ result.push(next);
54
+ }
55
+ return result;
56
+ }
57
+ /**
58
+ * Pull the per-step override for the launcher-tiles step's templates
59
+ * (if any). Used by the launcher-tiles description thunk.
60
+ */
61
+ export function launcherTileTemplates(overrides) {
62
+ var _a;
63
+ const step = (_a = overrides === null || overrides === void 0 ? void 0 : overrides.steps) === null || _a === void 0 ? void 0 : _a['launcher-tiles'];
64
+ if (!step) {
65
+ return {};
66
+ }
67
+ const result = {};
68
+ if (typeof step.description_singular === 'string') {
69
+ result.singular = clamp(step.description_singular, MAX_DESCRIPTION_CHARS);
70
+ }
71
+ if (typeof step.description_plural === 'string') {
72
+ result.plural = clamp(step.description_plural, MAX_DESCRIPTION_CHARS);
73
+ }
74
+ return result;
75
+ }
76
+ /**
77
+ * Resolve a UI button label: admin override if set, otherwise the
78
+ * default supplied by the caller.
79
+ */
80
+ export function uiLabel(overrides, key, fallback) {
81
+ var _a;
82
+ const raw = (_a = overrides === null || overrides === void 0 ? void 0 : overrides.ui) === null || _a === void 0 ? void 0 : _a[key];
83
+ if (typeof raw === 'string' && raw.length > 0) {
84
+ return clamp(raw, MAX_BUTTON_LABEL_CHARS);
85
+ }
86
+ return fallback;
87
+ }
88
+ /**
89
+ * Resolve the command-palette label for the tour command: admin
90
+ * override if set, otherwise the default supplied by the caller.
91
+ */
92
+ export function commandLabel(overrides, fallback) {
93
+ var _a;
94
+ const raw = (_a = overrides === null || overrides === void 0 ? void 0 : overrides.command) === null || _a === void 0 ? void 0 : _a.label;
95
+ if (typeof raw === 'string' && raw.length > 0) {
96
+ return clamp(raw, MAX_COMMAND_LABEL_CHARS);
97
+ }
98
+ return fallback;
99
+ }
@@ -0,0 +1,58 @@
1
+ {
2
+ "command": {
3
+ "label": "Show NBI tour"
4
+ },
5
+ "ui": {
6
+ "skip": "Skip tour",
7
+ "next": "Next",
8
+ "back": "Back",
9
+ "done": "Done"
10
+ },
11
+ "steps": {
12
+ "welcome": {
13
+ "title": "Welcome to Notebook Intelligence",
14
+ "description": "Your AI assistant lives in this sidebar. Take a quick tour of the settings, attachments, and chat modes so you can use it confidently."
15
+ },
16
+ "new-chat": {
17
+ "title": "Start a new chat",
18
+ "description": "Begin a fresh session when context gets crowded or you want to switch tasks."
19
+ },
20
+ "claude-history": {
21
+ "title": "Pick up where you left off",
22
+ "description": "Reopen an earlier Claude session from this workspace."
23
+ },
24
+ "settings-gear": {
25
+ "title": "Settings",
26
+ "description": "Configure providers, API keys, and extensions (MCP servers, skills, admin policies)."
27
+ },
28
+ "slash-commands": {
29
+ "title": "Run a slash command",
30
+ "description": "Open the menu for built-in commands and installed skills. Typing / at the start of the prompt does the same thing."
31
+ },
32
+ "add-context": {
33
+ "title": "Attach files as context",
34
+ "description": "Pin workspace files so the assistant can reference them. Typing @ in the prompt opens the same picker."
35
+ },
36
+ "upload-file": {
37
+ "title": "Upload from your computer",
38
+ "description": "Add files from outside the workspace. They're saved to the workspace and attached to this chat."
39
+ },
40
+ "drag-and-drop": {
41
+ "title": "Drag and drop",
42
+ "description": "Drag files from the JupyterLab file browser or your desktop onto the prompt. Keyboard users can attach the same files with the 'Attach files as context' and 'Upload from your computer' buttons."
43
+ },
44
+ "chat-mode": {
45
+ "title": "Pick a chat mode",
46
+ "description": "Ask is read-only and only answers questions. Agent can also run tools, such as file edits and shell commands."
47
+ },
48
+ "launcher-tiles": {
49
+ "title": "Run a coding agent in a terminal",
50
+ "description_singular": "The JupyterLab Launcher has a {launchers} tile in the 'Coding Agent' section. It opens a CLI session in a Jupyter terminal next to your notebook.",
51
+ "description_plural": "The JupyterLab Launcher has a 'Coding Agent' section with tiles for {launchers}. Each tile opens a CLI session in a Jupyter terminal next to your notebook."
52
+ },
53
+ "done": {
54
+ "title": "You're ready",
55
+ "description": "Try this: open a notebook and ask, 'Explain what this notebook does and suggest one improvement.' You can replay this tour anytime from the command palette."
56
+ }
57
+ }
58
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Decoupled trigger channel for the first-run tour.
3
+ *
4
+ * The JupyterLab command (`notebook-intelligence:show-tour`) is
5
+ * registered in `src/index.ts` so it shows up in the command palette
6
+ * and can be invoked from anywhere. The tour itself is rendered inside
7
+ * the chat sidebar (`src/chat-sidebar.tsx`) so it can access React
8
+ * state cleanly. Wiring those two together via DOM CustomEvents keeps
9
+ * index.ts free of any direct sidebar import and lets the tour be
10
+ * triggered from any future surface (a Settings dialog entry, a Help
11
+ * menu, a banner) by dispatching the same event.
12
+ *
13
+ * Dispatched on `document` to match the existing `copilotSidebar:*`
14
+ * channel convention elsewhere in this codebase.
15
+ */
16
+ export declare const TOUR_START_EVENT = "nbi:show-tour";
17
+ export declare const TOUR_STOP_EVENT = "nbi:hide-tour";
18
+ export declare function dispatchShowTour(): void;
19
+ export declare function dispatchHideTour(): void;
@@ -0,0 +1,30 @@
1
+ // Copyright (c) Mehmet Bektas <mbektasgh@outlook.com>
2
+ /**
3
+ * Decoupled trigger channel for the first-run tour.
4
+ *
5
+ * The JupyterLab command (`notebook-intelligence:show-tour`) is
6
+ * registered in `src/index.ts` so it shows up in the command palette
7
+ * and can be invoked from anywhere. The tour itself is rendered inside
8
+ * the chat sidebar (`src/chat-sidebar.tsx`) so it can access React
9
+ * state cleanly. Wiring those two together via DOM CustomEvents keeps
10
+ * index.ts free of any direct sidebar import and lets the tour be
11
+ * triggered from any future surface (a Settings dialog entry, a Help
12
+ * menu, a banner) by dispatching the same event.
13
+ *
14
+ * Dispatched on `document` to match the existing `copilotSidebar:*`
15
+ * channel convention elsewhere in this codebase.
16
+ */
17
+ export const TOUR_START_EVENT = 'nbi:show-tour';
18
+ export const TOUR_STOP_EVENT = 'nbi:hide-tour';
19
+ export function dispatchShowTour() {
20
+ if (typeof document === 'undefined') {
21
+ return;
22
+ }
23
+ document.dispatchEvent(new CustomEvent(TOUR_START_EVENT));
24
+ }
25
+ export function dispatchHideTour() {
26
+ if (typeof document === 'undefined') {
27
+ return;
28
+ }
29
+ document.dispatchEvent(new CustomEvent(TOUR_STOP_EVENT));
30
+ }
@@ -0,0 +1,6 @@
1
+ /// <reference types="react" />
2
+ interface ITourOverlayProps {
3
+ onClose: () => void;
4
+ }
5
+ export declare function TourOverlay(props: ITourOverlayProps): JSX.Element | null;
6
+ export {};
@@ -0,0 +1,350 @@
1
+ // Copyright (c) Mehmet Bektas <mbektasgh@outlook.com>
2
+ import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
3
+ import { createPortal } from 'react-dom';
4
+ import { activeTourSteps } from './tour-steps';
5
+ import { markTourCompleted } from './tour-state';
6
+ import { NBIAPI } from '../api';
7
+ import { uiLabel } from './tour-config';
8
+ const TOOLTIP_GAP_PX = 12;
9
+ const TOOLTIP_WIDTH_PX = 320;
10
+ // Fallback used on the first render before the tooltip is measured.
11
+ // Chosen to be larger than the typical rendered height so the initial
12
+ // clamp errs toward staying on-screen rather than overshooting.
13
+ const TOOLTIP_HEIGHT_FALLBACK_PX = 200;
14
+ /**
15
+ * Compute the on-screen position of the tooltip given the anchor's
16
+ * bounding rect, the requested placement, and the tooltip's measured
17
+ * height. The clamp keeps the box fully on-screen even when the anchor
18
+ * sits near a viewport edge.
19
+ *
20
+ * Placement semantics: 'top' puts the tooltip above the anchor (its
21
+ * bottom edge gap-px above anchor.top), 'bottom' puts it below, 'left'
22
+ * to the left, 'right' to the right. The previous version omitted
23
+ * tooltip width/height from the top/left placements, which let the box
24
+ * overlap the anchor instead of sitting outside it.
25
+ */
26
+ function computeTooltipPosition(anchorRect, placement, tooltipHeight) {
27
+ let top = 0;
28
+ let left = 0;
29
+ switch (placement) {
30
+ case 'top':
31
+ top = anchorRect.top - TOOLTIP_GAP_PX - tooltipHeight;
32
+ left = anchorRect.left + anchorRect.width / 2 - TOOLTIP_WIDTH_PX / 2;
33
+ break;
34
+ case 'bottom':
35
+ top = anchorRect.bottom + TOOLTIP_GAP_PX;
36
+ left = anchorRect.left + anchorRect.width / 2 - TOOLTIP_WIDTH_PX / 2;
37
+ break;
38
+ case 'left':
39
+ top = anchorRect.top + anchorRect.height / 2 - tooltipHeight / 2;
40
+ left = anchorRect.left - TOOLTIP_GAP_PX - TOOLTIP_WIDTH_PX;
41
+ break;
42
+ case 'right':
43
+ top = anchorRect.top + anchorRect.height / 2 - tooltipHeight / 2;
44
+ left = anchorRect.right + TOOLTIP_GAP_PX;
45
+ break;
46
+ }
47
+ // Clamp inside the viewport with an 8px margin so the tooltip never
48
+ // disappears off-screen near the edges. The vertical clamp accounts
49
+ // for tooltip height so the bottom edge stays visible even when the
50
+ // anchor is near the bottom of the viewport.
51
+ const margin = 8;
52
+ left = Math.max(margin, Math.min(left, window.innerWidth - TOOLTIP_WIDTH_PX - margin));
53
+ top = Math.max(margin, Math.min(top, window.innerHeight - tooltipHeight - margin));
54
+ return { top, left };
55
+ }
56
+ function findAnchor(anchorId) {
57
+ if (!anchorId) {
58
+ return null;
59
+ }
60
+ return document.querySelector(`[data-tour-id="${anchorId}"]`);
61
+ }
62
+ export function TourOverlay(props) {
63
+ // Compute the list of steps once. Capabilities can change at runtime
64
+ // (e.g. user switches Claude mode mid-tour) but recomputing the list
65
+ // mid-flight would change indices under the user; freeze on mount.
66
+ const steps = useMemo(() => activeTourSteps(), []);
67
+ // Freeze the admin overrides snapshot at mount, alongside `steps`, so
68
+ // a config push mid-tour doesn't relabel the active overlay's buttons.
69
+ const labels = useMemo(() => {
70
+ const overrides = NBIAPI.config.tourOverrides;
71
+ return {
72
+ skip: uiLabel(overrides, 'skip', 'Skip tour'),
73
+ next: uiLabel(overrides, 'next', 'Next'),
74
+ back: uiLabel(overrides, 'back', 'Back'),
75
+ done: uiLabel(overrides, 'done', 'Done')
76
+ };
77
+ }, []);
78
+ const [index, setIndex] = useState(0);
79
+ const [anchorRect, setAnchorRect] = useState(null);
80
+ const [tooltipHeight, setTooltipHeight] = useState(TOOLTIP_HEIGHT_FALLBACK_PX);
81
+ const tooltipRef = useRef(null);
82
+ const rootRef = useRef(null);
83
+ const previouslyFocusedRef = useRef(null);
84
+ const step = steps[index];
85
+ // Save focus on mount, restore on unmount. Pairs with the focus trap
86
+ // below so the tour leaves focus where it found it (e.g. the prompt
87
+ // textarea) instead of dumping the user to document.body.
88
+ useEffect(() => {
89
+ var _a;
90
+ previouslyFocusedRef.current =
91
+ (_a = document.activeElement) !== null && _a !== void 0 ? _a : null;
92
+ return () => {
93
+ const prev = previouslyFocusedRef.current;
94
+ if (prev && document.contains(prev) && typeof prev.focus === 'function') {
95
+ prev.focus();
96
+ }
97
+ };
98
+ }, []);
99
+ // Measure the tooltip *before* paint so the next frame clamps its
100
+ // position against the actual rendered height. useLayoutEffect (not
101
+ // useEffect) prevents the user seeing a frame at the fallback height.
102
+ // Re-measure only on step change so a no-op render doesn't pay the
103
+ // getBoundingClientRect tax.
104
+ useLayoutEffect(() => {
105
+ if (!tooltipRef.current) {
106
+ return;
107
+ }
108
+ const h = tooltipRef.current.getBoundingClientRect().height;
109
+ if (h && Math.abs(h - tooltipHeight) > 0.5) {
110
+ setTooltipHeight(h);
111
+ }
112
+ // tooltipHeight intentionally omitted from deps: this effect only
113
+ // re-runs on step change and the > 0.5px guard already prevents
114
+ // infinite loops if measurement converges.
115
+ }, [step === null || step === void 0 ? void 0 : step.id, index]);
116
+ // Resolve the anchor synchronously before the next paint. If the
117
+ // anchor isn't in the DOM yet (parent React tree may not have
118
+ // committed), retry across a handful of animation frames before
119
+ // giving up; missing anchors auto-advance so the user doesn't land
120
+ // on a blank step. useLayoutEffect on the synchronous path
121
+ // eliminates the one-frame "wrong position" flash on step change.
122
+ useLayoutEffect(() => {
123
+ if (!step) {
124
+ return;
125
+ }
126
+ if (!step.anchorId) {
127
+ setAnchorRect(null);
128
+ return;
129
+ }
130
+ // Clear the stale rect from the previous step immediately so a
131
+ // late paint never shows the spotlight in the old position.
132
+ setAnchorRect(null);
133
+ let rafId = 0;
134
+ let attempts = 0;
135
+ const MAX_ATTEMPTS = 10;
136
+ let removeListeners = () => { };
137
+ const tryResolve = () => {
138
+ const anchor = findAnchor(step.anchorId);
139
+ if (anchor) {
140
+ const updateRect = () => setAnchorRect(anchor.getBoundingClientRect());
141
+ updateRect();
142
+ window.addEventListener('resize', updateRect);
143
+ window.addEventListener('scroll', updateRect, true);
144
+ removeListeners = () => {
145
+ window.removeEventListener('resize', updateRect);
146
+ window.removeEventListener('scroll', updateRect, true);
147
+ };
148
+ return;
149
+ }
150
+ attempts += 1;
151
+ if (attempts >= MAX_ATTEMPTS) {
152
+ // Genuinely missing; auto-advance so the user doesn't land on a
153
+ // blank step.
154
+ setIndex(i => i + 1);
155
+ return;
156
+ }
157
+ rafId = requestAnimationFrame(tryResolve);
158
+ };
159
+ // Attempt the first resolve synchronously so a happy path step
160
+ // change paints in the right place on the very next frame.
161
+ tryResolve();
162
+ return () => {
163
+ cancelAnimationFrame(rafId);
164
+ removeListeners();
165
+ };
166
+ }, [step === null || step === void 0 ? void 0 : step.anchorId, index]);
167
+ // Stable callback refs so the keydown handler can stay bound for the
168
+ // overlay's lifetime without re-binding per step. Closing over `index`
169
+ // directly would make the deps load-bearing in a non-obvious way.
170
+ const advanceRef = useRef(() => { });
171
+ const backRef = useRef(() => { });
172
+ const finishRef = useRef(() => { });
173
+ // Keyboard nav: Esc dismisses, Enter / Right advance, Left back.
174
+ // Tab/Shift-Tab are trapped inside the dialog (paired with
175
+ // aria-modal="true" so screen readers' modal semantics match reality).
176
+ // Ignore key presses targeted at editable elements so the prompt
177
+ // textarea's history-nav arrow keys still work if focus somehow
178
+ // landed there before the tour mounted.
179
+ useEffect(() => {
180
+ const handler = (e) => {
181
+ const target = e.target;
182
+ const inEditable = !!target &&
183
+ (target.tagName === 'INPUT' ||
184
+ target.tagName === 'TEXTAREA' ||
185
+ target.isContentEditable);
186
+ // Tab/Shift-Tab focus trap is enforced even when target is an
187
+ // editable element so focus can't escape the dialog through the
188
+ // textarea below it.
189
+ if (e.key === 'Tab') {
190
+ const root = rootRef.current;
191
+ if (!root) {
192
+ return;
193
+ }
194
+ const focusables = Array.from(root.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])')).filter(el => !el.hasAttribute('disabled'));
195
+ if (focusables.length === 0) {
196
+ return;
197
+ }
198
+ const first = focusables[0];
199
+ const last = focusables[focusables.length - 1];
200
+ const active = document.activeElement;
201
+ // If focus is outside the dialog entirely (e.g. clicked through
202
+ // the scrim somehow), pull it back to the first focusable.
203
+ if (!active || !root.contains(active)) {
204
+ e.preventDefault();
205
+ first.focus();
206
+ return;
207
+ }
208
+ if (e.shiftKey && active === first) {
209
+ e.preventDefault();
210
+ last.focus();
211
+ }
212
+ else if (!e.shiftKey && active === last) {
213
+ e.preventDefault();
214
+ first.focus();
215
+ }
216
+ return;
217
+ }
218
+ if (inEditable) {
219
+ return;
220
+ }
221
+ if (e.key === 'Escape') {
222
+ e.preventDefault();
223
+ finishRef.current();
224
+ }
225
+ else if (e.key === 'ArrowRight' || e.key === 'Enter') {
226
+ e.preventDefault();
227
+ advanceRef.current();
228
+ }
229
+ else if (e.key === 'ArrowLeft') {
230
+ e.preventDefault();
231
+ backRef.current();
232
+ }
233
+ };
234
+ window.addEventListener('keydown', handler);
235
+ return () => window.removeEventListener('keydown', handler);
236
+ }, []);
237
+ // If we run out of steps (e.g. every anchor disappeared) close the
238
+ // tour on the next tick rather than calling setState inside the
239
+ // render below.
240
+ useEffect(() => {
241
+ if (!step) {
242
+ finish();
243
+ }
244
+ }, [step]);
245
+ const advance = () => {
246
+ if (index >= steps.length - 1) {
247
+ finish();
248
+ return;
249
+ }
250
+ setIndex(i => i + 1);
251
+ };
252
+ const back = () => {
253
+ if (index > 0) {
254
+ setIndex(i => i - 1);
255
+ }
256
+ };
257
+ const finish = () => {
258
+ markTourCompleted();
259
+ props.onClose();
260
+ };
261
+ // Refresh the callback refs every render so the keydown listener
262
+ // (bound once) always reads the latest closures.
263
+ advanceRef.current = advance;
264
+ backRef.current = back;
265
+ finishRef.current = finish;
266
+ if (!step) {
267
+ // No step to render right now. The cleanup effect above will fire
268
+ // `finish()` on the next tick; in the meantime return null so
269
+ // React doesn't see a setState inside this render path.
270
+ return null;
271
+ }
272
+ const isCenter = step.placement === 'center' || !step.anchorId;
273
+ // Anchored steps are "ready" once we have measured the anchor for
274
+ // *this* step. While unmeasured we keep the dialog mounted but
275
+ // visually hidden so the user doesn't see a flash at the centered
276
+ // fallback position before the real position lands.
277
+ const measured = isCenter || anchorRect !== null;
278
+ const tooltipStyle = isCenter
279
+ ? {
280
+ top: '50%',
281
+ left: '50%',
282
+ transform: 'translate(-50%, -50%)',
283
+ width: TOOLTIP_WIDTH_PX
284
+ }
285
+ : (() => {
286
+ // Fall back to a zero-size rect at the viewport center when the
287
+ // anchor hasn't been measured yet. `DOMRect` is the standard
288
+ // constructor in the browser; jsdom-only test environments
289
+ // sometimes lack it, so build a plain object that satisfies
290
+ // the fields `computeTooltipPosition` reads.
291
+ const rect = anchorRect !== null && anchorRect !== void 0 ? anchorRect : {
292
+ top: window.innerHeight / 2,
293
+ left: window.innerWidth / 2,
294
+ right: window.innerWidth / 2,
295
+ bottom: window.innerHeight / 2,
296
+ width: 0,
297
+ height: 0,
298
+ x: window.innerWidth / 2,
299
+ y: window.innerHeight / 2,
300
+ toJSON: () => ({})
301
+ };
302
+ // step.placement is guaranteed not to be 'center' here (isCenter
303
+ // would have been true otherwise); narrow for the type checker.
304
+ const placement = step.placement;
305
+ return {
306
+ ...computeTooltipPosition(rect, placement, tooltipHeight),
307
+ width: TOOLTIP_WIDTH_PX,
308
+ visibility: measured ? 'visible' : 'hidden'
309
+ };
310
+ })();
311
+ const spotlight = !isCenter && anchorRect && (React.createElement("div", { className: "nbi-tour-spotlight", style: {
312
+ top: anchorRect.top - 4,
313
+ left: anchorRect.left - 4,
314
+ width: anchorRect.width + 8,
315
+ height: anchorRect.height + 8
316
+ } }));
317
+ const isLast = index === steps.length - 1;
318
+ const progress = ((index + 1) / steps.length) * 100;
319
+ // Render into document.body via portal so the overlay can extend
320
+ // outside the sidebar's clip region and float over the rest of
321
+ // JupyterLab.
322
+ // ARIA: the dialog is modal-ish (covers the UI), so flag it as such.
323
+ // The label / description IDs change per step, but most screen
324
+ // readers don't re-announce when those attrs change without remount.
325
+ // The step body is wrapped in an aria-live="polite" region so each
326
+ // transition is announced as the content changes.
327
+ const titleId = `nbi-tour-title-${step.id}`;
328
+ const descId = `nbi-tour-desc-${step.id}`;
329
+ // Arrow direction is the inverse of placement (a tooltip placed
330
+ // ABOVE the anchor has its tail pointing DOWN). Center-placement
331
+ // tooltips have no anchor, so no arrow.
332
+ const arrowDirection = isCenter ? null : step.placement;
333
+ return createPortal(React.createElement("div", { ref: rootRef, className: "nbi-tour-root", role: "dialog", "aria-modal": "true", "aria-labelledby": titleId, "aria-describedby": descId },
334
+ React.createElement("div", { className: "nbi-tour-scrim" }),
335
+ spotlight,
336
+ React.createElement("div", { ref: tooltipRef, className: `nbi-tour-tooltip${isCenter ? ' nbi-tour-tooltip-center' : ''}`, style: tooltipStyle },
337
+ arrowDirection && (React.createElement("div", { className: `nbi-tour-arrow nbi-tour-arrow-${arrowDirection}`, "aria-hidden": "true" })),
338
+ React.createElement("div", { className: "nbi-tour-progress", role: "progressbar", "aria-valuemin": 1, "aria-valuemax": steps.length, "aria-valuenow": index + 1, "aria-label": `Tour progress: step ${index + 1} of ${steps.length}` },
339
+ React.createElement("div", { className: "nbi-tour-progress-bar", style: { width: `${progress}%` } })),
340
+ React.createElement("div", { className: "nbi-tour-body", "aria-live": "polite", "aria-atomic": "true", key: step.id },
341
+ React.createElement("div", { className: "nbi-tour-title", id: titleId }, step.title),
342
+ React.createElement("div", { className: "nbi-tour-description", id: descId }, step.description)),
343
+ React.createElement("div", { className: "nbi-tour-actions" },
344
+ React.createElement("button", { type: "button", className: "nbi-tour-skip-link", onClick: finish }, labels.skip),
345
+ React.createElement("div", { style: { flexGrow: 1 } }),
346
+ index > 0 && (React.createElement("button", { type: "button", className: "jp-Dialog-button jp-mod-reject jp-mod-styled", onClick: back },
347
+ React.createElement("div", { className: "jp-Dialog-buttonLabel" }, labels.back))),
348
+ React.createElement("button", { type: "button", className: "jp-Dialog-button jp-mod-accept jp-mod-styled", onClick: advance, autoFocus: true },
349
+ React.createElement("div", { className: "jp-Dialog-buttonLabel" }, isLast ? labels.done : labels.next))))), document.body);
350
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * localStorage-backed persistence for the first-run tour.
3
+ *
4
+ * The tour is shown exactly once per browser per major tour version. The
5
+ * version is bumped (in code) whenever the step set changes meaningfully
6
+ * so existing users get re-prompted instead of silently missing new
7
+ * onboarding content. Users can also re-run the tour on demand via the
8
+ * `notebook-intelligence:show-tour` command, which calls `resetTour()`.
9
+ *
10
+ * Why localStorage and not server-side state: the tour is purely UI; a
11
+ * round trip to the Jupyter server on every sidebar mount just to check
12
+ * a single flag would add latency to a hot path. localStorage is
13
+ * per-browser, which is the right granularity (a user signing in on a
14
+ * second browser benefits from seeing the tour again).
15
+ */
16
+ export declare const TOUR_VERSION = 1;
17
+ export declare const TOUR_STORAGE_KEY = "nbi.tour.completed";
18
+ export declare function hasCompletedTour(): boolean;
19
+ export declare function markTourCompleted(): void;
20
+ export declare function resetTour(): void;