@marimo-team/frontend 0.19.3-dev8 → 0.19.4-dev0

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 (179) hide show
  1. package/dist/assets/{CellStatus-BwPGnX3z.js → CellStatus--kUu6N2K.js} +1 -1
  2. package/dist/assets/{ConnectedDataExplorerComponent-KlUs_Sz3.js → ConnectedDataExplorerComponent-BKJwCHu7.js} +1 -1
  3. package/dist/assets/{ErrorBoundary-Drf1manw.js → ErrorBoundary-C7JBxSzd.js} +1 -1
  4. package/dist/assets/{ImperativeModal-q6QlC2aZ.js → ImperativeModal-DVhvP4lH.js} +1 -1
  5. package/dist/assets/{JsonOutput--AuyEErr.js → JsonOutput-BSGE-MRo.js} +5 -5
  6. package/dist/assets/{LazyAnyLanguageCodeMirror-jpEDlD0M.js → LazyAnyLanguageCodeMirror-Cp2punaU.js} +2 -2
  7. package/dist/assets/{MarimoErrorOutput-BZjY8e2w.js → MarimoErrorOutput-CX0SCJOZ.js} +2 -2
  8. package/dist/assets/{RenderHTML-BTLaM20B.js → RenderHTML-Do_PVqRy.js} +1 -1
  9. package/dist/assets/VisuallyHidden-B9t3FhTP.js +1 -0
  10. package/dist/assets/{add-cell-with-ai-BWWVs9qV.js → add-cell-with-ai-manh7kBT.js} +21 -21
  11. package/dist/assets/{add-database-form-Bw_YRH1r.js → add-database-form-CgkV0MRs.js} +2 -2
  12. package/dist/assets/agent-panel-D-OmT-rw.js +287 -0
  13. package/dist/assets/{ai-model-dropdown-BrUOgnWS.js → ai-model-dropdown-DzyBY5VA.js} +1 -1
  14. package/dist/assets/{alert-dialog-k5KxevGr.js → alert-dialog-jcHA5geR.js} +1 -1
  15. package/dist/assets/{any-language-editor-DQu1Tt2N.js → any-language-editor-Cm83E7D_.js} +1 -1
  16. package/dist/assets/{app-config-button-B8CXELx0.js → app-config-button-DC3alCuB.js} +1 -1
  17. package/dist/assets/button-B8cGZzP5.js +1 -0
  18. package/dist/assets/{cache-panel-C1So4Zu3.js → cache-panel-1FqnpB9y.js} +1 -1
  19. package/dist/assets/cell-editor-RHFZmO74.js +23 -0
  20. package/dist/assets/cell-link-Dqj_nfXA.js +1 -0
  21. package/dist/assets/{cells-DU3EySUd.js → cells-BNQUQiDS.js} +49 -49
  22. package/dist/assets/{chat-components-Bc9j9ls4.js → chat-components-CWiXtKu6.js} +1 -1
  23. package/dist/assets/{chat-display-BrTi6c8V.js → chat-display-CGnOamQG.js} +1 -1
  24. package/dist/assets/{chat-panel-8Dym5Gv3.js → chat-panel-Dh1M55c9.js} +2 -2
  25. package/dist/assets/client-CDjmJmVw.js +4 -0
  26. package/dist/assets/{column-preview-Ck6B_-sQ.js → column-preview-CKxT2s-S.js} +1 -1
  27. package/dist/assets/{command-B_minI8b.js → command-YPFTinLj.js} +1 -1
  28. package/dist/assets/{command-palette-BT3u6JBB.js → command-palette-7fVEhKGc.js} +1 -1
  29. package/dist/assets/common-DJkPpBxC.js +1 -0
  30. package/dist/assets/config-D6nhy4FA.js +1 -0
  31. package/dist/assets/context-DHfVoQfl.js +1 -0
  32. package/dist/assets/{copy-icon-B69c-352.js → copy-icon-jWsqdLn1.js} +1 -1
  33. package/dist/assets/{datasource-DCvPlnaJ.js → datasource-DerBLc6V.js} +2 -2
  34. package/dist/assets/{dependency-graph-panel-C9jYZ6pA.js → dependency-graph-panel-Vd-OsVLa.js} +4 -4
  35. package/dist/assets/{dialog-DUEuLcT2.js → dialog-CF5DtF1E.js} +1 -1
  36. package/dist/assets/{dist-DOFFh6Ii.js → dist-Dg7UO_Vw.js} +1 -1
  37. package/dist/assets/{documentation-panel-AsatrTfg.js → documentation-panel-xG2-zpwg.js} +1 -1
  38. package/dist/assets/{download-PR1bF3g_.js → download-B6EJS7Ar.js} +1 -1
  39. package/dist/assets/edit-page-7Hkti2j_.js +12 -0
  40. package/dist/assets/{error-banner-DU5Qb8a8.js → error-banner-DvT0IGDZ.js} +1 -1
  41. package/dist/assets/{error-panel-D_wVKV6I.js → error-panel-BxBpZYvt.js} +1 -1
  42. package/dist/assets/{es-CEE_7T0w.js → es-BoHEdemq.js} +1 -1
  43. package/dist/assets/{field-DDKGFzpC.js → field-Clr_fqUr.js} +1 -1
  44. package/dist/assets/{file-explorer-panel-DltK8JVp.js → file-explorer-panel-C9K0vIPl.js} +1 -1
  45. package/dist/assets/{floating-outline-BfdazXWm.js → floating-outline-DCrTuu2G.js} +1 -1
  46. package/dist/assets/{focus-CtlWIiQr.js → focus-DM53w5BH.js} +1 -1
  47. package/dist/assets/{form-Cy5TkLzh.js → form-BcKfhfZc.js} +2 -2
  48. package/dist/assets/{glide-data-editor-D_bRnWfy.js → glide-data-editor-CRb9AiCG.js} +1 -1
  49. package/dist/assets/{globals-BSLm1nlz.js → globals-Bf30kOQF.js} +1 -1
  50. package/dist/assets/{home-page-CEnaUutq.js → home-page-BRyNf7fl.js} +2 -2
  51. package/dist/assets/index-CBMqMxiq.js +43 -0
  52. package/dist/assets/index-DDc_1b-N.css +2 -0
  53. package/dist/assets/input-B80Yt1uu.js +1 -0
  54. package/dist/assets/{kiosk-mode-BnTZR6mM.js → kiosk-mode-P-NYHJID.js} +1 -1
  55. package/dist/assets/{label-qwandMoh.js → label-CNZLffHW.js} +1 -1
  56. package/dist/assets/{layout-BTiWDrbh.js → layout-DT91GUei.js} +4 -4
  57. package/dist/assets/links-D529u6GQ.js +1 -0
  58. package/dist/assets/{logs-panel-Cnp9tO_1.js → logs-panel-C2dfrRig.js} +1 -1
  59. package/dist/assets/{markdown-renderer-BSrbBHwX.js → markdown-renderer-BPnVa0ym.js} +2 -2
  60. package/dist/assets/{mermaid-BPkO79lo.js → mermaid--ZwxKP7u.js} +1 -1
  61. package/dist/assets/mode-Dq8MKjNR.js +1 -0
  62. package/dist/assets/{multi-map-fjX9ImVF.js → multi-map-CQd4MZr5.js} +1 -1
  63. package/dist/assets/name-cell-input-BaEPC7ON.js +1 -0
  64. package/dist/assets/{outline-panel-DCfj1bI-.js → outline-panel-Cca864H0.js} +1 -1
  65. package/dist/assets/{packages-panel-BiEckVdM.js → packages-panel-Cy_KAYmq.js} +1 -1
  66. package/dist/assets/{panels-BXRys72u.js → panels-BzlLZfye.js} +1 -1
  67. package/dist/assets/{process-output-wGlHkL-Q.js → process-output-Dn1rOp26.js} +1 -1
  68. package/dist/assets/{readonly-python-code-xbh7G2Y2.js → readonly-python-code-CXeF74Iq.js} +1 -1
  69. package/dist/assets/{renderShortcut-D0Pei-OA.js → renderShortcut-eU5Hsfml.js} +1 -1
  70. package/dist/assets/{run-page-BCwJRhCq.js → run-page-CM_n6pXD.js} +1 -1
  71. package/dist/assets/scratchpad-panel-XCkVY3Hp.js +1 -0
  72. package/dist/assets/{secrets-panel-CDWmmmBS.js → secrets-panel-BMY6PPth.js} +1 -1
  73. package/dist/assets/{select-D0g5GnIs.js → select-D9lTzMzP.js} +1 -1
  74. package/dist/assets/{session-panel-DuQl_oQp.js → session-panel-BDt6Y_mU.js} +1 -1
  75. package/dist/assets/{slides-component-MkPkpql1.js → slides-component-Dp0Yv5b0.js} +1 -1
  76. package/dist/assets/{snippets-panel-R_ql6HGu.js → snippets-panel-K-JKJQBf.js} +1 -1
  77. package/dist/assets/state-DWRZTH2y.js +1 -0
  78. package/dist/assets/state-JzO-Ni5T.js +1 -0
  79. package/dist/assets/{switch-CWzL-0WF.js → switch-RowEjq0T.js} +1 -1
  80. package/dist/assets/{terminal-BWM0fOMh.js → terminal-BhbNfCNw.js} +1 -1
  81. package/dist/assets/{textarea-CfvBt_Xm.js → textarea-Di1KKcL4.js} +1 -1
  82. package/dist/assets/{tracing-Kscqc1t3.js → tracing-nvbrZdpf.js} +1 -1
  83. package/dist/assets/{tracing-panel-BEzOflWc.js → tracing-panel-CTXJaO-A.js} +2 -2
  84. package/dist/assets/{types-DhuSHMNQ.js → types-CT2U5Ljy.js} +1 -1
  85. package/dist/assets/{useAddCell-C9lbOVO1.js → useAddCell-COb93CUl.js} +1 -1
  86. package/dist/assets/{useBoolean-B-A0dyIW.js → useBoolean-B_S7yTZz.js} +1 -1
  87. package/dist/assets/{useCellActionButton-fsh9MTAX.js → useCellActionButton-D5Zt1dDz.js} +1 -1
  88. package/dist/assets/{useDateFormatter-CV0QXb5P.js → useDateFormatter-DsANziQR.js} +1 -1
  89. package/dist/assets/useDeleteCell-DHF_xvAh.js +1 -0
  90. package/dist/assets/{useDependencyPanelTab-CngFbla0.js → useDependencyPanelTab-D59iW_MD.js} +1 -1
  91. package/dist/assets/useInterval-BGPIviJp.js +1 -0
  92. package/dist/assets/{useNotebookActions-D01w160c.js → useNotebookActions-DEl-rH-3.js} +1 -1
  93. package/dist/assets/{useNumberFormatter-D8ks3oPN.js → useNumberFormatter-FoXhpyAb.js} +1 -1
  94. package/dist/assets/usePress-DTwIUo40.js +7 -0
  95. package/dist/assets/useRunCells-CKEmgeKM.js +1 -0
  96. package/dist/assets/useSplitCell-D9YiO-z5.js +1 -0
  97. package/dist/assets/{useTheme-DfP1CWaW.js → useTheme-CNj0G_ol.js} +1 -1
  98. package/dist/assets/utilities.esm-DG4qccZc.js +3 -0
  99. package/dist/assets/utils-pfqq9IdB.js +1 -0
  100. package/dist/assets/{vega-component-B8ghmMYW.js → vega-component-C1voDf5W.js} +1 -1
  101. package/dist/assets/{write-secret-modal-CLm48gMe.js → write-secret-modal-hOetwavI.js} +1 -1
  102. package/dist/index.html +56 -56
  103. package/package.json +5 -5
  104. package/src/__mocks__/requests.ts +1 -0
  105. package/src/__tests__/mount.test.ts +128 -0
  106. package/src/components/app-config/__tests__/get-dirty-values.test.ts +1 -1
  107. package/src/components/app-config/ai-config.tsx +328 -28
  108. package/src/components/app-config/user-config-form.tsx +10 -3
  109. package/src/components/chat/acp/agent-panel.tsx +56 -43
  110. package/src/components/chat/chat-utils.ts +0 -19
  111. package/src/components/data-table/column-header.tsx +1 -1
  112. package/src/components/editor/KernelStartupErrorModal.tsx +2 -2
  113. package/src/components/editor/actions/name-cell-input.tsx +10 -4
  114. package/src/components/editor/ai/completion-handlers.tsx +1 -1
  115. package/src/components/editor/alerts/connecting-alert.tsx +33 -6
  116. package/src/components/editor/chrome/types.ts +2 -4
  117. package/src/components/editor/chrome/wrapper/app-chrome.tsx +55 -58
  118. package/src/components/editor/chrome/wrapper/footer-items/runtime-settings.tsx +150 -96
  119. package/src/components/editor/renderers/vertical-layout/__tests__/useFocusFirstEditor.test.ts +27 -0
  120. package/src/components/editor/renderers/vertical-layout/useFocusFirstEditor.ts +6 -0
  121. package/src/components/utils/lazy-mount.tsx +29 -8
  122. package/src/core/ai/ids/ids.ts +12 -4
  123. package/src/core/cells/cells.ts +2 -0
  124. package/src/core/cells/scrollCellIntoView.ts +3 -2
  125. package/src/core/codemirror/cm.ts +2 -0
  126. package/src/core/codemirror/lsp/__tests__/notebook-lsp.test.ts +123 -0
  127. package/src/core/codemirror/lsp/notebook-lsp.ts +44 -4
  128. package/src/core/codemirror/misc/__tests__/string-braces.test.ts +200 -0
  129. package/src/core/codemirror/misc/string-braces.ts +37 -0
  130. package/src/core/config/__tests__/config-schema.test.ts +36 -0
  131. package/src/core/config/config-schema.ts +1 -0
  132. package/src/core/export/__tests__/hooks.test.ts +504 -0
  133. package/src/core/export/hooks.ts +93 -4
  134. package/src/core/islands/bridge.ts +1 -0
  135. package/src/core/kernel/__tests__/handlers.test.ts +2 -2
  136. package/src/core/kernel/state.ts +1 -0
  137. package/src/core/network/__tests__/requests-lazy.test.ts +1 -1
  138. package/src/core/network/__tests__/requests-network.test.ts +0 -18
  139. package/src/core/network/requests-lazy.ts +3 -2
  140. package/src/core/network/requests-network.ts +10 -7
  141. package/src/core/network/requests-static.ts +1 -0
  142. package/src/core/network/requests-toasting.tsx +1 -0
  143. package/src/core/network/types.ts +2 -0
  144. package/src/core/wasm/bridge.ts +1 -0
  145. package/src/css/globals.css +2 -0
  146. package/src/hooks/__tests__/useInterval.test.tsx +104 -0
  147. package/src/hooks/useInterval.ts +32 -6
  148. package/src/mount.tsx +6 -0
  149. package/src/plugins/impl/chat/ChatPlugin.tsx +2 -4
  150. package/src/plugins/impl/chat/chat-ui.tsx +62 -191
  151. package/src/plugins/impl/chat/types.ts +5 -12
  152. package/src/plugins/impl/data-frames/DataFramePlugin.tsx +3 -1
  153. package/src/utils/events.ts +1 -0
  154. package/dist/assets/VisuallyHidden-BodIky8L.js +0 -1
  155. package/dist/assets/agent-panel-Bm-vW8YL.js +0 -287
  156. package/dist/assets/button-DuYGqRtX.js +0 -1
  157. package/dist/assets/cell-editor-Cdtc1m3g.js +0 -23
  158. package/dist/assets/cell-link-CFAPzUg5.js +0 -1
  159. package/dist/assets/client-DfkWorYM.js +0 -4
  160. package/dist/assets/common-jorbwXZC.js +0 -1
  161. package/dist/assets/config-Ba3eeYri.js +0 -1
  162. package/dist/assets/context-BAYdLMF_.js +0 -1
  163. package/dist/assets/edit-page-B1Ed6RKp.js +0 -12
  164. package/dist/assets/index-DNg7_e7t.js +0 -43
  165. package/dist/assets/index-__6MNWbe.css +0 -2
  166. package/dist/assets/input-CaEtLL8p.js +0 -1
  167. package/dist/assets/links-Bpd4gqTj.js +0 -1
  168. package/dist/assets/mode-yhfN-4ye.js +0 -1
  169. package/dist/assets/name-cell-input-CmuWqgFR.js +0 -1
  170. package/dist/assets/scratchpad-panel-C6PpCYtK.js +0 -1
  171. package/dist/assets/state-DEHWsmkM.js +0 -1
  172. package/dist/assets/state-DXAf-ejz.js +0 -1
  173. package/dist/assets/useDeleteCell-ByImoTpm.js +0 -1
  174. package/dist/assets/useInterval-DpipYmgs.js +0 -1
  175. package/dist/assets/usePress-C2LPFxyv.js +0 -7
  176. package/dist/assets/useRunCells-CmnSPQtM.js +0 -1
  177. package/dist/assets/useSplitCell-BTH64tve.js +0 -1
  178. package/dist/assets/utilities.esm-CMQs6YPp.js +0 -3
  179. package/dist/assets/utils-CJJIceVn.js +0 -1
