@marimo-team/islands 0.17.8 → 0.18.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 (53) hide show
  1. package/dist/{Combination-BH_L276x.js → Combination-D68fi0fY.js} +22 -21
  2. package/dist/{ConnectedDataExplorerComponent-WbiFXhKG.js → ConnectedDataExplorerComponent-BUgUSo2B.js} +7 -7
  3. package/dist/{any-language-editor-YPQMljy9.js → any-language-editor-BS-Z5AY5.js} +3 -3
  4. package/dist/assets/__vite-browser-external-CSegkGa0.js +1 -0
  5. package/dist/assets/{worker-BrDpRi2I.js → worker-CiT2i-Vo.js} +2 -2
  6. package/dist/{error-banner-BqE1uF21.js → error-banner-CPLhCPHA.js} +24 -24
  7. package/dist/{esm-hR1r0nyt.js → esm-DxgKy8Wv.js} +1 -1
  8. package/dist/{formats-dvT8nDgH.js → formats-oddMfm9_.js} +27 -7
  9. package/dist/{glide-data-editor-B26PhZvE.js → glide-data-editor-BFv4VQnc.js} +4 -4
  10. package/dist/{label-D3LNCORf.js → label-Dsm6T1fr.js} +72 -72
  11. package/dist/main.js +359 -250
  12. package/dist/{mermaid-Dl3ywmV2.js → mermaid-BeGlg1JH.js} +2 -2
  13. package/dist/{react-vega-ypEMYp9o.js → react-vega-DDXWt_PN.js} +852 -1544
  14. package/dist/{react-vega-BIDT9Ttp.js → react-vega-DV2IwPx_.js} +1 -1
  15. package/dist/{spec-qDDGe5hl.js → spec-BotzCMo3.js} +2 -2
  16. package/dist/style.css +1 -1
  17. package/dist/{types-2eTEqSwS.js → types-IRrkdH-H.js} +14 -14
  18. package/dist/{useAsyncData-6gisQ4pR.js → useAsyncData-CsSW6_Zh.js} +1 -1
  19. package/dist/{useTheme-B-2frT0L.js → useTheme-D56Xlrez.js} +1 -0
  20. package/dist/{vega-component-C-bCSv1b.js → vega-component-CLjz4see.js} +6 -6
  21. package/package.json +2 -2
  22. package/src/components/chat/chat-panel.tsx +6 -2
  23. package/src/components/data-table/TableActions.tsx +18 -14
  24. package/src/components/data-table/data-table.tsx +3 -0
  25. package/src/components/editor/chrome/panels/packages-panel.tsx +3 -1
  26. package/src/components/editor/file-tree/__tests__/file-expolorer.test.ts +178 -0
  27. package/src/components/editor/file-tree/file-explorer.tsx +70 -1
  28. package/src/components/pages/home-page.tsx +8 -3
  29. package/src/core/ai/tools/__tests__/registry.test.ts +6 -2
  30. package/src/core/ai/tools/registry.ts +5 -2
  31. package/src/core/cells/__tests__/session.test.ts +0 -9
  32. package/src/core/cells/session.ts +0 -1
  33. package/src/core/codemirror/copilot/client.ts +21 -1
  34. package/src/core/codemirror/copilot/copilot-config.tsx +29 -1
  35. package/src/core/config/__tests__/config-schema.test.ts +2 -0
  36. package/src/core/config/config-schema.ts +1 -0
  37. package/src/core/packages/__tests__/package-input-utils.test.ts +93 -0
  38. package/src/core/packages/package-input-utils.ts +36 -0
  39. package/src/css/md.css +5 -0
  40. package/src/plugins/core/__test__/sanitize.test.ts +1 -1
  41. package/src/plugins/core/sanitize.ts +3 -1
  42. package/src/plugins/impl/DataTablePlugin.tsx +10 -1
  43. package/src/plugins/impl/chat/ChatPlugin.tsx +1 -0
  44. package/src/plugins/impl/chat/chat-ui.tsx +140 -10
  45. package/src/plugins/impl/data-frames/DataFramePlugin.tsx +1 -0
  46. package/src/plugins/layout/NavigationMenuPlugin.tsx +14 -3
  47. package/src/plugins/layout/ProgressPlugin.tsx +8 -5
  48. package/src/plugins/layout/StatPlugin.tsx +11 -4
  49. package/src/plugins/layout/__test__/ProgressPlugin.test.ts +37 -21
  50. package/src/utils/__tests__/urls.test.ts +165 -1
  51. package/src/utils/urls.ts +120 -0
  52. package/src/utils/vitals.ts +1 -1
  53. package/dist/assets/__vite-browser-external-BTNiCQ6O.js +0 -1
