@marimo-team/frontend 0.25.3-dev82 → 0.25.3-dev86

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 (93) hide show
  1. package/dist/assets/{CellStatus-DXroYLOP.js → CellStatus-DYRvLOiP.js} +1 -1
  2. package/dist/assets/{ErrorBoundary-DaQ9THH_.js → ErrorBoundary-BKQo68Df.js} +2 -2
  3. package/dist/assets/{JsonOutput-VxzHyItt.js → JsonOutput-DryOywaN.js} +5 -5
  4. package/dist/assets/{MarimoErrorOutput-D-hLaDA-.js → MarimoErrorOutput-XrTcOYTI.js} +1 -1
  5. package/dist/assets/{RenderHTML-DjOEZL25.js → RenderHTML-DaVbCAsb.js} +1 -1
  6. package/dist/assets/{RunButton-CuhNTo5_.js → RunButton-D3ScLvwb.js} +1 -1
  7. package/dist/assets/{add-cell-with-ai-BbVntTh7.js → add-cell-with-ai-BKk0pvon.js} +1 -1
  8. package/dist/assets/{add-connection-dialog-Qf63aKV6.js → add-connection-dialog-CNIwGJVL.js} +1 -1
  9. package/dist/assets/{agent-panel-DMOfF3qF.js → agent-panel-6t16-QG0.js} +1 -1
  10. package/dist/assets/{ai-model-dropdown-B15l0R62.js → ai-model-dropdown-prnCk5hq.js} +1 -1
  11. package/dist/assets/{app-config-button-BIMB8qjD.js → app-config-button-O_yI5nzB.js} +1 -1
  12. package/dist/assets/{blob-De9GR2s6.js → blob-FjxwDE1i.js} +1 -1
  13. package/dist/assets/{cell-editor-Di0cSFhC.js → cell-editor-CM_kQ_VW.js} +1 -1
  14. package/dist/assets/{cell-link-DR3W2HC7.js → cell-link-D8EbRFJJ.js} +1 -1
  15. package/dist/assets/{cells-styV7IX8.js → cells-DfF1DLV_.js} +1 -1
  16. package/dist/assets/{chat-display-BzdYdN39.js → chat-display-BtQI9LeB.js} +1 -1
  17. package/dist/assets/{chat-panel-De3YzkSF.js → chat-panel-tuUN0wet.js} +1 -1
  18. package/dist/assets/{chat-ui-NTNapdtP.js → chat-ui-Bb-HxAv4.js} +1 -1
  19. package/dist/assets/{code-visibility-csc3tgA0.js → code-visibility-ny5n3-GH.js} +1 -1
  20. package/dist/assets/{column-preview-DkH-oD8f.js → column-preview-CqFSXTtf.js} +1 -1
  21. package/dist/assets/{command-palette-Dg_xstl0.js → command-palette-CkDj29px.js} +1 -1
  22. package/dist/assets/{common-D7VAS6rd.js → common-D2oGA09G.js} +1 -1
  23. package/dist/assets/{components-CNmvC781.js → components-1zsrzJ_K.js} +1 -1
  24. package/dist/assets/config-BWKfkUWA.js +1 -0
  25. package/dist/assets/{config-BbRbT9Ww.js → config-CP8Y8_R0.js} +1 -1
  26. package/dist/assets/{datasource-C3yfeDa2.js → datasource-rV_-mqbc.js} +1 -1
  27. package/dist/assets/{dependency-graph-panel-CjoA6L0w.js → dependency-graph-panel-By-cv0Nw.js} +1 -1
  28. package/dist/assets/{documentation-panel-DHWFOiG1.js → documentation-panel-DHMjvOPt.js} +1 -1
  29. package/dist/assets/{download-BrCpRZKN.js → download-CMTzX3rG.js} +1 -1
  30. package/dist/assets/{edit-page-BPMo_x-T.js → edit-page-Bgkov1Jy.js} +4 -4
  31. package/dist/assets/{error-entries-BkEAmmnD.js → error-entries-BGCjRkVy.js} +1 -1
  32. package/dist/assets/{error-panel-BhTfPA_i.js → error-panel-BDTFHLEt.js} +1 -1
  33. package/dist/assets/{feedback-button-D9InK87G.js → feedback-button-C-ScLxY2.js} +1 -1
  34. package/dist/assets/feedback-button-CLRAP9DE.js +1 -0
  35. package/dist/assets/{file-explorer-panel-BSd_ZbUC.js → file-explorer-panel-B9jBSwhj.js} +1 -1
  36. package/dist/assets/{file-name-input-RHURglL_.js → file-name-input-DIvrcC82.js} +1 -1
  37. package/dist/assets/{floating-outline-Cm49rYYe.js → floating-outline-DJnZ45VA.js} +1 -1
  38. package/dist/assets/{gallery-page-DdMdOtdv.js → gallery-page-CHJD1mdU.js} +1 -1
  39. package/dist/assets/{glide-data-editor-eiSlEUyR.js → glide-data-editor-BsE9Em-a.js} +1 -1
  40. package/dist/assets/{home-page-DwWvBB0R.js → home-page-CCbdig5p.js} +1 -1
  41. package/dist/assets/{hooks-CHeUZHPp.js → hooks-yjSoxKTw.js} +1 -1
  42. package/dist/assets/{index-DckSn2w6.js → index-0pnwTVch.js} +5 -5
  43. package/dist/assets/index-BnhSnwvB.css +2 -0
  44. package/dist/assets/{layout-_lp06T0C.js → layout-B2MJFBFp.js} +3 -3
  45. package/dist/assets/{logs-panel-CAAk72Gb.js → logs-panel-B34vxRV7.js} +1 -1
  46. package/dist/assets/{markdown-renderer-CAyCgogC.js → markdown-renderer-CkN_xAjW.js} +1 -1
  47. package/dist/assets/{name-cell-input-DA8hU9XT.js → name-cell-input-eyYneuGK.js} +1 -1
  48. package/dist/assets/{outline-panel-Cc5oQ4LL.js → outline-panel-rpjpMDYX.js} +1 -1
  49. package/dist/assets/{packages-panel-DTfKez6I.js → packages-panel-gXdFgCro.js} +1 -1
  50. package/dist/assets/{panel-accordion-state-BZHex38W.js → panel-accordion-state-CImIsdc8.js} +1 -1
  51. package/dist/assets/{panels-DU-SaRHy.js → panels-DI8gAi31.js} +1 -1
  52. package/dist/assets/{process-output-CrDc_O1D.js → process-output-BClx23Jv.js} +1 -1
  53. package/dist/assets/{readonly-code-display-BR-_jUj8.js → readonly-code-display-JC_YsLL_.js} +1 -1
  54. package/dist/assets/{readonly-python-code-D7jcy9ds.js → readonly-python-code-DeBED4OB.js} +1 -1
  55. package/dist/assets/{reveal-component-eLftBwOe.js → reveal-component-BoGgZrdo.js} +1 -1
  56. package/dist/assets/{run-page-mURyATDJ.js → run-page-DnvqkxCy.js} +1 -1
  57. package/dist/assets/{runtime-details-state-hb7A1jgR.js → runtime-details-state-7JHj3efu.js} +1 -1
  58. package/dist/assets/{scratchpad-panel-B_gEnoqj.js → scratchpad-panel-DZDmNIPw.js} +1 -1
  59. package/dist/assets/{session-panel-DmDjaPsN.js → session-panel-B1QB5LN7.js} +1 -1
  60. package/dist/assets/{snippets-panel-o9KL5jMi.js → snippets-panel-BNjtDbnR.js} +1 -1
  61. package/dist/assets/{state-BkAqJoW7.js → state-DUP4ToPd.js} +1 -1
  62. package/dist/assets/{state-Os7LSUUZ.js → state-DhaPGGBO.js} +1 -1
  63. package/dist/assets/{terminal-BI85khrS.js → terminal-BwLC9HS5.js} +1 -1
  64. package/dist/assets/{tracing-AsNGuDsh.js → tracing-Ch4LR62-.js} +1 -1
  65. package/dist/assets/{tracing-panel-CBcmea6L.js → tracing-panel-B8fX4PhX.js} +2 -2
  66. package/dist/assets/{tree-actions-Cx5EDpA6.js → tree-actions-Zg3Ycse4.js} +1 -1
  67. package/dist/assets/{useAddCell-uJJYtgwa.js → useAddCell-BX9lq_0J.js} +1 -1
  68. package/dist/assets/{useBoolean-8wlrTeus.js → useBoolean-WABMLM83.js} +1 -1
  69. package/dist/assets/{useCellActionButton-CmQ-OcPa.js → useCellActionButton-D7Ruyes-.js} +1 -1
  70. package/dist/assets/{useDeleteCell-CXgOCDqY.js → useDeleteCell-AbkQI6-c.js} +1 -1
  71. package/dist/assets/{useDependencyPanelTab-Ch6gm7uM.js → useDependencyPanelTab-B0b2KkvS.js} +1 -1
  72. package/dist/assets/{useInstallPackage-CYQoMdMQ.js → useInstallPackage-CWcPxSLP.js} +1 -1
  73. package/dist/assets/{useNotebookActions-DGShHZ_1.js → useNotebookActions-DJuAUAGI.js} +1 -1
  74. package/dist/assets/{useRunCells-DAfDmOMC.js → useRunCells-CV81rge-.js} +1 -1
  75. package/dist/assets/{useSplitCell-NLULgcq7.js → useSplitCell-BTwCB3JS.js} +1 -1
  76. package/dist/assets/{vega-component-BKJU3uaB.js → vega-component-CY6HmM06.js} +1 -1
  77. package/dist/index.html +25 -25
  78. package/package.json +1 -1
  79. package/src/components/data-table/__tests__/columns.test.tsx +105 -0
  80. package/src/components/data-table/__tests__/search-highlight.test.tsx +109 -0
  81. package/src/components/data-table/__tests__/url-detector.test.tsx +74 -0
  82. package/src/components/data-table/columns.tsx +43 -45
  83. package/src/components/data-table/data-table.tsx +7 -1
  84. package/src/components/data-table/search-highlight.tsx +79 -0
  85. package/src/components/data-table/types.ts +1 -0
  86. package/src/components/data-table/url-detector.tsx +49 -9
  87. package/src/core/runtime/__tests__/createWsUrl.test.ts +77 -7
  88. package/src/core/runtime/__tests__/runtime.test.ts +13 -5
  89. package/src/core/runtime/runtime.ts +23 -27
  90. package/src/css/globals.css +2 -0
  91. package/dist/assets/config-BQT0uYUo.js +0 -1
  92. package/dist/assets/feedback-button-BAcClv7c.js +0 -1
  93. package/dist/assets/index-BdsOMJac.css +0 -2