@@ -34,7 +34,8 @@ import { Textarea } from "@/components/ui/textarea";
34
34
  import type { SupportedRole } from "@/core/ai/config";
35
35
  import {
36
36
  AiModelId,
37
- PROVIDERS,
37
+ KNOWN_PROVIDERS,
38
+ type KnownProviderId,
38
39
  type ProviderId,
39
40
  type QualifiedModelId,
40
41
  type ShortModelId,
@@ -90,6 +91,11 @@ interface AiProviderTitleProps {
90
91
  children: React.ReactNode;
91
92
  }
92
93
 
94
+ interface CustomProviderConfig {
95
+ api_key?: string;
96
+ base_url?: string;
97
+ }
98
+
93
99
  export const AiProviderTitle: React.FC<AiProviderTitleProps> = ({
94
100
  provider,
95
101
  children,
@@ -555,15 +561,18 @@ const AccordionFormItem = ({
555
561
  provider,
556
562
  children,
557
563
  isConfigured,
564
+ value,
558
565
  }: {
559
566
  title: string;
560
567
  triggerClassName?: string;
561
568
  provider: AiProviderIconProps["provider"];
562
569
  children: React.ReactNode;
563
570
  isConfigured: boolean;
571
+ /** Custom value for the accordion item. Defaults to provider. */
572
+ value?: string;
564
573
  }) => {
565
574
  return (
566
- <AccordionItem value={provider}>
575
+ <AccordionItem value={value ?? provider}>
567
576
  <AccordionTrigger className={triggerClassName}>
568
577
  <AiProviderTitle provider={provider}>
569
578
  {title}
@@ -581,9 +590,228 @@ const AccordionFormItem = ({
581
590
  );
582
591
  };
583
592
 
593
+ export const CustomProvidersConfig: React.FC<AiConfigProps> = ({
594
+ form,
595
+ config,
596
+ onSubmit,
597
+ }) => {
598
+ const [isAddingProvider, setIsAddingProvider] = useState(false);
599
+ const [newProviderName, setNewProviderName] = useState("");
600
+ const [newProviderApiKey, setNewProviderApiKey] = useState("");
601
+ const [newProviderBaseUrl, setNewProviderBaseUrl] = useState("");
602
+
603
+ const providerNameInputId = useId();
604
+ const apiKeyInputId = useId();
605
+ const baseUrlInputId = useId();
606
+
607
+ const normalizedName = newProviderName.toLowerCase().replaceAll(/\s+/g, "_");
608
+ const customProviders = form.watch("ai.custom_providers");
609
+ const isDuplicate =
610
+ KNOWN_PROVIDERS.includes(normalizedName as KnownProviderId) ||
611
+ (customProviders && Object.keys(customProviders).includes(normalizedName));
612
+
613
+ const hasValidValues =
614
+ normalizedName.trim() && newProviderBaseUrl.trim() && !isDuplicate;
615
+
616
+ const resetForm = () => {
617
+ setNewProviderName("");
618
+ setNewProviderApiKey("");
619
+ setNewProviderBaseUrl("");
620
+ setIsAddingProvider(false);
621
+ };
622
+
623
+ return (
624
+ <FormField
625
+ control={form.control}
626
+ name="ai.custom_providers"
627
+ render={({ field }) => {
628
+ const customProviders = (field.value || {}) as Record<
629
+ string,
630
+ CustomProviderConfig
631
+ >;
632
+ const customProviderEntries = Object.entries(customProviders);
633
+
634
+ const addProvider = () => {
635
+ if (!hasValidValues) {
636
+ return;
637
+ }
638
+ field.onChange({
639
+ ...customProviders,
640
+ [normalizedName]: {
641
+ api_key: newProviderApiKey || undefined,
642
+ base_url: newProviderBaseUrl,
643
+ },
644
+ });
645
+ onSubmit(form.getValues());
646
+ resetForm();
647
+ };
648
+
649
+ const removeProvider = (providerName: string) => {
650
+ const { [providerName]: _, ...rest } = customProviders;
651
+ // Reset to clear nested dirty state, then set new value
652
+ form.resetField("ai.custom_providers");
653
+ form.setValue("ai.custom_providers", rest, { shouldDirty: true });
654
+ onSubmit(form.getValues());
655
+ };
656
+
657
+ const providerForm = (
658
+ <div className="flex flex-col gap-3 p-4 border border-border rounded-md bg-muted/20">
659
+ <div className="flex flex-col gap-1.5">
660
+ <Label htmlFor={providerNameInputId}>Provider Name</Label>
661
+ <Input
662
+ id={providerNameInputId}
663
+ placeholder="e.g., together, groq, mistral"
664
+ value={newProviderName}
665
+ onChange={(e) => setNewProviderName(e.target.value)}
666
+ />
667
+ {isDuplicate && (
668
+ <p className="text-xs text-destructive">
669
+ A provider with this name already exists.
670
+ </p>
671
+ )}
672
+ {newProviderName && (
673
+ <p className="text-xs text-muted-secondary">
674
+ Use models with prefix:{" "}
675
+ <Kbd className="inline text-xs">{normalizedName}/</Kbd>
676
+ </p>
677
+ )}
678
+ </div>
679
+
680
+ <div className="flex flex-col gap-1.5">
681
+ <Label htmlFor={baseUrlInputId}>
682
+ Base URL <span className="text-destructive">*</span>
683
+ </Label>
684
+ <Input
685
+ id={baseUrlInputId}
686
+ placeholder="e.g., https://api.together.xyz/v1"
687
+ value={newProviderBaseUrl}
688
+ onChange={(e) => setNewProviderBaseUrl(e.target.value)}
689
+ />
690
+ </div>
691
+
692
+ <div className="flex flex-col gap-1.5">
693
+ <Label htmlFor={apiKeyInputId}>API Key (optional)</Label>
694
+ <Input
695
+ id={apiKeyInputId}
696
+ placeholder="sk-..."
697
+ type="password"
698
+ value={newProviderApiKey}
699
+ onChange={(e) => setNewProviderApiKey(e.target.value)}
700
+ />
701
+ </div>
702
+
703
+ <div className="flex gap-2 mt-1">
704
+ <Button
705
+ onClick={addProvider}
706
+ disabled={!hasValidValues}
707
+ size="xs"
708
+ >
709
+ Add Provider
710
+ </Button>
711
+ <Button variant="outline" onClick={resetForm} size="xs">
712
+ Cancel
713
+ </Button>
714
+ </div>
715
+ </div>
716
+ );
717
+
718
+ const renderAccordionItem = ({
719
+ providerName,
720
+ providerConfig,
721
+ onRemove,
722
+ }: {
723
+ providerName: string;
724
+ providerConfig: CustomProviderConfig;
725
+ onRemove: (name: string) => void;
726
+ }) => {
727
+ const displayName = Strings.startCase(providerName);
728
+ const isConfigured =
729
+ !!providerConfig.api_key || !!providerConfig.base_url;
730
+
731
+ return (
732
+ <AccordionFormItem
733
+ key={`custom-${providerName}`}
734
+ title={displayName}
735
+ provider={providerName}
736
+ value={`custom-${providerName}`}
737
+ isConfigured={isConfigured}
738
+ >
739
+ <ApiKey
740
+ form={form}
741
+ config={config}
742
+ name={
743
+ `ai.custom_providers.${providerName}.api_key` as FieldPath<UserConfig>
744
+ }
745
+ placeholder="sk-..."
746
+ testId={`custom-provider-${providerName}-api-key`}
747
+ />
748
+ <BaseUrl
749
+ form={form}
750
+ config={config}
751
+ name={
752
+ `ai.custom_providers.${providerName}.base_url` as FieldPath<UserConfig>
753
+ }
754
+ placeholder="https://api.example.com/v1"
755
+ testId={`custom-provider-${providerName}-base-url`}
756
+ />
757
+ <Button
758
+ variant="destructive"
759
+ size="xs"
760
+ onClick={(e) => {
761
+ e.stopPropagation();
762
+ e.preventDefault();
763
+ onRemove(providerName);
764
+ }}
765
+ className="w-fit self-end"
766
+ >
767
+ <Trash2Icon className="h-4 w-4 mr-2" />
768
+ Remove Provider
769
+ </Button>
770
+ </AccordionFormItem>
771
+ );
772
+ };
773
+
774
+ return (
775
+ <SettingGroup>
776
+ <SettingSubtitle>Custom Providers</SettingSubtitle>
777
+ <p className="text-sm text-muted-secondary">
778
+ Add your own OpenAI-compatible provider. Once added, you can
779
+ configure models in the AI Models tab.
780
+ </p>
781
+
782
+ {customProviderEntries.length > 0 && (
783
+ <Accordion type="multiple" className="-mt-4">
784
+ {customProviderEntries.map(([name, providerConfig]) =>
785
+ renderAccordionItem({
786
+ providerName: name,
787
+ providerConfig,
788
+ onRemove: removeProvider,
789
+ }),
790
+ )}
791
+ </Accordion>
792
+ )}
793
+
794
+ {isAddingProvider ? (
795
+ providerForm
796
+ ) : (
797
+ <AddButton
798
+ className="self-start"
799
+ isFormOpen={isAddingProvider}
800
+ setIsFormOpen={setIsAddingProvider}
801
+ label="Add Provider"
802
+ />
803
+ )}
804
+ </SettingGroup>
805
+ );
806
+ }}
807
+ />
808
+ );
809
+ };
810
+
584
811
  export const AiProvidersConfig: React.FC<AiConfigProps> = ({
585
812
  form,
586
813
  config,
814
+ onSubmit,
587
815
  }) => {
588
816
  const isWasmRuntime = isWasm();
589
817
 
@@ -903,13 +1131,17 @@ export const AiProvidersConfig: React.FC<AiConfigProps> = ({
903
1131
  </AccordionFormItem>
904
1132
 
905
1133
  <AccordionFormItem
906
- title="OpenAI-Compatible"
1134
+ title="OpenAI-Compatible (Legacy)"
907
1135
  provider="openai-compatible"
908
1136
  isConfigured={
909
1137
  hasValue("ai.open_ai_compatible.api_key") &&
910
1138
  hasValue("ai.open_ai_compatible.base_url")
911
1139
  }
912
1140
  >
1141
+ <p className="text-sm text-amber-600 dark:text-amber-400 mb-2">
1142
+ Consider using Custom Providers instead, which allows you to add
1143
+ multiple providers with distinct names.
1144
+ </p>
913
1145
  <ApiKey
914
1146
  form={form}
915
1147
  config={config}
@@ -933,6 +1165,8 @@ export const AiProvidersConfig: React.FC<AiConfigProps> = ({
933
1165
  />
934
1166
  </AccordionFormItem>
935
1167
  </Accordion>
1168
+
1169
+ <CustomProvidersConfig form={form} config={config} onSubmit={onSubmit} />
936
1170
  </SettingGroup>
937
1171
  );
938
1172
  };
@@ -1133,6 +1367,16 @@ export const AiModelDisplayConfig: React.FC<AiConfigProps> = ({
1133
1367
  name: "ai.models.custom_models",
1134
1368
  }) as QualifiedModelId[];
1135
1369
 
1370
+ const customProviders = useWatch({
1371
+ control: form.control,
1372
+ name: "ai.custom_providers",
1373
+ }) as Record<string, CustomProviderConfig> | undefined;
1374
+
1375
+ const customProviderNames = useMemo(
1376
+ () => Object.keys(customProviders || {}),
1377
+ [customProviders],
1378
+ );
1379
+
1136
1380
  const aiModelRegistry = useMemo(
1137
1381
  () =>
1138
1382
  AiModelRegistry.create({
@@ -1155,7 +1399,9 @@ export const AiModelDisplayConfig: React.FC<AiConfigProps> = ({
1155
1399
  ? currentDisplayedModels.filter((id) => id !== modelId)
1156
1400
  : [...currentDisplayedModels, modelId];
1157
1401
 
1158
- form.setValue("ai.models.displayed_models", newModels);
1402
+ form.setValue("ai.models.displayed_models", newModels, {
1403
+ shouldDirty: true,
1404
+ });
1159
1405
  onSubmit(form.getValues());
1160
1406
  });
1161
1407
 
@@ -1172,14 +1418,18 @@ export const AiModelDisplayConfig: React.FC<AiConfigProps> = ({
1172
1418
  ? [...new Set([...currentDisplayedModels, ...qualifiedModelIds])]
1173
1419
  : currentDisplayedModels.filter((id) => !qualifiedModelIds.has(id));
1174
1420
 
1175
- form.setValue("ai.models.displayed_models", newModels);
1421
+ form.setValue("ai.models.displayed_models", newModels, {
1422
+ shouldDirty: true,
1423
+ });
1176
1424
  onSubmit(form.getValues());
1177
1425
  },
1178
1426
  );
1179
1427
 
1180
1428
  const deleteModel = useEvent((modelId: QualifiedModelId) => {
1181
1429
  const newModels = customModels.filter((id) => id !== modelId);
1182
- form.setValue("ai.models.custom_models", newModels);
1430
+ form.setValue("ai.models.custom_models", newModels, {
1431
+ shouldDirty: true,
1432
+ });
1183
1433
  onSubmit(form.getValues());
1184
1434
  });
1185
1435
 
@@ -1212,6 +1462,7 @@ export const AiModelDisplayConfig: React.FC<AiConfigProps> = ({
1212
1462
  <AddModelForm
1213
1463
  form={form}
1214
1464
  customModels={customModels}
1465
+ customProviderNames={customProviderNames}
1215
1466
  onSubmit={onSubmit}
1216
1467
  />
1217
1468
  </SettingGroup>
@@ -1221,8 +1472,9 @@ export const AiModelDisplayConfig: React.FC<AiConfigProps> = ({
1221
1472
  export const AddModelForm: React.FC<{
1222
1473
  form: UseFormReturn<UserConfig>;
1223
1474
  customModels: QualifiedModelId[];
1475
+ customProviderNames: string[];
1224
1476
  onSubmit: (values: UserConfig) => void;
1225
- }> = ({ form, customModels, onSubmit }) => {
1477
+ }> = ({ form, customModels, customProviderNames, onSubmit }) => {
1226
1478
  const [isFormOpen, setIsFormOpen] = useState(false);
1227
1479
  const [modelAdded, setModelAdded] = useState(false);
1228
1480
  const [provider, setProvider] = useState<ProviderId | "custom" | null>(null);
@@ -1254,7 +1506,9 @@ export const AddModelForm: React.FC<{
1254
1506
  modelName as ShortModelId,
1255
1507
  );
1256
1508
 
1257
- form.setValue("ai.models.custom_models", [newModel.id, ...customModels]);
1509
+ form.setValue("ai.models.custom_models", [newModel.id, ...customModels], {
1510
+ shouldDirty: true,
1511
+ });
1258
1512
  onSubmit(form.getValues());
1259
1513
  resetForm();
1260
1514
 
@@ -1293,23 +1547,46 @@ export const AddModelForm: React.FC<{
1293
1547
  </SelectTrigger>
1294
1548
  <SelectContent>
1295
1549
  <SelectGroup>
1550
+ {customProviderNames.length > 0 && (
1551
+ <>
1552
+ <p className="px-2 py-1 text-xs text-muted-secondary font-medium">
1553
+ Custom Providers
1554
+ </p>
1555
+ {customProviderNames.map((p) => (
1556
+ <SelectItem key={p} value={p}>
1557
+ <div className="flex items-center gap-2">
1558
+ <AiProviderIcon provider={p} className="h-4 w-4" />
1559
+ <span>{Strings.startCase(p)}</span>
1560
+ </div>
1561
+ </SelectItem>
1562
+ ))}
1563
+ <p className="px-2 py-1 text-xs text-muted-secondary font-medium mt-1">
1564
+ Built-in Providers
1565
+ </p>
1566
+ </>
1567
+ )}
1568
+ {KNOWN_PROVIDERS.filter(
1569
+ (p) => p !== "marimo" && !customProviderNames.includes(p),
1570
+ ).map((p) => (
1571
+ <SelectItem key={p} value={p}>
1572
+ <div className="flex items-center gap-2">
1573
+ <AiProviderIcon provider={p} className="h-4 w-4" />
1574
+ <span>{getProviderLabel(p)}</span>
1575
+ </div>
1576
+ </SelectItem>
1577
+ ))}
1578
+ <p className="px-2 py-1 text-xs text-muted-secondary font-medium mt-1">
1579
+ Other
1580
+ </p>
1296
1581
  <SelectItem value="custom">
1297
1582
  <div className="flex items-center gap-2">
1298
1583
  <AiProviderIcon
1299
1584
  provider="openai-compatible"
1300
1585
  className="h-4 w-4"
1301
1586
  />
1302
- <span>Custom</span>
1587
+ <span>Enter provider name</span>
1303
1588
  </div>
1304
1589
  </SelectItem>
1305
- {PROVIDERS.filter((p) => p !== "marimo").map((p) => (
1306
- <SelectItem key={p} value={p}>
1307
- <div className="flex items-center gap-2">
1308
- <AiProviderIcon provider={p} className="h-4 w-4" />
1309
- <span>{getProviderLabel(p)}</span>
1310
- </div>
1311
- </SelectItem>
1312
- ))}
1313
1590
  </SelectGroup>
1314
1591
  </SelectContent>
1315
1592
  </Select>
@@ -1379,17 +1656,12 @@ export const AddModelForm: React.FC<{
1379
1656
  <div>
1380
1657
  {isFormOpen && inputForm}
1381
1658
  <div className="flex flex-row text-sm">
1382
- <Button
1383
- onClick={(e) => {
1384
- e.preventDefault();
1385
- setIsFormOpen(true);
1386
- }}
1387
- variant="link"
1388
- disabled={isFormOpen}
1389
- >
1390
- <PlusIcon className="h-4 w-4 mr-2 mb-0.5" />
1391
- Add Model
1392
- </Button>
1659
+ <AddButton
1660
+ isFormOpen={isFormOpen}
1661
+ setIsFormOpen={setIsFormOpen}
1662
+ label="Add Model"
1663
+ className="pl-2"
1664
+ />
1393
1665
  {modelAdded && (
1394
1666
  <div className="flex items-center gap-1 text-green-700 bg-green-500/10 px-2 py-1 rounded-md ml-auto">
1395
1667
  ✓ Model added
@@ -1400,6 +1672,34 @@ export const AddModelForm: React.FC<{
1400
1672
  );
1401
1673
  };
1402
1674
 
1675
+ const AddButton = ({
1676
+ isFormOpen,
1677
+ setIsFormOpen,
1678
+ label,
1679
+ className,
1680
+ }: {
1681
+ isFormOpen: boolean;
1682
+ setIsFormOpen: (isOpen: boolean) => void;
1683
+ label: string;
1684
+ className?: string;
1685
+ }) => {
1686
+ return (
1687
+ <Button
1688
+ onClick={(e) => {
1689
+ e.stopPropagation();
1690
+ e.preventDefault();
1691
+ setIsFormOpen(true);
1692
+ }}
1693
+ variant="link"
1694
+ disabled={isFormOpen}
1695
+ className={cn("px-0", className)}
1696
+ >
1697
+ <PlusIcon className="h-4 w-4 mr-2 mb-0.5" />
1698
+ {label}
1699
+ </Button>
1700
+ );
1701
+ };
1702
+
1403
1703
  export const AiConfig: React.FC<AiConfigProps> = ({
1404
1704
  form,
1405
1705
  config,
@@ -69,14 +69,21 @@ export function getDirtyValues<T extends FieldValues>(
69
69
  dirtyFields: Partial<Record<keyof T, unknown>>,
70
70
  ): Partial<T> {
71
71
  const result: Partial<T> = {};
72
- for (const key of Object.keys(dirtyFields) as Array<keyof T>) {
72
+ for (const key of Object.keys(dirtyFields) as (keyof T)[]) {
73
73
  const dirty = dirtyFields[key];
74
+ const value = values[key];
75
+
76
+ // Skip if the value no longer exists (e.g., deleted from a record)
77
+ if (value === undefined) {
78
+ continue;
79
+ }
80
+
74
81
  if (dirty === true) {
75
- result[key] = values[key];
82
+ result[key] = value;
76
83
  } else if (typeof dirty === "object" && dirty !== null) {
77
84
  // Nested object - recurse
78
85
  const nested = getDirtyValues(
79
- values[key] as FieldValues,
86
+ value as FieldValues,
80
87
  dirty as Partial<Record<string, unknown>>,
81
88
  );
82
89
  if (Object.keys(nested).length > 0) {
@@ -677,7 +677,7 @@ const AgentPanel: React.FC = () => {
677
677
  ? getAgentWebSocketUrl(selectedTab.agentId)
678
678
  : NO_WS_SET;
679
679
  const { sendUpdateFile, sendFileDetails } = useRequestClient();
680
- const isCreatingNewSession = useRef(false);
680
+ const creatingOrResumingSession = useRef(false);
681
681
 
682
682
  const acpClient = useAcpClient({
683
683
  wsUrl,
@@ -716,6 +716,7 @@ const AgentPanel: React.FC = () => {
716
716
  sessionMode,
717
717
  activeSessionId,
718
718
  agent,
719
+ clearNotifications,
719
720
  } = acpClient;
720
721
 
721
722
  useEffect(() => {
@@ -757,40 +758,42 @@ const AgentPanel: React.FC = () => {
757
758
  return;
758
759
  }
759
760
 
760
- // If there is an active session, we should stop it
761
- if (activeSessionId) {
762
- setActiveSessionId(null);
763
- await agent.cancel({ sessionId: activeSessionId }).catch((error) => {
764
- logger.error("Failed to cancel active session", { error });
765
- });
766
- }
761
+ creatingOrResumingSession.current = true;
762
+
763
+ try {
764
+ // If there is an active session, we should stop it
765
+ if (activeSessionId) {
766
+ await agent.cancel({ sessionId: activeSessionId }).catch((error) => {
767
+ logger.error("Failed to cancel active session", { error });
768
+ });
769
+ clearNotifications(activeSessionId);
770
+ setActiveSessionId(null);
771
+ }
767
772
 
768
- // Get the selected model from the current session state
769
- const currentModel = selectedTab?.selectedModel ?? null;
770
- logger.debug("Creating new agent session", { model: currentModel });
771
- isCreatingNewSession.current = true;
772
- const newSession = await agent
773
- .newSession({
773
+ // Get the selected model from the current session state
774
+ const currentModel = selectedTab?.selectedModel ?? null;
775
+ logger.debug("Creating new agent session", { model: currentModel });
776
+ const newSession = await agent.newSession({
774
777
  cwd: getCwd(),
775
778
  mcpServers: [],
776
779
  _meta: currentModel ? { model: currentModel } : undefined,
777
- })
778
- .finally(() => {
779
- isCreatingNewSession.current = false;
780
780
  });
781
781
 
782
- // Capture models from the response
783
- if (newSession.models) {
784
- logger.debug("Session models received", { models: newSession.models });
785
- setSessionModels(newSession.models);
786
- }
782
+ // Capture models from the response
783
+ if (newSession.models) {
784
+ logger.debug("Session models received", { models: newSession.models });
785
+ setSessionModels(newSession.models);
786
+ }
787
787
 
788
- setSessionState((prev) =>
789
- updateSessionExternalAgentSessionId(
790
- prev,
791
- newSession.sessionId as ExternalAgentSessionId,
792
- ),
793
- );
788
+ setSessionState((prev) =>
789
+ updateSessionExternalAgentSessionId(
790
+ prev,
791
+ newSession.sessionId as ExternalAgentSessionId,
792
+ ),
793
+ );
794
+ } finally {
795
+ creatingOrResumingSession.current = false;
796
+ }
794
797
  });
795
798
 
796
799
  const handleResumeSession = useEvent(
@@ -804,23 +807,28 @@ const AgentPanel: React.FC = () => {
804
807
  if (!agent.loadSession) {
805
808
  throw new Error("Agent does not support loading sessions");
806
809
  }
807
- const loadedSession = await agent.loadSession({
808
- sessionId: previousSessionId,
809
- cwd: getCwd(),
810
- mcpServers: [],
811
- });
812
-
813
- // Capture models from the response if available
814
- if (loadedSession?.models) {
815
- logger.debug("Session models received", {
816
- models: loadedSession.models,
810
+ creatingOrResumingSession.current = true;
811
+ try {
812
+ const loadedSession = await agent.loadSession({
813
+ sessionId: previousSessionId,
814
+ cwd: getCwd(),
815
+ mcpServers: [],
817
816
  });
818
- setSessionModels(loadedSession.models);
819
- }
820
817
 
821
- setSessionState((prev) =>
822
- updateSessionExternalAgentSessionId(prev, previousSessionId),
823
- );
818
+ // Capture models from the response if available
819
+ if (loadedSession?.models) {
820
+ logger.debug("Session models received", {
821
+ models: loadedSession.models,
822
+ });
823
+ setSessionModels(loadedSession.models);
824
+ }
825
+
826
+ setSessionState((prev) =>
827
+ updateSessionExternalAgentSessionId(prev, previousSessionId),
828
+ );
829
+ } finally {
830
+ creatingOrResumingSession.current = false;
831
+ }
824
832
  },
825
833
  );
826
834
 
@@ -838,6 +846,11 @@ const AgentPanel: React.FC = () => {
838
846
  return;
839
847
  }
840
848
 
849
+ // Prevent race conditions
850
+ if (creatingOrResumingSession.current) {
851
+ return;
852
+ }
853
+
841
854
  // If there is an available session, resume it, otherwise create a new one
842
855
  const createOrResumeSession = async () => {
843
856
  const availableSession = tabLastActiveSessionId ?? activeSessionId;
@@ -8,7 +8,6 @@ import type {
8
8
  InvokeAiToolRequest,
9
9
  InvokeAiToolResponse,
10
10
  } from "@/core/network/types";
11
- import type { ChatMessage } from "@/plugins/impl/chat/types";
12
11
  import { blobToString } from "@/utils/fileToBase64";
13
12
  import { Logger } from "@/utils/Logger";
14
13
  import { getAICompletionBodyWithAttachments } from "../editor/ai/completion-utils";
@@ -68,7 +67,6 @@ function stringifyTextParts(parts: UIMessage["parts"]): string {
68
67
  export async function buildCompletionRequestBody(
69
68
  messages: UIMessage[],
70
69
  ): Promise<{
71
- messages: ChatMessage[]; // Deprecated. TODO: Remove in the future
72
70
  uiMessages: UIMessage[];
73
71
  context?: (null | components["schemas"]["AiCompletionContext"]) | undefined;
74
72
  includeOtherCode: string;
@@ -91,25 +89,8 @@ export async function buildCompletionRequestBody(
91
89
  };
92
90
  }
93
91
 
94
- function toChatMessage(message: UIMessage, isLast: boolean): ChatMessage {
95
- // Clone parts to avoid mutating the original message
96
- const parts = [...message.parts];
97
- if (isLast) {
98
- parts.push(...completionBody.attachments);
99
- }
100
- return {
101
- id: message.id,
102
- role: message.role,
103
- content: stringifyTextParts(message.parts), // This is no longer used in the backend
104
- parts,
105
- };
106
- }
107
-
108
92
  return {
109
93
  ...completionBody.body,
110
- messages: messages.map((m, idx) =>
111
- toChatMessage(m, idx === messages.length - 1),
112
- ),
113
94
  uiMessages: messages.map((m, idx) =>
114
95
  addAttachmentsToMessage(m, idx === messages.length - 1),
115
96
  ),