diff --git a/integrations/dsh/src/client/env-tab.tsx b/integrations/dsh/src/client/env-tab.tsx index 18a3efe4..b556020e 100644 --- a/integrations/dsh/src/client/env-tab.tsx +++ b/integrations/dsh/src/client/env-tab.tsx @@ -21,6 +21,9 @@ import type { StatusRpc } from "./status-page.js"; const MINICONDA_URL = "https://docs.conda.io/en/latest/miniconda.html"; +/** Interpreter rows shown before the list asks to expand. */ +const ENV_PAGE = 6; + export const envEn = { connectionTitle: "Service connection", endpointLabel: "Service URL", @@ -138,8 +141,39 @@ export const envEn = { serviceError: "Service operation failed", configTitle: "ReMe parameters", controlTitle: "Service control", - upcoming: - "This section arrives with the next milestone of the environment feature.", + heroEyebrow: "ReMe local runtime", + heroReady: "Ready — memory runs locally", + heroSetup: "Finish the setup in the steps below", + heroService: "Service", + heroPython: "Python", + heroReme: "ReMe", + noneSelected: "not selected", + noneDetected: "not detected", + envCount: "{count} environments", + envRemeCount: "{count} with ReMe", + envOthers: "{count} other environments", + scannedAt: "Detected at {time}", + currentInterpreter: "Current interpreter", + notInDetection: "not detected", + autoSelected: + "The recommended interpreter is used until you pick another one.", + savingSelection: "Saving…", + changeInterpreter: "Change interpreter", + collapseList: "Collapse list", + searchPlaceholder: "Filter by version, origin or path", + noMatch: "No environment matches the filter.", + showAllEnvs: "Show all {count}", + showFewer: "Show fewer", + depReady: "Ready", + depUpgrade: "Upgrade available", + depMissing: "Not installed", + expand: "Expand", + collapse: "Collapse", + recommendWhyReme: + "Already has a supported reme-ai, and it is a dedicated conda environment.", + recommendWhyRemeAny: "Already has a supported reme-ai.", + recommendWhyNamed: "Dedicated conda environment, ready for a ReMe install.", + recommendWhyFallback: "Fallback: the first usable interpreter.", }; export const envZh: typeof envEn = { @@ -252,7 +286,37 @@ export const envZh: typeof envEn = { serviceError: "服务操作失败", configTitle: "ReMe 参数", controlTitle: "服务控制", - upcoming: "该分区将随环境管理特性的后续里程碑提供。", + heroEyebrow: "ReMe 本地运行时", + heroReady: "已就绪,记忆保存在本地", + heroSetup: "按下方步骤完成配置", + heroService: "服务", + heroPython: "Python", + heroReme: "ReMe", + noneSelected: "未选择", + noneDetected: "未探测", + envCount: "{count} 个环境", + envRemeCount: "{count} 个已装 ReMe", + envOthers: "另有 {count} 个环境", + scannedAt: "探测于 {time}", + currentInterpreter: "当前解释器", + notInDetection: "不在探测结果中", + autoSelected: "当前使用推荐解释器;选择其他解释器后会写入配置。", + savingSelection: "正在保存…", + changeInterpreter: "更换解释器", + collapseList: "收起列表", + searchPlaceholder: "按版本、来源或路径筛选", + noMatch: "没有匹配的环境。", + showAllEnvs: "显示全部 {count} 个", + showFewer: "收起多余项", + depReady: "就绪", + depUpgrade: "可升级", + depMissing: "未安装", + expand: "展开", + collapse: "收起", + recommendWhyReme: "已安装可用的 reme-ai,且是独立的 conda 环境。", + recommendWhyRemeAny: "已安装可用的 reme-ai。", + recommendWhyNamed: "独立的 conda 环境,适合安装 ReMe。", + recommendWhyFallback: "兜底选择:第一个可用的解释器。", }; export type EnvTranslator = (key: keyof typeof envEn) => string; @@ -369,6 +433,85 @@ function originLabel(t: EnvTranslator, origin: PythonOrigin): string { } } +interface EnvBadge { + tone: "" | "ok" | "bad" | "progress"; + text: string; +} + +/** Human-readable reason behind the "recommended" marker. */ +function recommendReason(t: EnvTranslator, env: PythonEnv): string { + const named = env.origin.kind === "conda" && env.origin.envName !== "base"; + if (env.reme?.installed) + return t(named ? "recommendWhyReme" : "recommendWhyRemeAny"); + return t(named ? "recommendWhyNamed" : "recommendWhyFallback"); +} + +function connectionBadge( + t: EnvTranslator, + health: HealthState, + current: boolean, +): EnvBadge { + const label = (detail: string) => `${t("heroService")} · ${detail}`; + if (!current || health.phase === "loading") + return { tone: "", text: label(t("checking")) }; + if (health.phase === "ok") + return { + tone: "ok", + text: label(health.version ? `ReMe ${health.version}` : t("connected")), + }; + return { tone: "bad", text: label(t("disconnected")) }; +} + +function interpreterBadge( + t: EnvTranslator, + env: PythonEnv | undefined, + phase: DetectState["phase"], +): EnvBadge { + if (!env) + return { + tone: "", + text: `${t("heroPython")} · ${t( + phase === "error" ? "noneDetected" : "noneSelected", + )}`, + }; + return { + tone: env.ok ? "ok" : "progress", + text: `${t("heroPython")} ${env.version || "?"}`, + }; +} + +function remeSummaryBadge( + t: EnvTranslator, + check: RemeCheck | undefined, + checking: boolean, +): EnvBadge { + if (!check) + return { + tone: "", + text: `${t("heroReme")} · ${t(checking ? "checking" : "noneDetected")}`, + }; + if (!check.installed) + return { + tone: "progress", + text: `${t("heroReme")} · ${t("notInstalled")}`, + }; + return { + tone: check.versionOk && check.hasCore ? "ok" : "progress", + text: `${t("heroReme")} ${check.version}`, + }; +} + +function dependencyBadge( + t: EnvTranslator, + check: RemeCheck | undefined, +): EnvBadge { + if (!check) return { tone: "", text: "" }; + if (!check.installed) return { tone: "progress", text: t("depMissing") }; + return check.versionOk && check.hasCore + ? { tone: "ok", text: t("depReady") } + : { tone: "progress", text: t("depUpgrade") }; +} + function elapsedText(job: InstallStatus, now: number): string { const started = Date.parse(job.startedAt); if (!Number.isFinite(started) || now < started) return ""; @@ -428,6 +571,10 @@ export function ReMeEnvironmentTab({ lines: "", logPath: "", }); + const [listOpen, setListOpen] = useState(false); + const [envQuery, setEnvQuery] = useState(""); + const [showAllEnvs, setShowAllEnvs] = useState(false); + const [paramsOpen, setParamsOpen] = useState(false); // Derived values must precede hook dependency arrays. const detected = detection.phase === "ready" ? detection.data : undefined; @@ -924,29 +1071,88 @@ export function ReMeEnvironmentTab({ } }; + const selectedEnv = detected?.envs.find((env) => env.exe === selectedPath); + const others = (detected?.envs ?? []).filter( + (env) => env.exe !== selectedPath, + ); + const filter = envQuery.trim().toLowerCase(); + const matches = filter + ? others.filter((env) => + `${env.version} ${env.exe} ${originLabel(t, env.origin)} ${env.source}` + .toLowerCase() + .includes(filter), + ) + : others; + const visibleEnvs = showAllEnvs ? matches : matches.slice(0, ENV_PAGE); + const hiddenCount = matches.length - visibleEnvs.length; + const remeCount = (detected?.envs ?? []).filter( + (env) => env.reme?.installed === true, + ).length; + const scannedAt = detected?.scannedAt + ? Date.parse(detected.scannedAt) + : Number.NaN; + const scannedTime = Number.isFinite(scannedAt) + ? new Date(scannedAt).toLocaleTimeString() + : ""; + const ready = + healthCurrent && + health.phase === "ok" && + checkData?.installed === true && + checkData.versionOk; + const llmModel = envValue("LLM_MODEL_NAME").trim(); + const envSummaryLine = + env.phase === "ready" && env.data + ? `${env.data.exists ? env.data.envPath : t("envMissing")}${ + llmModel ? ` · ${llmModel}` : "" + }` + : t("envLoading"); + const { tone: healthTone, text: healthText } = connectionBadge( + t, + health, + healthCurrent, + ); + const { tone: pythonTone, text: pythonText } = interpreterBadge( + t, + selectedEnv, + detection.phase, + ); + const { tone: remeTone, text: remeText } = remeSummaryBadge( + t, + checkData, + checking, + ); + const { tone: depTone, text: depText } = dependencyBadge(t, checkData); + return (
-
-

{t("connectionTitle")}

-
- {t("endpointLabel")} - {endpoint} - {!healthCurrent ? ( - - - {t("checking")} - - ) : health.phase === "ok" ? ( - - - {health.version ? `ReMe ${health.version}` : t("connected")} - - ) : ( - - - {t("disconnected")} - - )} +
+
+ +
+ {t("heroEyebrow")} +

{t(ready ? "heroReady" : "heroSetup")}

+

+ {endpoint} +

+
+
+
+ + + {healthText} + + + + {pythonText} + + + + {remeText} + +
+
+ {local ? ( + + ) : null} + {scannedTime ? ( + + {t("scannedAt").replace("{time}", scannedTime)} + + ) : null}
- {healthCurrent && health.phase === "fail" && health.error ? ( -

{health.error}

- ) : null}
+ {healthCurrent && health.phase === "fail" && health.error ? ( +

+ {health.error} +

+ ) : null} {!local ? (

{t("remoteNotice")}

) : ( <> -
-

{t("pythonTitle")}

-

{t("pythonHint")}

-
- {detection.phase === "loading" ? ( - {t("detecting")} - ) : null} - -
- {detection.phase === "error" ? ( -

- {t("detectFailed")} - {detection.error ? `: ${detection.error}` : ""} -

- ) : null} - {detection.phase === "error" || - (detected && detected.envs.length === 0) ? ( -
- {t("emptyTitle")} - {t("emptyHint")} - - {t("miniconda")} - +
+
+ +
+

{t("pythonTitle")}

+

{t("pythonHint")}

- ) : null} - {detected && detected.envs.length > 0 ? ( -
- {detected.envs.map((env) => ( - - ))} -
- ) : null} - {detected && detected.errors.length > 0 ? ( -
- {t("errorsTitle")} -
{detected.errors.join("\n")}
-
- ) : null} -
-
-

{t("dependencyTitle")}

-

{t("dependencyHint")}

-
- {t("interpreterLabel")} - - {selectedPath || t("noInterpreter")} - - -
- {!selectedPath ? ( -

- {t("selectInterpreterFirst")} -

- ) : checking ? ( -

{t("checkingReme")}

- ) : null} - {checkError ? ( -

- {t("checkRemeFailed")} - {checkError ? `: ${checkError}` : ""} -

- ) : null} - {checkData ? ( -
-
- {t("remeLabel")} - {checkData.installed ? ( - - - {`reme-ai ${checkData.version}`} - {checkData.versionOk - ? "" - : ` · ${t("upgradeRecommended")}`} - - ) : ( - - - {t("notInstalled")} - - )} -
-
- {t("coreLabel")} - {checkData.installed ? ( - - - {checkData.hasCore ? t("coreOk") : t("coreMissing")} - - ) : ( - — - )} -
-
- {t("cliLabel")} - {checkData.installed ? ( - - - {checkData.cliPath || t("cliFallback")} - - ) : ( - — - )} -
-
- ) : null} - {checkData && install.phase !== "running" ? ( -
- - {confirming ? ( -
-

- {t("installConfirm").replace( - "{path}", - selectedPath || "", - )} -

- - -
- ) : checkData.installed && checkData.versionOk ? ( - - ) : checkData.installed ? ( - - ) : ( - - )} -

- {t("condaHint")} -

-
- ) : null} - {install.phase === "running" && install.job ? ( -
-
- - - {t("installing")} - {elapsedText(install.job, now || Date.now()) - ? ` · ${elapsedText(install.job, now || Date.now())}` + {detected && detected.envs.length > 0 ? ( + + ) : null} + +
+ {detection.phase === "loading" ? ( +

{t("detecting")}

+ ) : null} + {detection.phase === "error" ? ( +

+ {t("detectFailed")} + {detection.error ? `: ${detection.error}` : ""} +

+ ) : null} + {detection.phase === "error" || + (detected && detected.envs.length === 0) ? ( +
+ {t("emptyTitle")} + {t("emptyHint")} + - {t("cancelInstall")} - + {t("miniconda")} +
-
-                  {install.job.logTail || t("waitingForOutput")}
-                
+ ) : null} + {selectedEnv ? ( +
+
+ ) : selectedExe ? ( +
+
+ ) : null} + {!selectedExe && suggestedExe ? ( +

+ {t("autoSelected")} +

+ ) : null} + {others.length > 0 ? ( +
+
+ + {listOpen ? ( + { + setEnvQuery(event.target.value); + setShowAllEnvs(false); + }} + /> + ) : ( + + {t("envOthers").replace( + "{count}", + String(others.length), + )} + + )} +
+ {listOpen ? ( + <> + {visibleEnvs.length > 0 ? ( +
+ {visibleEnvs.map((env) => ( + + ))} +
+ ) : ( +

{t("noMatch")}

+ )} + {hiddenCount > 0 ? ( + + ) : matches.length > ENV_PAGE ? ( + + ) : null} + + ) : null} +
+ ) : null} + {detected && detected.errors.length > 0 ? ( +
+ {t("errorsTitle")} +
{detected.errors.join("\n")}
+
+ ) : null} +
+
+
+
+ +
+

{t("dependencyTitle")}

+

{t("dependencyHint")}

- ) : null} - {install.phase === "done" ? ( -
- {install.job?.phase === "succeeded" ? ( -

- {t("installSucceeded")} -

- ) : null} - {install.job?.phase === "cancelled" ? ( -

{t("installCancelled")}

- ) : null} - {install.job?.phase === "failed" ? ( - <> -

{t("installFailed")}

- {install.job.logTail ? ( -
{install.job.logTail}
- ) : null} -

{install.job.logPath}

- - ) : null} - {install.error ? ( -

{install.error}

- ) : null} + {depTone ? ( + + ) : null} +
+
+
+ + {t("interpreterLabel")} + + + {selectedPath || t("noInterpreter")} +
- ) : null} -
-
-

{t("configTitle")}

-

{t("configHint")}

-
- -
- setWorkspaceInput(event.target.value)} - /> - -
-
-
- {env.phase === "loading" ? ( -

{t("envLoading")}

- ) : null} - {env.phase === "error" ? ( -

- {t("saveEnvFailed")} - {env.error ? `: ${env.error}` : ""} -

- ) : null} - {env.phase === "ready" && env.data ? ( - <> + {!selectedPath ? (

- {env.data.exists - ? `${t("envPath")}: ${env.data.envPath}` - : t("envMissing")} + {t("selectInterpreterFirst")}

-
-
{t("llmGroup")}
- - - - - - updateEnvDraft("LLM_MODEL_NAME", event.target.value) - } - /> - - - - updateEnvDraft("LLM_API_KEY", event.target.value) - } - /> - - - - updateEnvDraft("LLM_BASE_URL", event.target.value) - } - /> - -
{t("embeddingGroup")}
- - - - - - updateEnvDraft( - "EMBEDDING_MODEL_NAME", - event.target.value, +
+
+
+ {t("coreLabel")} +
+ {checkData.installed ? ( + + + {checkData.hasCore ? t("coreOk") : t("coreMissing")} + + ) : ( + — + )} +
+
+
+ {t("cliLabel")} +
+ {checkData.installed ? ( + checkData.cliPath ? ( + + {checkData.cliPath} + + ) : ( + + + {t("cliFallback")} + ) - } - /> - - - - updateEnvDraft("EMBEDDING_API_KEY", event.target.value) - } - /> - - - - updateEnvDraft("EMBEDDING_BASE_URL", event.target.value) - } - /> - + ) : ( + — + )} +
+
-
- - - - {envResult.kind === "saved" ? ( - - {t("savedEnv").replace( + ) : null} + {checkData && install.phase !== "running" ? ( +
+ + {confirming ? ( +
+

+ {t("installConfirm").replace( "{path}", - env.data?.envPath ?? "", + selectedPath || "", )} - - ) : null} - {envResult.kind === "failed" ? ( - - {`${t("saveEnvFailed")}${ - envResult.message ? `: ${envResult.message}` : "" - }`} - - ) : null} - {llmTest.phase === "ok" && llmTest.result ? ( - - {t("llmOk").replace( - "{ms}", - String(llmTest.result.latencyMs), - )} - - ) : null} - {llmTest.phase === "fail" && llmTest.result ? ( - - {`${t("llmFailed")}${ - llmTest.result.error - ? `: ${llmTest.result.error}` - : "" - }`} - - ) : null} - -

- - ) : null} -
-
-

{t("controlTitle")}

-

{t("serviceHint")}

-
- {t("endpointLabel")} - {endpoint} - {service.phase === "checking" || service.phase === "starting" ? ( - - - {t( - service.phase === "starting" - ? "serviceBadgeStarting" - : "checking", - )} - - ) : service.phase === "running" ? ( - - - {service.data?.version - ? `ReMe ${service.data.version}` - : t("serviceRunning")} - - ) : service.phase === "conflict" ? ( - - - {t("serviceBadgeConflict")} - - ) : ( - - - {t("serviceBadgeStopped")} - - )} - -
- {service.phase === "running" ? ( -

- {service.data?.managed - ? t("managedNote").replace( - "{pid}", - String(service.data?.pid ?? ""), - ) - : t("foreignNote")} -

- ) : null} - {service.phase === "conflict" ? ( -

{t("conflictNote")}

- ) : null} - {service.phase === "error" && service.error ? ( -

- {`${t("serviceError")}: ${service.error}`} -

- ) : null} -
- {service.phase === "running" ? ( - confirmingStop ? ( -
-

{t("stopConfirm")}

+

+ + +
+ ) : checkData.installed && checkData.versionOk ? ( + + ) : checkData.installed ? ( + ) : ( + + )} +

+ {t("condaHint")} +

+
+ ) : null} + {install.phase === "running" && install.job ? ( +
+
+ + + {t("installing")} + {elapsedText(install.job, now || Date.now()) + ? ` · ${elapsedText(install.job, now || Date.now())}` + : ""} + + +
+
+                    {install.job.logTail || t("waitingForOutput")}
+                  
+
+ ) : null} + {install.phase === "done" ? ( +
+ {install.job?.phase === "succeeded" ? ( +

+ {t("installSucceeded")} +

+ ) : null} + {install.job?.phase === "cancelled" ? ( +

+ {t("installCancelled")} +

+ ) : null} + {install.job?.phase === "failed" ? ( + <> +

+ {t("installFailed")} +

+ {install.job.logTail ? ( +
+                          {install.job.logTail}
+                        
+ ) : null} +

+ {install.job.logPath} +

+ + ) : null} + {install.error ? ( +

{install.error}

+ ) : null} + +
+ ) : null} +
+ +
+
+ +
+

{t("configTitle")}

+

{envSummaryLine}

+
+ +
+ {paramsOpen ? ( +
+

{t("configHint")}

+
+ +
+ + setWorkspaceInput(event.target.value) + } + /> + +
+
+
+ {env.phase === "loading" ? ( +

{t("envLoading")}

+ ) : null} + {env.phase === "error" ? ( +

+ {t("saveEnvFailed")} + {env.error ? `: ${env.error}` : ""} +

+ ) : null} + {env.phase === "ready" && env.data ? ( + <> +

+ {env.data.exists + ? `${t("envPath")}: ${env.data.envPath}` + : t("envMissing")} +

+
+
{t("llmGroup")}
+ + + + + + updateEnvDraft("LLM_MODEL_NAME", event.target.value) + } + /> + + + + updateEnvDraft("LLM_API_KEY", event.target.value) + } + /> + + + + updateEnvDraft("LLM_BASE_URL", event.target.value) + } + /> + +
{t("embeddingGroup")}
+ + + + + + updateEnvDraft( + "EMBEDDING_MODEL_NAME", + event.target.value, + ) + } + /> + + + + updateEnvDraft( + "EMBEDDING_API_KEY", + event.target.value, + ) + } + /> + + + + updateEnvDraft( + "EMBEDDING_BASE_URL", + event.target.value, + ) + } + /> + +
+
+ + + + {envResult.kind === "saved" ? ( + + {t("savedEnv").replace( + "{path}", + env.data?.envPath ?? "", + )} + + ) : null} + {envResult.kind === "failed" ? ( + + {`${t("saveEnvFailed")}${ + envResult.message ? `: ${envResult.message}` : "" + }`} + + ) : null} + {llmTest.phase === "ok" && llmTest.result ? ( + + {t("llmOk").replace( + "{ms}", + String(llmTest.result.latencyMs), + )} + + ) : null} + {llmTest.phase === "fail" && llmTest.result ? ( + + {`${t("llmFailed")}${ + llmTest.result.error + ? `: ${llmTest.result.error}` + : "" + }`} + + ) : null} + +
+ + ) : null} +
+ ) : null} +
+
+
+ +
+

