@marimo-team/islands 0.25.3-dev51 → 0.25.3-dev55

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 (52) hide show
  1. package/dist/{ErrorBoundary-CbxCC2kk.js → ErrorBoundary-DL6Vkcwu.js} +1 -1
  2. package/dist/{chat-ui-C1Gjgokv.js → chat-ui-C0_lCzql.js} +52 -53
  3. package/dist/{code-visibility-DoZ9lxzx.js → code-visibility-CtaHItnb.js} +1 -1
  4. package/dist/{common-D1PacyXO.js → common-Bqcl1bUR.js} +6366 -6327
  5. package/dist/{data-grid-overlay-editor-B50UpkXd.js → data-grid-overlay-editor-C7NRbnX6.js} +1 -1
  6. package/dist/{dropdown-menu-MG5hiCIa.js → dropdown-menu-B3eUiVJB.js} +161 -153
  7. package/dist/{feedback-button-DqPqwryB.js → feedback-button-CpJHBUq4.js} +2 -2
  8. package/dist/{glide-data-editor-BxMhUd3K.js → glide-data-editor-Cd3d8sya.js} +4 -4
  9. package/dist/{globals-DTJa_doJ.js → globals-C5LrswXh.js} +1 -1
  10. package/dist/{html-to-image-By72ALyI.js → html-to-image-6xcoTKhC.js} +1 -1
  11. package/dist/main.js +2599 -2539
  12. package/dist/{number-overlay-editor-Z_H6x5b-.js → number-overlay-editor-Do7_pdca.js} +1 -1
  13. package/dist/{process-output-D-OMW5sV.js → process-output-BJRJFobf.js} +1 -1
  14. package/dist/{reveal-component-tAB5K3QE.js → reveal-component-CVYLwPCH.js} +12 -12
  15. package/package.json +1 -1
  16. package/src/components/app-config/ai-config.tsx +1 -1
  17. package/src/components/data-table/__tests__/export-actions.test.tsx +17 -0
  18. package/src/components/data-table/charts/charts.tsx +14 -19
  19. package/src/components/data-table/export-actions.tsx +5 -3
  20. package/src/components/editor/actions/__tests__/pair-with-agent-modal.test.tsx +24 -0
  21. package/src/components/editor/actions/pair-with-agent-modal.tsx +18 -15
  22. package/src/components/editor/cell/__tests__/useShouldShowInterrupt.test.ts +39 -0
  23. package/src/components/editor/cell/useShouldShowInterrupt.ts +2 -12
  24. package/src/components/editor/chrome/panels/__tests__/packages-panel.test.tsx +30 -1
  25. package/src/components/editor/chrome/panels/packages-panel.tsx +4 -7
  26. package/src/components/editor/file-tree/__tests__/file-explorer-interactions.test.tsx +6 -0
  27. package/src/components/editor/file-tree/__tests__/use-file-search.test.tsx +91 -0
  28. package/src/components/editor/file-tree/file-explorer.tsx +10 -5
  29. package/src/components/editor/file-tree/use-file-search.ts +35 -21
  30. package/src/components/icons/__tests__/large-spinner.test.tsx +39 -0
  31. package/src/components/icons/large-spinner.tsx +3 -14
  32. package/src/core/network/useConnectionNotice.ts +8 -2
  33. package/src/hooks/__tests__/useDebounce.test.tsx +51 -0
  34. package/src/hooks/__tests__/useDeepCompareMemoize.test.tsx +39 -0
  35. package/src/hooks/__tests__/useDelayElapsed.test.tsx +28 -0
  36. package/src/hooks/__tests__/useInternalStateWithSync.test.tsx +20 -0
  37. package/src/hooks/__tests__/useScript.test.tsx +77 -0
  38. package/src/hooks/__tests__/useStateForKey.test.tsx +257 -0
  39. package/src/hooks/useDebounce.ts +5 -8
  40. package/src/hooks/useDeepCompareMemoize.ts +1 -1
  41. package/src/hooks/useDelayElapsed.ts +14 -10
  42. package/src/hooks/useInternalStateWithSync.ts +6 -6
  43. package/src/hooks/useScript.ts +67 -70
  44. package/src/hooks/useStateForKey.ts +93 -0
  45. package/src/plugins/impl/DataTablePlugin.tsx +14 -15
  46. package/src/plugins/impl/FileBrowserPlugin.tsx +4 -16
  47. package/src/plugins/impl/SliderPlugin.tsx +6 -8
  48. package/src/plugins/impl/__tests__/DataTablePlugin.test.tsx +117 -1
  49. package/src/plugins/impl/__tests__/FileBrowserPlugin.test.tsx +45 -0
  50. package/src/plugins/impl/__tests__/SliderPlugin.test.tsx +24 -3
  51. package/src/plugins/impl/chat/__tests__/chat-prompts.test.tsx +93 -0
  52. package/src/plugins/impl/chat/chat-ui.tsx +10 -9