@@ -446,9 +446,6 @@ describe("notebookStateFromSession", () => {
446
446
  const session = createSession([]);
447
447
  const result = notebookStateFromSession(session, null);
448
448
 
449
- expect(Logger.warn).toHaveBeenCalledWith(
450
- "Session and notebook must have at least one cell",
451
- );
452
449
  expect(result).toBeNull();
453
450
  });
454
451
 
@@ -456,9 +453,6 @@ describe("notebookStateFromSession", () => {
456
453
  const notebook = createNotebook([]);
457
454
  const result = notebookStateFromSession(null, notebook);
458
455
 
459
- expect(Logger.warn).toHaveBeenCalledWith(
460
- "Session and notebook must have at least one cell",
461
- );
462
456
  expect(result).toBeNull();
463
457
  });
464
458
 
@@ -467,9 +461,6 @@ describe("notebookStateFromSession", () => {
467
461
  const notebook = createNotebook([]);
468
462
  const result = notebookStateFromSession(session, notebook);
469
463
 
470
- expect(Logger.warn).toHaveBeenCalledWith(
471
- "Session and notebook must have at least one cell",
472
- );
473
464
  expect(result).toBeNull();
474
465
  });
475
466
  });
@@ -246,7 +246,6 @@ export function notebookStateFromSession(
246
246
  mergeSessionAndNotebookCells(session, notebook);
247
247
 
248
248
  if (cellIds.length === 0) {
249
- Logger.warn("Session and notebook must have at least one cell");
250
249
  return null;
251
250
  }
252
251
 
@@ -89,9 +89,29 @@ class LazyWebsocketTransport extends Transport {
89
89
  data: JSONRPCRequestData,
90
90
  timeout: number | null | undefined,
91
91
  ) {
92
+ // If delegate is undefined, try to reconnect
93
+ if (!this.delegate) {
94
+ Logger.log("Copilot#sendData: Delegate not initialized, reconnecting...");
95
+ try {
96
+ await this.tryConnect();
97
+ } catch (error) {
98
+ Logger.error("Copilot#sendData: Failed to reconnect transport", error);
99
+ throw new Error(
100
+ "Unable to connect to GitHub Copilot. Please check your settings and try again.",
101
+ );
102
+ }
103
+ }
104
+
105
+ // After reconnection, delegate should be initialized
106
+ if (!this.delegate) {
107
+ throw new Error(
108
+ "Failed to initialize GitHub Copilot connection. Please try again.",
109
+ );
110
+ }
111
+
92
112
  // Clamp timeout to 5 seconds
93
113
  timeout = Math.min(timeout ?? 5000, 5000);
94
- return this.delegate?.sendData(data, timeout);
114
+ return this.delegate.sendData(data, timeout);
95
115
  }
96
116
  }
97
117
 