{t("controlTitle")}

+

{t("serviceHint")}

+
+ +
+
+
+ + {t("endpointLabel")} + + + {endpoint} + + +
+ {service.phase === "running" ? ( +

+ {service.data?.managed + ? t("managedNote").replace( + "{pid}", + String(service.data?.pid ?? ""), + ) + : t("foreignNote")} +

+ ) : null} + {service.phase === "conflict" ? ( +

{t("conflictNote")}

+ ) : null} + {service.phase === "error" && service.error ? ( +

+ {`${t("serviceError")}: ${service.error}`} +

+ ) : null} +
+ {service.phase === "running" ? ( + confirmingStop ? ( +
+

{t("stopConfirm")}

+ + +
+ ) : ( + - -
+ ) ) : ( - ) - ) : ( - - )} - - -
- {logView.open ? ( -
-

- {`${t("envPath")}: ${logView.logPath}`} -

-
-                  {logView.lines || t("logEmpty")}
-                
+ )} +
- ) : null} + {logView.open ? ( +
+

+ {`${t("envPath")}: ${logView.logPath}`} +

+
+                    {logView.lines || t("logEmpty")}
+                  
+ +
+ ) : null} +
)} diff --git a/integrations/dsh/src/client/styles.ts b/integrations/dsh/src/client/styles.ts index 5abb7639..1801be46 100644 --- a/integrations/dsh/src/client/styles.ts +++ b/integrations/dsh/src/client/styles.ts @@ -37,39 +37,86 @@ export const styles = ` .reme-settings-tabs button.active{color:var(--dsw-alias-label-primary);font-weight:600} .reme-settings-tabs button.active:after{content:"";position:absolute;right:8px;bottom:-1px;left:8px;height:2px;border-radius:2px;background:var(--dsw-alias-label-primary)} .reme-settings-tabs button:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:-2px} -.reme-env{display:flex;flex-direction:column} -.reme-env-row{display:flex;flex-wrap:wrap;align-items:center;gap:8px;padding:12px 0} -.reme-env-row+.reme-settings-error{margin:0} -.reme-env-label{flex:none;color:var(--dsw-alias-label-primary);font-size:13px;font-weight:500} -.reme-env-endpoint{min-width:0;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--dsw-alias-label-tertiary);font-size:12px} -.reme-env-notice{margin:12px 0 0} -.reme-env-list{display:flex;flex-direction:column;border-top:1px solid var(--dsw-alias-border-l2)} -.reme-env-python{display:flex;align-items:center;gap:10px;padding:10px 0;border-bottom:1px solid var(--dsw-alias-border-l2);cursor:pointer} -.reme-env-python input{margin:0;accent-color:var(--dsw-alias-brand-primary)} -.reme-env-python-main{min-width:0;flex:1;display:flex;flex-direction:column;gap:2px} -.reme-env-python-main strong{font-size:13px;font-weight:600;line-height:1.5} -.reme-env-python-main small{color:var(--dsw-alias-label-tertiary);font-size:11px;line-height:1.5;overflow-wrap:anywhere} -.reme-env-hint{display:flex;flex-direction:column;align-items:flex-start;gap:8px;padding:14px 0;color:var(--dsw-alias-label-tertiary);font-size:13px;line-height:1.5} -.reme-env-hint strong{color:var(--dsw-alias-label-primary)} -.reme-env-upcoming{margin:0;padding:4px 0 12px} -.reme-env .reme-settings-details{margin-top:8px} -.reme-env-checks{display:flex;flex-direction:column;border-top:1px solid var(--dsw-alias-border-l2);margin-top:4px} -.reme-env-check{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:9px 0;border-bottom:1px solid var(--dsw-alias-border-l2);color:var(--dsw-alias-label-secondary);font-size:12px} -.reme-env-check>span:first-child{flex:none;font-weight:500;color:var(--dsw-alias-label-primary)} -.reme-env-check .reme-status-badge{max-width:75%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} -.reme-env-actions{display:flex;flex-wrap:wrap;align-items:center;gap:8px;padding-top:12px;border-top:1px solid var(--dsw-alias-border-l2)} -.reme-env-mirror{display:flex;align-items:center;gap:6px;color:var(--dsw-alias-label-secondary);font-size:12px} -.reme-env-mirror select{min-height:28px;border:1px solid var(--dsw-alias-border-l2);border-radius:7px;background:var(--dsw-alias-bg-layer-3);color:var(--dsw-alias-label-primary);padding:2px 8px;font:inherit;font-size:12px} +.reme-env{display:flex;flex-direction:column;gap:12px;padding:16px 0 8px} +.reme-env-hero{position:relative;display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:center;gap:14px 18px;overflow:hidden;border:1px solid color-mix(in srgb,var(--dsw-alias-brand-primary) 18%,var(--dsw-alias-border-l2));border-radius:16px;background:linear-gradient(128deg,color-mix(in srgb,var(--dsw-alias-brand-primary) 8%,var(--dsw-alias-bg-layer-3)),var(--dsw-alias-bg-layer-3) 58%);padding:17px 19px} +.reme-env-hero:after{position:absolute;right:-110px;bottom:-170px;width:230px;height:230px;border-radius:50%;background:color-mix(in srgb,var(--dsw-alias-brand-primary) 7%,transparent);content:"";pointer-events:none} +.reme-env-hero-brand{display:flex;min-width:0;align-items:center;gap:12px} +.reme-env-hero-mark{display:grid;width:40px;height:40px;flex:none;place-items:center;border-radius:12px;background:var(--dsw-alias-label-primary);color:var(--dsw-alias-bg-layer-3);font-size:13px;font-weight:750;letter-spacing:-.4px;box-shadow:0 8px 22px color-mix(in srgb,var(--dsw-alias-label-primary) 12%,transparent)} +.reme-env-hero-copy{min-width:0} +.reme-env-eyebrow{display:block;color:var(--dsw-alias-label-tertiary);font-size:10px;font-weight:650;letter-spacing:.08em;line-height:1.4;text-transform:uppercase} +.reme-env-hero-copy h3{margin:2px 0 0;font-size:17px;line-height:1.35} +.reme-env-hero-sub{margin:3px 0 0;overflow:hidden;color:var(--dsw-alias-label-tertiary);font-size:11px;line-height:1.4;text-overflow:ellipsis;white-space:nowrap} +.reme-env-hero-chips{position:relative;z-index:1;display:flex;flex-wrap:wrap;justify-content:flex-end;gap:6px} +.reme-env-hero-actions{grid-column:1 / -1;display:flex;flex-wrap:wrap;align-items:center;gap:8px;border-top:1px solid color-mix(in srgb,var(--dsw-alias-brand-primary) 14%,var(--dsw-alias-border-l2));padding-top:12px} +.reme-env-hero-meta{margin-left:auto;color:var(--dsw-alias-label-tertiary);font-size:11px} +.reme-env-hero-error,.reme-env-notice{margin:0} +.reme-env-card{border:1px solid var(--dsw-alias-border-l2);border-radius:13px;background:var(--dsw-alias-bg-layer-3)} +.reme-env-card>header{display:flex;align-items:flex-start;gap:11px;padding:13px 15px} +.reme-env-card.collapsed>header{padding-bottom:14px} +.reme-env-step{display:grid;width:22px;height:22px;flex:none;place-items:center;margin-top:-1px;border-radius:50%;background:color-mix(in srgb,var(--dsw-alias-brand-primary) 12%,transparent);color:var(--dsw-alias-brand-primary);font-size:11px;font-weight:700;font-variant-numeric:tabular-nums} +.reme-env-card>header>div{min-width:0;flex:1} +.reme-env-card>header h4{margin:0;font-size:13px;font-weight:600;line-height:1.5} +.reme-env-card>header p{display:-webkit-box;margin:2px 0 0;overflow:hidden;color:var(--dsw-alias-label-tertiary);font-size:11px;line-height:1.5;-webkit-box-orient:vertical;-webkit-line-clamp:2} +.reme-env-card>header p.reme-env-summary{display:block;text-overflow:ellipsis;white-space:nowrap} +.reme-env-card>header aside{display:flex;flex:none;align-items:center;gap:8px} +.reme-env-count{flex:none;border-radius:999px;background:var(--dsw-alias-bg-module-platform);padding:2px 9px;color:var(--dsw-alias-label-secondary);font-size:11px;line-height:17px;white-space:nowrap} +.reme-env-card-body{display:flex;flex-direction:column;gap:10px;border-top:1px solid var(--dsw-alias-border-l2);padding:12px 15px 14px} +.reme-env-picked{display:flex;align-items:center;gap:11px;border:1px solid color-mix(in srgb,var(--dsw-alias-brand-primary) 30%,var(--dsw-alias-border-l2));border-radius:11px;background:color-mix(in srgb,var(--dsw-alias-brand-primary) 5%,var(--dsw-alias-bg-layer-3));padding:10px 12px} +.reme-env-picked-mark{width:16px;height:16px;flex:none;box-sizing:border-box;border:1px solid color-mix(in srgb,var(--dsw-alias-brand-primary) 45%,transparent);border-radius:50%;background:var(--dsw-alias-brand-primary);background-clip:content-box;padding:4px} +.reme-env-picked-main{display:flex;min-width:0;flex:1;flex-direction:column;gap:3px} +.reme-env-title{display:flex;min-width:0;flex-wrap:wrap;align-items:center;gap:6px} +.reme-env-title strong{font-size:13px;font-weight:600;line-height:1.5} +.reme-env-tag{flex:none;border:1px solid var(--dsw-alias-border-l2);border-radius:999px;padding:1px 7px;color:var(--dsw-alias-label-secondary);font-size:10.5px;line-height:15px;white-space:nowrap} +.reme-env-tag.ok{border-color:color-mix(in srgb,#21a366 25%,transparent);background:color-mix(in srgb,#21a366 8%,transparent);color:#168653} +.reme-env-tag.brand{border-color:color-mix(in srgb,var(--dsw-alias-brand-primary) 28%,transparent);background:color-mix(in srgb,var(--dsw-alias-brand-primary) 8%,transparent);color:var(--dsw-alias-brand-primary)} +.reme-env-tag.warn{border-color:color-mix(in srgb,#d99416 30%,transparent);background:color-mix(in srgb,#d99416 9%,transparent);color:color-mix(in srgb,#d99416 72%,var(--dsw-alias-label-primary))} +.reme-env-tag.bad{border-color:color-mix(in srgb,var(--dsw-alias-label-error) 25%,transparent);background:color-mix(in srgb,var(--dsw-alias-label-error) 7%,transparent);color:var(--dsw-alias-label-error)} +.reme-env-path{min-width:0;overflow:hidden;color:var(--dsw-alias-label-tertiary);font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:11px;line-height:1.5;text-overflow:ellipsis;white-space:nowrap} +.reme-env-note{margin:0} +.reme-env-picker{display:flex;flex-direction:column;gap:9px} +.reme-env-picker-bar{display:flex;flex-wrap:wrap;align-items:center;gap:8px} +.reme-env-picker-bar .reme-settings-muted{margin:0} +.reme-env-search{box-sizing:border-box;flex:1;min-width:180px;border:1px solid var(--dsw-alias-border-l2);border-radius:9px;background:var(--dsw-alias-bg-layer-3);padding:6px 10px;color:var(--dsw-alias-label-primary);font:inherit;font-size:12px;line-height:1.5} +.reme-env-search:focus-visible{border-color:var(--dsw-alias-brand-primary);outline:0} +.reme-env-options{display:flex;flex-direction:column;overflow:hidden;border:1px solid var(--dsw-alias-border-l2);border-radius:11px;background:var(--dsw-alias-bg-layer-3)} +.reme-env-option{display:flex;align-items:center;gap:8px;padding:8px 11px;cursor:pointer} +.reme-env-option+.reme-env-option{border-top:1px solid var(--dsw-alias-border-l2)} +.reme-env-option:hover{background:color-mix(in srgb,var(--dsw-alias-label-primary) 3%,transparent)} +.reme-env-option input{flex:none;margin:0;accent-color:var(--dsw-alias-brand-primary)} +.reme-env-option strong{flex:none;font-size:12px;font-weight:600;line-height:1.5;white-space:nowrap} +.reme-env-option em{flex:none;color:var(--dsw-alias-label-tertiary);font-size:11px;font-style:normal;line-height:1.5;white-space:nowrap} +.reme-env-option-path{flex:1;min-width:60px;overflow:hidden;text-align:right;color:var(--dsw-alias-label-tertiary);font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:10.5px;line-height:1.5;text-overflow:ellipsis;white-space:nowrap} +.reme-env-more{align-self:flex-start;color:var(--dsw-alias-brand-primary);font-size:12px} +.reme-env-hint{display:flex;flex-direction:column;align-items:flex-start;gap:8px;border:1px dashed var(--dsw-alias-border-l2);border-radius:11px;padding:13px 14px;color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:1.6} +.reme-env-hint strong{color:var(--dsw-alias-label-primary);font-size:13px} +.reme-env-factrow{display:flex;align-items:center;gap:9px} +.reme-env-factrow .reme-env-path{flex:1} +.reme-env-field-label{flex:none;color:var(--dsw-alias-label-secondary);font-size:11.5px;line-height:1.5} +.reme-env-metrics{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px} +.reme-env-metric{min-width:0;border:1px solid var(--dsw-alias-border-l2);border-radius:10px;background:color-mix(in srgb,var(--dsw-alias-bg-module-platform) 45%,var(--dsw-alias-bg-layer-3));padding:9px 11px} +.reme-env-metric>span{display:block;color:var(--dsw-alias-label-tertiary);font-size:10.5px;line-height:1.4} +.reme-env-metric>div{display:flex;min-width:0;flex-wrap:wrap;align-items:center;gap:5px;margin-top:6px} +.reme-env-metric small{color:var(--dsw-alias-label-tertiary);font-size:10px;line-height:1.4} +.reme-env-metric .reme-status-badge{max-width:100%;white-space:normal;align-items:flex-start;text-align:left} +.reme-env-metric .reme-status-badge i{margin-top:5px} +.reme-env-metric-path{min-width:0;overflow:hidden;color:var(--dsw-alias-label-primary);font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:10.5px;line-height:1.5;text-overflow:ellipsis;white-space:nowrap} +.reme-env-cta{display:flex;flex-wrap:wrap;align-items:center;gap:8px;border-top:1px solid var(--dsw-alias-border-l2);padding-top:11px} .reme-env-confirm{flex:1 0 100%;display:flex;flex-wrap:wrap;align-items:center;gap:8px;border:1px solid color-mix(in srgb,var(--dsw-alias-brand-primary) 25%,var(--dsw-alias-border-l2));border-radius:9px;background:color-mix(in srgb,var(--dsw-alias-brand-primary) 5%,transparent);padding:9px 12px} .reme-env-confirm p{flex:1 0 100%;margin:0;color:var(--dsw-alias-label-secondary);font-size:12px;line-height:1.5} -.reme-env-hint-inline{flex:1 0 100%;margin:0} -.reme-env-progress{padding-top:10px} -.reme-env-log{max-height:220px;overflow:auto;margin:6px 0 0;border-radius:8px;background:var(--dsw-alias-bg-layer-3);padding:10px 12px;font-size:11px;line-height:1.5;white-space:pre-wrap;word-break:break-word} -.reme-env-result{padding-top:10px;border-top:1px solid var(--dsw-alias-border-l2)} -.reme-env-inline{display:flex;gap:8px;align-items:center} +.reme-env-mirror{display:flex;align-items:center;gap:6px;color:var(--dsw-alias-label-secondary);font-size:12px} +.reme-env-mirror select{min-height:28px;border:1px solid var(--dsw-alias-border-l2);border-radius:7px;background:var(--dsw-alias-bg-layer-3);color:var(--dsw-alias-label-primary);padding:2px 8px;font:inherit;font-size:12px} +.reme-env-autostart{margin-left:auto;border:0;padding:0;color:var(--dsw-alias-label-secondary);font-size:12px} +.reme-env-progress{display:flex;flex-direction:column;gap:8px} +.reme-env-log{max-height:220px;overflow:auto;margin:0;border-radius:8px;background:var(--dsw-alias-bg-module-platform);padding:10px 12px;font-size:11px;line-height:1.5;white-space:pre-wrap;word-break:break-word} +.reme-env-result{display:flex;flex-direction:column;align-items:flex-start;gap:8px;border-top:1px solid var(--dsw-alias-border-l2);padding-top:11px} +.reme-env-result p{margin:0} +.reme-env-actions{display:flex;flex-wrap:wrap;align-items:center;gap:8px;border-top:1px solid var(--dsw-alias-border-l2);padding-top:11px} +.reme-env-inline{display:flex;align-items:center;gap:8px} .reme-env-inline input{flex:1;min-width:0} -.reme-env-config{padding:4px 0 0} -.reme-env-group{margin:14px 0 0;color:var(--dsw-alias-label-secondary);font-size:12px;font-weight:600;letter-spacing:.02em} -.reme-env-check-inline{padding:0;border:0} +.reme-env-config{padding:2px 0 0} +.reme-env-group{margin:12px 0 0;color:var(--dsw-alias-label-secondary);font-size:11px;font-weight:650;letter-spacing:.02em} +.reme-env .reme-settings-details{margin:0} +.reme-env .reme-settings-details pre{margin:8px 0 0} +@media(max-width:820px){.reme-env-hero{grid-template-columns:1fr}.reme-env-hero-chips{justify-content:flex-start}.reme-env-metrics{grid-template-columns:1fr}.reme-env-option-path{display:none}.reme-env-autostart{margin-left:0}.reme-env-hero-meta{margin-left:0;width:100%}} @media(max-width:760px){.reme-settings-health,.reme-settings-component-grid,.reme-status-component-grid,.reme-status-capability-grid{grid-template-columns:1fr}.reme-settings-result{display:none}.reme-status-overview-hero{grid-template-columns:1fr}.reme-status-overview-actions{grid-column:1;grid-row:auto;flex-direction:row}.reme-status-overview-actions .reme-status-button{flex:1}.reme-status-file-browser{grid-template-columns:1fr}.reme-status-file-browser>aside{max-height:210px;border-right:0;border-bottom:1px solid var(--dsw-alias-border-l2)}.reme-status-file-browser>article{min-height:300px}.reme-status-task-list summary{grid-template-columns:1fr}} `; diff --git a/integrations/dsh/src/pyenv/reme.ts b/integrations/dsh/src/pyenv/reme.ts index 17066ba1..4c07ac9e 100644 --- a/integrations/dsh/src/pyenv/reme.ts +++ b/integrations/dsh/src/pyenv/reme.ts @@ -17,6 +17,8 @@ export interface RemeIO { export interface RemeProbe { installed: boolean; version: string; + /** True when the version satisfies {@link MINIMUM_REME}. */ + versionOk: boolean; } export interface RemeCheck extends RemeProbe { @@ -68,11 +70,15 @@ export async function probeReme( timeoutMs: 10_000, }); if (result.error || result.code !== 0) - return { installed: false, version: "" }; + return { installed: false, version: "", versionOk: false }; const version = parseRemeVersion(result.stdout); - return { installed: version !== "", version }; + return { + installed: version !== "", + version, + versionOk: remeVersionAtLeast(version), + }; } catch { - return { installed: false, version: "" }; + return { installed: false, version: "", versionOk: false }; } } diff --git a/integrations/dsh/src/pyenv/types.ts b/integrations/dsh/src/pyenv/types.ts index 977b3c79..f0ad2c9e 100644 --- a/integrations/dsh/src/pyenv/types.ts +++ b/integrations/dsh/src/pyenv/types.ts @@ -18,7 +18,7 @@ export interface PythonEnv { /** Human-readable discovery channels, e.g. "conda env list; PATH". */ source: string; /** reme-ai installation facts, probed for the interpreter recommendation. */ - reme?: { installed: boolean; version: string }; + reme?: { installed: boolean; version: string; versionOk?: boolean }; } export interface PythonDetection { diff --git a/integrations/dsh/tests/env-detect.test.mjs b/integrations/dsh/tests/env-detect.test.mjs index 8450d08a..020cee9c 100644 --- a/integrations/dsh/tests/env-detect.test.mjs +++ b/integrations/dsh/tests/env-detect.test.mjs @@ -394,10 +394,26 @@ test("detects, classifies and suggests Windows interpreters end to end", async ( assert.equal(store.origin.kind, "windows-store"); assert.equal(store.ok, true); - assert.deepEqual(base.reme, { installed: true, version: "0.4.0.9" }); - assert.deepEqual(named.reme, { installed: true, version: "0.4.0.9" }); - assert.deepEqual(pythonOrg.reme, { installed: true, version: "0.4.1.11" }); - assert.deepEqual(store.reme, { installed: false, version: "" }); + assert.deepEqual(base.reme, { + installed: true, + version: "0.4.0.9", + versionOk: true, + }); + assert.deepEqual(named.reme, { + installed: true, + version: "0.4.0.9", + versionOk: true, + }); + assert.deepEqual(pythonOrg.reme, { + installed: true, + version: "0.4.1.11", + versionOk: true, + }); + assert.deepEqual(store.reme, { + installed: false, + version: "", + versionOk: false, + }); }); test("suggests nothing usable by preferring reme installs, then named conda envs", () => { diff --git a/integrations/dsh/tests/env-tab.test.mjs b/integrations/dsh/tests/env-tab.test.mjs new file mode 100644 index 00000000..6278745b --- /dev/null +++ b/integrations/dsh/tests/env-tab.test.mjs @@ -0,0 +1,231 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import React from "react"; +import { act, create } from "react-test-renderer"; + +import { ReMeEnvironmentTab, envZh } from "../dist/client/env-tab.js"; + +globalThis.IS_REACT_ACT_ENVIRONMENT = true; + +const CONDA = "D:\\ProgramFils\\Miniconda"; +const SUGGESTED = `${CONDA}\\envs\\reme-serve\\python.exe`; + +function env(name, version, reme) { + const root = name === "base" ? CONDA : `${CONDA}\\envs\\${name}`; + return { + exe: `${root}\\python.exe`, + version, + ok: true, + origin: { kind: "conda", envName: name, root }, + source: "conda env list", + ...(reme ? { reme: { installed: true, version: reme } } : {}), + }; +} + +const ENVS = [ + env("base", "3.12.3", "0.4.0.9"), + env("com_env", "3.12.9"), + env("copaw", "3.12.0", "0.3.1.8"), + env("drama", "3.12.13"), + env("mochi", "3.12.13"), + env("mochi_test", "3.12.13"), + env("nl_agent", "3.12.0"), + env("reme-serve", "3.12.13", "0.4.0.9"), +]; + +const RESPONSES = { + "remeEnv/python": { + platform: "win32", + scannedAt: new Date().toISOString(), + envs: ENVS, + suggested: SUGGESTED, + condaRoot: CONDA, + errors: [], + }, + "remeEnv/checkReme": { + installed: true, + version: "0.4.0.9", + versionOk: true, + hasCore: true, + cliPath: `${CONDA}\\envs\\reme-serve\\Scripts\\reme.exe`, + }, + "remeEnv/installStatus": null, + "remeEnv/readEnv": { + exists: true, + envPath: "C:\\Users\\test\\.reme\\.env", + entries: [ + { key: "LLM_BACKEND", value: "dashscope", secret: false, set: true }, + { + key: "LLM_MODEL_NAME", + value: "qwen3.7-plus", + secret: false, + set: true, + }, + ], + }, + "remeEnv/serviceStatus": { + phase: "running", + version: "0.4.0.9", + managed: false, + pid: 98820, + endpoint: "http://127.0.0.1:2333", + }, +}; + +const t = (key) => envZh[key]; + +function textOf(value) { + if (Array.isArray(value)) return value.map(textOf).join(""); + return typeof value === "string" ? value : ""; +} + +function classesOf(node, found = new Map()) { + if (!node || typeof node === "string") return found; + if (Array.isArray(node)) { + node.forEach((child) => classesOf(child, found)); + return found; + } + for (const name of String(node.props.className ?? "").split(" ")) { + if (name) found.set(name, (found.get(name) ?? 0) + 1); + } + (node.children ?? []).forEach((child) => classesOf(child, found)); + return found; +} + +function texts(node, found = []) { + if (!node) return found; + if (typeof node === "string") { + found.push(node); + return found; + } + if (Array.isArray(node)) { + node.forEach((child) => texts(child, found)); + return found; + } + (node.children ?? []).forEach((child) => texts(child, found)); + return found; +} + +async function mount() { + const calls = { mutate: [] }; + const snapshot = { + status: "ready", + writable: true, + revision: 3, + value: { + endpoint: "http://127.0.0.1:2333", + pythonPath: "", + remeWorkspaceDir: "", + autoStart: false, + requestTimeoutMs: 10000, + backgroundTimeoutMs: 3600000, + }, + }; + const scope = { + subscribe: () => () => undefined, + getSnapshot: () => snapshot, + mutate: async (ops, revision) => { + calls.mutate.push({ ops, revision }); + return true; + }, + }; + const rpc = { + call: async (_path, method) => ({ + ok: true, + value: RESPONSES[method] ?? null, + }), + }; + + let renderer; + await act(async () => { + renderer = create( + React.createElement(ReMeEnvironmentTab, { scope, rpc, t }), + ); + }); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 20)); + }); + return { renderer, calls }; +} + +function buttonByText(renderer, label) { + const found = renderer.root.findAll( + (node) => node.type === "button" && textOf(node.props.children) === label, + ); + assert.ok(found.length > 0, `button not found: ${label}`); + return found[0]; +} + +test("renders the status hero and the four setup steps", async () => { + const { renderer } = await mount(); + const json = renderer.toJSON(); + const classes = classesOf(json); + const copy = texts(json).join(" "); + + assert.equal(classes.get("reme-env-hero"), 1); + assert.equal(classes.get("reme-env-card"), 4); + assert.equal(classes.get("reme-env-picked"), 1); + assert.equal(classes.get("reme-env-metrics"), 1); + assert.equal(classes.get("reme-env-autostart"), 1); + + assert.match(copy, /ReMe 本地运行时/); + assert.match(copy, /已就绪,记忆保存在本地/); + assert.match(copy, /http:\/\/127\.0\.0\.1:2333/); + assert.match(copy, /8 个环境/); + assert.match(copy, /服务 · ReMe 0\.4\.0\.9/); + assert.match(copy, /Python 3\.12\.13/); + assert.match(copy, /reme-ai 0\.4\.0\.9/); + assert.match(copy, /C:\\Users\\test\\\.reme\\\.env/); +}); + +test("keeps the interpreter list collapsed until asked, then paginates", async () => { + const { renderer } = await mount(); + assert.equal(classesOf(renderer.toJSON()).get("reme-env-option"), undefined); + + await act(async () => { + buttonByText(renderer, envZh.changeInterpreter).props.onClick(); + }); + const expanded = classesOf(renderer.toJSON()); + assert.equal(expanded.get("reme-env-option"), 6); + assert.equal(expanded.get("reme-env-search"), 1); + assert.equal(expanded.get("reme-env-more"), 1); + + await act(async () => { + renderer.root + .findAll( + (node) => + node.type === "button" && textOf(node.props.children).includes("7"), + )[0] + .props.onClick(); + }); + assert.equal(classesOf(renderer.toJSON()).get("reme-env-option"), 7); +}); + +test("marks the suggested interpreter and explains why", async () => { + const { renderer, calls } = await mount(); + const json = renderer.toJSON(); + const recommended = renderer.root.findAll( + (node) => + String(node.props.className ?? "").includes("reme-env-tag brand") && + textOf(node.props.children) === envZh.recommended, + ); + assert.ok(recommended.length >= 1); + assert.equal(recommended[0].props.title, envZh.recommendWhyReme); + assert.ok(texts(json).join(" ").includes(SUGGESTED)); + + await act(async () => { + buttonByText(renderer, envZh.changeInterpreter).props.onClick(); + }); + const radios = renderer.root.findAll( + (node) => node.type === "input" && node.props.type === "radio", + ); + const target = radios.find((node) => node.props.disabled === false); + assert.ok(target); + await act(async () => { + target.props.onChange(); + }); + assert.deepEqual(calls.mutate.at(-1)?.ops, [ + { op: "set", path: ["pythonPath"], value: ENVS[0].exe }, + ]); +});