@@ -41,6 +41,7 @@ import {
41
41
  isNumericType,
42
42
  } from "./types";
43
43
  import { SentinelCell, WhitespaceMarkers } from "./sentinel-cell";
44
+ import { highlightMatches } from "./search-highlight";
44
45
  import { detectSentinel, splitLeadingTrailingWhitespace } from "./utils";
45
46
  import { uniformSample } from "./uniformSample";
46
47
  import { MarkdownUrlDetector, UrlDetector } from "./url-detector";
@@ -278,7 +279,7 @@ export function generateColumns<T>({
278
279
  );
279
280
  },
280
281
 
281
- cell: ({ column, renderValue, getValue, cell }) => {
282
+ cell: ({ column, renderValue, getValue, cell, table }) => {
282
283
  function selectCell() {
283
284
  if (selection !== "single-cell" && selection !== "multi-cell") {
284
285
  return;
@@ -310,6 +311,7 @@ export function generateColumns<T>({
310
311
  getValue,
311
312
  selectCell,
312
313
  cellStyles,
314
+ highlightPattern: table.options.meta?.highlightPattern,
313
315
  });
314
316
 
315
317
  // Row headers are bold
@@ -586,12 +588,14 @@ function renderDate({
586
588
  dtype,
587
589
  displayValue,
588
590
  locale,
591
+ highlightPattern,
589
592
  }: {
590
593
  value: Date;
591
594
  dataType?: DataType;
592
595
  dtype?: string;
593
596
  displayValue?: string;
594
597
  locale: string;
598
+ highlightPattern?: RegExp;
595
599
  }): React.ReactNode {
596
600
  const type = dataType === "date" ? "date" : "datetime";
597
601
  const timezone = extractTimezone(dtype);
@@ -600,7 +604,7 @@ function renderDate({
600
604
 
601
605
  return (
602
606
  <DatePopover date={value} type={type}>
603
- {exactValue}
607
+ {highlightMatches(exactValue, highlightPattern)}
604
608
  </DatePopover>
605
609
  );
606
610
  }
@@ -611,12 +615,14 @@ export function renderCellValue<TData, TValue>({
611
615
  getValue,
612
616
  selectCell,
613
617
  cellStyles,
618
+ highlightPattern,
614
619
  }: {
615
620
  column: Column<TData, TValue>;
616
621
  renderValue: () => TValue | null;
617
622
  getValue: () => TValue;
618
623
  selectCell?: () => void;
619
624
  cellStyles?: string;
625
+ highlightPattern?: RegExp;
620
626
  }) {
621
627
  const value = getValue();
622
628
  const format = column.getColumnFormatting?.();
@@ -626,6 +632,28 @@ export function renderCellValue<TData, TValue>({
626
632
 
627
633
  const isWrapped = column.getColumnWrapping?.() === "wrap";
628
634
 
635
+ const textCell = (text: string) => (
636
+ <div onClick={selectCell} className={cellStyles}>
637
+ {highlightMatches(text, highlightPattern)}
638
+ </div>
639
+ );
640
+ const dateCell = (date: Date, displayValue?: string) => (
641
+ <div onClick={selectCell} className={cellStyles}>
642
+ <WithLocale>
643
+ {(locale) =>
644
+ renderDate({
645
+ value: date,
646
+ dataType,
647
+ dtype,
648
+ displayValue,
649
+ locale,
650
+ highlightPattern,
651
+ })
652
+ }
653
+ </WithLocale>
654
+ </div>
655
+ );
656
+
629
657
  // Sentinel values (null, whitespace, NaN, Infinity, NaT) rendered specially.
630
658
  // Empty strings are left as-is
631
659
  const sentinel = detectSentinel(value, dataType, dtype);
@@ -641,23 +669,10 @@ export function renderCellValue<TData, TValue>({
641
669
  try {
642
670
  const date = new Date(value);
643
671
  if (!format || !isValid(date)) {
644
- return (
645
- <div onClick={selectCell} className={cellStyles}>
646
- {value}
647
- </div>
648
- );
672
+ return textCell(value);
649
673
  }
650
674
 
651
- const displayValue = String(column.applyColumnFormatting(value));
652
- return (
653
- <div onClick={selectCell} className={cellStyles}>
654
- <WithLocale>
655
- {(locale) =>
656
- renderDate({ value: date, dataType, dtype, displayValue, locale })
657
- }
658
- </WithLocale>
659
- </div>
660
- );
675
+ return dateCell(date, String(column.applyColumnFormatting(value)));
661
676
  } catch (error) {
662
677
  Logger.error("Error parsing datetime, fallback to string", error);
663
678
  }
@@ -673,17 +688,9 @@ export function renderCellValue<TData, TValue>({
673
688
  }
674
689
 
675
690
  // e.g. 2010-10-07 17:15:00
676
- const displayValue = format
677
- ? String(column.applyColumnFormatting(value))
678
- : undefined;
679
- return (
680
- <div onClick={selectCell} className={cellStyles}>
681
- <WithLocale>
682
- {(locale) =>
683
- renderDate({ value, dataType, dtype, displayValue, locale })
684
- }
685
- </WithLocale>
686
- </div>
691
+ return dateCell(
692
+ value,
693
+ format ? String(column.applyColumnFormatting(value)) : undefined,
687
694
  );
688
695
  }
689
696
 
@@ -729,7 +736,7 @@ export function renderCellValue<TData, TValue>({
729
736
  className={cn(cellStyles, isWrapped && COLUMN_WRAPPING_STYLES)}
730
737
  >
731
738
  <WhitespaceMarkers value={leading} />
732
- <UrlDetector parts={parts} />
739
+ <UrlDetector parts={parts} highlightPattern={highlightPattern} />
733
740
  <WhitespaceMarkers value={trailing} />
734
741
  </div>
735
742
  );
@@ -755,11 +762,7 @@ export function renderCellValue<TData, TValue>({
755
762
  }
756
763
 
757
764
  if (format) {
758
- return (
759
- <div onClick={selectCell} className={cellStyles}>
760
- {column.applyColumnFormatting(value)}
761
- </div>
762
- );
765
+ return textCell(String(column.applyColumnFormatting(value)));
763
766
  }
764
767
 
765
768
  // Format to the correct locale
@@ -769,26 +772,19 @@ export function renderCellValue<TData, TValue>({
769
772
  <LocaleNumber
770
773
  value={value}
771
774
  minFractionDigits={column.columnDef.meta?.minFractionDigits}
775
+ highlightPattern={highlightPattern}
772
776
  />
773
777
  </div>
774
778
  );
775
779
  }
776
780
 
777
781
  if (typeof value === "boolean") {
778
- return (
779
- <div onClick={selectCell} className={cellStyles}>
780
- {value ? "True" : "False"}
781
- </div>
782
- );
782
+ return textCell(value ? "True" : "False");
783
783
  }
784
784
 
785
785
  if (isPrimitiveOrNullish(value)) {
786
786
  const rendered = renderValue();
787
- return (
788
- <div onClick={selectCell} className={cellStyles}>
789
- {rendered == null ? "" : String(rendered)}
790
- </div>
791
- );
787
+ return textCell(rendered == null ? "" : String(rendered));
792
788
  }
793
789
 
794
790
  const mimeValues = getMimeValues(value);
@@ -826,14 +822,16 @@ export function renderCellValue<TData, TValue>({
826
822
  export const LocaleNumber = ({
827
823
  value,
828
824
  minFractionDigits,
825
+ highlightPattern,
829
826
  }: {
830
827
  value: number;
831
828
  minFractionDigits?: number;
829
+ highlightPattern?: RegExp;
832
830
  }) => {
833
831
  const { locale } = useLocale();
834
832
  const format = useNumberFormatter({
835
833
  minimumFractionDigits: minFractionDigits,
836
834
  maximumFractionDigits: maxFractionalDigits(locale),
837
835
  });
838
- return format.format(value);
836
+ return highlightMatches(format.format(value), highlightPattern);
839
837
  };
@@ -58,6 +58,7 @@ import { useColumnPinning } from "./hooks/use-column-pinning";
58
58
  import { useScrollContainerHeight } from "./hooks/use-scroll-container-height";
59
59
  import { CellSelectionProvider } from "./range-focus/provider";
60
60
  import { DataTableBody, renderTableHeader } from "./renderers";
61
+ import { buildHighlightPattern } from "./search-highlight";
61
62
  import { TableBottomBar } from "./TableBottomBar";
62
63
  import { TableTopBar } from "./TableTopBar";
63
64
  import {
@@ -231,6 +232,11 @@ const DataTableInternal = <TData,>({
231
232
  return idx + offset;
232
233
  }
233
234
 
235
+ const highlightPattern = React.useMemo(
236
+ () => buildHighlightPattern(searchQuery),
237
+ [searchQuery],
238
+ );
239
+
234
240
  const table = useReactTable<TData>({
235
241
  _features: [
236
242
  ColumnPinning,
@@ -245,7 +251,7 @@ const DataTableInternal = <TData,>({
245
251
  ],
246
252
  data,
247
253
  columns,
248
- meta: { rawData },
254
+ meta: { rawData, highlightPattern },
249
255
  getCoreRowModel: getCoreRowModel(),
250
256
  // pagination
251
257
  rowCount: totalRows === "too_many" ? undefined : totalRows,
@@ -0,0 +1,79 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ import type { ReactNode } from "react";
4
+
5
+ /** Half-open `[start, end)` offsets into a string. */
6
+ export type MatchRange = readonly [start: number, end: number];
7
+
8
+ /**
9
+ * Build the pattern that finds `query` in a display string. Returns
10
+ * `undefined` for an empty query. The query is matched as a literal,
11
+ * case-insensitive substring, which mirrors how the backend selects rows.
12
+ * The `u` flag selects Unicode simple case folding, the same folding the
13
+ * backend regex engines use.
14
+ */
15
+ export function buildHighlightPattern(query?: string): RegExp | undefined {
16
+ if (!query) {
17
+ return undefined;
18
+ }
19
+ const escaped = query.replace(/[$()*+.?[\\\]^{|}]/g, "\\$&");
20
+ return new RegExp(escaped, "giu");
21
+ }
22
+
23
+ /** Offsets of every match of `pattern` in `text`. */
24
+ export function findMatchRanges(text: string, pattern?: RegExp): MatchRange[] {
25
+ if (!pattern) {
26
+ return [];
27
+ }
28
+ const ranges: MatchRange[] = [];
29
+ for (const match of text.matchAll(pattern)) {
30
+ ranges.push([match.index, match.index + match[0].length]);
31
+ }
32
+ return ranges;
33
+ }
34
+
35
+ /**
36
+ * Wrap the parts of `text` covered by `ranges` in `<mark>` elements.
37
+ * `ranges` are offsets into a larger string in which `text` starts at
38
+ * `offset`, so one set of ranges can be shared across adjacent segments.
39
+ * Returns `text` unchanged when no range overlaps it.
40
+ */
41
+ export function highlightRanges(
42
+ text: string,
43
+ ranges: readonly MatchRange[],
44
+ offset = 0,
45
+ ): ReactNode {
46
+ const segments: ReactNode[] = [];
47
+ let cursor = 0;
48
+ for (const [rangeStart, rangeEnd] of ranges) {
49
+ const start = Math.max(rangeStart - offset, cursor);
50
+ const end = Math.min(rangeEnd - offset, text.length);
51
+ if (end <= start) {
52
+ continue;
53
+ }
54
+ if (start > cursor) {
55
+ segments.push(text.slice(cursor, start));
56
+ }
57
+ segments.push(
58
+ <mark key={start} className="bg-search-highlight text-inherit">
59
+ {text.slice(start, end)}
60
+ </mark>,
61
+ );
62
+ cursor = end;
63
+ }
64
+ if (segments.length === 0) {
65
+ return text;
66
+ }
67
+ if (cursor < text.length) {
68
+ segments.push(text.slice(cursor));
69
+ }
70
+ return segments;
71
+ }
72
+
73
+ /**
74
+ * Wrap every match of `pattern` in `text` in a `<mark>` element. Returns
75
+ * `text` unchanged when `pattern` is absent or does not match.
76
+ */
77
+ export function highlightMatches(text: string, pattern?: RegExp): ReactNode {
78
+ return highlightRanges(text, findMatchRanges(text, pattern));
79
+ }
@@ -6,6 +6,7 @@ import type { DataType } from "@/core/kernel/messages";
6
6
  declare module "@tanstack/react-table" {
7
7
  interface TableMeta<TData extends RowData> {
8
8
  rawData?: TData[]; // raw data for filtering/copying (present only if format_mapping is provided)
9
+ highlightPattern?: RegExp; // built from the committed search query that produced the current rows
9
10
  }
10
11
  }
11
12
 
@@ -1,7 +1,7 @@
1
1
  /* Copyright 2026 Marimo. All rights reserved. */
2
2
 
3
3
  import { marked } from "marked";
4
- import { useState } from "react";
4
+ import { Fragment, type ReactNode, useState } from "react";
5
5
  import { MarkdownRenderer } from "@/components/markdown/markdown-renderer";
6
6
  import {
7
7
  Popover,
@@ -10,13 +10,20 @@ import {
10
10
  } from "@/components/ui/popover";
11
11
  import { Events } from "@/utils/events";
12
12
  import type { ContentPart } from "@/utils/url-parser";
13
+ import { findMatchRanges, highlightRanges } from "./search-highlight";
13
14
 
14
- const ImageWithFallback = ({ url }: { url: string }) => {
15
+ const ImageWithFallback = ({
16
+ url,
17
+ fallbackLabel,
18
+ }: {
19
+ url: string;
20
+ fallbackLabel?: ReactNode;
21
+ }) => {
15
22
  const [error, setError] = useState(false);
16
23
  const [open, setOpen] = useState(false);
17
24
 
18
25
  if (error) {
19
- return <URLAnchor url={url} />;
26
+ return <URLAnchor url={url}>{fallbackLabel}</URLAnchor>;
20
27
  }
21
28
 
22
29
  return (
@@ -93,21 +100,54 @@ export const MarkdownUrlDetector = ({
93
100
  return <UrlDetector parts={parts} />;
94
101
  };
95
102
 
96
- export const UrlDetector = ({ parts }: { parts: ContentPart[] }) => {
103
+ export const UrlDetector = ({
104
+ parts,
105
+ highlightPattern,
106
+ }: {
107
+ parts: ContentPart[];
108
+ highlightPattern?: RegExp;
109
+ }) => {
110
+ // Match against the whole string so a query that spans a text part and
111
+ // a link is still marked. Each part then takes its slice of the ranges.
112
+ const ranges = highlightPattern
113
+ ? findMatchRanges(parts.map(partText).join(""), highlightPattern)
114
+ : [];
115
+
116
+ let offset = 0;
97
117
  const markup = parts.map((part, idx) => {
118
+ const text = partText(part);
119
+ const label = highlightRanges(text, ranges, offset);
120
+ offset += text.length;
121
+
98
122
  if (part.type === "url") {
99
- return <URLAnchor key={idx} url={part.url} />;
123
+ return (
124
+ <URLAnchor key={idx} url={part.url}>
125
+ {label}
126
+ </URLAnchor>
127
+ );
100
128
  }
101
129
  if (part.type === "image") {
102
- return <ImageWithFallback key={idx} url={part.url} />;
130
+ return (
131
+ <ImageWithFallback key={idx} url={part.url} fallbackLabel={label} />
132
+ );
103
133
  }
104
- return part.value;
134
+ return <Fragment key={idx}>{label}</Fragment>;
105
135
  });
106
136
 
107
137
  return markup;
108
138
  };
109
139
 
110
- const URLAnchor = ({ url }: { url: string }) => {
140
+ function partText(part: ContentPart): string {
141
+ return part.type === "text" ? part.value : part.url;
142
+ }
143
+
144
+ const URLAnchor = ({
145
+ url,
146
+ children,
147
+ }: {
148
+ url: string;
149
+ children?: ReactNode;
150
+ }) => {
111
151
  return (
112
152
  <a
113
153
  href={url}
@@ -116,7 +156,7 @@ const URLAnchor = ({ url }: { url: string }) => {
116
156
  onClick={Events.stopPropagation()}
117
157
  className="text-link hover:underline"
118
158
  >
119
- {url}
159
+ {children ?? url}
120
160
  </a>
121
161
  );
122
162
  };
@@ -13,7 +13,9 @@ describe("RuntimeManager.getWsURL", () => {
13
13
  });
14
14
  const sessionId = "1234" as SessionId;
15
15
  const url = runtime.getWsURL(sessionId);
16
- expect(url.toString()).toBe("wss://marimo.app/ws?session_id=1234");
16
+ expect(url.toString()).toBe(
17
+ "wss://marimo.app/ws?query_params=&session_id=1234",
18
+ );
17
19
  expect(url.searchParams.get(KnownQueryParams.sessionId)).toBe(sessionId);
18
20
  });
19
21
 
@@ -24,7 +26,9 @@ describe("RuntimeManager.getWsURL", () => {
24
26
  });
25
27
  const sessionId = "1234" as SessionId;
26
28
  const url = runtime.getWsURL(sessionId);
27
- expect(url.toString()).toBe("ws://marimo.app/ws?session_id=1234");
29
+ expect(url.toString()).toBe(
30
+ "ws://marimo.app/ws?query_params=&session_id=1234",
31
+ );
28
32
  expect(url.searchParams.get(KnownQueryParams.sessionId)).toBe(sessionId);
29
33
  });
30
34
 
@@ -35,7 +39,9 @@ describe("RuntimeManager.getWsURL", () => {
35
39
  });
36
40
  const sessionId = "1234" as SessionId;
37
41
  const url = runtime.getWsURL(sessionId);
38
- expect(url.toString()).toBe("ws://marimo.app/nested/ws?session_id=1234");
42
+ expect(url.toString()).toBe(
43
+ "ws://marimo.app/nested/ws?query_params=&session_id=1234",
44
+ );
39
45
  expect(url.searchParams.get(KnownQueryParams.sessionId)).toBe(sessionId);
40
46
  });
41
47
 
@@ -48,7 +54,7 @@ describe("RuntimeManager.getWsURL", () => {
48
54
  const sessionId = "1234" as SessionId;
49
55
  const url = runtime.getWsURL(sessionId);
50
56
  expect(url.toString()).toBe(
51
- "ws://marimo.app/nested/ws?foo=bar&file=test.py&session_id=1234",
57
+ "ws://marimo.app/nested/ws?foo=bar&file=test.py&query_params=foo%3Dbar%26file%3Dtest.py&session_id=1234",
52
58
  );
53
59
  expect(url.searchParams.get(KnownQueryParams.sessionId)).toBe(sessionId);
54
60
  });
@@ -62,11 +68,75 @@ describe("RuntimeManager.getWsURL", () => {
62
68
  const sessionId = "1234" as SessionId;
63
69
  const url = runtime.getWsURL(sessionId);
64
70
  expect(url.toString()).toBe(
65
- "ws://marimo.app/ws?file=test.py&search=test&other=last&session_id=1234",
71
+ "ws://marimo.app/ws?file=test.py&query_params=file%3Dtest.py%26search%3Dtest%26other%3Dlast&session_id=1234",
66
72
  );
67
73
  expect(url.searchParams.get("file")).toBe("test.py");
68
- expect(url.searchParams.get("search")).toBe("test");
69
- expect(url.searchParams.get("other")).toBe("last");
74
+ expect(url.searchParams.get("query_params")).toBe(
75
+ "file=test.py&search=test&other=last",
76
+ );
70
77
  expect(url.searchParams.get(KnownQueryParams.sessionId)).toBe(sessionId);
71
78
  });
72
79
  });
80
+
81
+ describe.each(["getWsURL", "getSseURL"] as const)(
82
+ "RuntimeManager.%s",
83
+ (method) => {
84
+ it("preserves runtime parameters and repeated page values", () => {
85
+ window.history.pushState({}, "", "/?tag=page-one&tag=page-two");
86
+ const runtime = new RuntimeManager(
87
+ {
88
+ url: `${window.location.origin}/nested?proxy_token=secret&tag=base&base=one&base=two`,
89
+ lazy: true,
90
+ },
91
+ true,
92
+ );
93
+ const url = runtime[method]("session" as SessionId);
94
+ expect([...url.searchParams]).toEqual([
95
+ ["proxy_token", "secret"],
96
+ ["tag", "base"],
97
+ ["base", "one"],
98
+ ["base", "two"],
99
+ [
100
+ "query_params",
101
+ "proxy_token=secret&base=one&base=two&tag=page-one&tag=page-two",
102
+ ],
103
+ ["session_id", "session"],
104
+ ]);
105
+ });
106
+
107
+ it("keeps notebook parameters out of the stream options", () => {
108
+ const notebookParams = new URLSearchParams([
109
+ ["types", "scatter"],
110
+ ["cursor", "page-2"],
111
+ ["run_id", "experiment"],
112
+ ["query_params", "nested=value&other=1"],
113
+ ["tag", "one"],
114
+ ["tag", "two"],
115
+ ["empty", ""],
116
+ ["text", "a+b & café"],
117
+ ["file", "test.py"],
118
+ ["kiosk", "true"],
119
+ ]);
120
+ window.history.pushState(
121
+ {},
122
+ "",
123
+ `/?${notebookParams}&session_id=page-session`,
124
+ );
125
+ const runtime = new RuntimeManager(
126
+ { url: window.location.origin, lazy: true },
127
+ true,
128
+ );
129
+ const url = runtime[method]("actual-session" as SessionId);
130
+
131
+ expect(Object.fromEntries(url.searchParams)).toEqual({
132
+ file: "test.py",
133
+ kiosk: "true",
134
+ session_id: "actual-session",
135
+ query_params: notebookParams.toString(),
136
+ });
137
+ expect([
138
+ ...new URLSearchParams(url.searchParams.get("query_params") ?? ""),
139
+ ]).toEqual([...notebookParams]);
140
+ });
141
+ },
142
+ );
@@ -249,7 +249,8 @@ describe("RuntimeManager", () => {
249
249
  const url = runtime.getSseURL("s_123" as SessionId);
250
250
 
251
251
  expect(url.searchParams.get("access_token")).toBeNull();
252
- expect(url.searchParams.get("foo")).toBe("bar");
252
+ expect(url.searchParams.get("query_params")).toBe("foo=bar");
253
+ expect(url.toString()).not.toContain("page-token");
253
254
  } finally {
254
255
  window.history.pushState({}, "", original);
255
256
  }
@@ -669,9 +670,16 @@ describe("RuntimeManager", () => {
669
670
  expect(httpUrl.searchParams.get("base_param")).toBe("existing");
670
671
 
671
672
  // Should preserve all window-level query params (including custom ones)
672
- expect(wsUrl.searchParams.get("custom_param")).toBe("value123");
673
- expect(wsUrl.searchParams.get("user_token")).toBe("abc");
674
- expect(wsUrl.searchParams.get("theme")).toBe("dark");
673
+ expect(
674
+ Object.fromEntries(
675
+ new URLSearchParams(wsUrl.searchParams.get("query_params") ?? ""),
676
+ ),
677
+ ).toEqual({
678
+ base_param: "existing",
679
+ custom_param: "value123",
680
+ user_token: "abc",
681
+ theme: "dark",
682
+ });
675
683
  expect(httpUrl.searchParams.get("custom_param")).toBe("value123");
676
684
  expect(httpUrl.searchParams.get("user_token")).toBe("abc");
677
685
  expect(httpUrl.searchParams.get("theme")).toBe("dark");
@@ -738,7 +746,7 @@ describe("RuntimeManager", () => {
738
746
  const sessionId = "test" as SessionId;
739
747
 
740
748
  expect(runtime.getWsURL(sessionId).toString()).toMatchInlineSnapshot(
741
- `"blob:https://example.com/12345678-1234-1234-1234-123456789abc?session_id=test"`,
749
+ `"blob:https://example.com/12345678-1234-1234-1234-123456789abc?query_params=&session_id=test"`,
742
750
  );
743
751
  expect(Logger.warn).toHaveBeenCalledOnce();
744
752
  });
@@ -128,33 +128,32 @@ export class RuntimeManager {
128
128
  return asWsUrl(this.appendCrossOriginAuth(url).toString());
129
129
  }
130
130
 
131
- /**
132
- * Search params for a session connection: the base URL's params, merged
133
- * with the current page's, plus the session id.
134
- */
135
- private getSessionSearchParams(sessionId: SessionId): URLSearchParams {
136
- const baseUrl = new URL(this.config.url);
137
- const searchParams = new URLSearchParams(baseUrl.search);
138
-
139
- // Merge in current page's query parameters
140
- const currentParams = new URLSearchParams(window.location.search);
141
- currentParams.forEach((value, key) => {
142
- // Don't override base URL params
143
- if (!searchParams.has(key)) {
144
- searchParams.set(key, value);
145
- }
146
- });
147
-
148
- searchParams.set(KnownQueryParams.sessionId, sessionId);
149
- return searchParams;
131
+ /** Keep notebook page parameters separate from connection options. */
132
+ private getSessionURL(path: string, sessionId: SessionId): URL {
133
+ const url = this.formatHttpURL({ path });
134
+ const notebookParams = new URLSearchParams(this.httpURL.search);
135
+ const pageParams = new URLSearchParams(window.location.search);
136
+ for (const key of pageParams.keys()) {
137
+ notebookParams.delete(key);
138
+ }
139
+ for (const [key, value] of pageParams) {
140
+ notebookParams.append(key, value);
141
+ }
142
+ // These parameters are internal and are not exposed by mo.query_params().
143
+ notebookParams.delete(KnownQueryParams.accessToken);
144
+ notebookParams.delete("refresh_token");
145
+ notebookParams.delete(KnownQueryParams.sessionId);
146
+ url.searchParams.set("query_params", notebookParams.toString());
147
+ url.searchParams.set(KnownQueryParams.sessionId, sessionId);
148
+ return url;
150
149
  }
151
150
 
152
151
  /**
153
152
  * The WebSocket URL of the runtime.
154
153
  */
155
154
  public getWsURL(sessionId: SessionId): URL {
156
- const searchParams = this.getSessionSearchParams(sessionId);
157
- return this.formatWsURL("/ws", searchParams);
155
+ const url = this.getSessionURL("/ws", sessionId);
156
+ return asWsUrl(this.appendCrossOriginAuth(url).toString());
158
157
  }
159
158
 
160
159
  /**
@@ -164,11 +163,7 @@ export class RuntimeManager {
164
163
  * (see `headers()`) instead of the URL.
165
164
  */
166
165
  public getSseURL(sessionId: SessionId): URL {
167
- const url = this.formatHttpURL({
168
- path: "/sse",
169
- searchParams: this.getSessionSearchParams(sessionId),
170
- restrictToKnownQueryParams: false,
171
- });
166
+ const url = this.getSessionURL("/sse", sessionId);
172
167
  // The page URL can still carry access_token (merged in from the
173
168
  // current page's params, before auth cleanup strips it); never
174
169
  // forward it — a token in the URL leaks into proxy/server logs.
@@ -180,7 +175,8 @@ export class RuntimeManager {
180
175
  * The WebSocket Sync URL of the runtime, for real-time updates.
181
176
  */
182
177
  public getWsSyncURL(sessionId: SessionId): URL {
183
- return this.formatWsURL("/ws_sync", this.getSessionSearchParams(sessionId));
178
+ const url = this.getSessionURL("/ws_sync", sessionId);
179
+ return asWsUrl(this.appendCrossOriginAuth(url).toString());
184
180
  }
185
181
 
186
182
  /**
@@ -92,6 +92,7 @@
92
92
  --action-hover: var(--yellow-10, #ffdc00);
93
93
  --action-border: var(--yellow-11, #9e6c00);
94
94
  --action-foreground: var(--yellow-12, #473b1f);
95
+ --search-highlight: var(--yellow-4, #fff394);
95
96
  --link: light-dark(hsl(211deg 90% 42%), hsl(211deg 90% 62%));
96
97
  --link-visited: light-dark(hsl(272deg 51% 54%), hsl(272deg 51% 74%));
97
98
  --stale: hsl(42deg 56% 44% / 25%);
@@ -140,6 +141,7 @@
140
141
  --action-hover: var(--yellow-7, #665417);
141
142
  --action-border: var(--yellow-11, #f5e147);
142
143
  --action-foreground: var(--yellow-12, #f6eeb4);
144
+ --search-highlight: var(--yellow-6, #524202);
143
145
  --stale: hsl(54deg 100% 86.7% / 25%);
144
146
  }
145
147
  }