@plmbr/notebook-intelligence 5.0.0 → 5.1.0-a.1

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.
@@ -72,14 +72,16 @@ import type { Contents } from '@jupyterlab/services';
72
72
  import {
73
73
  extractLLMGeneratedCode,
74
74
  isDarkTheme,
75
- safeAnchorUri,
76
75
  writeTextToClipboard
77
76
  } from './utils';
78
77
  import { CheckBoxItem } from './components/checkbox';
78
+ import { SafeAnchor } from './components/safe-anchor';
79
79
  import { mcpServerSettingsToEnabledState } from './components/mcp-util';
80
80
  import claudeSvgStr from '../style/icons/claude.svg';
81
81
  import { AskUserQuestion } from './components/ask-user-question';
82
82
  import { ClaudeSessionPicker } from './components/claude-session-picker';
83
+ import { ToolCallGroup } from './components/tool-call-group';
84
+ import { upsertToolCallContent } from './tool-call-stream';
83
85
  import { TourOverlay } from './tour/tour-overlay';
84
86
  import { TOUR_ANCHOR } from './tour/tour-anchors';
85
87
  import { TOUR_START_EVENT, TOUR_STOP_EVENT } from './tour/tour-events';
@@ -522,6 +524,92 @@ function ChatResponseHTMLFrame(props: any) {
522
524
  // Memoize ChatResponse for performance
523
525
  function ChatResponse(props: any) {
524
526
  const [renderCount, setRenderCount] = useState(0);
527
+ const shuffledOrder = useRef<number[]>([]);
528
+ const shufflePos = useRef(0);
529
+
530
+ const _spinnerVerbs = NBIAPI.config.isInClaudeCodeMode
531
+ ? (NBIAPI.config.spinnerVerbs ?? null)
532
+ : null;
533
+ const hasCustomVerbs =
534
+ _spinnerVerbs?.mode === 'replace' &&
535
+ Array.isArray(_spinnerVerbs.verbs) &&
536
+ _spinnerVerbs.verbs.length > 0;
537
+
538
+ // Fisher-Yates shuffle. When `avoidFirst` is set, swap it out of
539
+ // position 0 so the new first verb is never the same as the last shown
540
+ // (prevents an identical repeat at the wrap point between passes).
541
+ const shuffleVerbs = (verbs: any[], avoidFirst?: number): number[] => {
542
+ const order = verbs.map((_, i) => i);
543
+ for (let i = order.length - 1; i > 0; i--) {
544
+ const j = Math.floor(Math.random() * (i + 1));
545
+ [order[i], order[j]] = [order[j], order[i]];
546
+ }
547
+ if (
548
+ avoidFirst !== undefined &&
549
+ order[0] === avoidFirst &&
550
+ order.length > 1
551
+ ) {
552
+ [order[0], order[1]] = [order[1], order[0]];
553
+ }
554
+ return order;
555
+ };
556
+
557
+ // Initialize the shuffle synchronously in useState so the correct verb
558
+ // is shown on the very first paint. A useEffect-based init fires after
559
+ // paint and causes a single-frame flash of verbs[0] before correcting.
560
+ const [verbIndex, setVerbIndex] = useState(() => {
561
+ const sv = NBIAPI.config.isInClaudeCodeMode
562
+ ? (NBIAPI.config.spinnerVerbs ?? null)
563
+ : null;
564
+ if (
565
+ !sv ||
566
+ sv.mode !== 'replace' ||
567
+ !Array.isArray(sv.verbs) ||
568
+ sv.verbs.length === 0
569
+ ) {
570
+ return 0;
571
+ }
572
+ const order = shuffleVerbs(sv.verbs);
573
+ shuffledOrder.current = order;
574
+ shufflePos.current = 0;
575
+ return order[0];
576
+ });
577
+
578
+ useEffect(() => {
579
+ if (!props.showGenerating || !hasCustomVerbs) {
580
+ return;
581
+ }
582
+
583
+ const verbs = _spinnerVerbs!.verbs;
584
+
585
+ // Shuffle already initialized by useState on mount. Only re-initialize
586
+ // if hasCustomVerbs just became true after a capabilities refresh
587
+ // (shuffledOrder would be empty because the lazy init found no verbs).
588
+ if (shuffledOrder.current.length === 0) {
589
+ shuffledOrder.current = shuffleVerbs(verbs);
590
+ shufflePos.current = 0;
591
+ setVerbIndex(shuffledOrder.current[0]);
592
+ }
593
+
594
+ let id: ReturnType<typeof setTimeout>;
595
+ const scheduleNext = () => {
596
+ const delay = 4000 + Math.random() * 3000;
597
+ id = setTimeout(() => {
598
+ shufflePos.current++;
599
+ if (shufflePos.current >= shuffledOrder.current.length) {
600
+ const lastShown =
601
+ shuffledOrder.current[shuffledOrder.current.length - 1];
602
+ shuffledOrder.current = shuffleVerbs(verbs, lastShown);
603
+ shufflePos.current = 0;
604
+ }
605
+ setVerbIndex(shuffledOrder.current[shufflePos.current]);
606
+ scheduleNext();
607
+ }, delay);
608
+ };
609
+ scheduleNext();
610
+ return () => clearTimeout(id);
611
+ }, [props.showGenerating, hasCustomVerbs]);
612
+
525
613
  const msg: IChatMessage = props.message;
526
614
  const timestamp = msg.date.toLocaleTimeString('en-US', { hour12: false });
527
615
 
@@ -630,6 +718,19 @@ function ChatResponse(props: any) {
630
718
  if (item.reasoningFinished) {
631
719
  lastItem.reasoningFinished = true;
632
720
  }
721
+ } else if (
722
+ item.type === ResponseStreamDataType.ToolCall &&
723
+ lastItemType === ResponseStreamDataType.ToolCall
724
+ ) {
725
+ // Bundle a run of consecutive tool calls into one group item so the
726
+ // renderer can collapse them; ToolCallGroup unwraps content.toolCalls.
727
+ const lastItem = groupedContents[groupedContents.length - 1];
728
+ lastItem.content.toolCalls.push(structuredClone(item.content));
729
+ } else if (item.type === ResponseStreamDataType.ToolCall) {
730
+ const grouped = structuredClone(item);
731
+ grouped.content = { toolCalls: [structuredClone(item.content)] };
732
+ groupedContents.push(grouped);
733
+ lastItemType = item.type;
633
734
  } else {
634
735
  groupedContents.push(structuredClone(item));
635
736
  lastItemType = item.type;
@@ -712,18 +813,26 @@ function ChatResponse(props: any) {
712
813
  }`}
713
814
  aria-hidden="true"
714
815
  />
715
- <div
716
- className="generating-label"
717
- aria-live="polite"
718
- aria-atomic="true"
719
- >
816
+ <div className="generating-label" aria-hidden="true">
720
817
  {props.isStalled
721
818
  ? 'Still working, server may be slow'
722
- : 'Generating'}
819
+ : hasCustomVerbs
820
+ ? _spinnerVerbs!.verbs[verbIndex]
821
+ : 'Generating'}
723
822
  {props.showGenerating && props.elapsedSeconds > 0
724
823
  ? ` (${formatElapsedSeconds(props.elapsedSeconds)})`
725
824
  : ''}
726
825
  </div>
826
+ {/* aria-live region contains only the verb — no elapsed suffix —
827
+ so screen readers announce only on verb changes, not on every
828
+ elapsed-seconds tick. */}
829
+ <div className="nbi-sr-only" aria-live="polite" aria-atomic="true">
830
+ {props.isStalled
831
+ ? 'Still working, server may be slow'
832
+ : hasCustomVerbs
833
+ ? _spinnerVerbs!.verbs[verbIndex]
834
+ : 'Generating'}
835
+ </div>
727
836
  </div>
728
837
  </div>
729
838
  <div className="chat-message-timestamp">{timestamp}</div>
@@ -836,23 +945,11 @@ function ChatResponse(props: any) {
836
945
  </div>
837
946
  );
838
947
  case ResponseStreamDataType.Anchor: {
839
- const safeUri = safeAnchorUri(item.content.uri);
840
- if (!safeUri) {
841
- return (
842
- <div className="chat-response-anchor" key={`key-${index}`}>
843
- <span>
844
- {item.content.title}
845
- <span className="nbi-sr-only"> (link blocked)</span>
846
- </span>
847
- </div>
848
- );
849
- }
850
948
  return (
851
949
  <div className="chat-response-anchor" key={`key-${index}`}>
852
- <a href={safeUri} target="_blank" rel="noopener noreferrer">
950
+ <SafeAnchor href={item.content.uri}>
853
951
  {item.content.title}
854
- <span className="nbi-sr-only"> (opens in new tab)</span>
855
- </a>
952
+ </SafeAnchor>
856
953
  </div>
857
954
  );
858
955
  }
@@ -869,6 +966,17 @@ function ChatResponse(props: any) {
869
966
  {item.content}
870
967
  </div>
871
968
  ) : null;
969
+ case ResponseStreamDataType.ToolCall:
970
+ // Unlike Progress, tool-call cards persist after the turn ends,
971
+ // so they render regardless of `showGenerating`. The grouping
972
+ // pass bundled this run's calls into content.toolCalls; the
973
+ // group renders a single card or a collapsible group.
974
+ return (
975
+ <ToolCallGroup
976
+ key={`key-${index}`}
977
+ toolCalls={item.content.toolCalls}
978
+ />
979
+ );
872
980
  case ResponseStreamDataType.Confirmation:
873
981
  return answeredForms.get(item.id) ===
874
982
  'confirmed' ? null : answeredForms.get(item.id) ===
@@ -985,9 +1093,13 @@ function ChatResponse(props: any) {
985
1093
  )}
986
1094
  </div>
987
1095
  {msg.from === 'copilot' &&
988
- !props.showGenerating &&
1096
+ (NBIAPI.config.chatFeedbackAlwaysVisible || !props.showGenerating) &&
989
1097
  NBIAPI.config.chatFeedbackEnabled && (
990
- <div className="chat-message-feedback">
1098
+ <div
1099
+ className={`chat-message-feedback${
1100
+ NBIAPI.config.chatFeedbackAlwaysVisible ? ' always-visible' : ''
1101
+ }`}
1102
+ >
991
1103
  <button
992
1104
  className={`chat-feedback-btn ${msg.feedback === 'positive' ? 'selected' : ''}`}
993
1105
  onClick={() => {
@@ -1006,9 +1118,9 @@ function ChatResponse(props: any) {
1006
1118
  });
1007
1119
  }
1008
1120
  }}
1009
- aria-label="Rate as helpful"
1121
+ aria-label="Rate response as good"
1010
1122
  aria-pressed={msg.feedback === 'positive'}
1011
- title="Helpful"
1123
+ title="Good response"
1012
1124
  >
1013
1125
  {msg.feedback === 'positive' ? (
1014
1126
  <VscThumbsupFilled />
@@ -1034,9 +1146,9 @@ function ChatResponse(props: any) {
1034
1146
  });
1035
1147
  }
1036
1148
  }}
1037
- aria-label="Rate as unhelpful"
1149
+ aria-label="Rate response as bad"
1038
1150
  aria-pressed={msg.feedback === 'negative'}
1039
- title="Not helpful"
1151
+ title="Bad response"
1040
1152
  >
1041
1153
  {msg.feedback === 'negative' ? (
1042
1154
  <VscThumbsdownFilled />
@@ -2932,22 +3044,33 @@ function SidebarComponent(props: any) {
2932
3044
  }
2933
3045
  if (delta['nbiContent']) {
2934
3046
  const nbiContent = delta['nbiContent'];
2935
- contents.push({
2936
- id: UUID.uuid4(),
2937
- type: nbiContent.type,
2938
- content: nbiContent.content || '',
2939
- reasoningContent: nbiContent.reasoning_content || '',
2940
- reasoningTag: nbiContent.reasoning_content
2941
- ? '<think>'
2942
- : undefined,
2943
- reasoningFinished:
2944
- nbiContent.type === ResponseStreamDataType.Markdown &&
2945
- nbiContent.reasoning_content
2946
- ? true
2947
- : false,
2948
- contentDetail: nbiContent.detail,
2949
- created: new Date(response.created)
2950
- });
3047
+ if (nbiContent.type === ResponseStreamDataType.ToolCall) {
3048
+ // A tool call streams twice under one id (start, then finish);
3049
+ // merge by id so it stays one persistent card. See
3050
+ // upsertToolCallContent.
3051
+ upsertToolCallContent(
3052
+ contents,
3053
+ nbiContent.content,
3054
+ new Date(response.created)
3055
+ );
3056
+ } else {
3057
+ contents.push({
3058
+ id: UUID.uuid4(),
3059
+ type: nbiContent.type,
3060
+ content: nbiContent.content || '',
3061
+ reasoningContent: nbiContent.reasoning_content || '',
3062
+ reasoningTag: nbiContent.reasoning_content
3063
+ ? '<think>'
3064
+ : undefined,
3065
+ reasoningFinished:
3066
+ nbiContent.type === ResponseStreamDataType.Markdown &&
3067
+ nbiContent.reasoning_content
3068
+ ? true
3069
+ : false,
3070
+ contentDetail: nbiContent.detail,
3071
+ created: new Date(response.created)
3072
+ });
3073
+ }
2951
3074
  } else {
2952
3075
  responseMessage =
2953
3076
  response.data['choices']?.[0]?.['delta']?.['content'];
@@ -3368,22 +3491,33 @@ function SidebarComponent(props: any) {
3368
3491
 
3369
3492
  if (delta['nbiContent']) {
3370
3493
  const nbiContent = delta['nbiContent'];
3371
- contents.push({
3372
- id: UUID.uuid4(),
3373
- type: nbiContent.type,
3374
- content: nbiContent.content || '',
3375
- reasoningContent: nbiContent.reasoning_content || '',
3376
- reasoningTag: nbiContent.reasoning_content
3377
- ? '<think>'
3378
- : undefined,
3379
- reasoningFinished:
3380
- nbiContent.type === ResponseStreamDataType.Markdown &&
3381
- nbiContent.reasoning_content
3382
- ? true
3383
- : false,
3384
- contentDetail: nbiContent.detail,
3385
- created: new Date(response.created)
3386
- });
3494
+ if (nbiContent.type === ResponseStreamDataType.ToolCall) {
3495
+ // A tool call streams twice under one id (start, then finish);
3496
+ // merge by id so it stays one persistent card. See
3497
+ // upsertToolCallContent.
3498
+ upsertToolCallContent(
3499
+ contents,
3500
+ nbiContent.content,
3501
+ new Date(response.created)
3502
+ );
3503
+ } else {
3504
+ contents.push({
3505
+ id: UUID.uuid4(),
3506
+ type: nbiContent.type,
3507
+ content: nbiContent.content || '',
3508
+ reasoningContent: nbiContent.reasoning_content || '',
3509
+ reasoningTag: nbiContent.reasoning_content
3510
+ ? '<think>'
3511
+ : undefined,
3512
+ reasoningFinished:
3513
+ nbiContent.type === ResponseStreamDataType.Markdown &&
3514
+ nbiContent.reasoning_content
3515
+ ? true
3516
+ : false,
3517
+ contentDetail: nbiContent.detail,
3518
+ created: new Date(response.created)
3519
+ });
3520
+ }
3387
3521
  } else {
3388
3522
  const responseMessage =
3389
3523
  response.data['choices']?.[0]?.['delta']?.['content'];
@@ -4956,48 +5090,28 @@ function GitHubCopilotLoginDialogBodyComponent(props: any) {
4956
5090
  memory.
4957
5091
  </div>
4958
5092
  <div>
4959
- <a
4960
- href="https://github.com/features/copilot"
4961
- target="_blank"
4962
- rel="noopener noreferrer"
4963
- >
5093
+ <SafeAnchor href="https://github.com/features/copilot">
4964
5094
  GitHub Copilot
4965
- <span className="nbi-sr-only"> (opens in new tab)</span>
4966
- </a>{' '}
5095
+ </SafeAnchor>{' '}
4967
5096
  requires a subscription and it has a free tier. GitHub Copilot is
4968
5097
  subject to the{' '}
4969
- <a
4970
- href="https://docs.github.com/en/site-policy/github-terms/github-terms-for-additional-products-and-features"
4971
- target="_blank"
4972
- rel="noopener noreferrer"
4973
- >
5098
+ <SafeAnchor href="https://docs.github.com/en/site-policy/github-terms/github-terms-for-additional-products-and-features">
4974
5099
  GitHub Terms for Additional Products and Features
4975
- <span className="nbi-sr-only"> (opens in new tab)</span>
4976
- </a>
5100
+ </SafeAnchor>
4977
5101
  .
4978
5102
  </div>
4979
5103
  <div>
4980
5104
  <h4>Privacy and terms</h4>
4981
5105
  By using Notebook Intelligence with GitHub Copilot subscription you
4982
5106
  agree to{' '}
4983
- <a
4984
- href="https://docs.github.com/en/copilot/responsible-use-of-github-copilot-features/responsible-use-of-github-copilot-chat-in-your-ide"
4985
- target="_blank"
4986
- rel="noopener noreferrer"
4987
- >
5107
+ <SafeAnchor href="https://docs.github.com/en/copilot/responsible-use-of-github-copilot-features/responsible-use-of-github-copilot-chat-in-your-ide">
4988
5108
  GitHub Copilot chat terms
4989
- <span className="nbi-sr-only"> (opens in new tab)</span>
4990
- </a>
5109
+ </SafeAnchor>
4991
5110
  . Review the terms to understand about usage, limitations and ways
4992
5111
  to improve GitHub Copilot. Please review{' '}
4993
- <a
4994
- href="https://docs.github.com/en/site-policy/privacy-policies/github-general-privacy-statement"
4995
- target="_blank"
4996
- rel="noopener noreferrer"
4997
- >
5112
+ <SafeAnchor href="https://docs.github.com/en/site-policy/privacy-policies/github-general-privacy-statement">
4998
5113
  Privacy Statement
4999
- <span className="nbi-sr-only"> (opens in new tab)</span>
5000
- </a>
5114
+ </SafeAnchor>
5001
5115
  .
5002
5116
  </div>
5003
5117
  <div>
@@ -5046,13 +5160,9 @@ function GitHubCopilotLoginDialogBodyComponent(props: any) {
5046
5160
  </b>
5047
5161
  </span>{' '}
5048
5162
  and enter at{' '}
5049
- <a
5050
- href={deviceActivationURL}
5051
- target="_blank"
5052
- rel="noopener noreferrer"
5053
- >
5163
+ <SafeAnchor href={deviceActivationURL}>
5054
5164
  {deviceActivationURL}
5055
- </a>{' '}
5165
+ </SafeAnchor>{' '}
5056
5166
  to allow access to GitHub Copilot from this app. Activation could
5057
5167
  take up to a minute after you enter the code.
5058
5168
  </div>
@@ -0,0 +1,161 @@
1
+ // Copyright (c) Mehmet Bektas <mbektasgh@outlook.com>
2
+
3
+ import React from 'react';
4
+ import { JupyterFrontEnd } from '@jupyterlab/application';
5
+ import { PathExt } from '@jupyterlab/coreutils';
6
+ import { SafeAnchor } from './safe-anchor';
7
+ import { hasDangerousTextCodepoints } from '../utils';
8
+
9
+ // Match an absolute URI by its scheme prefix so a workspace-relative path
10
+ // (`README.md`) is distinguished from a protocol-rooted URL (`http://...`).
11
+ // Mirrors the SCHEME_RE in utils.ts; kept local because this discriminant
12
+ // answers a different question (presence vs. allowlist).
13
+ const SCHEME_PREFIX_RE = /^[A-Za-z][A-Za-z0-9+.-]*:/;
14
+
15
+ /**
16
+ * True when a freshly-joined workspace path is *not* safe to hand to
17
+ * `docmanager:open` or expose on a rendered anchor. Rejects:
18
+ *
19
+ * - leading `..` segments or absolute paths: the join didn't anchor and
20
+ * the path escapes the Jupyter root (ContentsManager rejects too, but
21
+ * we want to fail closed visually as well so the status bar/title
22
+ * never previews a traversal target),
23
+ * - any embedded scheme: `PathExt.join('', 'java\tscript:alert(1)')`
24
+ * returns the input verbatim, so a path that looks workspace-relative
25
+ * pre-join can unmask into a `javascript:` href when `baseDir` is
26
+ * empty (any active doc at server root),
27
+ * - dangerous codepoints (bidi-override, zero-width, C0/C1/DEL, etc.):
28
+ * the WHATWG URL parser strips these from the scheme during
29
+ * recognition, and they also visually impersonate the link target on
30
+ * hover / in dev-tools logs.
31
+ */
32
+ function isUnsafeWorkspacePath(path: string): boolean {
33
+ // Empty / cwd-only paths reach here when react-markdown's built-in
34
+ // `urlTransform` strips an unsafe scheme (`javascript:`, `data:`, ...)
35
+ // to an empty string before our override runs: the result joins to
36
+ // either `""` or `"."`, both of which would render as a dead
37
+ // `<a href="#">` that 404s on click. Surface them as blocked-link
38
+ // spans so the user sees why nothing happened.
39
+ if (path === '' || path === '.' || path === './') {
40
+ return true;
41
+ }
42
+ if (path.startsWith('/') || path === '..' || path.startsWith('../')) {
43
+ return true;
44
+ }
45
+ if (SCHEME_PREFIX_RE.test(path)) {
46
+ return true;
47
+ }
48
+ if (hasDangerousTextCodepoints(path)) {
49
+ return true;
50
+ }
51
+ return false;
52
+ }
53
+
54
+ type MarkdownLinkProps = {
55
+ app: JupyterFrontEnd;
56
+ // Directory the LLM-emitted relative link should resolve against. The
57
+ // active document's directory matches the user's mental model: a
58
+ // workspace-relative link like `[file](README.md)` in a chat scoped to
59
+ // `notebooks/proj/work.ipynb` lands at `notebooks/proj/README.md`, not
60
+ // at the server-root README. Empty string is treated as "server root".
61
+ baseDir: string;
62
+ href: unknown;
63
+ title?: unknown;
64
+ children?: React.ReactNode;
65
+ };
66
+
67
+ /**
68
+ * Render an anchor node coming out of `react-markdown` so chat-sidebar
69
+ * links can never replace the JupyterLab shell or pivot through the
70
+ * lab origin.
71
+ *
72
+ * Three branches:
73
+ * - Fragment-only (`#section`): inert plain text. A new-tab open would
74
+ * navigate to `about:blank#section`, and a same-tab open would scroll
75
+ * the wrong document; neither matches what the LLM meant.
76
+ * - Workspace-relative (no scheme, no leading `/`, no `//` prefix):
77
+ * resolved against the active document's directory, re-validated,
78
+ * and routed through JupyterLab's `docmanager:open` command so a
79
+ * `.ipynb` opens with the notebook factory and a `.md` opens in the
80
+ * editor. The anchor's `href` stays `"#"` because a populated `href`
81
+ * bypasses React's onClick on middle/Cmd-click, letting the browser
82
+ * navigate `/lab/<path>` with session cookies attached; the hover
83
+ * preview moves to `title` so the user still sees the intended
84
+ * target.
85
+ * - Everything else: handed to `SafeAnchor`, which enforces the
86
+ * `safeAnchorUri` scheme allowlist and emits a `_blank` anchor with
87
+ * `rel="noopener noreferrer"`.
88
+ */
89
+ export function MarkdownLink({
90
+ app,
91
+ baseDir,
92
+ href,
93
+ title,
94
+ children
95
+ }: MarkdownLinkProps): React.ReactElement {
96
+ if (typeof href === 'string') {
97
+ if (href.startsWith('#')) {
98
+ return <span>{children}</span>;
99
+ }
100
+ if (
101
+ !SCHEME_PREFIX_RE.test(href) &&
102
+ !href.startsWith('/') &&
103
+ !href.startsWith('//')
104
+ ) {
105
+ // PathExt.join: plain concatenation + normalization. Resolve()
106
+ // would fall back to the browser process cwd when `baseDir` is
107
+ // relative, which gives nonsense like `/Users/.../notebooks/...`.
108
+ const resolvedPath = PathExt.join(baseDir, href);
109
+ // Re-validate post-join. Two attack/confusion shapes the pre-check
110
+ // alone misses: `[x](java\tscript:alert(1))` survives the scheme
111
+ // sniff because `\t` isn't a scheme char, then unmasks once the
112
+ // WHATWG parser sees the joined href; `[x](../../../etc/passwd)`
113
+ // looks workspace-relative but escapes the workspace root.
114
+ if (isUnsafeWorkspacePath(resolvedPath)) {
115
+ return (
116
+ <SafeAnchor href={null} title={undefined}>
117
+ {children}
118
+ </SafeAnchor>
119
+ );
120
+ }
121
+ // href="#" rather than href={resolvedPath}: a modifier-click on a
122
+ // populated href bypasses the React onClick, lets the browser
123
+ // navigate the chat sidebar to /lab/<path> in a new tab, and would
124
+ // ride along the user's Jupyter session cookies. The hover preview
125
+ // moves to `title` so the user still sees the intended target.
126
+ const safeTitleFromMd =
127
+ typeof title === 'string' && !hasDangerousTextCodepoints(title)
128
+ ? title
129
+ : undefined;
130
+ const hoverTitle = safeTitleFromMd ?? resolvedPath;
131
+ const onClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
132
+ e.preventDefault();
133
+ // ContentsManager rejects paths outside the Jupyter root with a
134
+ // promise rejection. Catch so the failure surfaces in logs instead
135
+ // of an unhandled rejection, and the user can see the rendered
136
+ // anchor was attempted even when the target doesn't exist.
137
+ Promise.resolve(
138
+ app.commands.execute('docmanager:open', { path: resolvedPath })
139
+ ).catch(err => {
140
+ console.warn(
141
+ `NBI: failed to open workspace path "${resolvedPath}":`,
142
+ err
143
+ );
144
+ });
145
+ };
146
+ return (
147
+ <a href="#" title={hoverTitle} onClick={onClick}>
148
+ {children}
149
+ </a>
150
+ );
151
+ }
152
+ }
153
+ return (
154
+ <SafeAnchor
155
+ href={typeof href === 'string' ? href : null}
156
+ title={typeof title === 'string' ? title : undefined}
157
+ >
158
+ {children}
159
+ </SafeAnchor>
160
+ );
161
+ }
@@ -0,0 +1,60 @@
1
+ // Copyright (c) Mehmet Bektas <mbektasgh@outlook.com>
2
+
3
+ import React from 'react';
4
+ import { hasDangerousTextCodepoints, safeAnchorUri } from '../utils';
5
+
6
+ type SafeAnchorProps = {
7
+ href: string | undefined | null;
8
+ children: React.ReactNode;
9
+ title?: string;
10
+ className?: string;
11
+ };
12
+
13
+ /**
14
+ * The single render path for anchor elements driven by LLM / tool output.
15
+ *
16
+ * Runs `href` through `safeAnchorUri`, which mirrors the server-side
17
+ * `safe_anchor_uri` allowlist (`http` / `https` / `mailto`) and rejects
18
+ * dangerous codepoints. On accept it renders a `_blank` anchor with
19
+ * `rel="noopener noreferrer"` and an SR-only "(opens in new tab)" suffix;
20
+ * on reject it falls through to plain text plus an SR-only "(link
21
+ * blocked)" note so screen readers can tell why the link disappeared.
22
+ *
23
+ * The `title` attribute is scrubbed for the same dangerous codepoints
24
+ * the URI check rejects, since react-markdown forwards CommonMark
25
+ * `[text](url "title")` titles to the rendered anchor and an LLM can
26
+ * smuggle bidi-override or zero-width characters there to visually
27
+ * impersonate the link target on hover.
28
+ */
29
+ export function SafeAnchor({
30
+ href,
31
+ children,
32
+ title,
33
+ className
34
+ }: SafeAnchorProps): React.ReactElement {
35
+ const safeUri = safeAnchorUri(href ?? '');
36
+ if (!safeUri) {
37
+ return (
38
+ <span className={className}>
39
+ {children}
40
+ <span className="nbi-sr-only"> (link blocked)</span>
41
+ </span>
42
+ );
43
+ }
44
+ const safeTitle =
45
+ typeof title === 'string' && !hasDangerousTextCodepoints(title)
46
+ ? title
47
+ : undefined;
48
+ return (
49
+ <a
50
+ href={safeUri}
51
+ target="_blank"
52
+ rel="noopener noreferrer"
53
+ title={safeTitle}
54
+ className={className}
55
+ >
56
+ {children}
57
+ <span className="nbi-sr-only"> (opens in new tab)</span>
58
+ </a>
59
+ );
60
+ }