@@ -1,7 +1,7 @@
1
1
  /* Copyright 2026 Marimo. All rights reserved. */
2
2
 
3
3
  import { type LucideIcon, ChevronRightIcon, CornerLeftUp } from "lucide-react";
4
- import { type JSX, useEffect, useLayoutEffect, useRef, useState } from "react";
4
+ import { type JSX, useLayoutEffect, useRef, useState } from "react";
5
5
  import { z } from "zod";
6
6
  import {
7
7
  FILE_ICON as FILE_TYPE_ICONS,
@@ -16,6 +16,7 @@ import { Table, TableBody, TableCell, TableRow } from "@/components/ui/table";
16
16
  import { toast } from "@/components/ui/use-toast";
17
17
  import { RANDOM_ID_ATTR } from "@/core/dom/ui-element-constants";
18
18
  import { useAsyncData } from "@/hooks/useAsyncData";
19
+ import { useDelayElapsed } from "@/hooks/useDelayElapsed";
19
20
  import { useInternalStateWithSync } from "@/hooks/useInternalStateWithSync";
20
21
  import { cn } from "@/utils/cn";
21
22
  import { type FilePath, PathBuilder, Paths } from "@/utils/paths";
@@ -200,7 +201,6 @@ export const FileBrowser = ({
200
201
  }: FileBrowserProps): JSX.Element | null => {
201
202
  const [path, setPath] = useInternalStateWithSync(initialPath);
202
203
  const [isUpdatingPath, setIsUpdatingPath] = useState(false);
203
- const [showLoadingOverlay, setShowLoadingOverlay] = useState(false);
204
204
  const [activeIndex, setActiveIndex] = useState(0);
205
205
  const rowRefs = useRef<(HTMLTableRowElement | null)[]>([]);
206
206
  const gridRef = useRef<HTMLTableElement | null>(null);
@@ -219,20 +219,8 @@ export const FileBrowser = ({
219
219
  return list_directory({ path: path });
220
220
  }, [path, randomId, list_directory]);
221
221
 
222
- useEffect(() => {
223
- if (!isPending) {
224
- setShowLoadingOverlay(false);
225
- return;
226
- }
227
-
228
- const timeout = window.setTimeout(() => {
229
- setShowLoadingOverlay(true);
230
- }, 200);
231
-
232
- return () => {
233
- window.clearTimeout(timeout);
234
- };
235
- }, [isPending]);
222
+ const loadingDelayElapsed = useDelayElapsed(isPending ? 200 : 0);
223
+ const showLoadingOverlay = isPending && loadingDelayElapsed;
236
224
 
237
225
  // Reset the roving tabindex whenever the listing reloads (a new path or a
238
226
  // same-path refresh) so activeIndex never points past the current rows.
@@ -1,8 +1,9 @@
1
1
  /* Copyright 2026 Marimo. All rights reserved. */
2
- import { type JSX, useEffect, useId, useMemo, useState } from "react";
2
+ import { type JSX, useId, useMemo } from "react";
3
3
  import { useLocale } from "react-aria";
4
4
  import { z } from "zod";
5
5
  import { NumberField } from "@/components/ui/number-field";
6
+ import { useInternalStateWithSync } from "@/hooks/useInternalStateWithSync";
6
7
  import { cn } from "@/utils/cn";
7
8
  import { clamp } from "@/utils/math";
8
9
  import {
@@ -156,13 +157,10 @@ const SliderComponent = ({
156
157
  const { locale } = useLocale();
157
158
 
158
159
  // Hold internal value
159
- const [internalValue, setInternalValue] = useState(value);
160
- // Update internal value on prop change
161
- useEffect(() => {
162
- // TODO: Replace this synchronization with a controlled-state primitive.
163
- // oxlint-disable-next-line react/no-deriving-state-in-effects -- Synchronize the editable buffer when the controlled value changes.
164
- setInternalValue(value);
165
- }, [value]);
160
+ const [internalValue, setInternalValue] = useInternalStateWithSync(
161
+ value,
162
+ Object.is,
163
+ );
166
164
 
167
165
  const stepsConfig = useMemo(() => computeStepsConfig(steps), [steps]);
168
166
 
@@ -12,11 +12,13 @@ import {
12
12
  waitFor,
13
13
  } from "@testing-library/react";
14
14
  import { Provider } from "jotai";
15
+ import { useState } from "react";
15
16
  import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
16
17
  import { SetupMocks } from "@/__mocks__/common";
17
18
  import type { DownloadAsFunction } from "@/components/data-table/schemas";
18
19
  import type { FieldTypesWithExternalType } from "@/components/data-table/types";
19
20
  import { store } from "@/core/state/jotai";
21
+ import { Deferred } from "@/utils/Deferred";
20
22
  import {
21
23
  type GetDataUrl,
22
24
  type GetRowIds,
@@ -463,7 +465,7 @@ describe("LoadingDataTableComponent", () => {
463
465
  });
464
466
  });
465
467
 
466
- describe("static notebook control suppression", () => {
468
+ describe("table pagination and static notebook control suppression", () => {
467
469
  const fieldTypes: FieldTypesWithExternalType = [
468
470
  ["id", ["integer", "integer"]],
469
471
  ["name", ["string", "string"]],
@@ -538,6 +540,120 @@ describe("static notebook control suppression", () => {
538
540
  mockIsStatic.mockReturnValue(false);
539
541
  });
540
542
 
543
+ it("requests only the first page with the new configured page size", async () => {
544
+ const props = makeProps();
545
+ const view = (pageSize: number) => (
546
+ <Wrapper>
547
+ <LoadingDataTableComponent {...props} pageSize={pageSize} />
548
+ </Wrapper>
549
+ );
550
+ const { rerender } = render(view(PAGE_SIZE));
551
+ await waitFor(() =>
552
+ expect(screen.getByTestId("next-page-button")).toBeEnabled(),
553
+ );
554
+ fireEvent.click(screen.getByTestId("next-page-button"));
555
+ await waitFor(() =>
556
+ expect(props.search).toHaveBeenLastCalledWith(
557
+ expect.objectContaining({ page_number: 1, page_size: PAGE_SIZE }),
558
+ ),
559
+ );
560
+ await waitFor(() =>
561
+ expect(screen.getByTestId("next-page-button")).toBeEnabled(),
562
+ );
563
+ props.search.mockClear();
564
+
565
+ rerender(view(5));
566
+ await waitFor(() => expect(props.search).toHaveBeenCalled());
567
+ expect(
568
+ props.search.mock.calls.every(
569
+ ([request]) => request.page_number === 0 && request.page_size === 5,
570
+ ),
571
+ ).toBe(true);
572
+ });
573
+
574
+ it("preserves a new page size when a pending response changes the row count in the same batch", async () => {
575
+ const props = makeProps();
576
+ const Harness = () => {
577
+ const [pageSize, setPageSize] = useState(PAGE_SIZE);
578
+ return (
579
+ <Wrapper>
580
+ <button type="button" onClick={() => setPageSize(5)}>
581
+ Change configured page size
582
+ </button>
583
+ <LoadingDataTableComponent {...props} pageSize={pageSize} />
584
+ </Wrapper>
585
+ );
586
+ };
587
+ render(<Harness />);
588
+ await waitFor(() =>
589
+ expect(screen.getByTestId("next-page-button")).toBeEnabled(),
590
+ );
591
+ const response = {
592
+ data: firstPage,
593
+ total_rows: 25,
594
+ cell_styles: null,
595
+ cell_hover_texts: null,
596
+ };
597
+ const pendingPage = new Deferred<typeof response>();
598
+ props.search.mockReturnValueOnce(pendingPage.promise);
599
+ fireEvent.click(screen.getByTestId("next-page-button"));
600
+ await waitFor(() =>
601
+ expect(props.search).toHaveBeenLastCalledWith(
602
+ expect.objectContaining({ page_number: 1, page_size: PAGE_SIZE }),
603
+ ),
604
+ );
605
+ props.search.mockClear();
606
+
607
+ await act(async () => {
608
+ pendingPage.resolve(response);
609
+ // Settle the table-loading promises without committing the queued render.
610
+ await new Promise((resolve) => setTimeout(resolve, 0));
611
+ fireEvent.click(
612
+ screen.getByRole("button", { name: "Change configured page size" }),
613
+ );
614
+ });
615
+
616
+ await waitFor(() => expect(props.search).toHaveBeenCalled());
617
+ expect(props.search).toHaveBeenLastCalledWith(
618
+ expect.objectContaining({ page_number: 0, page_size: 5 }),
619
+ );
620
+ });
621
+
622
+ it("returns to the first page when a refreshed result changes the row count", async () => {
623
+ const props = makeProps();
624
+ const view = () => (
625
+ <Wrapper>
626
+ <LoadingDataTableComponent {...props} />
627
+ </Wrapper>
628
+ );
629
+ const { rerender } = render(view());
630
+ await waitFor(() =>
631
+ expect(screen.getByTestId("next-page-button")).toBeEnabled(),
632
+ );
633
+ fireEvent.click(screen.getByTestId("next-page-button"));
634
+ await waitFor(() =>
635
+ expect(props.search).toHaveBeenLastCalledWith(
636
+ expect.objectContaining({ page_number: 1 }),
637
+ ),
638
+ );
639
+ await waitFor(() =>
640
+ expect(screen.getByTestId("next-page-button")).toBeEnabled(),
641
+ );
642
+ props.search = vi.fn().mockResolvedValue({
643
+ data: firstPage,
644
+ total_rows: 25,
645
+ cell_styles: null,
646
+ cell_hover_texts: null,
647
+ });
648
+
649
+ rerender(view());
650
+ await waitFor(() =>
651
+ expect(props.search).toHaveBeenLastCalledWith(
652
+ expect.objectContaining({ page_number: 0, page_size: PAGE_SIZE }),
653
+ ),
654
+ );
655
+ });
656
+
541
657
  it("renders kernel-dependent controls in normal mode", async () => {
542
658
  const props = makeProps();
543
659
  render(
@@ -1,6 +1,7 @@
1
1
  /* Copyright 2026 Marimo. All rights reserved. */
2
2
 
3
3
  import {
4
+ act,
4
5
  fireEvent,
5
6
  render,
6
7
  screen,
@@ -88,6 +89,50 @@ beforeAll(() => {
88
89
  });
89
90
 
90
91
  describe("FileBrowserPlugin session cache recovery", () => {
92
+ it("delays the initial loading overlay and hides it when listing completes", async () => {
93
+ vi.useFakeTimers();
94
+ try {
95
+ let resolveListing: (value: {
96
+ files: MockFile[];
97
+ total_count: number;
98
+ is_truncated: boolean;
99
+ }) => void = () => undefined;
100
+ const listDirectory = vi.fn(
101
+ () =>
102
+ new Promise((resolve) => {
103
+ resolveListing = resolve;
104
+ }),
105
+ );
106
+ const { unmount } = renderBrowser({ list_directory: listDirectory });
107
+
108
+ await act(async () => {
109
+ vi.advanceTimersByTime(199);
110
+ });
111
+ expect(screen.queryByText("Listing files...")).not.toBeInTheDocument();
112
+ await act(async () => {
113
+ vi.advanceTimersByTime(1);
114
+ });
115
+ expect(screen.getByText("Listing files...")).toBeInTheDocument();
116
+
117
+ await act(async () => {
118
+ resolveListing({
119
+ files: FILES,
120
+ total_count: FILES.length,
121
+ is_truncated: false,
122
+ });
123
+ });
124
+ expect(screen.queryByText("Listing files...")).not.toBeInTheDocument();
125
+ expect(screen.getByText("docs")).toBeInTheDocument();
126
+ await act(async () => {
127
+ vi.advanceTimersByTime(200);
128
+ });
129
+ expect(screen.queryByText("Listing files...")).not.toBeInTheDocument();
130
+ unmount();
131
+ } finally {
132
+ vi.useRealTimers();
133
+ }
134
+ });
135
+
91
136
  it("retries a failed cached listing when the plugin resets", async () => {
92
137
  const host = document.createElement("marimo-file-browser");
93
138
  const wrapper = document.createElement("marimo-ui-element");
@@ -212,6 +212,28 @@ describe("SliderPlugin", () => {
212
212
  expect(setValue).toHaveBeenCalledWith(9);
213
213
  });
214
214
 
215
+ it("preserves an uncommitted drag until the controlled value changes", () => {
216
+ const plugin = new SliderPlugin();
217
+ const setValue = vi.fn();
218
+ const props = createProps({
219
+ debounce: true,
220
+ includeInput: false,
221
+ setValue,
222
+ });
223
+ const { getByRole, rerender } = render(plugin.render(props));
224
+
225
+ fireEvent.click(getByRole("button", { name: "Slider change" }));
226
+ rerender(
227
+ plugin.render({ ...props, data: { ...props.data, label: "Updated" } }),
228
+ );
229
+ fireEvent.click(getByRole("button", { name: "Slider commit" }));
230
+ expect(setValue).toHaveBeenLastCalledWith(6);
231
+
232
+ rerender(plugin.render({ ...props, value: 2 }));
233
+ fireEvent.click(getByRole("button", { name: "Slider commit" }));
234
+ expect(setValue).toHaveBeenLastCalledWith(2);
235
+ });
236
+
215
237
  describe("editable input with steps (regression for #9850)", () => {
216
238
  it("displays the actual step value, not the index", () => {
217
239
  const plugin = new SliderPlugin();
@@ -381,9 +403,8 @@ describe("SliderPlugin", () => {
381
403
  vi.advanceTimersByTime(0);
382
404
  });
383
405
 
384
- // Shrink `steps` so the held index (4) is now out of range. The index
385
- // syncs in an effect after this render, so the render must not throw on
386
- // the stale, out-of-range index.
406
+ // Shrink `steps` so the held index (4) is now out of range. Updating
407
+ // the controlled index must render the new valid step without throwing.
387
408
  const shrunkProps = createStepsProps({
388
409
  steps: [10, 20],
389
410
  valueIndex: 1,
@@ -0,0 +1,93 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ import { fireEvent, render, screen } from "@testing-library/react";
4
+ import type { ComponentProps } from "react";
5
+ import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
6
+ import type { PopoverContent } from "@/components/ui/popover";
7
+ import { TooltipProvider } from "@/components/ui/tooltip";
8
+ import { Chatbot } from "../chat-ui";
9
+
10
+ vi.mock("@/components/editor/ai/add-cell-with-ai", () => ({
11
+ PromptInput: ({ value }: { value: string }) => (
12
+ <textarea aria-label="Chat prompt" value={value} readOnly={true} />
13
+ ),
14
+ }));
15
+
16
+ vi.mock("@/components/ui/popover", async (importOriginal) => {
17
+ const actual =
18
+ await importOriginal<typeof import("@/components/ui/popover")>();
19
+ return {
20
+ ...actual,
21
+ // Keep the form mounted when the prompt menu takes focus so this tests
22
+ // the prompt reset rather than the overlay's incidental unmount.
23
+ PopoverContent: (props: ComponentProps<typeof PopoverContent>) => (
24
+ <actual.PopoverContent
25
+ {...props}
26
+ onInteractOutside={(event) => event.preventDefault()}
27
+ />
28
+ ),
29
+ };
30
+ });
31
+
32
+ describe("chat prompt variables", () => {
33
+ const originalScrollTo = HTMLElement.prototype.scrollTo;
34
+ beforeAll(() => {
35
+ HTMLElement.prototype.scrollTo = vi.fn();
36
+ });
37
+ afterAll(() => {
38
+ HTMLElement.prototype.scrollTo = originalScrollTo;
39
+ });
40
+
41
+ it("clears entered values and replaces the fields when selecting another prompt", async () => {
42
+ const firstPrompt = "Tell {{name}} about {{topic}}";
43
+ const secondPrompt = "Send {{name}} to {{destination}}";
44
+ render(
45
+ <TooltipProvider>
46
+ <Chatbot
47
+ prompts={[firstPrompt, secondPrompt]}
48
+ config={{
49
+ max_tokens: null,
50
+ temperature: null,
51
+ top_p: null,
52
+ top_k: null,
53
+ frequency_penalty: null,
54
+ presence_penalty: null,
55
+ }}
56
+ showConfigurationControls={false}
57
+ maxHeight={undefined}
58
+ allowAttachments={false}
59
+ disabled={false}
60
+ value={[]}
61
+ setValue={vi.fn()}
62
+ host={document.createElement("div")}
63
+ get_chat_history={vi.fn().mockResolvedValue({ messages: [] })}
64
+ delete_chat_history={vi.fn().mockResolvedValue(null)}
65
+ delete_chat_message={vi.fn().mockResolvedValue(null)}
66
+ send_prompt={vi.fn().mockResolvedValue(null)}
67
+ cancel_prompt={vi.fn().mockResolvedValue(null)}
68
+ />
69
+ </TooltipProvider>,
70
+ );
71
+
72
+ const promptMenu = screen.getByRole("button", { expanded: false });
73
+ fireEvent.keyDown(promptMenu, { key: "ArrowDown" });
74
+ fireEvent.click(await screen.findByRole("menuitem", { name: firstPrompt }));
75
+ fireEvent.change(await screen.findByLabelText("name"), {
76
+ target: { value: "Ada" },
77
+ });
78
+ fireEvent.change(screen.getByLabelText("topic"), {
79
+ target: { value: "Python" },
80
+ });
81
+ expect(screen.getByRole("button", { name: "Submit" })).toBeEnabled();
82
+
83
+ fireEvent.keyDown(promptMenu, { key: "ArrowDown" });
84
+ fireEvent.click(
85
+ await screen.findByRole("menuitem", { name: secondPrompt }),
86
+ );
87
+
88
+ expect(await screen.findByLabelText("destination")).toHaveValue("");
89
+ expect(screen.getByLabelText("name")).toHaveValue("");
90
+ expect(screen.queryByLabelText("topic")).not.toBeInTheDocument();
91
+ expect(screen.getByRole("button", { name: "Submit" })).toBeDisabled();
92
+ });
93
+ });
@@ -24,7 +24,7 @@ import {
24
24
  Trash2Icon,
25
25
  X,
26
26
  } from "lucide-react";
27
- import React, { useEffect, useRef, useState } from "react";
27
+ import React, { useEffect, useLayoutEffect, useRef, useState } from "react";
28
28
  import { z } from "zod";
29
29
  import {
30
30
  describeChatAbortReason,
@@ -163,7 +163,9 @@ export const Chatbot: React.FC<Props> = (props) => {
163
163
 
164
164
  // Use a ref to avoid stale closure in the fetch callback
165
165
  const configRef = useRef<ChatConfig>(config);
166
- configRef.current = config;
166
+ useLayoutEffect(() => {
167
+ configRef.current = config;
168
+ }, [config]);
167
169
 
168
170
  // For frontend-managed streaming, create a controller to enqueue chunks to.
169
171
  const frontendStreamControllerRef =
@@ -411,9 +413,8 @@ export const Chatbot: React.FC<Props> = (props) => {
411
413
  });
412
414
  }, [messages.length, scrollContainerRef]);
413
415
 
414
- const codemirrorView = codeMirrorInputRef.current?.view;
415
-
416
416
  const resetInput = () => {
417
+ const codemirrorView = codeMirrorInputRef.current?.view;
417
418
  // Clear input immediately by directly manipulating the editor
418
419
  // There is some delay if we use setInput("") only
419
420
  if (codemirrorView) {
@@ -566,6 +567,7 @@ export const Chatbot: React.FC<Props> = (props) => {
566
567
  onSelect={(prompt) => {
567
568
  setInput(prompt);
568
569
  requestAnimationFrame(() => {
570
+ const codemirrorView = codeMirrorInputRef.current?.view;
569
571
  codemirrorView?.focus();
570
572
  moveToEndOfEditor(codemirrorView);
571
573
  });
@@ -862,6 +864,7 @@ const PromptsPopover: React.FC<{
862
864
 
863
865
  <PopoverContent side="right" align="end" className="min-w-80 px-2">
864
866
  <PromptVariablesForm
867
+ key={selectedPrompt}
865
868
  prompt={selectedPrompt}
866
869
  onClose={() => setIsPopoverOpen(false)}
867
870
  onSelect={onSelect}
@@ -876,9 +879,7 @@ const PromptVariablesForm: React.FC<{
876
879
  onClose: () => void;
877
880
  onSelect: (prompt: string) => void;
878
881
  }> = ({ prompt, onClose, onSelect }) => {
879
- const [variables, setVariables] = useState<{ [key: string]: string }>({});
880
-
881
- useEffect(() => {
882
+ const [variables, setVariables] = useState<{ [key: string]: string }>(() => {
882
883
  const variableRegex = /{{(\w+)}}/g;
883
884
  const matches = [...prompt.matchAll(variableRegex)];
884
885
  const initialVariables = matches.reduce<{ [key: string]: string }>(
@@ -888,8 +889,8 @@ const PromptVariablesForm: React.FC<{
888
889
  },
889
890
  {},
890
891
  );
891
- setVariables(initialVariables);
892
- }, [prompt]);
892
+ return initialVariables;
893
+ });
893
894
 
894
895
  const handleVariableChange = (variable: string, value: string) => {
895
896
  setVariables((prev) => ({ ...prev, [variable]: value }));