diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/BaseUrlSelect.js b/src/app/(dashboard)/dashboard/cli-tools/components/BaseUrlSelect.js
index 58482a9b..52ca8b09 100644
--- a/src/app/(dashboard)/dashboard/cli-tools/components/BaseUrlSelect.js
+++ b/src/app/(dashboard)/dashboard/cli-tools/components/BaseUrlSelect.js
@@ -2,8 +2,8 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { UPDATER_CONFIG } from "@/shared/constants/config";
+import { readPresets, upsertPreset, deletePreset, subscribePresets, stripSlash } from "./cliEndpointPresets";
-const STORAGE_KEY = "9router.cliToolEndpointPresets";
const CUSTOM_VALUE = "__custom__";
const SAVE_VALUE = "__save__";
@@ -13,22 +13,6 @@ const ensureV1 = (url) => {
return /\/v1$/.test(trimmed) ? trimmed : `${trimmed}/v1`;
};
-const readSavedPresets = () => {
- if (typeof window === "undefined") return [];
- try {
- const raw = JSON.parse(window.localStorage.getItem(STORAGE_KEY) || "[]");
- if (!Array.isArray(raw)) return [];
- return raw.filter((p) => p?.name && p?.baseUrl);
- } catch {
- return [];
- }
-};
-
-const writeSavedPresets = (presets) => {
- if (typeof window === "undefined") return;
- window.localStorage.setItem(STORAGE_KEY, JSON.stringify(presets));
-};
-
const buildOptions = ({ requiresExternalUrl, tunnelEnabled, tunnelPublicUrl, tailscaleEnabled, tailscaleUrl, cloudEnabled, cloudUrl, savedPresets, withV1 }) => {
const opts = [];
const wrap = (url) => (withV1 ? ensureV1(url) : (url || "").replace(/\/+$/, ""));
@@ -66,14 +50,34 @@ export default function BaseUrlSelect({
cloudEnabled = false,
cloudUrl = "",
withV1 = true,
+ currentUrl = "",
}) {
const [savedPresets, setSavedPresets] = useState([]);
+ const [presetsLoaded, setPresetsLoaded] = useState(false);
const [mode, setMode] = useState("");
const [customInput, setCustomInput] = useState("");
const initializedRef = useRef(false);
+ const customInputRef = useRef("");
useEffect(() => {
- setSavedPresets(readSavedPresets());
+ const sync = () => {
+ const presets = readPresets();
+ setSavedPresets(presets);
+ // A preset saved elsewhere (e.g. on Apply) takes over the custom slot
+ setMode((prev) => {
+ if (prev !== CUSTOM_VALUE) return prev;
+ const typed = stripSlash(customInputRef.current);
+ if (!typed) return prev;
+ const match = presets.find((p) => {
+ const saved = stripSlash(p.baseUrl);
+ return saved === typed || saved === ensureV1(typed);
+ });
+ return match ? `saved:${match.name}` : prev;
+ });
+ };
+ sync();
+ setPresetsLoaded(true);
+ return subscribePresets(sync);
}, []);
const options = useMemo(
@@ -81,19 +85,23 @@ export default function BaseUrlSelect({
[requiresExternalUrl, tunnelEnabled, tunnelPublicUrl, tailscaleEnabled, tailscaleUrl, cloudEnabled, cloudUrl, savedPresets, withV1]
);
- // Always default to first option (127.0.0.1) on mount, ignore persisted value
+ // Prefer a saved preset matching the currently configured URL, else first option
useEffect(() => {
if (initializedRef.current) return;
- if (options.length === 0) return;
+ if (!presetsLoaded || options.length === 0) return;
initializedRef.current = true;
- const first = options.find((o) => o.value !== CUSTOM_VALUE);
- if (first) {
- setMode(first.value);
- onChange(first.url);
+ const current = stripSlash(currentUrl);
+ const matched = current
+ ? options.find((o) => o.saved && stripSlash(o.url) === current)
+ : null;
+ const target = matched || options.find((o) => o.value !== CUSTOM_VALUE);
+ if (target) {
+ setMode(target.value);
+ onChange(target.url);
} else {
setMode(CUSTOM_VALUE);
}
- }, [options, onChange]);
+ }, [presetsLoaded, options, onChange, currentUrl]);
const handleSelect = (e) => {
const next = e.target.value;
@@ -103,11 +111,8 @@ export default function BaseUrlSelect({
let defaultName = trimmed;
try { defaultName = new URL(trimmed).host; } catch {}
const name = window.prompt("Save endpoint as:", defaultName);
- if (!name?.trim()) return;
- const updated = [...savedPresets.filter((p) => p.name !== name.trim()), { name: name.trim(), baseUrl: trimmed }]
- .sort((a, b) => a.name.localeCompare(b.name));
- setSavedPresets(updated);
- writeSavedPresets(updated);
+ const saved = name?.trim() ? upsertPreset(trimmed, name.trim()) : null;
+ if (saved) setMode(`saved:${saved}`);
return;
}
setMode(next);
@@ -122,19 +127,23 @@ export default function BaseUrlSelect({
const handleCustomInput = (e) => {
const v = e.target.value;
+ customInputRef.current = v;
setCustomInput(v);
onChange(v);
};
const handleDeleteSaved = () => {
if (!mode.startsWith("saved:")) return;
- const name = mode.slice(6);
- const updated = savedPresets.filter((p) => p.name !== name);
- setSavedPresets(updated);
- writeSavedPresets(updated);
- setMode(CUSTOM_VALUE);
+ deletePreset(mode.slice(6));
setCustomInput("");
- onChange("");
+ const fallback = options.find((o) => o.value !== CUSTOM_VALUE && o.value !== mode);
+ if (fallback) {
+ setMode(fallback.value);
+ onChange(fallback.url);
+ } else {
+ setMode(CUSTOM_VALUE);
+ onChange("");
+ }
};
const isSaved = mode.startsWith("saved:");
diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/ClaudeToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/ClaudeToolCard.js
index 7ee5ad2e..912114d6 100644
--- a/src/app/(dashboard)/dashboard/cli-tools/components/ClaudeToolCard.js
+++ b/src/app/(dashboard)/dashboard/cli-tools/components/ClaudeToolCard.js
@@ -4,6 +4,7 @@ import { useState, useEffect, useRef } from "react";
import { Card, Button, ModelSelectModal, ManualConfigModal, Tooltip } from "@/shared/components";
import Image from "next/image";
import BaseUrlSelect from "./BaseUrlSelect";
+import { rememberEndpoint } from "./cliEndpointPresets";
import ApiKeySelect from "./ApiKeySelect";
import { matchKnownEndpoint } from "./cliEndpointMatch";
@@ -53,6 +54,8 @@ export default function ClaudeToolCard({
const [maxContextTokens, setMaxContextTokens] = useState("");
const hasInitializedModels = useRef(false);
+ const currentBaseUrl = claudeStatus?.settings?.env?.ANTHROPIC_BASE_URL || "";
+
const getConfigStatus = () => {
if (!claudeStatus?.installed) return null;
const currentUrl = claudeStatus.settings?.env?.ANTHROPIC_BASE_URL;
@@ -189,6 +192,8 @@ export default function ClaudeToolCard({
});
const data = await res.json();
if (res.ok) {
+ // Remember the endpoint so it stays selectable next time
+ rememberEndpoint(getEffectiveBaseUrl(), { tunnelPublicUrl, tailscaleUrl });
setMessage({ type: "success", text: "Settings applied successfully!" });
setClaudeStatus(prev => ({ ...prev, hasBackup: true, settings: { ...prev?.settings, env }, exaMcpEnabled }));
} else {
@@ -334,6 +339,7 @@ export default function ClaudeToolCard({
tunnelPublicUrl={tunnelPublicUrl}
tailscaleEnabled={tailscaleEnabled}
tailscaleUrl={tailscaleUrl}
+ currentUrl={currentBaseUrl}
/>
diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/ClineToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/ClineToolCard.js
index a88f79e6..b1deb02f 100644
--- a/src/app/(dashboard)/dashboard/cli-tools/components/ClineToolCard.js
+++ b/src/app/(dashboard)/dashboard/cli-tools/components/ClineToolCard.js
@@ -4,6 +4,7 @@ import { useState, useEffect } from "react";
import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components";
import Image from "next/image";
import BaseUrlSelect from "./BaseUrlSelect";
+import { rememberEndpoint } from "./cliEndpointPresets";
import ApiKeySelect from "./ApiKeySelect";
import { matchKnownEndpoint } from "./cliEndpointMatch";
@@ -50,6 +51,8 @@ export default function ClineToolCard({ tool, isExpanded, onToggle, baseUrl, api
}
};
+ const currentBaseUrl = status?.settings?.openAiBaseUrl || "";
+
const getConfigStatus = () => {
if (!status?.installed) return null;
if (!status.has9Router) return "not_configured";
@@ -94,6 +97,8 @@ export default function ClineToolCard({ tool, isExpanded, onToggle, baseUrl, api
});
const data = await res.json();
if (res.ok) {
+ // Remember the endpoint so it stays selectable next time
+ rememberEndpoint(getEffectiveBaseUrl(), { tunnelPublicUrl, tailscaleUrl });
setMessage({ type: "success", text: "Settings applied successfully!" });
checkStatus();
} else {
@@ -226,6 +231,7 @@ export default function ClineToolCard({ tool, isExpanded, onToggle, baseUrl, api
tunnelPublicUrl={tunnelPublicUrl}
tailscaleEnabled={tailscaleEnabled}
tailscaleUrl={tailscaleUrl}
+ currentUrl={currentBaseUrl}
/>
diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/CodexToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/CodexToolCard.js
index f5aa88d7..6fed52de 100644
--- a/src/app/(dashboard)/dashboard/cli-tools/components/CodexToolCard.js
+++ b/src/app/(dashboard)/dashboard/cli-tools/components/CodexToolCard.js
@@ -6,6 +6,7 @@ import Image from "next/image";
import BaseUrlSelect from "./BaseUrlSelect";
import ApiKeySelect from "./ApiKeySelect";
import { matchKnownEndpoint } from "./cliEndpointMatch";
+import { rememberEndpoint } from "./cliEndpointPresets";
export default function CodexToolCard({ tool, isExpanded, onToggle, baseUrl, apiKeys, activeProviders, cloudEnabled, initialStatus, tunnelEnabled, tunnelPublicUrl, tailscaleEnabled, tailscaleUrl }) {
const [codexStatus, setCodexStatus] = useState(initialStatus || null);
@@ -62,12 +63,17 @@ export default function CodexToolCard({ tool, isExpanded, onToggle, baseUrl, api
}
}, [codexStatus]);
+ const getCurrentBaseUrl = () => {
+ const parsed = codexStatus?.config?.match(/base_url\s*=\s*"([^"]+)"/);
+ return parsed ? parsed[1] : "";
+ };
+
+ const currentBaseUrl = getCurrentBaseUrl();
+
const getConfigStatus = () => {
if (!codexStatus?.installed) return null;
if (!codexStatus.config) return "not_configured";
- const parsed = codexStatus.config.match(/base_url\s*=\s*"([^"]+)"/);
- const currentUrl = parsed ? parsed[1] : "";
- return matchKnownEndpoint(currentUrl, { tunnelPublicUrl, tailscaleUrl }) ? "configured" : "other";
+ return matchKnownEndpoint(currentBaseUrl, { tunnelPublicUrl, tailscaleUrl }) ? "configured" : "other";
};
const configStatus = getConfigStatus();
@@ -114,6 +120,8 @@ export default function CodexToolCard({ tool, isExpanded, onToggle, baseUrl, api
});
const data = await res.json();
if (res.ok) {
+ // Remember the endpoint so it stays selectable next time
+ rememberEndpoint(getEffectiveBaseUrl(), { tunnelPublicUrl, tailscaleUrl });
setMessage({ type: "success", text: "Settings applied successfully!" });
checkCodexStatus();
} else {
@@ -273,13 +281,12 @@ default_subagent_model = "${effectiveSubagentModel}"
tunnelPublicUrl={tunnelPublicUrl}
tailscaleEnabled={tailscaleEnabled}
tailscaleUrl={tailscaleUrl}
+ currentUrl={currentBaseUrl}
/>
{/* Current configured */}
{codexStatus?.config && (() => {
- const parsed = codexStatus.config.match(/base_url\s*=\s*"([^"]+)"/);
- const currentBaseUrl = parsed ? parsed[1] : null;
return currentBaseUrl ? (
Current
diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/CopilotToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/CopilotToolCard.js
index 90f96f94..43242938 100644
--- a/src/app/(dashboard)/dashboard/cli-tools/components/CopilotToolCard.js
+++ b/src/app/(dashboard)/dashboard/cli-tools/components/CopilotToolCard.js
@@ -4,6 +4,7 @@ import { useState, useEffect, useRef } from "react";
import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components";
import Image from "next/image";
import BaseUrlSelect from "./BaseUrlSelect";
+import { rememberEndpoint } from "./cliEndpointPresets";
import ApiKeySelect from "./ApiKeySelect";
import { matchKnownEndpoint } from "./cliEndpointMatch";
@@ -77,6 +78,8 @@ export default function CopilotToolCard({ tool, isExpanded, onToggle, baseUrl, a
}
};
+ const currentBaseUrl = status?.currentUrl || "";
+
const getConfigStatus = () => {
if (!status) return null;
if (!status.has9Router) return "not_configured";
@@ -123,6 +126,8 @@ export default function CopilotToolCard({ tool, isExpanded, onToggle, baseUrl, a
});
const data = await res.json();
if (res.ok) {
+ // Remember the endpoint so it stays selectable next time
+ rememberEndpoint(getEffectiveBaseUrl(), { tunnelPublicUrl, tailscaleUrl });
setMessage({ type: "success", text: data.message || "Settings applied! Reload VS Code." });
checkStatus();
} else {
@@ -230,6 +235,7 @@ export default function CopilotToolCard({ tool, isExpanded, onToggle, baseUrl, a
tunnelPublicUrl={tunnelPublicUrl}
tailscaleEnabled={tailscaleEnabled}
tailscaleUrl={tailscaleUrl}
+ currentUrl={currentBaseUrl}
/>
diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/CoworkToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/CoworkToolCard.js
index 2d1fb820..367ef5db 100644
--- a/src/app/(dashboard)/dashboard/cli-tools/components/CoworkToolCard.js
+++ b/src/app/(dashboard)/dashboard/cli-tools/components/CoworkToolCard.js
@@ -4,6 +4,7 @@ import { useState, useEffect } from "react";
import { Card, Button, ManualConfigModal, ComboFormModal, McpMarketplaceModal, ModelSelectModal } from "@/shared/components";
import Image from "next/image";
import BaseUrlSelect from "./BaseUrlSelect";
+import { rememberEndpoint } from "./cliEndpointPresets";
import ApiKeySelect from "./ApiKeySelect";
const ENDPOINT = "/api/cli-tools/cowork-settings";
@@ -110,6 +111,8 @@ export default function CoworkToolCard({
const getEffectiveBaseUrl = () => ensureV1(customBaseUrl);
+ const currentBaseUrl = status?.cowork?.baseUrl || "";
+
const getConfigStatus = () => {
if (!status?.installed) return null;
const url = status?.cowork?.baseUrl;
@@ -148,6 +151,8 @@ export default function CoworkToolCard({
});
const data = await res.json();
if (res.ok) {
+ // Remember the endpoint so it stays selectable next time
+ rememberEndpoint(getEffectiveBaseUrl(), { tunnelPublicUrl, tailscaleUrl });
setMessage({ type: "success", text: "Settings applied. Quit & reopen Claude Desktop to load." });
checkStatus();
} else {
@@ -306,6 +311,7 @@ export default function CoworkToolCard({
tailscaleUrl={tailscaleUrl}
cloudEnabled={cloudEnabled}
cloudUrl={cloudUrl}
+ currentUrl={currentBaseUrl}
/>
diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/DeepSeekTuiToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/DeepSeekTuiToolCard.js
index ad84a4b7..db051f6b 100644
--- a/src/app/(dashboard)/dashboard/cli-tools/components/DeepSeekTuiToolCard.js
+++ b/src/app/(dashboard)/dashboard/cli-tools/components/DeepSeekTuiToolCard.js
@@ -4,6 +4,7 @@ import { useState, useEffect, useRef } from "react";
import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components";
import Image from "next/image";
import BaseUrlSelect from "./BaseUrlSelect";
+import { rememberEndpoint } from "./cliEndpointPresets";
import ApiKeySelect from "./ApiKeySelect";
import { matchKnownEndpoint } from "./cliEndpointMatch";
@@ -37,6 +38,8 @@ export default function DeepSeekTuiToolCard({
const [customBaseUrl, setCustomBaseUrl] = useState("");
const hasInitializedModel = useRef(false);
+ const currentBaseUrl = deepseekStatus?.settings?.["providers.openai"]?.base_url || "";
+
const getConfigStatus = () => {
if (!deepseekStatus?.installed) return null;
const openaiSection = deepseekStatus.settings?.["providers.openai"];
@@ -128,6 +131,8 @@ export default function DeepSeekTuiToolCard({
});
const data = await res.json();
if (res.ok) {
+ // Remember the endpoint so it stays selectable next time
+ rememberEndpoint(getEffectiveBaseUrl(), { tunnelPublicUrl, tailscaleUrl });
setMessage({ type: "success", text: "Settings applied successfully!" });
checkStatus();
} else {
@@ -263,6 +268,7 @@ model = "${selectedModel || "provider/model-id"}"
tunnelPublicUrl={tunnelPublicUrl}
tailscaleEnabled={tailscaleEnabled}
tailscaleUrl={tailscaleUrl}
+ currentUrl={currentBaseUrl}
/>
diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/DroidToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/DroidToolCard.js
index adc2a7ae..d6068f11 100644
--- a/src/app/(dashboard)/dashboard/cli-tools/components/DroidToolCard.js
+++ b/src/app/(dashboard)/dashboard/cli-tools/components/DroidToolCard.js
@@ -4,6 +4,7 @@ import { useState, useEffect, useRef } from "react";
import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components";
import Image from "next/image";
import BaseUrlSelect from "./BaseUrlSelect";
+import { rememberEndpoint } from "./cliEndpointPresets";
import ApiKeySelect from "./ApiKeySelect";
import { matchKnownEndpoint } from "./cliEndpointMatch";
@@ -39,6 +40,8 @@ export default function DroidToolCard({
const [customBaseUrl, setCustomBaseUrl] = useState("");
const hasInitializedModel = useRef(false);
+ const currentBaseUrl = droidStatus?.settings?.customModels?.find((m) => m.id?.startsWith("custom:9Router"))?.baseUrl || "";
+
const getConfigStatus = () => {
if (!droidStatus?.installed) return null;
// Check for any 9Router model entry (support multi-model: custom:9Router-0, custom:9Router-1, ...)
@@ -154,6 +157,8 @@ export default function DroidToolCard({
});
const data = await res.json();
if (res.ok) {
+ // Remember the endpoint so it stays selectable next time
+ rememberEndpoint(getEffectiveBaseUrl(), { tunnelPublicUrl, tailscaleUrl });
setMessage({ type: "success", text: "Settings applied successfully!" });
checkDroidStatus();
} else {
@@ -299,6 +304,7 @@ export default function DroidToolCard({
tunnelPublicUrl={tunnelPublicUrl}
tailscaleEnabled={tailscaleEnabled}
tailscaleUrl={tailscaleUrl}
+ currentUrl={currentBaseUrl}
/>
diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/GrokBuildToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/GrokBuildToolCard.js
index f1ea3c22..fde57a38 100644
--- a/src/app/(dashboard)/dashboard/cli-tools/components/GrokBuildToolCard.js
+++ b/src/app/(dashboard)/dashboard/cli-tools/components/GrokBuildToolCard.js
@@ -5,6 +5,7 @@ import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/comp
import { useModelCaps } from "@/shared/hooks/useModelCaps";
import Image from "next/image";
import BaseUrlSelect from "./BaseUrlSelect";
+import { rememberEndpoint } from "./cliEndpointPresets";
import ApiKeySelect from "./ApiKeySelect";
import { matchKnownEndpoint } from "./cliEndpointMatch";
@@ -96,6 +97,7 @@ export default function GrokBuildToolCard({
const hasFetchedStatus = useRef(Boolean(initialStatus));
const configuredModel = grokStatus?.settings?.model;
+ const currentBaseUrl = configuredModel?.base_url || "";
const configStatus = !grokStatus?.installed
? null
: !configuredModel?.base_url
@@ -184,6 +186,8 @@ export default function GrokBuildToolCard({
});
const data = await res.json();
if (res.ok) {
+ // Remember the endpoint so it stays selectable next time
+ rememberEndpoint(getEffectiveBaseUrl(), { tunnelPublicUrl, tailscaleUrl });
setMessage({ type: "success", text: "Main and subagent models applied successfully!" });
checkStatus();
} else {
@@ -310,7 +314,7 @@ export default function GrokBuildToolCard({
Select Endpoint
arrow_forward
-
+
{configuredModel?.base_url && (
diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/HermesToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/HermesToolCard.js
index 9ef6cddf..bf5c25d1 100644
--- a/src/app/(dashboard)/dashboard/cli-tools/components/HermesToolCard.js
+++ b/src/app/(dashboard)/dashboard/cli-tools/components/HermesToolCard.js
@@ -4,6 +4,7 @@ import { useState, useEffect, useRef } from "react";
import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components";
import Image from "next/image";
import BaseUrlSelect from "./BaseUrlSelect";
+import { rememberEndpoint } from "./cliEndpointPresets";
import ApiKeySelect from "./ApiKeySelect";
import { matchKnownEndpoint } from "./cliEndpointMatch";
@@ -37,6 +38,8 @@ export default function HermesToolCard({
const [customBaseUrl, setCustomBaseUrl] = useState("");
const hasInitializedModel = useRef(false);
+ const currentBaseUrl = hermesStatus?.settings?.model?.base_url || "";
+
const getConfigStatus = () => {
if (!hermesStatus?.installed) return null;
const cfg = hermesStatus.settings?.model;
@@ -128,6 +131,8 @@ export default function HermesToolCard({
});
const data = await res.json();
if (res.ok) {
+ // Remember the endpoint so it stays selectable next time
+ rememberEndpoint(getEffectiveBaseUrl(), { tunnelPublicUrl, tailscaleUrl });
setMessage({ type: "success", text: "Settings applied successfully!" });
checkStatus();
} else {
@@ -242,6 +247,7 @@ export default function HermesToolCard({
tunnelPublicUrl={tunnelPublicUrl}
tailscaleEnabled={tailscaleEnabled}
tailscaleUrl={tailscaleUrl}
+ currentUrl={currentBaseUrl}
/>
diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/JcodeToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/JcodeToolCard.js
index c4544fa3..c616980c 100644
--- a/src/app/(dashboard)/dashboard/cli-tools/components/JcodeToolCard.js
+++ b/src/app/(dashboard)/dashboard/cli-tools/components/JcodeToolCard.js
@@ -4,6 +4,7 @@ import { useState, useEffect, useRef } from "react";
import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components";
import Image from "next/image";
import BaseUrlSelect from "./BaseUrlSelect";
+import { rememberEndpoint } from "./cliEndpointPresets";
import ApiKeySelect from "./ApiKeySelect";
import { matchKnownEndpoint } from "./cliEndpointMatch";
@@ -35,6 +36,8 @@ export default function JcodeToolCard({
const [customBaseUrl, setCustomBaseUrl] = useState("");
const hasInitializedModel = useRef(false);
+ const currentBaseUrl = jcodeStatus?.config?.providers?.["9router"]?.base_url || "";
+
const getConfigStatus = () => {
if (!jcodeStatus?.installed) return null;
if (!jcodeStatus?.has9Router) return "not_configured";
@@ -140,6 +143,8 @@ export default function JcodeToolCard({
});
const data = await res.json();
if (res.ok) {
+ // Remember the endpoint so it stays selectable next time
+ rememberEndpoint(getEffectiveBaseUrl(), { tunnelPublicUrl, tailscaleUrl });
setMessage({ type: "success", text: "Settings applied successfully!" });
checkJcodeStatus();
} else {
@@ -295,6 +300,7 @@ id = "${selectedModel || "cc/claude-opus-4-7"}"`;
tunnelPublicUrl={tunnelPublicUrl}
tailscaleEnabled={tailscaleEnabled}
tailscaleUrl={tailscaleUrl}
+ currentUrl={currentBaseUrl}
/>
diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/KiloToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/KiloToolCard.js
index be348595..87f14f54 100644
--- a/src/app/(dashboard)/dashboard/cli-tools/components/KiloToolCard.js
+++ b/src/app/(dashboard)/dashboard/cli-tools/components/KiloToolCard.js
@@ -4,6 +4,7 @@ import { useState, useEffect } from "react";
import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components";
import Image from "next/image";
import BaseUrlSelect from "./BaseUrlSelect";
+import { rememberEndpoint } from "./cliEndpointPresets";
import ApiKeySelect from "./ApiKeySelect";
import { matchKnownEndpoint } from "./cliEndpointMatch";
@@ -88,6 +89,8 @@ export default function KiloToolCard({ tool, isExpanded, onToggle, baseUrl, apiK
});
const data = await res.json();
if (res.ok) {
+ // Remember the endpoint so it stays selectable next time
+ rememberEndpoint(getEffectiveBaseUrl(), { tunnelPublicUrl, tailscaleUrl });
setMessage({ type: "success", text: "Settings applied successfully!" });
checkStatus();
} else {
diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/OpenClawToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/OpenClawToolCard.js
index b646a6eb..8e5cb8c2 100644
--- a/src/app/(dashboard)/dashboard/cli-tools/components/OpenClawToolCard.js
+++ b/src/app/(dashboard)/dashboard/cli-tools/components/OpenClawToolCard.js
@@ -4,6 +4,7 @@ import { useState, useEffect, useRef } from "react";
import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components";
import Image from "next/image";
import BaseUrlSelect from "./BaseUrlSelect";
+import { rememberEndpoint } from "./cliEndpointPresets";
import ApiKeySelect from "./ApiKeySelect";
import { matchKnownEndpoint } from "./cliEndpointMatch";
@@ -37,6 +38,8 @@ export default function OpenClawToolCard({
const [customBaseUrl, setCustomBaseUrl] = useState("");
const hasInitializedModel = useRef(false);
+ const currentBaseUrl = openclawStatus?.settings?.models?.providers?.["9router"]?.baseUrl || "";
+
const getConfigStatus = () => {
if (!openclawStatus?.installed) return null;
const currentProvider = openclawStatus.settings?.models?.providers?.["9router"];
@@ -146,6 +149,8 @@ export default function OpenClawToolCard({
});
const data = await res.json();
if (res.ok) {
+ // Remember the endpoint so it stays selectable next time
+ rememberEndpoint(getEffectiveBaseUrl(), { tunnelPublicUrl, tailscaleUrl });
setMessage({ type: "success", text: "Settings applied successfully!" });
checkOpenclawStatus();
} else {
@@ -291,6 +296,7 @@ export default function OpenClawToolCard({
tunnelPublicUrl={tunnelPublicUrl}
tailscaleEnabled={tailscaleEnabled}
tailscaleUrl={tailscaleUrl}
+ currentUrl={currentBaseUrl}
/>
diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/OpenCodeToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/OpenCodeToolCard.js
index c99edc98..5c800bc2 100644
--- a/src/app/(dashboard)/dashboard/cli-tools/components/OpenCodeToolCard.js
+++ b/src/app/(dashboard)/dashboard/cli-tools/components/OpenCodeToolCard.js
@@ -4,6 +4,7 @@ import { useState, useEffect, useRef } from "react";
import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components";
import Image from "next/image";
import BaseUrlSelect from "./BaseUrlSelect";
+import { rememberEndpoint } from "./cliEndpointPresets";
import ApiKeySelect from "./ApiKeySelect";
import { matchKnownEndpoint } from "./cliEndpointMatch";
@@ -94,6 +95,8 @@ export default function OpenCodeToolCard({ tool, isExpanded, onToggle, baseUrl,
}
};
+ const currentBaseUrl = status?.config?.provider?.["9router"]?.options?.baseURL || "";
+
const getConfigStatus = () => {
if (!status?.installed) return null;
if (!status.config) return "not_configured";
@@ -145,6 +148,8 @@ export default function OpenCodeToolCard({ tool, isExpanded, onToggle, baseUrl,
});
const data = await res.json();
if (res.ok) {
+ // Remember the endpoint so it stays selectable next time
+ rememberEndpoint(getEffectiveBaseUrl(), { tunnelPublicUrl, tailscaleUrl });
setMessage({ type: "success", text: "Settings applied successfully!" });
checkStatus();
} else {
@@ -297,6 +302,7 @@ export default function OpenCodeToolCard({ tool, isExpanded, onToggle, baseUrl,
tunnelPublicUrl={tunnelPublicUrl}
tailscaleEnabled={tailscaleEnabled}
tailscaleUrl={tailscaleUrl}
+ currentUrl={currentBaseUrl}
/>
diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/cliEndpointPresets.js b/src/app/(dashboard)/dashboard/cli-tools/components/cliEndpointPresets.js
new file mode 100644
index 00000000..53cd0d43
--- /dev/null
+++ b/src/app/(dashboard)/dashboard/cli-tools/components/cliEndpointPresets.js
@@ -0,0 +1,71 @@
+import { UPDATER_CONFIG } from "@/shared/constants/config";
+
+// Browser-local endpoint presets shared by every CLI tool card
+const STORAGE_KEY = "9router.cliToolEndpointPresets";
+const CHANGE_EVENT = "9router:endpoint-presets-changed";
+
+const stripSlash = (url) => (url || "").replace(/\/+$/, "");
+
+export function readPresets() {
+ if (typeof window === "undefined") return [];
+ try {
+ const raw = JSON.parse(window.localStorage.getItem(STORAGE_KEY) || "[]");
+ if (!Array.isArray(raw)) return [];
+ return raw.filter((p) => p?.name && p?.baseUrl);
+ } catch {
+ return [];
+ }
+}
+
+function writePresets(presets) {
+ if (typeof window === "undefined") return;
+ window.localStorage.setItem(STORAGE_KEY, JSON.stringify(presets));
+ window.dispatchEvent(new CustomEvent(CHANGE_EVENT));
+}
+
+export function subscribePresets(handler) {
+ if (typeof window === "undefined") return () => {};
+ window.addEventListener(CHANGE_EVENT, handler);
+ return () => window.removeEventListener(CHANGE_EVENT, handler);
+}
+
+function defaultNameFor(url) {
+ try { return new URL(url).host; } catch { return url; }
+}
+
+// Adds or replaces a preset; returns the stored name, or null when skipped
+export function upsertPreset(baseUrl, name) {
+ const url = stripSlash(baseUrl);
+ if (!url) return null;
+
+ const presets = readPresets();
+ const existing = presets.find((p) => stripSlash(p.baseUrl) === url);
+ if (existing && !name) return existing.name;
+
+ const finalName = (name || defaultNameFor(url)).trim();
+ if (!finalName) return null;
+
+ const next = [...presets.filter((p) => p.name !== finalName && stripSlash(p.baseUrl) !== url), { name: finalName, baseUrl: url }]
+ .sort((a, b) => a.name.localeCompare(b.name));
+ writePresets(next);
+ return finalName;
+}
+
+// Save an applied endpoint unless it exactly matches a built-in dropdown option
+export function rememberEndpoint(baseUrl, { tunnelPublicUrl, tailscaleUrl, cloudUrl } = {}) {
+ const url = stripSlash(baseUrl);
+ if (!url) return null;
+
+ const builtIns = [`http://127.0.0.1:${UPDATER_CONFIG.appPort}`, tunnelPublicUrl, tailscaleUrl, cloudUrl]
+ .filter(Boolean)
+ .flatMap((u) => [stripSlash(u), `${stripSlash(u)}/v1`]);
+ if (builtIns.includes(url)) return null;
+
+ return upsertPreset(url);
+}
+
+export function deletePreset(name) {
+ writePresets(readPresets().filter((p) => p.name !== name));
+}
+
+export { stripSlash };