@@ -24,13 +24,41 @@ export const CopilotConfig = memo(() => {
24
24
  evt.preventDefault();
25
25
  setLoading(true);
26
26
  try {
27
- const { verificationUri, status, userCode } = await initiateSignIn();
27
+ const result = await initiateSignIn();
28
+
29
+ // Validate the response has required fields
30
+ if (!result || !result.verificationUri || !result.userCode) {
31
+ Logger.error("Copilot#trySignIn: Invalid response from sign-in", {
32
+ result,
33
+ });
34
+ setStep("connectionError");
35
+ toast({
36
+ title: "GitHub Copilot Connection Error",
37
+ description:
38
+ "Unable to connect to GitHub Copilot. Please check your settings and try again.",
39
+ variant: "danger",
40
+ });
41
+ return;
42
+ }
43
+
44
+ const { verificationUri, status, userCode } = result;
28
45
  if (isSignedIn(status)) {
29
46
  copilotChangeSignIn(true);
30
47
  } else {
31
48
  setStep("signingIn");
32
49
  setLocalData({ url: verificationUri, code: userCode });
33
50
  }
51
+ } catch (error) {
52
+ Logger.error("Copilot#trySignIn: Error during sign-in", error);
53
+ setStep("connectionError");
54
+ toast({
55
+ title: "GitHub Copilot Connection Error",
56
+ description:
57
+ error instanceof Error
58
+ ? error.message
59
+ : "Unable to connect to GitHub Copilot. Please check your settings and try again.",
60
+ variant: "danger",
61
+ });
34
62
  } finally {
35
63
  setLoading(false);
36
64
  }
@@ -87,6 +87,7 @@ test("default UserConfig - empty", () => {
87
87
  "default_auto_download": [],
88
88
  "default_sql_output": "auto",
89
89
  "on_cell_change": "autorun",
90
+ "reactive_tests": true,
90
91
  "watcher_on_save": "lazy",
91
92
  },
92
93
  "save": {
@@ -154,6 +155,7 @@ test("default UserConfig - one level", () => {
154
155
  "default_auto_download": [],
155
156
  "default_sql_output": "auto",
156
157
  "on_cell_change": "autorun",
158
+ "reactive_tests": true,
157
159
  "watcher_on_save": "lazy",
158
160
  },
159
161
  "save": {
@@ -119,6 +119,7 @@ export const UserConfigSchema = z
119
119
  auto_instantiate: z.boolean().prefault(true),
120
120
  on_cell_change: z.enum(["lazy", "autorun"]).prefault("autorun"),
121
121
  auto_reload: z.enum(["off", "lazy", "autorun"]).prefault("off"),
122
+ reactive_tests: z.boolean().prefault(true),
122
123
  watcher_on_save: z.enum(["lazy", "autorun"]).prefault("lazy"),
123
124
  default_sql_output: z.enum(VALID_SQL_OUTPUT_FORMATS).prefault("auto"),
124
125
  default_auto_download: z
@@ -0,0 +1,93 @@
1
+ /* Copyright 2024 Marimo. All rights reserved. */
2
+ import { describe, expect, it } from "vitest";
3
+ import { stripPackageManagerPrefix } from "../package-input-utils";
4
+
5
+ describe("stripPackageManagerPrefix", () => {
6
+ it("should remove 'pip install' prefix", () => {
7
+ expect(stripPackageManagerPrefix("pip install httpx")).toBe("httpx");
8
+ expect(stripPackageManagerPrefix("pip install httpx requests")).toBe(
9
+ "httpx requests",
10
+ );
11
+ });
12
+
13
+ it("should remove 'pip3 install' prefix", () => {
14
+ expect(stripPackageManagerPrefix("pip3 install pandas")).toBe("pandas");
15
+ });
16
+
17
+ it("should remove 'uv add' prefix", () => {
18
+ expect(stripPackageManagerPrefix("uv add numpy")).toBe("numpy");
19
+ expect(stripPackageManagerPrefix("uv add pandas numpy")).toBe(
20
+ "pandas numpy",
21
+ );
22
+ });
23
+
24
+ it("should remove 'uv pip install' prefix", () => {
25
+ expect(stripPackageManagerPrefix("uv pip install scipy")).toBe("scipy");
26
+ });
27
+
28
+ it("should remove 'poetry add' prefix", () => {
29
+ expect(stripPackageManagerPrefix("poetry add flask")).toBe("flask");
30
+ });
31
+
32
+ it("should remove 'conda install' prefix", () => {
33
+ expect(stripPackageManagerPrefix("conda install matplotlib")).toBe(
34
+ "matplotlib",
35
+ );
36
+ });
37
+
38
+ it("should remove 'pipenv install' prefix", () => {
39
+ expect(stripPackageManagerPrefix("pipenv install django")).toBe("django");
40
+ });
41
+
42
+ it("should be case insensitive", () => {
43
+ expect(stripPackageManagerPrefix("PIP INSTALL httpx")).toBe("httpx");
44
+ expect(stripPackageManagerPrefix("Pip Install requests")).toBe("requests");
45
+ expect(stripPackageManagerPrefix("UV ADD numpy")).toBe("numpy");
46
+ });
47
+
48
+ it("should handle extra whitespace", () => {
49
+ expect(stripPackageManagerPrefix(" pip install httpx ")).toBe("httpx");
50
+ expect(stripPackageManagerPrefix("uv add pandas ")).toBe("pandas");
51
+ });
52
+
53
+ it("should return input unchanged if no prefix matches", () => {
54
+ expect(stripPackageManagerPrefix("httpx")).toBe("httpx");
55
+ expect(stripPackageManagerPrefix("pandas numpy")).toBe("pandas numpy");
56
+ expect(stripPackageManagerPrefix("httpx==0.27.0")).toBe("httpx==0.27.0");
57
+ });
58
+
59
+ it("should handle package specifications with versions", () => {
60
+ expect(stripPackageManagerPrefix("pip install httpx==0.27.0")).toBe(
61
+ "httpx==0.27.0",
62
+ );
63
+ expect(stripPackageManagerPrefix("uv add pandas>=2.0.0")).toBe(
64
+ "pandas>=2.0.0",
65
+ );
66
+ });
67
+
68
+ it("should handle git URLs", () => {
69
+ expect(
70
+ stripPackageManagerPrefix(
71
+ "pip install git+https://github.com/encode/httpx",
72
+ ),
73
+ ).toBe("git+https://github.com/encode/httpx");
74
+ });
75
+
76
+ it("should handle multiple packages", () => {
77
+ expect(stripPackageManagerPrefix("pip install httpx requests pandas")).toBe(
78
+ "httpx requests pandas",
79
+ );
80
+ });
81
+
82
+ it("should only remove the first matching prefix", () => {
83
+ // Edge case: input contains prefix-like text multiple times
84
+ expect(stripPackageManagerPrefix("pip install pip install httpx")).toBe(
85
+ "pip install httpx",
86
+ );
87
+ });
88
+
89
+ it("should handle empty string", () => {
90
+ expect(stripPackageManagerPrefix("")).toBe("");
91
+ expect(stripPackageManagerPrefix(" ")).toBe("");
92
+ });
93
+ });
@@ -0,0 +1,36 @@
1
+ /* Copyright 2024 Marimo. All rights reserved. */
2
+
3
+ /**
4
+ * Removes common package manager command prefixes from an input string.
5
+ * This allows users to paste commands like "pip install httpx" and have
6
+ * the "pip install" prefix automatically removed.
7
+ *
8
+ * @param input - The raw input string that may contain a package manager prefix
9
+ * @returns The input with any recognized prefix removed and trimmed
10
+ *
11
+ * @example
12
+ * stripPackageManagerPrefix("pip install httpx") // returns "httpx"
13
+ * stripPackageManagerPrefix("uv add pandas numpy") // returns "pandas numpy"
14
+ * stripPackageManagerPrefix("httpx") // returns "httpx"
15
+ */
16
+ export function stripPackageManagerPrefix(input: string): string {
17
+ const trimmedInput = input.trim();
18
+
19
+ const prefixes = [
20
+ "pip install",
21
+ "pip3 install",
22
+ "uv add",
23
+ "uv pip install",
24
+ "poetry add",
25
+ "conda install",
26
+ "pipenv install",
27
+ ];
28
+
29
+ for (const prefix of prefixes) {
30
+ if (trimmedInput.toLowerCase().startsWith(prefix.toLowerCase())) {
31
+ return trimmedInput.slice(prefix.length).trim();
32
+ }
33
+ }
34
+
35
+ return trimmedInput;
36
+ }
package/src/css/md.css CHANGED
@@ -78,6 +78,11 @@ a .markdown iconify-icon:first-child {
78
78
  margin-inline-end: 0.4em;
79
79
  }
80
80
 
81
+ /* make links and their contents inline-flex to avoid extra gaps */
82
+ a > .markdown > .paragraph {
83
+ display: inline-flex;
84
+ }
85
+
81
86
  iconify-icon {
82
87
  display: inline-flex;
83
88
  align-items: center;
@@ -141,7 +141,7 @@ describe("sanitizeHtml", () => {
141
141
  const html =
142
142
  "<marimo-mermaid data-diagram='&quot;sequenceDiagram&#92;n Alice-&gt;&gt;John&#92;n John--&gt;&gt;Alice&#92;n &quot;'></marimo-mermaid>";
143
143
  expect(sanitizeHtml(html)).toMatchInlineSnapshot(
144
- `"<marimo-mermaid></marimo-mermaid>"`,
144
+ `"<marimo-mermaid data-diagram="&quot;sequenceDiagram\\n Alice->>John\\n John-->>Alice\\n &quot;"></marimo-mermaid>"`,
145
145
  );
146
146
  });
147
147
 
@@ -80,7 +80,9 @@ export function sanitizeHtml(html: string) {
80
80
  tagNameCheck: /^(marimo-[A-Za-z][\w-]*|iconify-icon)$/,
81
81
  attributeNameCheck: /^[A-Za-z][\w-]*$/,
82
82
  },
83
- SAFE_FOR_XML: html.includes("marimo-mermaid"),
83
+ // This flag means we should sanitize such that is it safe for XML,
84
+ // but this is only used for HTML content.
85
+ SAFE_FOR_XML: !html.includes("marimo-mermaid"),
84
86
  };
85
87
  return DOMPurify.sanitize(html, sanitizationOptions);
86
88
  }
@@ -63,6 +63,7 @@ import { type CellId, findCellId } from "@/core/cells/ids";
63
63
  import { slotsController } from "@/core/slots/slots";
64
64
  import { store } from "@/core/state/jotai";
65
65
  import { isStaticNotebook } from "@/core/static/static-state";
66
+ import { isInVscodeExtension } from "@/core/vscode/is-in-vscode";
66
67
  import { useAsyncData } from "@/hooks/useAsyncData";
67
68
  import { useDeepCompareMemoize } from "@/hooks/useDeepCompareMemoize";
68
69
  import { useEffectSkipFirstRender } from "@/hooks/useEffectSkipFirstRender";
@@ -179,6 +180,7 @@ interface Data<T> {
179
180
  showDataTypes: boolean;
180
181
  showPageSizeSelector: boolean;
181
182
  showColumnExplorer: boolean;
183
+ showRowExplorer: boolean;
182
184
  showChartBuilder: boolean;
183
185
  rowHeaders: FieldTypesWithExternalType;
184
186
  fieldTypes?: FieldTypesWithExternalType | null;
@@ -250,6 +252,7 @@ export const DataTablePlugin = createPlugin<S>("marimo-table")
250
252
  showDataTypes: z.boolean().default(true),
251
253
  showPageSizeSelector: z.boolean().default(true),
252
254
  showColumnExplorer: z.boolean().default(true),
255
+ showRowExplorer: z.boolean().default(true),
253
256
  showChartBuilder: z.boolean().default(true),
254
257
  rowHeaders: columnToFieldTypesSchema,
255
258
  freezeColumnsLeft: z.array(z.string()).optional(),
@@ -718,6 +721,7 @@ const DataTableComponent = ({
718
721
  showDownload,
719
722
  showPageSizeSelector,
720
723
  showColumnExplorer,
724
+ showRowExplorer,
721
725
  showChartBuilder,
722
726
  showDataTypes,
723
727
  rowHeaders,
@@ -899,6 +903,8 @@ const DataTableComponent = ({
899
903
  const showColExplorer =
900
904
  showColumnExplorer && preview_column && isPanelOpen("column-explorer");
901
905
 
906
+ const isInVscode = isInVscodeExtension();
907
+
902
908
  return (
903
909
  <>
904
910
  {/* When the totalRows is "too_many" and the pageSize is the same as the
@@ -988,7 +994,10 @@ const DataTableComponent = ({
988
994
  toggleDisplayHeader={toggleDisplayHeader}
989
995
  showChartBuilder={showChartBuilder}
990
996
  showPageSizeSelector={showPageSizeSelector}
991
- showColumnExplorer={showColumnExplorer}
997
+ // Hidden in VSCode (for now) because we don't have a panel to show
998
+ // the column/row explorer.
999
+ showColumnExplorer={showColumnExplorer && !isInVscode}
1000
+ showRowExplorer={showRowExplorer && !isInVscode}
992
1001
  togglePanel={togglePanel}
993
1002
  isPanelOpen={isPanelOpen}
994
1003
  viewedRowIdx={viewedRowIdx}
@@ -89,6 +89,7 @@ export const ChatPlugin = createPlugin<{ messages: ChatMessage[] }>(
89
89
  send_prompt={props.functions.send_prompt}
90
90
  value={props.value?.messages || Arrays.EMPTY}
91
91
  setValue={(messages) => props.setValue({ messages })}
92
+ host={props.host}
92
93
  />
93
94
  </Suspense>
94
95
  </TooltipProvider>
@@ -12,6 +12,7 @@ import {
12
12
  DownloadIcon,
13
13
  HelpCircleIcon,
14
14
  PaperclipIcon,
15
+ RotateCwIcon,
15
16
  SendIcon,
16
17
  SettingsIcon,
17
18
  Trash2Icon,
@@ -42,7 +43,12 @@ import {
42
43
  import { Tooltip } from "@/components/ui/tooltip";
43
44
  import { toast } from "@/components/ui/use-toast";
44
45
  import { moveToEndOfEditor } from "@/core/codemirror/utils";
46
+ import { MarimoIncomingMessageEvent } from "@/core/dom/events";
45
47
  import { useAsyncData } from "@/hooks/useAsyncData";
48
+ import {
49
+ type HTMLElementNotDerivedFromRef,
50
+ useEventListener,
51
+ } from "@/hooks/useEventListener";
46
52
  import { cn } from "@/utils/cn";
47
53
  import { copyToClipboard } from "@/utils/copy";
48
54
  import { Logger } from "@/utils/Logger";
@@ -63,6 +69,7 @@ interface Props extends PluginFunctions {
63
69
  allowAttachments: boolean | string[];
64
70
  value: ChatMessage[];
65
71
  setValue: (messages: ChatMessage[]) => void;
72
+ host: HTMLElement;
66
73
  }
67
74
 
68
75
  export const Chatbot: React.FC<Props> = (props) => {
@@ -74,6 +81,12 @@ export const Chatbot: React.FC<Props> = (props) => {
74
81
  const codeMirrorInputRef = useRef<ReactCodeMirrorRef>(null);
75
82
  const scrollContainerRef = useRef<HTMLDivElement>(null);
76
83
 
84
+ // Track streaming state - maps backend message_id to frontend message index
85
+ const streamingStateRef = useRef<{
86
+ backendMessageId: string | null;
87
+ frontendMessageIndex: number | null;
88
+ }>({ backendMessageId: null, frontendMessageIndex: null });
89
+
77
90
  const { data: initialMessages } = useAsyncData(async () => {
78
91
  const chatMessages = await props.get_chat_history({});
79
92
  const messages: UIMessage[] = chatMessages.messages.map((message, idx) => ({
@@ -113,6 +126,19 @@ export const Chatbot: React.FC<Props> = (props) => {
113
126
  .join("\n"),
114
127
  parts: m.parts,
115
128
  }));
129
+
130
+ // Create a placeholder message for streaming
131
+ const messageId = Date.now().toString();
132
+
133
+ setMessages((prev) => [
134
+ ...prev,
135
+ {
136
+ id: messageId,
137
+ role: "assistant",
138
+ parts: [{ type: "text", text: "" }],
139
+ },
140
+ ]);
141
+
116
142
  const response = await props.send_prompt({
117
143
  messages: messages,
118
144
  config: {
@@ -124,18 +150,35 @@ export const Chatbot: React.FC<Props> = (props) => {
124
150
  presence_penalty: config.presence_penalty,
125
151
  },
126
152
  });
127
- // Update local state with AI response
128
- setMessages((prev) => [
129
- ...prev,
130
- {
131
- id: Date.now().toString(),
132
- role: "assistant",
133
- parts: [{ type: "text", text: response }],
134
- },
135
- ]);
153
+
154
+ // If streaming didn't happen (non-generator response), update the message
155
+ // Check if streaming state is still set (meaning no chunks were received)
156
+ if (
157
+ streamingStateRef.current.backendMessageId === null &&
158
+ streamingStateRef.current.frontendMessageIndex === null
159
+ ) {
160
+ setMessages((prev) => {
161
+ const updated = [...prev];
162
+ const index = updated.findIndex((m) => m.id === messageId);
163
+ if (index !== -1) {
164
+ updated[index] = {
165
+ ...updated[index],
166
+ parts: [{ type: "text", text: response }],
167
+ };
168
+ }
169
+ return updated;
170
+ });
171
+ }
172
+
136
173
  return new Response(response);
137
174
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
138
175
  } catch (error: any) {
176
+ // Clear streaming state on error
177
+ streamingStateRef.current = {
178
+ backendMessageId: null,
179
+ frontendMessageIndex: null,
180
+ };
181
+
139
182
  // HACK: strip the error message to clean up the response
140
183
  const strippedError = error.message
141
184
  .split("failed with exception ")
@@ -152,12 +195,98 @@ export const Chatbot: React.FC<Props> = (props) => {
152
195
  fileInputRef.current.value = "";
153
196
  }
154
197
  Logger.debug("Finished streaming message:", message);
198
+
199
+ // Clear streaming state
200
+ streamingStateRef.current = {
201
+ backendMessageId: null,
202
+ frontendMessageIndex: null,
203
+ };
155
204
  },
156
205
  onError: (error) => {
157
206
  Logger.error("An error occurred:", error);
207
+ // Clear streaming state on error
208
+ streamingStateRef.current = {
209
+ backendMessageId: null,
210
+ frontendMessageIndex: null,
211
+ };
158
212
  },
159
213
  });
160
214
 
215
+ // Listen for streaming chunks from backend
216
+ useEventListener(
217
+ props.host as HTMLElementNotDerivedFromRef,
218
+ MarimoIncomingMessageEvent.TYPE,
219
+ (e) => {
220
+ const message = e.detail.message;
221
+ if (
222
+ typeof message === "object" &&
223
+ message !== null &&
224
+ "type" in message &&
225
+ message.type === "stream_chunk"
226
+ ) {
227
+ const chunkMessage = message as {
228
+ type: string;
229
+ message_id: string;
230
+ content: string;
231
+ is_final: boolean;
232
+ };
233
+
234
+ // Initialize streaming state on first chunk if not already set
235
+ if (streamingStateRef.current.backendMessageId === null) {
236
+ // Find the last assistant message (which should be the placeholder we created)
237
+ setMessages((prev) => {
238
+ const updated = [...prev];
239
+ // Find the last assistant message
240
+ for (let i = updated.length - 1; i >= 0; i--) {
241
+ if (updated[i].role === "assistant") {
242
+ streamingStateRef.current = {
243
+ backendMessageId: chunkMessage.message_id,
244
+ frontendMessageIndex: i,
245
+ };
246
+ break;
247
+ }
248
+ }
249
+ return updated;
250
+ });
251
+ }
252
+
253
+ // Only process chunks for the current streaming message
254
+ const frontendIndex = streamingStateRef.current.frontendMessageIndex;
255
+ if (
256
+ streamingStateRef.current.backendMessageId ===
257
+ chunkMessage.message_id &&
258
+ frontendIndex !== null
259
+ ) {
260
+ setMessages((prev) => {
261
+ const updated = [...prev];
262
+ const index = frontendIndex;
263
+
264
+ // Update the message at the tracked index
265
+ if (index < updated.length) {
266
+ const messageToUpdate = updated[index];
267
+ if (messageToUpdate.role === "assistant") {
268
+ updated[index] = {
269
+ ...messageToUpdate,
270
+ parts: [{ type: "text", text: chunkMessage.content }],
271
+ };
272
+ }
273
+ }
274
+
275
+ return updated;
276
+ });
277
+
278
+ // Clear streaming state when final chunk arrives
279
+ if (chunkMessage.is_final) {
280
+ streamingStateRef.current = {
281
+ backendMessageId: null,
282
+ frontendMessageIndex: null,
283
+ };
284
+ }
285
+ }
286
+ }
287
+ },
288
+ );
289
+
161
290
  const isLoading = status === "submitted" || status === "streaming";
162
291
 
163
292
  const handleDelete = (id: string) => {
@@ -283,13 +412,14 @@ export const Chatbot: React.FC<Props> = (props) => {
283
412
  <Button
284
413
  variant="text"
285
414
  size="icon"
415
+ disabled={messages.length === 0}
286
416
  onClick={() => {
287
417
  setMessages([]);
288
418
  props.setValue([]);
289
419
  props.delete_chat_history({});
290
420
  }}
291
421
  >
292
- <Trash2Icon className="h-3 w-3" />
422
+ <RotateCwIcon className="h-3 w-3" />
293
423
  </Button>
294
424
  </div>
295
425
  <div
@@ -297,6 +297,7 @@ export const DataFrameComponent = memo(
297
297
  get_column_summaries={getColumnSummaries}
298
298
  showPageSizeSelector={(total_rows && total_rows > 5) || false}
299
299
  showColumnExplorer={false}
300
+ showRowExplorer={true}
300
301
  showChartBuilder={false}
301
302
  value={Arrays.EMPTY}
302
303
  setValue={Functions.NOOP}
@@ -14,11 +14,13 @@ import {
14
14
  import { Tooltip, TooltipProvider } from "@/components/ui/tooltip";
15
15
  import { renderHTML } from "@/plugins/core/RenderHTML";
16
16
  import { cn } from "@/utils/cn";
17
+ import { appendQueryParams } from "@/utils/urls";
17
18
  import type {
18
19
  IStatelessPlugin,
19
20
  IStatelessPluginProps,
20
21
  } from "../stateless-plugin";
21
22
  import "./navigation-menu.css";
23
+ import { KnownQueryParams } from "@/core/constants";
22
24
 
23
25
  interface MenuItem {
24
26
  label: string;
@@ -99,6 +101,15 @@ const NavMenuComponent = ({
99
101
  return "_self";
100
102
  };
101
103
 
104
+ const preserveQueryParams = (href: string) => {
105
+ const currentUrl = new URL(globalThis.location.href);
106
+ return appendQueryParams({
107
+ href,
108
+ queryParams: currentUrl.search,
109
+ keys: [KnownQueryParams.filePath],
110
+ });
111
+ };
112
+
102
113
  const renderMenuItem = (item: MenuItem | MenuItemGroup) => {
103
114
  if ("items" in item) {
104
115
  return orientation === "horizontal" ? (
@@ -113,7 +124,7 @@ const NavMenuComponent = ({
113
124
  <ListItem
114
125
  key={subItem.label}
115
126
  label={subItem.label}
116
- href={subItem.href}
127
+ href={preserveQueryParams(subItem.href)}
117
128
  target={target(subItem.href)}
118
129
  >
119
130
  {subItem.description &&
@@ -142,7 +153,7 @@ const NavMenuComponent = ({
142
153
  {maybeWithTooltip(
143
154
  <NavigationMenuLink
144
155
  key={subItem.label}
145
- href={subItem.href}
156
+ href={preserveQueryParams(subItem.href)}
146
157
  target={target(subItem.href)}
147
158
  className={navigationMenuTriggerStyle({
148
159
  orientation: orientation,
@@ -162,7 +173,7 @@ const NavMenuComponent = ({
162
173
  return (
163
174
  <NavigationMenuItem key={item.label}>
164
175
  <NavigationMenuLink
165
- href={item.href}
176
+ href={preserveQueryParams(item.href)}
166
177
  target={target(item.href)}
167
178
  className={navigationMenuTriggerStyle({
168
179
  orientation: orientation,
@@ -93,10 +93,13 @@ export const ProgressComponent = ({
93
93
 
94
94
  const elements: React.ReactNode[] = [];
95
95
  if (rate) {
96
- elements.push(
97
- <span key="rate">{rate} iter/s</span>,
98
- <span key="spacer-rate">&middot;</span>,
99
- );
96
+ if (rate < 1) {
97
+ elements.push(<span key="rate">{prettyTime(1 / rate)} per iter</span>);
98
+ } else {
99
+ elements.push(<span key="rate">{rate} iter/s</span>);
100
+ }
101
+
102
+ elements.push(<span key="spacer-rate">&middot;</span>);
100
103
  }
101
104
 
102
105
  if (!hasCompleted && eta) {
@@ -170,6 +173,6 @@ export function prettyTime(seconds: number): string {
170
173
  language: "shortEn",
171
174
  largest: 2,
172
175
  spacer: "",
173
- maxDecimalPoints: 2,
176
+ maxDecimalPoints: seconds < 10 ? 2 : 0,
174
177
  });
175
178
  }