Initial ForgeLM import

This commit is contained in:
Evandro Fonseca Junior committed 2026-03-29 19:10:57 -03:00
commit a392b9dc60
4296 files changed
+1212675

No files matched your search

+151
View File
@@ -0,0 +1,151 @@
import { invoke } from "@tauri-apps/api/core";
import { createDefaultRuntimeSnapshot, createRuntimeLifecycleResult } from "./runtime-utils";
import type {
ModelLibraryState,
HuggingFaceModelResult,
RuntimeConfig,
RuntimeGpuInfo,
RuntimeModelFile,
RuntimePickDirResult,
RuntimeSnapshot,
} from "./types";
const STORAGE_KEY = "forgelm_runtime_snapshot_v1";
function canUseLocalStorage(): boolean {
return typeof window !== "undefined" && typeof window.localStorage !== "undefined";
}
function readFallbackSnapshot(): RuntimeSnapshot {
if (!canUseLocalStorage()) {
return createDefaultRuntimeSnapshot();
}
const raw = window.localStorage.getItem(STORAGE_KEY);
if (!raw) {
return createDefaultRuntimeSnapshot();
}
try {
return { ...createDefaultRuntimeSnapshot(), ...JSON.parse(raw) } as RuntimeSnapshot;
} catch {
return createDefaultRuntimeSnapshot();
}
}
function writeFallbackSnapshot(snapshot: RuntimeSnapshot): RuntimeSnapshot {
if (canUseLocalStorage()) {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(snapshot));
}
return snapshot;
}
export async function bootstrapRuntime(): Promise<RuntimeSnapshot> {
try {
return await invoke<RuntimeSnapshot>("bootstrap_runtime_command");
} catch {
return readFallbackSnapshot();
}
}
export async function getRuntimeSnapshot(): Promise<RuntimeSnapshot> {
try {
return await invoke<RuntimeSnapshot>("get_runtime_snapshot_command");
} catch {
return readFallbackSnapshot();
}
}
export async function saveRuntimeConfig(config: RuntimeConfig): Promise<RuntimeSnapshot> {
try {
return await invoke<RuntimeSnapshot>("save_runtime_config_command_handler", { config });
} catch {
const snapshot = readFallbackSnapshot();
return writeFallbackSnapshot({
...snapshot,
active_command: "idle",
last_command_result: createRuntimeLifecycleResult("save", "Save completed."),
config,
});
}
}
export async function startRuntime(): Promise<RuntimeSnapshot> {
try {
return await invoke<RuntimeSnapshot>("start_runtime_command");
} catch {
const snapshot = readFallbackSnapshot();
return writeFallbackSnapshot({
...snapshot,
status: "error",
active_command: "idle",
last_command_result: createRuntimeLifecycleResult(
"start",
"Local runtime control requires the Tauri desktop app.",
"failed",
),
last_error: { code: "tauri_unavailable", message: "Local runtime control requires the Tauri desktop app." },
});
}
}
export async function stopRuntime(): Promise<RuntimeSnapshot> {
try {
return await invoke<RuntimeSnapshot>("stop_runtime_command");
} catch {
const snapshot = readFallbackSnapshot();
return writeFallbackSnapshot({
...snapshot,
status: "stopped",
process_running: false,
ready: false,
active_command: "idle",
last_command_result: createRuntimeLifecycleResult("stop", "Local API stopped."),
});
}
}
export async function queryRuntimeGpu(): Promise<RuntimeGpuInfo | null> {
try {
return await invoke<RuntimeGpuInfo>("query_gpu");
} catch {
return null;
}
}
export async function scanRuntimeModels(dir: string): Promise<RuntimeModelFile[]> {
return invoke<RuntimeModelFile[]>("scan_models", { dir });
}
export async function pickRuntimeModelsDir(): Promise<RuntimePickDirResult | null> {
try {
return await invoke<RuntimePickDirResult>("pick_models_dir");
} catch (error) {
if (String(error) === "cancelled") {
return null;
}
throw error;
}
}
export async function pickRuntimeModelFiles(): Promise<RuntimeModelFile[]> {
try {
return await invoke<RuntimeModelFile[]>("pick_model_files");
} catch (error) {
if (String(error) === "cancelled") {
return [];
}
throw error;
}
}
export async function searchHuggingFaceModels(query: string, token = ""): Promise<HuggingFaceModelResult[]> {
return invoke<HuggingFaceModelResult[]>("search_hugging_face_models_command", {
query,
token: token.trim() || null,
});
}
export async function getModelLibraryState(): Promise<ModelLibraryState> {
return invoke<ModelLibraryState>("get_model_library_state_command");
}
@@ -0,0 +1,606 @@
.runtime-drawer-shell {
position: fixed;
inset: 0;
z-index: 40;
display: flex;
justify-content: flex-end;
}
.runtime-drawer-backdrop {
flex: 1;
border: 0;
background: rgba(5, 6, 10, 0.58);
backdrop-filter: blur(6px);
}
.runtime-drawer {
width: min(560px, 100%);
height: 100%;
display: flex;
flex-direction: column;
background:
radial-gradient(circle at top right, rgba(72, 166, 255, 0.14), transparent 24%),
linear-gradient(180deg, #10161f, #0b1017 72%);
border-left: 1px solid rgba(255, 255, 255, 0.07);
box-shadow: -28px 0 70px rgba(0, 0, 0, 0.48);
}
.runtime-drawer-header,
.runtime-drawer-footer {
padding: 20px 22px;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
}
.runtime-drawer-header-sticky,
.runtime-drawer-footer-sticky {
position: sticky;
z-index: 2;
backdrop-filter: blur(16px);
background: rgba(10, 16, 22, 0.92);
}
.runtime-drawer-header-sticky {
top: 0;
}
.runtime-drawer-footer-sticky {
bottom: 0;
}
.runtime-drawer-footer {
border-bottom: 0;
border-top: 1px solid rgba(255, 255, 255, 0.05);
display: flex;
gap: 10px;
}
.runtime-drawer-kicker {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
color: #78bdf4;
margin-bottom: 6px;
}
.runtime-drawer-header h2 {
font-size: 28px;
line-height: 1.1;
letter-spacing: -0.03em;
}
.runtime-drawer-copy {
margin-top: 10px;
max-width: 34ch;
color: var(--text2);
line-height: 1.55;
}
.runtime-drawer-summary-row {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
margin-top: 14px;
}
.runtime-drawer-summary-badge,
.runtime-live-chip,
.runtime-live-chip-value,
.runtime-field-badge {
border-radius: 999px;
min-height: 28px;
display: inline-flex;
align-items: center;
padding: 0 10px;
font-size: 12px;
font-weight: 700;
}
.runtime-drawer-summary-badge,
.runtime-live-chip-value {
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.06);
color: var(--text2);
}
.runtime-drawer-summary-badge.is-dirty,
.runtime-field-badge {
border: 1px solid rgba(72, 166, 255, 0.45);
background: rgba(72, 166, 255, 0.16);
color: #9cd2ff;
}
.runtime-live-chip {
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.03);
color: var(--text3);
}
.runtime-field-badge-danger {
border-color: rgba(240, 64, 64, 0.45);
background: rgba(240, 64, 64, 0.14);
color: #ffb7b7;
}
.runtime-field-badge-pending {
border-color: rgba(243, 211, 139, 0.32);
background: rgba(243, 211, 139, 0.12);
color: #f3d38b;
}
.runtime-drawer-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.runtime-drawer-close {
width: 36px;
height: 36px;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.04);
color: var(--text);
font-size: 26px;
line-height: 1;
cursor: pointer;
}
.runtime-drawer-body {
flex: 1;
overflow-y: auto;
padding: 20px 22px 28px;
}
.runtime-drawer-section {
margin-bottom: 18px;
}
.runtime-section-card,
.runtime-overview-card {
border: 1px solid rgba(255, 255, 255, 0.07);
border-radius: 18px;
background: rgba(255, 255, 255, 0.035);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.03);
}
.runtime-section-card {
padding: 16px;
}
.runtime-overview-card {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 14px;
padding: 18px;
margin-bottom: 18px;
}
.runtime-overview-label {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text3);
margin-bottom: 8px;
}
.runtime-overview-value {
color: var(--text);
font-size: 14px;
line-height: 1.45;
}
.runtime-overview-mono,
.runtime-static-value-mono {
font-family: "IBM Plex Mono", "SFMono-Regular", Consolas, monospace;
font-size: 13px;
}
.runtime-drawer-section-title,
.runtime-field-label {
display: block;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text3);
margin-bottom: 8px;
}
.runtime-segmented {
display: flex;
gap: 8px;
}
.runtime-segmented-btn,
.runtime-footer-btn {
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.03);
color: var(--text);
font-family: inherit;
font-size: 13px;
font-weight: 700;
cursor: pointer;
transition: border-color 0.12s ease, background 0.12s ease, transform 0.12s ease;
}
.runtime-segmented-btn {
flex: 1;
padding: 11px 12px;
}
.runtime-segmented-btn.active {
border-color: rgba(72, 166, 255, 0.55);
background: rgba(72, 166, 255, 0.16);
}
.runtime-field-input,
.runtime-static-value {
width: 100%;
min-height: 44px;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.03);
color: var(--text);
padding: 11px 13px;
font-size: 14px;
font-family: inherit;
}
.runtime-field-input.is-draft,
.runtime-static-value.is-draft,
.runtime-segmented-btn.is-draft,
.runtime-chip-selectable.is-draft {
border-color: rgba(72, 166, 255, 0.45);
box-shadow: 0 0 0 1px rgba(72, 166, 255, 0.14);
}
.runtime-field-input.is-invalid,
.runtime-static-value.is-invalid {
border-color: rgba(240, 64, 64, 0.45);
box-shadow: 0 0 0 1px rgba(240, 64, 64, 0.12);
}
.runtime-static-value {
display: flex;
align-items: center;
}
.runtime-field-select {
appearance: none;
}
.runtime-static-value-path {
word-break: break-all;
}
.runtime-grid-two {
display: grid;
gap: 12px;
}
.runtime-search-grow {
flex: 1;
}
.runtime-field-row {
display: flex;
justify-content: space-between;
gap: 14px;
align-items: flex-end;
margin-bottom: 14px;
}
.runtime-field-row-top {
align-items: flex-start;
}
.runtime-field-support {
color: var(--text2);
font-size: 13px;
line-height: 1.5;
}
.runtime-inline-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.runtime-inline-btn,
.runtime-preset,
.runtime-chip {
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.04);
color: var(--text);
font-family: inherit;
cursor: pointer;
}
.runtime-inline-btn {
min-height: 40px;
padding: 0 12px;
font-size: 13px;
font-weight: 700;
}
.runtime-inline-btn-danger {
border-color: rgba(240, 64, 64, 0.45);
background: rgba(240, 64, 64, 0.14);
color: #ffb7b7;
}
.runtime-inline-btn:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.runtime-chip-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 12px;
}
.runtime-chip {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
font-size: 12px;
}
.runtime-chip-selectable {
transition: border-color 0.12s ease, background 0.12s ease, transform 0.12s ease;
}
.runtime-chip-selectable.active {
border-color: rgba(72, 166, 255, 0.55);
background: rgba(72, 166, 255, 0.16);
}
.runtime-chip span {
color: var(--text3);
}
.runtime-preset-row {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin-top: 12px;
}
.runtime-preset-row-first {
margin-top: 0;
margin-bottom: 14px;
}
.runtime-preset {
padding: 8px 12px;
font-size: 12px;
font-weight: 700;
}
.runtime-preset.active {
border-color: rgba(72, 166, 255, 0.55);
background: rgba(72, 166, 255, 0.16);
}
.runtime-search-results {
display: grid;
gap: 10px;
margin-top: 14px;
}
.runtime-search-card {
border: 1px solid rgba(255, 255, 255, 0.07);
border-radius: 14px;
background: rgba(255, 255, 255, 0.03);
padding: 12px 14px;
}
.runtime-search-title {
color: var(--text);
font-size: 14px;
font-weight: 700;
margin-bottom: 4px;
}
.runtime-verified-file-list {
display: grid;
gap: 8px;
margin-top: 10px;
}
.runtime-verified-file {
display: flex;
flex-direction: column;
gap: 2px;
padding: 10px 12px;
border-radius: 12px;
background: rgba(9, 14, 21, 0.72);
border: 1px solid rgba(255, 255, 255, 0.05);
word-break: break-word;
}
.runtime-verified-file-meta {
color: var(--text3);
font-size: 12px;
}
.runtime-checkbox-row {
display: flex;
gap: 10px;
align-items: flex-start;
color: var(--text2);
font-size: 14px;
line-height: 1.5;
}
.runtime-error-banner {
border-radius: 12px;
border: 1px solid rgba(240, 64, 64, 0.18);
background: rgba(240, 64, 64, 0.08);
color: #ffb7b7;
padding: 12px 14px;
line-height: 1.5;
}
.runtime-warning-banner {
border-radius: 12px;
border: 1px solid rgba(243, 211, 139, 0.18);
background: rgba(243, 211, 139, 0.08);
color: #f3d38b;
padding: 12px 14px;
line-height: 1.5;
}
.runtime-apply-guidance {
border-radius: 12px;
border: 1px solid rgba(72, 166, 255, 0.2);
background: rgba(72, 166, 255, 0.08);
color: #9cd2ff;
padding: 12px 14px;
line-height: 1.5;
}
.runtime-apply-guidance.is-warning,
.runtime-apply-guidance-blocked {
border-color: rgba(243, 211, 139, 0.22);
background: rgba(243, 211, 139, 0.08);
color: #f3d38b;
}
.runtime-apply-guidance-failed {
border-color: rgba(240, 64, 64, 0.22);
background: rgba(240, 64, 64, 0.08);
color: #ffb7b7;
}
.runtime-apply-guidance-succeeded {
border-color: rgba(48, 209, 88, 0.22);
background: rgba(48, 209, 88, 0.08);
color: #baf0c8;
}
.runtime-field-state {
margin-top: 10px;
padding: 10px 12px;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.06);
background: rgba(255, 255, 255, 0.03);
}
.runtime-field-state.is-draft {
border-color: rgba(72, 166, 255, 0.24);
background: rgba(72, 166, 255, 0.08);
}
.runtime-field-state.is-invalid {
border-color: rgba(240, 64, 64, 0.26);
background: rgba(240, 64, 64, 0.08);
}
.runtime-field-state-row {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
}
.runtime-field-state-label {
font-size: 12px;
font-weight: 700;
color: var(--text3);
text-transform: uppercase;
letter-spacing: 0.06em;
}
.runtime-field-state-value,
.runtime-field-error-text {
font-size: 13px;
line-height: 1.5;
}
.runtime-field-state-value {
color: var(--text2);
}
.runtime-field-error-text {
margin-top: 6px;
color: #ffb7b7;
}
.runtime-footer-btn {
flex: 1;
padding: 12px 14px;
}
.runtime-footer-main-actions {
display: flex;
gap: 10px;
flex: 1;
}
.runtime-footer-btn-tertiary {
flex: 1.2;
}
.runtime-discard-confirmation {
border-top: 1px solid rgba(255, 255, 255, 0.05);
padding: 14px 22px 20px;
background: rgba(20, 32, 45, 0.94);
}
.runtime-discard-copy {
color: var(--text2);
line-height: 1.5;
margin-bottom: 10px;
}
.runtime-footer-btn:active:not(:disabled),
.runtime-segmented-btn:active:not(:disabled) {
transform: scale(0.98);
}
.runtime-footer-btn:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.runtime-footer-btn-secondary:hover:not(:disabled) {
border-color: var(--accent);
}
.runtime-footer-btn-primary {
color: #fff;
border-color: rgba(72, 166, 255, 0.55);
background: linear-gradient(135deg, #2c8ce8, #49c0ff);
}
@media (max-width: 720px) {
.runtime-drawer {
width: 100%;
}
.runtime-overview-card,
.runtime-grid-two,
.runtime-field-row {
grid-template-columns: 1fr;
flex-direction: column;
align-items: stretch;
}
.runtime-drawer-footer {
flex-direction: column;
}
.runtime-footer-main-actions {
width: 100%;
}
}
@@ -0,0 +1,962 @@
import { useEffect, useMemo, useRef, useState } from "react";
import {
getModelLibraryState,
pickRuntimeModelFiles,
pickRuntimeModelsDir,
queryRuntimeGpu,
scanRuntimeModels,
searchHuggingFaceModels,
} from "../api";
import {
chooseModel,
createRuntimeConfigPatch,
dedupeModels,
getLocalRuntimeStartReason,
pathName,
resolveDraftModelSelection,
toModelFile,
} from "../runtime-settings-form";
import {
createRuntimeDraftFieldState,
type ModelLibraryState,
type HuggingFaceModelResult,
type RuntimeConfig,
type RuntimeDraftFieldKey,
type RuntimeDraftFieldMeta,
type RuntimeDraftFieldState,
type RuntimeGpuInfo,
type RuntimeLifecycleResult,
type RuntimeModelFile,
type RuntimeSnapshot,
} from "../types";
import "./RuntimeSettingsDrawer.css";
const CTX_PRESETS = [2048, 4096, 8192, 16384, 32768, 65536];
const REMOTE_PRESET_TEMPLATES = [
{ id: "openai", label: "OpenAI", provider: "open_ai", baseUrl: "https://api.openai.com/v1" },
{ id: "openrouter", label: "OpenRouter", provider: "open_router", baseUrl: "https://openrouter.ai/api/v1" },
{ id: "anthropic", label: "Anthropic", provider: "anthropic", baseUrl: "https://api.anthropic.com/v1" },
] as const;
interface Props {
open: boolean;
snapshot: RuntimeSnapshot;
busy: boolean;
activeLifecycleCommand?: RuntimeSnapshot["active_command"];
lastLifecycleResult?: RuntimeLifecycleResult;
lifecycleBusy?: boolean;
applyImpact?: "immediate" | "restart_required" | "save_before_apply";
onClose: () => void;
onSave: (patch: Partial<RuntimeConfig>) => Promise<RuntimeSnapshot>;
onStart: () => Promise<unknown>;
onStop: () => Promise<unknown>;
draftConfig?: RuntimeConfig;
savedBaseline?: RuntimeConfig;
draftDirty?: boolean;
draftFields?: RuntimeDraftFieldState;
onUpdateDraft?: (patch: Partial<RuntimeConfig>) => void;
onUpdateDraftFieldMeta?: (field: RuntimeDraftFieldKey, patch: Partial<RuntimeDraftFieldMeta>) => void;
onResetDraft?: () => void;
}
function valuesEqual(left: unknown, right: unknown): boolean {
return JSON.stringify(left) === JSON.stringify(right);
}
function maskSecret(value: string): string {
if (!value.trim()) return "Not configured";
return `${"*".repeat(Math.max(4, Math.min(12, value.trim().length)))} (${value.trim().length} chars)`;
}
function formatReferenceValue(field: keyof RuntimeConfig, value: RuntimeConfig[keyof RuntimeConfig]): string {
switch (field) {
case "connection_mode":
return value === "remote" ? "Remote API" : "Local API";
case "start_policy":
return value === "on_app_launch" ? "Start on app launch" : "Start manually";
case "server_url":
return String(value || "Not configured");
case "api_key":
return maskSecret(String(value || ""));
case "model_path":
return value ? pathName(String(value)) : "No model selected";
case "models_dir":
return String(value || "No folder selected");
case "addr":
return `http://${String(value || "127.0.0.1:8080")}`;
case "gpu_layers":
return `${String(value)} GPU layers`;
case "ctx_size":
return `${String(value)} context tokens`;
case "selected_model_id":
return String(value || "No managed model selected");
case "hugging_face_token":
return maskSecret(String(value || ""));
default:
return Array.isArray(value) ? `${value.length} configured` : String(value || "-");
}
}
export default function RuntimeSettingsDrawer({
open,
snapshot,
busy,
activeLifecycleCommand = snapshot.active_command,
lastLifecycleResult = snapshot.last_command_result,
lifecycleBusy = false,
applyImpact = "immediate",
onClose,
onSave,
onStart,
onStop,
draftConfig: controlledDraftConfig,
savedBaseline: controlledSavedBaseline,
draftDirty: controlledDraftDirty,
draftFields: controlledDraftFields,
onUpdateDraft,
onUpdateDraftFieldMeta,
onResetDraft,
}: Props) {
const draftConfig = controlledDraftConfig ?? snapshot.config;
const savedBaseline = controlledSavedBaseline ?? snapshot.config;
const draftFields = controlledDraftFields ?? createRuntimeDraftFieldState();
const draftDirty = controlledDraftDirty ?? !valuesEqual(draftConfig, savedBaseline);
const draftConfigRef = useRef(draftConfig);
const requestIdsRef = useRef<Record<string, number>>({});
const [scannedModels, setScannedModels] = useState<RuntimeModelFile[]>([]);
const [gpuInfo, setGpuInfo] = useState<RuntimeGpuInfo | null>(null);
const [gpuError, setGpuError] = useState("");
const [localError, setLocalError] = useState("");
const [scanning, setScanning] = useState(false);
const [hfQuery, setHfQuery] = useState("");
const [hfResults, setHfResults] = useState<HuggingFaceModelResult[]>([]);
const [hfLoading, setHfLoading] = useState(false);
const [libraryState, setLibraryState] = useState<ModelLibraryState>({ entries: [], managed_dir: "" });
const [confirmDiscard, setConfirmDiscard] = useState(false);
useEffect(() => {
draftConfigRef.current = draftConfig;
}, [draftConfig]);
const updateDraft = (patch: Partial<RuntimeConfig>) => {
onUpdateDraft?.(patch);
};
const updateDraftFieldMeta = (field: RuntimeDraftFieldKey, patch: Partial<RuntimeDraftFieldMeta>) => {
onUpdateDraftFieldMeta?.(field, patch);
};
const beginRequest = (key: string) => {
const nextId = (requestIdsRef.current[key] ?? 0) + 1;
requestIdsRef.current[key] = nextId;
return nextId;
};
const isLatestRequest = (key: string, requestId: number) => requestIdsRef.current[key] === requestId;
const extraModels = useMemo(
() => draftConfig.extra_model_paths.map(toModelFile),
[draftConfig.extra_model_paths],
);
const allModels = useMemo(() => {
const merged = dedupeModels([...scannedModels, ...extraModels]);
if (draftConfig.model_path && !merged.some((model) => model.path === draftConfig.model_path)) {
return [toModelFile(draftConfig.model_path), ...merged];
}
return merged;
}, [draftConfig.model_path, extraModels, scannedModels]);
const dirtyFieldCount = useMemo(() => {
const trackedFields: Array<keyof RuntimeConfig> = [
"connection_mode",
"server_url",
"api_key",
"models_dir",
"model_path",
"addr",
"ctx_size",
"gpu_layers",
"start_policy",
"hugging_face_token",
];
return trackedFields.filter((field) => !valuesEqual(draftConfig[field], savedBaseline[field])).length;
}, [draftConfig, savedBaseline]);
const hasInvalidDraft = Object.values(draftFields).some((meta) => meta?.invalid);
const hasPendingDraft = Object.values(draftFields).some((meta) => meta?.pending);
const hasBlockingDraftState = hasInvalidDraft || hasPendingDraft;
const hasPendingModeChange = draftConfig.connection_mode !== savedBaseline.connection_mode;
const restartRequiredWhileRunning =
draftDirty && draftConfig.connection_mode === "local" && snapshot.process_running && applyImpact === "restart_required";
const maxLayers = gpuInfo?.recommended_layers ?? 200;
const startReason = getLocalRuntimeStartReason({
mode: draftConfig.connection_mode,
binaryPath: snapshot.binary_path,
selectedModel: draftConfig.model_path || "",
});
const isFieldDirty = (field: keyof RuntimeConfig) => !valuesEqual(draftConfig[field], savedBaseline[field]);
const fieldMeta = (field: RuntimeDraftFieldKey) => draftFields[field] ?? { invalid: false, pending: false, message: null };
const showLifecycleResult = lastLifecycleResult.command !== "idle" && !lifecycleBusy;
const applyGuidance = !draftDirty || draftConfig.connection_mode !== "local"
? null
: hasPendingModeChange || applyImpact === "save_before_apply"
? "Stop the local API or save first before applying this change."
: applyImpact === "restart_required"
? "Restart required to apply saved changes."
: "Applies immediately after save.";
const saveLabel = activeLifecycleCommand === "save" ? "Saving changes..." : "Save changes";
const tertiaryLabel = hasPendingModeChange
? "Save mode before applying"
: activeLifecycleCommand === "start"
? "Starting local API..."
: activeLifecycleCommand === "stop"
? "Stopping local API..."
: restartRequiredWhileRunning
? "Restart required to apply saved changes"
: snapshot.process_running
? "Stop local API"
: "Save and start local API";
const renderFieldState = (field: RuntimeDraftFieldKey) => {
const meta = fieldMeta(field);
const dirty = isFieldDirty(field);
if (!dirty && !meta.invalid && !meta.pending) {
return null;
}
return (
<div className={`runtime-field-state${dirty ? " is-draft" : ""}${meta.invalid ? " is-invalid" : ""}`}>
<div className="runtime-field-state-row">
{dirty && <span className="runtime-field-badge">Draft</span>}
{meta.invalid && <span className="runtime-field-badge runtime-field-badge-danger">Invalid</span>}
{meta.pending && <span className="runtime-field-badge runtime-field-badge-pending">Pending</span>}
{dirty && (
<>
<span className="runtime-field-state-label">Live now</span>
<span className="runtime-field-state-value">{formatReferenceValue(field, savedBaseline[field])}</span>
</>
)}
</div>
{meta.message && <div className="runtime-field-error-text">{meta.message}</div>}
</div>
);
};
const applyResolvedModelState = (models: RuntimeModelFile[]) => {
const currentDraft = draftConfigRef.current;
const resolved = resolveDraftModelSelection(currentDraft.model_path || "", models, currentDraft.extra_model_paths.map(toModelFile));
updateDraft({
model_path: resolved.nextModel || null,
selected_model_id:
resolved.invalid || resolved.nextModel === currentDraft.model_path
? currentDraft.selected_model_id
: null,
});
updateDraftFieldMeta("model_path", {
invalid: resolved.invalid,
pending: false,
message: resolved.message,
});
};
const loadModelLibrary = async () => {
const requestId = beginRequest("library");
try {
const state = await getModelLibraryState();
if (!isLatestRequest("library", requestId)) return;
setLibraryState(state);
} catch (error) {
if (!isLatestRequest("library", requestId)) return;
setLocalError(String(error));
}
};
const loadGpuInfo = async () => {
const requestId = beginRequest("gpu");
try {
const info = await queryRuntimeGpu();
if (!isLatestRequest("gpu", requestId)) return;
setGpuInfo(info);
setGpuError("");
if (info && draftConfigRef.current.gpu_layers === 0) {
updateDraft({ gpu_layers: info.recommended_layers });
}
} catch (error) {
if (!isLatestRequest("gpu", requestId)) return;
setGpuError(String(error));
}
};
const handleRescan = async (dir = draftConfigRef.current.models_dir || "") => {
if (!dir.trim()) return;
const requestId = beginRequest("model-selection");
setScanning(true);
setLocalError("");
updateDraftFieldMeta("models_dir", { pending: true, invalid: false, message: null });
updateDraftFieldMeta("model_path", { pending: true });
try {
const models = await scanRuntimeModels(dir.trim());
if (!isLatestRequest("model-selection", requestId)) return;
setScannedModels(models);
applyResolvedModelState(models);
updateDraftFieldMeta("models_dir", { pending: false, invalid: false, message: null });
} catch (error) {
if (!isLatestRequest("model-selection", requestId)) return;
setLocalError(String(error));
setScannedModels([]);
updateDraftFieldMeta("models_dir", { pending: false });
updateDraftFieldMeta("model_path", { pending: false });
} finally {
if (isLatestRequest("model-selection", requestId)) {
setScanning(false);
}
}
};
const handlePickDir = async () => {
const requestId = beginRequest("model-selection");
setLocalError("");
updateDraftFieldMeta("models_dir", { pending: true, invalid: false, message: null });
updateDraftFieldMeta("model_path", { pending: true });
try {
const result = await pickRuntimeModelsDir();
if (!isLatestRequest("model-selection", requestId) || !result) return;
updateDraft({ models_dir: result.dir });
setScannedModels(result.models);
applyResolvedModelState(result.models);
updateDraftFieldMeta("models_dir", { pending: false, invalid: false, message: null });
} catch (error) {
if (!isLatestRequest("model-selection", requestId)) return;
setLocalError(String(error));
updateDraftFieldMeta("models_dir", { pending: false });
updateDraftFieldMeta("model_path", { pending: false });
}
};
const handlePickFiles = async () => {
const requestId = beginRequest("model-selection");
setLocalError("");
updateDraftFieldMeta("model_path", { pending: true });
try {
const picked = await pickRuntimeModelFiles();
if (!isLatestRequest("model-selection", requestId) || picked.length === 0) {
updateDraftFieldMeta("model_path", { pending: false });
return;
}
const currentDraft = draftConfigRef.current;
const nextExtra = dedupeModels([...currentDraft.extra_model_paths.map(toModelFile), ...picked]);
const nextModel = currentDraft.model_path || nextExtra[0]?.path || "";
updateDraft({
extra_model_paths: nextExtra.map((model) => model.path),
model_path: nextModel || null,
selected_model_id: currentDraft.model_path ? currentDraft.selected_model_id : null,
});
updateDraftFieldMeta("model_path", { pending: false, invalid: false, message: null });
} catch (error) {
if (!isLatestRequest("model-selection", requestId)) return;
setLocalError(String(error));
updateDraftFieldMeta("model_path", { pending: false });
}
};
const handleHubSearch = async () => {
if (!hfQuery.trim()) {
setHfResults([]);
return;
}
const requestId = beginRequest("hf-search");
setLocalError("");
setHfLoading(true);
try {
const results = await searchHuggingFaceModels(hfQuery.trim(), draftConfig.hugging_face_token.trim());
if (!isLatestRequest("hf-search", requestId)) return;
setHfResults(results);
} catch (error) {
if (!isLatestRequest("hf-search", requestId)) return;
setLocalError(String(error));
setHfResults([]);
} finally {
if (isLatestRequest("hf-search", requestId)) {
setHfLoading(false);
}
}
};
const handleSave = async () => {
setLocalError("");
try {
await onSave(buildPatch());
setConfirmDiscard(false);
} catch (error) {
setLocalError(String(error));
}
};
const handleSaveAndStart = async () => {
setLocalError("");
if (snapshot.process_running) {
try {
await onStop();
} catch (error) {
setLocalError(String(error));
}
return;
}
try {
const next = await onSave(buildPatch());
if (next.config.connection_mode !== "local") return;
if (!next.config.model_path?.trim()) return;
if (next.last_error?.code === "restart_required") return;
await onStart();
} catch (error) {
setLocalError(String(error));
}
};
useEffect(() => {
if (!open) return;
setConfirmDiscard(false);
setLocalError("");
setHfResults([]);
void loadModelLibrary();
void loadGpuInfo();
if (draftConfig.connection_mode === "local" && draftConfig.models_dir?.trim()) {
void handleRescan(draftConfig.models_dir);
}
}, [open]);
if (!open) return null;
const buildPatch = (): RuntimeConfig => ({
...createRuntimeConfigPatch(
savedBaseline,
{
mode: draftConfig.connection_mode,
startPolicy: draftConfig.start_policy,
serverUrl: draftConfig.server_url,
apiKey: draftConfig.api_key,
modelsDir: draftConfig.models_dir || "",
selectedModel: draftConfig.model_path || "",
selectedModelId: draftConfig.selected_model_id,
addr: draftConfig.addr,
gpuLayers: draftConfig.gpu_layers,
ctxSize: draftConfig.ctx_size,
extraModels,
},
maxLayers,
),
hugging_face_token: draftConfig.hugging_face_token.trim(),
remote_presets: draftConfig.remote_presets?.length
? draftConfig.remote_presets
: REMOTE_PRESET_TEMPLATES.map((preset) => ({
id: preset.id,
label: preset.label,
provider: preset.provider,
base_url: preset.baseUrl,
default_model: "",
})),
});
return (
<div className="runtime-drawer-shell" role="dialog" aria-modal="true" aria-label="Draft settings">
<button
className="runtime-drawer-backdrop"
onClick={() => {
setConfirmDiscard(false);
onClose();
}}
aria-label="Close runtime settings"
/>
<aside className="runtime-drawer">
<div className="runtime-drawer-header runtime-drawer-header-sticky">
<div>
<div className="runtime-drawer-kicker">Draft settings</div>
<h2>Draft settings</h2>
<div className="runtime-drawer-summary-row">
<span className={`runtime-drawer-summary-badge${draftDirty ? " is-dirty" : ""}`}>
{draftDirty ? `${dirtyFieldCount} unsaved ${dirtyFieldCount === 1 ? "change" : "changes"}` : "No draft changes yet"}
</span>
<span className="runtime-live-chip">Live now</span>
<span className="runtime-live-chip-value">
{snapshot.status === "ready"
? "Local API ready"
: snapshot.config.connection_mode === "remote"
? "Remote API mode"
: "Local API setup"}
</span>
</div>
<p className="runtime-drawer-copy">
Edit runtime settings as a draft. Close keeps this draft in-session until you save or discard it.
</p>
</div>
<button
className="runtime-drawer-close"
onClick={() => {
setConfirmDiscard(false);
onClose();
}}
type="button"
aria-label="Close runtime settings"
>
×
</button>
</div>
<div className="runtime-drawer-body">
<section className="runtime-overview-card">
<div>
<div className="runtime-overview-label">Current status</div>
<div className="runtime-overview-value">
{snapshot.status === "ready"
? "Local API ready"
: snapshot.config.connection_mode === "remote"
? "Remote API mode"
: "Local API setup"}
</div>
</div>
<div>
<div className="runtime-overview-label">Endpoint</div>
<div className="runtime-overview-value runtime-overview-mono">
{snapshot.config.connection_mode === "remote"
? snapshot.config.server_url || "Not configured"
: `http://${snapshot.config.addr}`}
</div>
</div>
<div>
<div className="runtime-overview-label">Saved model</div>
<div className="runtime-overview-value">
{savedBaseline.model_path ? pathName(savedBaseline.model_path) : "No model saved"}
</div>
</div>
</section>
<section className="runtime-drawer-section">
<div className="runtime-drawer-section-title">Connection mode</div>
<div className="runtime-segmented" role="tablist" aria-label="Connection mode">
<button
className={`runtime-segmented-btn${draftConfig.connection_mode === "local" ? " active" : ""}${isFieldDirty("connection_mode") ? " is-draft" : ""}`}
onClick={() => updateDraft({ connection_mode: "local" })}
type="button"
>
Local API
</button>
<button
className={`runtime-segmented-btn${draftConfig.connection_mode === "remote" ? " active" : ""}${isFieldDirty("connection_mode") ? " is-draft" : ""}`}
onClick={() => updateDraft({ connection_mode: "remote" })}
type="button"
>
Remote API
</button>
</div>
{isFieldDirty("connection_mode") && (
<div className="runtime-field-state is-draft">
<div className="runtime-field-state-row">
<span className="runtime-field-badge">Draft</span>
<span className="runtime-field-state-label">Live now</span>
<span className="runtime-field-state-value">
{formatReferenceValue("connection_mode", savedBaseline.connection_mode)}
</span>
</div>
</div>
)}
</section>
{draftConfig.connection_mode === "remote" ? (
<section className="runtime-drawer-section runtime-section-card">
<div className="runtime-field-label">Provider presets</div>
<div className="runtime-preset-row runtime-preset-row-first">
{REMOTE_PRESET_TEMPLATES.map((preset) => (
<button
key={preset.id}
className={`runtime-preset${draftConfig.server_url.trim() === preset.baseUrl ? " active" : ""}`}
onClick={() => updateDraft({ server_url: preset.baseUrl })}
type="button"
>
{preset.label}
</button>
))}
</div>
<label className="runtime-field-label" htmlFor="runtime-remote-url">Remote server URL</label>
<input
id="runtime-remote-url"
className={`runtime-field-input${isFieldDirty("server_url") ? " is-draft" : ""}${fieldMeta("server_url").invalid ? " is-invalid" : ""}`}
value={draftConfig.server_url}
onChange={(event) => updateDraft({ server_url: event.target.value })}
placeholder="https://api.example.com"
type="text"
/>
{renderFieldState("server_url")}
<label className="runtime-field-label" htmlFor="runtime-api-key">API key</label>
<input
id="runtime-api-key"
className={`runtime-field-input${isFieldDirty("api_key") ? " is-draft" : ""}${fieldMeta("api_key").invalid ? " is-invalid" : ""}`}
value={draftConfig.api_key}
onChange={(event) => updateDraft({ api_key: event.target.value })}
placeholder="sk-..."
type="password"
/>
{renderFieldState("api_key")}
</section>
) : (
<>
<section className="runtime-drawer-section runtime-section-card runtime-grid-two">
<div>
<div className="runtime-field-label">Managed endpoint</div>
<div className="runtime-static-value runtime-static-value-mono">http://{draftConfig.addr.trim() || snapshot.config.addr}</div>
</div>
<div>
<div className="runtime-field-label">Binary discovery</div>
<div className="runtime-static-value runtime-static-value-path">{snapshot.binary_path || "Binary not found yet"}</div>
</div>
</section>
<section className="runtime-drawer-section runtime-section-card">
<div className="runtime-field-row runtime-field-row-top">
<div>
<div className="runtime-field-label">Managed library</div>
<div className="runtime-field-support">
App-managed downloads live outside your scanned folders so they survive app updates.
</div>
</div>
</div>
<div className="runtime-static-value runtime-static-value-path">
{libraryState.managed_dir || "Loading managed storage path..."}
</div>
{libraryState.entries.length > 0 ? (
<div className="runtime-chip-list">
{libraryState.entries.map((entry) => (
<button
key={entry.id}
className={`runtime-chip runtime-chip-selectable${draftConfig.selected_model_id === entry.id ? " active" : ""}${isFieldDirty("model_path") ? " is-draft" : ""}`}
onClick={() => updateDraft({ model_path: entry.path, selected_model_id: entry.id })}
type="button"
title={entry.path}
>
{entry.label}
</button>
))}
</div>
) : (
<div className="runtime-field-support">No managed downloads yet.</div>
)}
</section>
<section className="runtime-drawer-section runtime-section-card">
<div className="runtime-field-row">
<div>
<div className="runtime-field-label">Models folder</div>
<div className={`runtime-static-value runtime-static-value-path${isFieldDirty("models_dir") ? " is-draft" : ""}${fieldMeta("models_dir").invalid ? " is-invalid" : ""}`}>
{draftConfig.models_dir || "Choose a folder or add individual files"}
</div>
</div>
<div className="runtime-inline-actions">
<button className="runtime-inline-btn" onClick={handlePickDir} type="button">Browse</button>
<button className="runtime-inline-btn" onClick={() => { void handleRescan(); }} type="button" disabled={!draftConfig.models_dir?.trim() || scanning}>
{scanning ? "Scanning..." : "Rescan"}
</button>
<button className="runtime-inline-btn" onClick={handlePickFiles} type="button">Add files</button>
</div>
</div>
{renderFieldState("models_dir")}
<label className="runtime-field-label" htmlFor="runtime-model-select">Selected model</label>
<select
id="runtime-model-select"
className={`runtime-field-input runtime-field-select${isFieldDirty("model_path") ? " is-draft" : ""}${fieldMeta("model_path").invalid ? " is-invalid" : ""}`}
value={draftConfig.model_path || ""}
onChange={(event) =>
updateDraft({
model_path: event.target.value || null,
selected_model_id: null,
})
}
>
<option value="">Choose a model</option>
{allModels.map((model) => (
<option key={model.path} value={model.path}>{model.name}</option>
))}
</select>
{renderFieldState("model_path")}
{extraModels.length > 0 && (
<div className="runtime-chip-list">
{extraModels.map((model) => (
<button
key={model.path}
className="runtime-chip"
onClick={() => {
const next = extraModels.filter((entry) => entry.path !== model.path);
updateDraft({
extra_model_paths: next.map((entry) => entry.path),
model_path:
draftConfig.model_path === model.path
? chooseModel("", scannedModels, next)
: draftConfig.model_path,
selected_model_id: draftConfig.model_path === model.path ? null : draftConfig.selected_model_id,
});
}}
type="button"
title={`Remove ${model.name}`}
>
{model.name}
<span>×</span>
</button>
))}
</div>
)}
</section>
<section className="runtime-drawer-section runtime-section-card">
<div className="runtime-field-row runtime-field-row-top">
<div>
<div className="runtime-field-label">Discover from Hugging Face</div>
<div className="runtime-field-support">
Search public GGUF repos, preview verified files, and keep your local library as the primary runtime source.
</div>
</div>
</div>
<label className="runtime-field-label" htmlFor="runtime-hf-token">Optional Hugging Face token</label>
<input
id="runtime-hf-token"
className={`runtime-field-input${isFieldDirty("hugging_face_token") ? " is-draft" : ""}`}
value={draftConfig.hugging_face_token}
onChange={(event) => updateDraft({ hugging_face_token: event.target.value })}
placeholder="hf_..."
type="password"
/>
<div className="runtime-field-row runtime-field-row-top">
<div className="runtime-search-grow">
<label className="runtime-field-label" htmlFor="runtime-hf-search">Search public GGUF repos</label>
<input
id="runtime-hf-search"
className="runtime-field-input"
value={hfQuery}
onChange={(event) => setHfQuery(event.target.value)}
onInput={(event) => setHfQuery((event.target as HTMLInputElement).value)}
placeholder="phi-4, mistral, qwen..."
type="text"
/>
</div>
<button className="runtime-inline-btn" onClick={() => { void handleHubSearch(); }} type="button" disabled={hfLoading}>
{hfLoading ? "Searching..." : "Search Hub"}
</button>
</div>
{hfResults.length > 0 ? (
<div className="runtime-search-results" aria-label="Hugging Face search results">
{hfResults.map((result) => (
<article key={result.repo_id} className="runtime-search-card">
<div className="runtime-search-title">{result.repo_id}</div>
<div className="runtime-field-support">{result.author || "Unknown author"}</div>
<div className="runtime-verified-file-list">
{result.verified_files.map((file) => (
<div key={`${result.repo_id}:${file.path}`} className="runtime-verified-file">
<span>{file.path}</span>
<span className="runtime-verified-file-meta">
{file.quantization || "gguf"}
{file.size ? ` · ${Math.round(file.size / (1024 * 1024))} MiB` : ""}
</span>
</div>
))}
</div>
</article>
))}
</div>
) : (
<div className="runtime-field-support">
{hfLoading ? "Looking up verified GGUF files..." : "Search for a model family to preview compatible local installs."}
</div>
)}
</section>
<section className="runtime-drawer-section runtime-section-card runtime-grid-two">
<div>
<label className="runtime-field-label" htmlFor="runtime-addr">Listen address</label>
<input
id="runtime-addr"
className={`runtime-field-input${isFieldDirty("addr") ? " is-draft" : ""}`}
value={draftConfig.addr}
onChange={(event) => updateDraft({ addr: event.target.value })}
placeholder="127.0.0.1:8080"
type="text"
/>
</div>
<div>
<label className="runtime-field-label" htmlFor="runtime-context">Context size</label>
<input
id="runtime-context"
className={`runtime-field-input${isFieldDirty("ctx_size") ? " is-draft" : ""}`}
min={512}
step={512}
value={draftConfig.ctx_size}
onChange={(event) => updateDraft({ ctx_size: Math.max(512, parseInt(event.target.value, 10) || 4096) })}
type="number"
/>
</div>
</section>
<section className="runtime-drawer-section runtime-section-card">
<div className="runtime-field-row runtime-field-row-top">
<div>
<div className="runtime-field-label">GPU layers</div>
<div className="runtime-field-support">
{gpuInfo ? `${gpuInfo.name} · recommended ${gpuInfo.recommended_layers}` : gpuError || "GPU detection unavailable"}
</div>
</div>
{gpuInfo && (
<button className="runtime-inline-btn" onClick={() => updateDraft({ gpu_layers: gpuInfo.recommended_layers })} type="button">
Use recommended
</button>
)}
</div>
<input
className={`runtime-field-input${isFieldDirty("gpu_layers") ? " is-draft" : ""}`}
min={0}
max={maxLayers}
value={draftConfig.gpu_layers}
onChange={(event) => updateDraft({ gpu_layers: Math.max(0, Math.min(maxLayers, parseInt(event.target.value, 10) || 0)) })}
type="number"
/>
<div className="runtime-preset-row">
{CTX_PRESETS.map((preset) => (
<button
key={preset}
className={`runtime-preset${draftConfig.ctx_size === preset ? " active" : ""}`}
onClick={() => updateDraft({ ctx_size: preset })}
type="button"
>
{preset >= 1024 ? `${preset / 1024}k` : preset}
</button>
))}
</div>
</section>
<section className="runtime-drawer-section runtime-section-card">
<label className="runtime-checkbox-row">
<input
checked={draftConfig.start_policy === "on_app_launch"}
onChange={(event) => updateDraft({ start_policy: event.target.checked ? "on_app_launch" : "manual" })}
type="checkbox"
/>
<span>Start the local API automatically when the desktop app opens</span>
</label>
</section>
</>
)}
{(localError || snapshot.last_error) && (
<section className="runtime-drawer-section">
<div className="runtime-error-banner">
{localError || snapshot.last_error?.message || "Couldn't save your draft. Review the highlighted fields and try again — your unsaved changes are still here."}
</div>
</section>
)}
{showLifecycleResult && (
<section className="runtime-drawer-section">
<div
className={`runtime-apply-guidance runtime-apply-guidance-${lastLifecycleResult.outcome}`}
role="status"
aria-live="polite"
>
{lastLifecycleResult.message}
</div>
</section>
)}
{applyGuidance && (
<section className="runtime-drawer-section">
<div
className={`runtime-apply-guidance${applyImpact !== "immediate" ? " is-warning" : ""}`}
role="status"
>
{applyGuidance}
</div>
</section>
)}
{!snapshot.process_running && draftConfig.connection_mode === "local" && startReason && (
<section className="runtime-drawer-section">
<div className="runtime-warning-banner">{startReason}</div>
</section>
)}
</div>
<div className="runtime-drawer-footer runtime-drawer-footer-sticky">
<div className="runtime-footer-main-actions">
<button
className="runtime-footer-btn runtime-footer-btn-secondary"
onClick={() => setConfirmDiscard(true)}
type="button"
disabled={busy || lifecycleBusy || !draftDirty}
>
Discard changes
</button>
<button
className="runtime-footer-btn runtime-footer-btn-primary"
onClick={() => { void handleSave(); }}
type="button"
disabled={busy || lifecycleBusy || !draftDirty || hasBlockingDraftState}
>
{saveLabel}
</button>
</div>
{draftConfig.connection_mode === "local" && (
<button
className="runtime-footer-btn runtime-footer-btn-tertiary"
onClick={() => { void handleSaveAndStart(); }}
type="button"
disabled={
busy ||
lifecycleBusy ||
hasBlockingDraftState ||
hasPendingModeChange ||
restartRequiredWhileRunning ||
(!snapshot.process_running && Boolean(startReason))
}
>
{tertiaryLabel}
</button>
)}
</div>
{confirmDiscard && (
<div className="runtime-discard-confirmation">
<div className="runtime-discard-copy">Revert to saved: Replace all unsaved edits with the last saved values.</div>
<div className="runtime-inline-actions">
<button className="runtime-inline-btn" onClick={() => setConfirmDiscard(false)} type="button">
Keep editing
</button>
<button
className="runtime-inline-btn runtime-inline-btn-danger"
onClick={() => {
onResetDraft?.();
setConfirmDiscard(false);
}}
type="button"
>
Revert to saved
</button>
</div>
</div>
)}
</aside>
</div>
);
}
@@ -0,0 +1,188 @@
.runtime-card {
margin: 10px 12px 8px;
padding: 15px;
border-radius: 18px;
border: 1px solid rgba(255, 255, 255, 0.08);
background:
radial-gradient(circle at top right, rgba(72, 166, 255, 0.18), transparent 40%),
linear-gradient(180deg, rgba(255, 255, 255, 0.035), rgba(255, 255, 255, 0.01));
box-shadow: 0 18px 42px rgba(0, 0, 0, 0.28);
}
.runtime-card-success {
border-color: rgba(48, 209, 88, 0.24);
}
.runtime-card-warning {
border-color: rgba(255, 214, 10, 0.24);
}
.runtime-card-danger {
border-color: rgba(240, 64, 64, 0.24);
}
.runtime-card-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 10px;
}
.runtime-card-kicker {
font-size: 10px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text3);
margin-bottom: 4px;
}
.runtime-card-title {
font-size: 16px;
font-weight: 700;
letter-spacing: -0.02em;
color: var(--text);
}
.runtime-card-mode {
flex-shrink: 0;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
padding: 5px 8px;
border-radius: 999px;
border: 1px solid var(--border2);
color: var(--text2);
background: rgba(255, 255, 255, 0.04);
}
.runtime-card-mode-local {
color: #71c5ff;
border-color: rgba(113, 197, 255, 0.28);
}
.runtime-card-mode-remote {
color: #92e5c7;
border-color: rgba(146, 229, 199, 0.24);
}
.runtime-card-copy {
margin-top: 10px;
color: var(--text2);
font-size: 12px;
line-height: 1.5;
}
.runtime-card-meta {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 12px;
}
.runtime-card-meta span,
.runtime-card-note {
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.04);
color: var(--text2);
font-size: 11px;
line-height: 1.3;
padding: 7px 10px;
}
.runtime-card-note {
display: block;
margin-top: 10px;
border-radius: 12px;
color: #f3d38b;
border-color: rgba(243, 211, 139, 0.16);
background: rgba(243, 211, 139, 0.08);
}
.runtime-card-error {
margin-top: 10px;
padding: 8px 10px;
border-radius: 10px;
border: 1px solid rgba(240, 64, 64, 0.2);
background: rgba(240, 64, 64, 0.08);
color: #ffb4b4;
font-size: 11px;
line-height: 1.5;
}
.runtime-card-result {
margin-top: 10px;
padding: 10px 12px;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.05);
color: var(--text);
font-size: 12px;
line-height: 1.5;
}
.runtime-card-result-success {
border-color: rgba(48, 209, 88, 0.22);
background: rgba(48, 209, 88, 0.1);
color: #baf0c8;
}
.runtime-card-result-warning {
border-color: rgba(243, 211, 139, 0.24);
background: rgba(243, 211, 139, 0.1);
color: #f3d38b;
}
.runtime-card-result-danger {
border-color: rgba(240, 64, 64, 0.22);
background: rgba(240, 64, 64, 0.1);
color: #ffb7b7;
}
.runtime-card-actions {
display: flex;
gap: 8px;
margin-top: 14px;
}
.runtime-card-btn {
flex: 1;
border-radius: 10px;
border: 1px solid var(--border2);
padding: 9px 12px;
font-size: 12px;
font-weight: 700;
cursor: pointer;
font-family: inherit;
transition: transform 0.12s ease, border-color 0.12s ease, background 0.12s ease;
}
.runtime-card-btn:active:not(:disabled) {
transform: scale(0.98);
}
.runtime-card-btn:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.runtime-card-btn-secondary {
color: var(--text);
background: rgba(255, 255, 255, 0.04);
}
.runtime-card-btn-secondary:hover:not(:disabled) {
border-color: var(--accent);
}
.runtime-card-btn-primary {
color: #fff;
border-color: rgba(72, 166, 255, 0.6);
background: linear-gradient(135deg, #2b88e6, #46c2ff);
}
.runtime-card-btn-primary:hover:not(:disabled) {
background: linear-gradient(135deg, #3193f5, #55cbff);
}
@@ -0,0 +1,114 @@
import { getRuntimeStartGuard, getRuntimeStateLabel, getRuntimeStatusTone } from "../runtime-utils";
import type { RuntimeLifecycleResult, RuntimeSnapshot } from "../types";
import "./RuntimeStatusCard.css";
function summarizePath(path: string | null): string {
if (!path) return "No model selected";
const segments = path.split(/[\\/]/).filter(Boolean);
return segments[segments.length - 1] || path;
}
interface Props {
snapshot: RuntimeSnapshot;
loading: boolean;
busy: boolean;
activeLifecycleCommand?: RuntimeSnapshot["active_command"];
lastLifecycleResult?: RuntimeLifecycleResult;
lifecycleBusy?: boolean;
onOpenSettings: () => void;
onStart: () => void;
onStop: () => void;
}
function getLifecycleActionLabel(command: RuntimeSnapshot["active_command"]): string | null {
switch (command) {
case "start":
return "Starting local API...";
case "stop":
return "Stopping local API...";
case "save":
return "Saving changes...";
case "save_and_start":
return "Saving and starting local API...";
default:
return null;
}
}
export default function RuntimeStatusCard({
snapshot,
loading,
busy,
activeLifecycleCommand = snapshot.active_command,
lastLifecycleResult = snapshot.last_command_result,
lifecycleBusy = false,
onOpenSettings,
onStart,
onStop,
}: Props) {
const tone = getRuntimeStatusTone(snapshot);
const isLocal = snapshot.config.connection_mode === "local";
const startGuard = getRuntimeStartGuard(snapshot);
const needsSetup = isLocal && !snapshot.process_running && !startGuard.canStart;
const primaryAction = needsSetup ? onOpenSettings : isLocal && !snapshot.process_running ? onStart : onStop;
const lifecycleLabel = getLifecycleActionLabel(activeLifecycleCommand);
const primaryLabel = lifecycleLabel ?? (needsSetup ? "Set up" : isLocal && !snapshot.process_running ? "Start" : "Stop");
const primaryDisabled = busy || lifecycleBusy || (isLocal && snapshot.process_running && snapshot.status === "stopping");
const showLifecycleResult = lastLifecycleResult.command !== "idle" && !lifecycleBusy;
const lifecycleResultClass =
lastLifecycleResult.outcome === "failed"
? "runtime-card-result-danger"
: lastLifecycleResult.outcome === "blocked"
? "runtime-card-result-warning"
: "runtime-card-result-success";
return (
<section className={`runtime-card runtime-card-${tone}`} aria-label="Runtime manager summary">
<div className="runtime-card-header">
<div>
<div className="runtime-card-kicker">Runtime Manager</div>
<div className="runtime-card-title">{loading ? "Loading runtime" : getRuntimeStateLabel(snapshot)}</div>
</div>
<span className={`runtime-card-mode runtime-card-mode-${snapshot.config.connection_mode}`}>
{snapshot.config.connection_mode === "local" ? "Local" : "Remote"}
</span>
</div>
<p className="runtime-card-copy">
{snapshot.config.connection_mode === "local"
? `Managed endpoint ${snapshot.resolved_url}`
: snapshot.config.server_url || "Configure a remote API endpoint"}
</p>
<div className="runtime-card-meta" aria-label="Runtime details">
<span>{isLocal ? summarizePath(snapshot.config.model_path) : "External provider"}</span>
<span>{snapshot.config.start_policy === "on_app_launch" ? "Auto-start" : "Manual start"}</span>
</div>
{needsSetup && <div className="runtime-card-note">{startGuard.reason}</div>}
{showLifecycleResult && (
<div className={`runtime-card-result ${lifecycleResultClass}`} role="status" aria-live="polite">
{lastLifecycleResult.message}
</div>
)}
{snapshot.last_error && (
<div className="runtime-card-error" role="status">
{snapshot.last_error.message}
</div>
)}
<div className="runtime-card-actions">
<button className="runtime-card-btn runtime-card-btn-secondary" onClick={onOpenSettings} type="button" disabled={lifecycleBusy}>
Manage
</button>
{snapshot.config.connection_mode === "local" && (
<button className="runtime-card-btn runtime-card-btn-primary" onClick={primaryAction} type="button" disabled={primaryDisabled}>
{primaryLabel}
</button>
)}
</div>
</section>
);
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,267 @@
.runtime-workspace {
display: flex;
flex-direction: column;
gap: 12px;
padding: 16px;
background: var(--surface-elevated, #fff);
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.runtime-workspace-loading {
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
color: var(--text-secondary, #666);
font-size: 14px;
}
.runtime-workspace-header {
display: flex;
flex-direction: column;
gap: 4px;
}
.runtime-workspace-header-top {
display: flex;
justify-content: space-between;
align-items: center;
}
.runtime-workspace-kicker {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-secondary, #666);
}
.runtime-workspace-mode {
font-size: 11px;
font-weight: 500;
padding: 2px 8px;
border-radius: 4px;
background: var(--bg-muted, #f5f5f5);
color: var(--text-secondary, #666);
}
.runtime-workspace-mode-local {
background: #e8f5e9;
color: #2e7d32;
}
.runtime-workspace-mode-remote {
background: #e3f2fd;
color: #1565c0;
}
.runtime-workspace-status {
font-size: 18px;
font-weight: 600;
color: var(--text-primary, #111);
}
.runtime-workspace-health {
padding: 12px;
border-radius: 6px;
display: flex;
flex-direction: column;
gap: 4px;
}
.runtime-workspace-health-neutral {
background: var(--bg-muted, #f5f5f5);
}
.runtime-workspace-health-success {
background: #e8f5e9;
}
.runtime-workspace-health-warning {
background: #fff3e0;
}
.runtime-workspace-health-danger {
background: #ffebee;
}
.runtime-workspace-health-headline {
font-size: 14px;
font-weight: 600;
color: var(--text-primary, #111);
}
.runtime-workspace-health-detail {
font-size: 13px;
color: var(--text-secondary, #666);
}
.runtime-workspace-details {
display: flex;
flex-direction: column;
gap: 8px;
padding: 12px;
background: var(--bg-muted, #f5f5f5);
border-radius: 6px;
}
.runtime-workspace-detail-row {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 13px;
}
.runtime-workspace-detail-label {
color: var(--text-secondary, #666);
}
.runtime-workspace-detail-value {
font-weight: 500;
color: var(--text-primary, #111);
font-family: monospace;
font-size: 12px;
}
.runtime-workspace-result {
padding: 8px 12px;
border-radius: 6px;
font-size: 13px;
font-weight: 500;
}
.runtime-workspace-result-success {
background: #e8f5e9;
color: #2e7d32;
}
.runtime-workspace-result-warning {
background: #fff3e0;
color: #e65100;
}
.runtime-workspace-result-danger {
background: #ffebee;
color: #c62828;
}
.runtime-workspace-actions {
display: flex;
gap: 8px;
justify-content: flex-end;
}
.runtime-workspace-btn {
padding: 8px 16px;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
border: none;
transition: background-color 0.15s ease;
}
.runtime-workspace-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.runtime-workspace-btn-primary {
background: var(--primary, #0066cc);
color: white;
}
.runtime-workspace-btn-primary:hover:not(:disabled) {
background: var(--primary-dark, #0052a3);
}
.runtime-workspace-btn-secondary {
background: var(--bg-muted, #f5f5f5);
color: var(--text-primary, #111);
border: 1px solid var(--border-color, #e0e0e0);
}
.runtime-workspace-btn-secondary:hover:not(:disabled) {
background: var(--bg-hover, #eee);
}
.runtime-workspace-draft-panel {
display: flex;
flex-direction: column;
gap: 12px;
padding: 16px;
background: var(--surface-elevated, #fff);
border: 1px solid var(--border-color, #e0e0e0);
border-radius: 8px;
}
.runtime-workspace-draft-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.runtime-workspace-draft-title {
font-size: 14px;
font-weight: 600;
color: var(--text-primary, #111);
}
.runtime-workspace-draft-badge {
font-size: 11px;
font-weight: 500;
padding: 2px 8px;
border-radius: 4px;
background: #fff3e0;
color: #e65100;
}
.runtime-workspace-apply-guidance {
padding: 8px 12px;
border-radius: 6px;
font-size: 13px;
background: #e8f5e9;
color: #2e7d32;
}
.runtime-workspace-apply-guidance.is-warning {
background: #fff3e0;
color: #e65100;
}
.runtime-workspace-draft-field {
display: flex;
flex-direction: column;
gap: 4px;
}
.runtime-workspace-draft-label {
font-size: 12px;
font-weight: 500;
color: var(--text-secondary, #666);
}
.runtime-workspace-draft-input {
padding: 8px 12px;
border: 1px solid var(--border-color, #e0e0e0);
border-radius: 6px;
font-size: 14px;
background: white;
}
.runtime-workspace-draft-input:focus {
outline: none;
border-color: var(--primary, #0066cc);
}
.runtime-workspace-draft-input.is-invalid {
border-color: #c62828;
}
.runtime-workspace-draft-actions {
display: flex;
gap: 8px;
justify-content: flex-end;
margin-top: 8px;
}
@@ -0,0 +1,311 @@
import { useRuntimeManager } from "../useRuntimeManager";
import { getRuntimeHealthSummary, getRuntimeWorkspaceSummary } from "../runtime-utils";
import type { RuntimeConfig, RuntimeDraftFieldKey, RuntimeDraftFieldMeta, RuntimeDraftFieldState, RuntimeSnapshot, RuntimeWorkspaceHealth, RuntimeWorkspaceSummary } from "../types";
import "./RuntimeWorkspace.css";
interface ExternalRuntimeProps {
snapshot: RuntimeSnapshot;
savedBaseline: RuntimeConfig;
draftConfig: RuntimeConfig;
draftDirty: boolean;
draftOpen?: boolean;
draftFields: RuntimeDraftFieldState;
loading?: boolean;
busy?: boolean;
activeLifecycleCommand?: RuntimeSnapshot["active_command"];
lastLifecycleResult?: RuntimeSnapshot["last_command_result"];
lifecycleBusy?: boolean;
applyImpact?: "immediate" | "restart_required" | "save_before_apply";
openDraft?: () => void;
closeDraft?: () => void;
resetDraft?: () => void;
updateDraft?: (patch: Partial<RuntimeConfig>) => void;
updateDraftField?: <K extends RuntimeDraftFieldKey>(field: K, value: RuntimeConfig[K]) => void;
updateDraftFieldMeta?: (field: RuntimeDraftFieldKey, patch: Partial<RuntimeDraftFieldMeta>) => void;
saveConfig?: (patch: Partial<RuntimeConfig>) => Promise<RuntimeSnapshot>;
startLocal?: () => Promise<unknown>;
stopLocal?: () => Promise<unknown>;
}
interface Props {
runtime?: ExternalRuntimeProps;
}
function summarizePath(path: string | null): string {
if (!path) return "No model selected";
const segments = path.split(/[\\/]/).filter(Boolean);
return segments[segments.length - 1] || path;
}
function getLifecycleActionLabel(command: string): string | null {
switch (command) {
case "start":
return "Starting local API...";
case "stop":
return "Stopping local API...";
case "save":
return "Saving changes...";
case "save_and_start":
return "Saving and starting local API...";
default:
return null;
}
}
export default function RuntimeWorkspace({ runtime: externalRuntime }: Props) {
const internalManager = useRuntimeManager();
// Use external runtime props if provided, otherwise use internal manager
const useExternal = !!externalRuntime;
const runtime = useExternal ? externalRuntime : internalManager;
// Compute workspace summary and health from snapshot when using external props
const workspaceSummary: RuntimeWorkspaceSummary = useExternal
? getRuntimeWorkspaceSummary(runtime.snapshot)
: internalManager.workspaceSummary;
const workspaceHealth: RuntimeWorkspaceHealth = useExternal
? getRuntimeHealthSummary(runtime.snapshot)
: internalManager.workspaceHealth;
const {
draftDirty,
draftConfig,
savedBaseline,
draftFields,
applyImpact,
busy,
activeLifecycleCommand,
lastLifecycleResult,
lifecycleBusy,
loading,
openDraft,
closeDraft,
resetDraft,
updateDraft,
updateDraftField,
updateDraftFieldMeta,
saveConfig,
startLocal,
stopLocal,
snapshot,
} = runtime;
const isLocal = snapshot.config.connection_mode === "local";
const lifecycleResult = lastLifecycleResult ?? { command: "idle" as const, outcome: "succeeded" as const, message: "" };
const showLifecycleResult = lifecycleResult.command !== "idle" && !lifecycleBusy;
const lifecycleLabel = getLifecycleActionLabel(activeLifecycleCommand ?? "");
const hasInvalidDraft = Object.values(draftFields).some((meta) => meta?.invalid);
const hasPendingDraft = Object.values(draftFields).some((meta) => meta?.pending);
const hasBlockingDraftState = hasInvalidDraft || hasPendingDraft;
const hasPendingModeChange = draftConfig.connection_mode !== savedBaseline.connection_mode;
const restartRequiredWhileRunning =
draftDirty && draftConfig.connection_mode === "local" && snapshot.process_running && applyImpact === "restart_required";
const applyGuidance = !draftDirty || draftConfig.connection_mode !== "local"
? null
: hasPendingModeChange || applyImpact === "save_before_apply"
? "Stop the local API or save first before applying this change."
: applyImpact === "restart_required"
? "Restart required to apply saved changes."
: "Applies immediately after save.";
const lifecycleResultClass =
lifecycleResult.outcome === "failed"
? "runtime-workspace-result-danger"
: lifecycleResult.outcome === "blocked"
? "runtime-workspace-result-warning"
: "runtime-workspace-result-success";
const healthToneClass = `runtime-workspace-health-${workspaceHealth.tone}`;
const saveLabel = activeLifecycleCommand === "save" ? "Saving changes..." : "Save changes";
const tertiaryLabel = hasPendingModeChange
? "Save mode before applying"
: activeLifecycleCommand === "start"
? "Starting local API..."
: activeLifecycleCommand === "stop"
? "Stopping local API..."
: restartRequiredWhileRunning
? "Restart required to apply saved changes"
: snapshot.process_running
? "Stop local API"
: "Save and start local API";
if (loading) {
return (
<section className="runtime-workspace" aria-label="Runtime workspace">
<div className="runtime-workspace-loading">Loading runtime...</div>
</section>
);
}
return (
<section className="runtime-workspace" aria-label="Runtime workspace">
{/* Header / Summary Section */}
<div className="runtime-workspace-header">
<div className="runtime-workspace-header-top">
<div className="runtime-workspace-kicker">Runtime workspace</div>
<span className={`runtime-workspace-mode runtime-workspace-mode-${workspaceSummary.modeLabel}`}>
{workspaceSummary.modeLabel === "local" ? "Local" : "Remote"}
</span>
</div>
<div className="runtime-workspace-status">{workspaceSummary.statusLabel}</div>
</div>
{/* Health Section */}
<div className="runtime-workspace-health-section">
<div className="runtime-workspace-section-label">Runtime health</div>
<div className={`runtime-workspace-health ${healthToneClass}`}>
<div className="runtime-workspace-health-headline">{workspaceHealth.headline}</div>
{workspaceHealth.detail && <div className="runtime-workspace-health-detail">{workspaceHealth.detail}</div>}
</div>
</div>
{/* Connection Details */}
<div className="runtime-workspace-details">
<div className="runtime-workspace-detail-row">
<span className="runtime-workspace-detail-label">Endpoint</span>
<span className="runtime-workspace-detail-value">{workspaceSummary.endpointLabel || "Not configured"}</span>
</div>
<div className="runtime-workspace-detail-row">
<span className="runtime-workspace-detail-label">Model</span>
<span className="runtime-workspace-detail-value">{workspaceSummary.modelLabel || "No model selected"}</span>
</div>
{isLocal && (
<div className="runtime-workspace-detail-row">
<span className="runtime-workspace-detail-label">Start policy</span>
<span className="runtime-workspace-detail-value">
{snapshot.config.start_policy === "on_app_launch" ? "Auto-start" : "Manual"}
</span>
</div>
)}
</div>
{/* Lifecycle Result Banner */}
{showLifecycleResult && (
<div className={`runtime-workspace-result ${lifecycleResultClass}`} role="status" aria-live="polite">
{lifecycleResult.message}
</div>
)}
{/* Actions */}
<div className="runtime-workspace-actions">
<button className="runtime-workspace-btn runtime-workspace-btn-secondary" onClick={openDraft ?? (() => {})} type="button" disabled={lifecycleBusy}>
{draftDirty ? "Edit draft" : "Manage"}
</button>
{isLocal && (
<>
<button
className="runtime-workspace-btn runtime-workspace-btn-primary"
onClick={snapshot.process_running ? (stopLocal ?? (() => {})) : (startLocal ?? (() => {}))}
type="button"
disabled={busy || lifecycleBusy || (snapshot.process_running && snapshot.status === "stopping")}
>
{lifecycleLabel ?? (snapshot.process_running ? "Stop" : "Start")}
</button>
</>
)}
</div>
{/* Draft Settings (inline when open) */}
{draftDirty && (
<div className="runtime-workspace-draft-panel">
<div className="runtime-workspace-draft-header">
<span className="runtime-workspace-draft-title">Draft settings</span>
<span className="runtime-workspace-draft-badge">Unsaved changes</span>
</div>
{/* Apply Guidance */}
{applyGuidance && (
<div className={`runtime-workspace-apply-guidance${applyImpact !== "immediate" ? " is-warning" : ""}`}>
{applyGuidance}
</div>
)}
{/* Local mode draft fields */}
{draftConfig.connection_mode === "local" && updateDraft && (
<>
<div className="runtime-workspace-draft-field">
<label className="runtime-workspace-draft-label">Models folder</label>
<input
className="runtime-workspace-draft-input"
value={draftConfig.models_dir || ""}
onChange={(e) => updateDraft({ models_dir: e.target.value })}
placeholder="D:/models"
/>
</div>
<div className="runtime-workspace-draft-field">
<label className="runtime-workspace-draft-label">Selected model</label>
<input
className="runtime-workspace-draft-input"
value={draftConfig.model_path || ""}
onChange={(e) => updateDraft({ model_path: e.target.value || null })}
placeholder="D:/models/model.gguf"
/>
</div>
<div className="runtime-workspace-draft-field">
<label className="runtime-workspace-draft-label">GPU layers</label>
<input
className="runtime-workspace-draft-input"
type="number"
min={0}
value={draftConfig.gpu_layers}
onChange={(e) => updateDraft({ gpu_layers: parseInt(e.target.value, 10) || 0 })}
/>
</div>
</>
)}
{/* Remote mode draft fields */}
{draftConfig.connection_mode === "remote" && updateDraft && (
<>
<div className="runtime-workspace-draft-field">
<label className="runtime-workspace-draft-label">Server URL</label>
<input
className="runtime-workspace-draft-input"
value={draftConfig.server_url}
onChange={(e) => updateDraft({ server_url: e.target.value })}
placeholder="https://api.example.com"
/>
</div>
<div className="runtime-workspace-draft-field">
<label className="runtime-workspace-draft-label">API key</label>
<input
className="runtime-workspace-draft-input"
type="password"
value={draftConfig.api_key}
onChange={(e) => updateDraft({ api_key: e.target.value })}
placeholder="sk-..."
/>
</div>
</>
)}
{/* Draft Actions */}
{updateDraft && resetDraft && (
<div className="runtime-workspace-draft-actions">
<button
className="runtime-workspace-btn runtime-workspace-btn-secondary"
onClick={resetDraft}
type="button"
disabled={busy || lifecycleBusy}
>
Discard
</button>
<button
className="runtime-workspace-btn runtime-workspace-btn-primary"
onClick={() => { void saveConfig?.(draftConfig); }}
type="button"
disabled={busy || lifecycleBusy || hasBlockingDraftState}
>
{saveLabel}
</button>
</div>
)}
</div>
)}
</section>
);
}
@@ -0,0 +1,309 @@
import { describe, expect, expectTypeOf, it } from "vitest";
import {
chooseModel,
createRuntimeConfigPatch,
dedupeModels,
getLocalRuntimeStartReason,
pathName,
resolveDraftModelSelection,
toModelFile,
type RuntimeSettingsFormState,
} from "./runtime-settings-form";
import { createDefaultRuntimeSnapshot } from "./runtime-utils";
import {
createRuntimeDraftState,
RUNTIME_DRAFT_FIELD_KEYS,
} from "./types";
import type {
RuntimeDraftFieldKey,
RuntimeDraftFieldMeta,
RuntimeDraftFieldState,
RuntimeDraftState,
RuntimeModelFile,
} from "./types";
function createFormState(overrides: Partial<RuntimeSettingsFormState> = {}): RuntimeSettingsFormState {
return {
mode: "local",
startPolicy: "manual",
serverUrl: "https://api.example.com/",
apiKey: " sk-demo ",
modelsDir: "D:/models",
selectedModel: "D:/models/demo.gguf",
selectedModelId: null,
addr: "127.0.0.1:8080",
gpuLayers: 48,
ctxSize: 8192,
extraModels: [{ name: "demo.gguf", path: "D:/models/demo.gguf" }],
...overrides,
};
}
describe("runtime settings form helpers", () => {
it("defines runtime draft ownership contracts separately from the live snapshot", () => {
expect(RUNTIME_DRAFT_FIELD_KEYS).toEqual([
"models_dir",
"model_path",
"selected_model_id",
"server_url",
"api_key",
]);
const snapshot = createDefaultRuntimeSnapshot();
const draftState = createRuntimeDraftState(snapshot);
expect(draftState).toMatchObject({
liveSnapshot: snapshot,
savedBaseline: snapshot.config,
draftConfig: snapshot.config,
isDirty: false,
isOpen: false,
fields: {
models_dir: { invalid: false, pending: false, message: null },
model_path: { invalid: false, pending: false, message: null },
selected_model_id: { invalid: false, pending: false, message: null },
server_url: { invalid: false, pending: false, message: null },
api_key: { invalid: false, pending: false, message: null },
},
});
expectTypeOf<RuntimeDraftFieldKey>().toEqualTypeOf<
"models_dir" | "model_path" | "selected_model_id" | "server_url" | "api_key"
>();
expectTypeOf<RuntimeDraftFieldMeta>().toMatchObjectType<{
invalid: boolean;
pending: boolean;
message: string | null;
}>();
expectTypeOf<RuntimeDraftFieldState>().toMatchTypeOf<
Partial<Record<RuntimeDraftFieldKey, RuntimeDraftFieldMeta>>
>();
expectTypeOf<RuntimeDraftState>().toMatchObjectType<{
liveSnapshot: ReturnType<typeof createDefaultRuntimeSnapshot>;
savedBaseline: ReturnType<typeof createDefaultRuntimeSnapshot>["config"];
draftConfig: ReturnType<typeof createDefaultRuntimeSnapshot>["config"];
isDirty: boolean;
isOpen: boolean;
fields: RuntimeDraftFieldState;
scannedModels: RuntimeModelFile[];
isScanning: boolean;
}>();
});
it("keeps the current model when it still exists", () => {
const current = "D:/models/demo.gguf";
const scanned = [{ name: "demo.gguf", path: current }];
const extra = [{ name: "other.gguf", path: "D:/models/other.gguf" }];
expect(chooseModel(current, scanned, extra)).toBe(current);
});
it("falls back to the first available model when the current one disappears", () => {
expect(
chooseModel(
"D:/models/missing.gguf",
[{ name: "demo.gguf", path: "D:/models/demo.gguf" }],
[],
),
).toBe("D:/models/demo.gguf");
});
it("keeps the drafted model during rescans when it is still valid", () => {
expect(
resolveDraftModelSelection(
"D:/models/demo.gguf",
[{ name: "demo.gguf", path: "D:/models/demo.gguf" }],
[],
),
).toEqual({
nextModel: "D:/models/demo.gguf",
invalid: false,
message: null,
});
});
it("keeps an invalid drafted model visible instead of silently falling back", () => {
expect(
resolveDraftModelSelection(
"D:/models/missing.gguf",
[{ name: "demo.gguf", path: "D:/models/demo.gguf" }],
[],
),
).toEqual({
nextModel: "D:/models/missing.gguf",
invalid: true,
message: "This draft model is no longer available in the current library or folder scan.",
});
});
it("chooses the first discovered model when there is no existing draft selection", () => {
expect(
resolveDraftModelSelection(
"",
[{ name: "demo.gguf", path: "D:/models/demo.gguf" }],
[],
),
).toEqual({
nextModel: "D:/models/demo.gguf",
invalid: false,
message: null,
});
});
it("deduplicates model entries by path while keeping first occurrence", () => {
expect(
dedupeModels([
{ name: "demo.gguf", path: "D:/models/demo.gguf" },
{ name: "duplicate.gguf", path: "D:/models/demo.gguf" },
{ name: "other.gguf", path: "D:/models/other.gguf" },
]),
).toEqual([
{ name: "demo.gguf", path: "D:/models/demo.gguf" },
{ name: "other.gguf", path: "D:/models/other.gguf" },
]);
});
it("builds a trimmed local patch from the form state", () => {
const snapshot = createDefaultRuntimeSnapshot();
const patch = createRuntimeConfigPatch(snapshot.config, createFormState(), 32);
expect(patch).toMatchObject({
connection_mode: "local",
server_url: "https://api.example.com",
api_key: "sk-demo",
model_path: "D:/models/demo.gguf",
selected_model_id: null,
models_dir: "D:/models",
gpu_layers: 32,
ctx_size: 8192,
extra_model_paths: ["D:/models/demo.gguf"],
remote_presets: [],
hugging_face_token: "",
});
});
it("keeps saved local values untouched when editing remote mode", () => {
const snapshot = createDefaultRuntimeSnapshot();
const patch = createRuntimeConfigPatch(
{
...snapshot.config,
model_path: "D:/models/keep.gguf",
models_dir: "D:/models",
extra_model_paths: ["D:/models/keep.gguf"],
},
createFormState({
mode: "remote",
serverUrl: " https://api.example.com/ ",
apiKey: " remote-key ",
modelsDir: "",
selectedModel: "",
extraModels: [],
}),
16,
);
expect(patch).toMatchObject({
connection_mode: "remote",
server_url: "https://api.example.com",
api_key: "remote-key",
model_path: "D:/models/keep.gguf",
selected_model_id: null,
models_dir: "D:/models",
extra_model_paths: ["D:/models/keep.gguf"],
remote_presets: [],
hugging_face_token: "",
});
});
it("preserves selected model id, presets, and token when building a patch", () => {
const snapshot = createDefaultRuntimeSnapshot();
snapshot.config.selected_model_id = "managed:phi-4-mini";
snapshot.config.remote_presets = [
{
id: "openrouter",
label: "OpenRouter",
provider: "open_router",
base_url: "https://openrouter.ai/api/v1",
default_model: "openrouter/auto",
},
];
snapshot.config.hugging_face_token = "hf_demo";
const patch = createRuntimeConfigPatch(
snapshot.config,
{
...createFormState(),
selectedModelId: "managed:phi-4-mini",
},
64,
);
expect(patch.selected_model_id).toBe("managed:phi-4-mini");
expect(patch.remote_presets).toEqual(snapshot.config.remote_presets);
expect(patch.hugging_face_token).toBe("hf_demo");
});
it("writes the selected managed model id when the local selection comes from the model library", () => {
const snapshot = createDefaultRuntimeSnapshot();
const patch = createRuntimeConfigPatch(
snapshot.config,
{
...createFormState(),
selectedModel: "D:/managed/phi-4-mini.gguf",
selectedModelId: "managed:phi-4-mini",
},
64,
);
expect(patch.model_path).toBe("D:/managed/phi-4-mini.gguf");
expect(patch.selected_model_id).toBe("managed:phi-4-mini");
});
it("clears the selected managed model id when switching back to a manual path", () => {
const snapshot = createDefaultRuntimeSnapshot();
snapshot.config.selected_model_id = "managed:phi-4-mini";
const patch = createRuntimeConfigPatch(
snapshot.config,
{
...createFormState(),
selectedModel: "D:/models/manual.gguf",
selectedModelId: null,
},
64,
);
expect(patch.model_path).toBe("D:/models/manual.gguf");
expect(patch.selected_model_id).toBeNull();
});
it("explains why local runtime start is blocked", () => {
expect(
getLocalRuntimeStartReason({ mode: "local", binaryPath: null, selectedModel: "D:/models/demo.gguf" }),
).toContain("Build or bundle");
expect(
getLocalRuntimeStartReason({ mode: "local", binaryPath: "D:/bin/localllm.exe", selectedModel: "" }),
).toContain("Choose a model");
expect(
getLocalRuntimeStartReason({
mode: "local",
binaryPath: "D:/bin/localllm.exe",
selectedModel: "D:/models/demo.gguf",
}),
).toBe("");
});
it("normalizes model file labels from a path", () => {
expect(pathName("D:/models/demo.gguf")).toBe("demo.gguf");
expect(toModelFile("D:/models/demo.gguf")).toEqual({
name: "demo.gguf",
path: "D:/models/demo.gguf",
});
});
});
@@ -0,0 +1,138 @@
import type {
ConnectionMode,
RuntimeConfig,
RuntimeModelFile,
StartPolicy,
} from "./types";
function normalizeUrl(value: string): string {
const trimmed = value.trim();
return trimmed.replace(/\/+$/, "");
}
export interface RuntimeSettingsFormState {
mode: ConnectionMode;
startPolicy: StartPolicy;
serverUrl: string;
apiKey: string;
modelsDir: string;
selectedModel: string;
selectedModelId: string | null;
addr: string;
gpuLayers: number;
ctxSize: number;
extraModels: RuntimeModelFile[];
}
export function pathName(path: string | null | undefined): string {
if (!path) return "";
const parts = path.split(/[\\/]/).filter(Boolean);
return parts[parts.length - 1] || path;
}
export function toModelFile(path: string): RuntimeModelFile {
return { name: pathName(path) || path, path };
}
export function dedupeModels(models: RuntimeModelFile[]): RuntimeModelFile[] {
const seen = new Set<string>();
return models.filter((model) => {
if (seen.has(model.path)) return false;
seen.add(model.path);
return true;
});
}
export function chooseModel(current: string, scanned: RuntimeModelFile[], extra: RuntimeModelFile[]): string {
const nextChoices = [...scanned, ...extra].map((model) => model.path);
if (current && (nextChoices.length === 0 || nextChoices.includes(current))) {
return current;
}
return scanned[0]?.path || extra[0]?.path || "";
}
export interface RuntimeDraftModelSelection {
nextModel: string;
invalid: boolean;
message: string | null;
}
export function resolveDraftModelSelection(
current: string,
scanned: RuntimeModelFile[],
extra: RuntimeModelFile[],
): RuntimeDraftModelSelection {
const nextChoices = [...scanned, ...extra].map((model) => model.path);
if (!current) {
return {
nextModel: scanned[0]?.path || extra[0]?.path || "",
invalid: false,
message: null,
};
}
if (nextChoices.length === 0 || nextChoices.includes(current)) {
return {
nextModel: current,
invalid: false,
message: null,
};
}
return {
nextModel: current,
invalid: true,
message: "This draft model is no longer available in the current library or folder scan.",
};
}
export function getLocalRuntimeStartReason(params: {
mode: ConnectionMode;
binaryPath: string | null;
selectedModel: string;
}): string {
const { mode, binaryPath, selectedModel } = params;
if (mode !== "local") {
return "Save mode before starting the local runtime.";
}
if (!binaryPath) {
return "Build or bundle the local API binary before starting.";
}
if (!selectedModel.trim()) {
return "Choose a model before starting the local runtime.";
}
return "";
}
export function createRuntimeConfigPatch(
snapshotConfig: RuntimeConfig,
form: RuntimeSettingsFormState,
maxLayers: number,
): RuntimeConfig {
return {
connection_mode: form.mode,
start_policy: form.startPolicy,
server_url: normalizeUrl(form.serverUrl),
api_key: form.apiKey.trim(),
models_dir: form.mode === "local" ? (form.modelsDir.trim() || null) : snapshotConfig.models_dir,
model_path: form.mode === "local" ? (form.selectedModel.trim() || null) : snapshotConfig.model_path,
addr: form.mode === "local" ? form.addr.trim() || snapshotConfig.addr : snapshotConfig.addr,
gpu_layers:
form.mode === "local"
? Math.max(0, Math.min(form.gpuLayers, maxLayers))
: snapshotConfig.gpu_layers,
ctx_size: form.mode === "local" ? Math.max(512, form.ctxSize || 4096) : snapshotConfig.ctx_size,
selected_model_id: form.mode === "local" ? form.selectedModelId ?? null : snapshotConfig.selected_model_id ?? null,
extra_model_paths:
form.mode === "local"
? form.extraModels.map((model) => model.path)
: snapshotConfig.extra_model_paths,
remote_presets: snapshotConfig.remote_presets ?? [],
hugging_face_token: snapshotConfig.hugging_face_token ?? "",
};
}
@@ -0,0 +1,179 @@
import { describe, expect, it } from "vitest";
import { createRuntimeLifecycleResult, getRuntimeConnection, getRuntimeStartGuard, getRuntimeStateLabel } from "./runtime-utils";
import type { RuntimeConfig, RuntimeSnapshot } from "./types";
type SnapshotOverrides = Omit<Partial<RuntimeSnapshot>, "config"> & {
config?: Partial<RuntimeConfig>;
};
function createSnapshot(overrides: SnapshotOverrides = {}): RuntimeSnapshot {
const { config: configOverrides, ...snapshotOverrides } = overrides;
const defaultConfig: RuntimeConfig = {
connection_mode: "local",
start_policy: "manual",
server_url: "",
api_key: "",
model_path: null,
models_dir: null,
addr: "127.0.0.1:8080",
gpu_layers: 0,
ctx_size: 4096,
extra_model_paths: [],
selected_model_id: null,
remote_presets: [],
hugging_face_token: "",
};
return {
status: "stopped",
ready: false,
process_running: false,
resolved_url: "http://127.0.0.1:8080",
binary_path: null,
last_error: null,
active_command: "idle",
last_command_result: createRuntimeLifecycleResult(),
config: {
...defaultConfig,
...(configOverrides ?? {}),
},
...snapshotOverrides,
};
}
describe("getRuntimeConnection", () => {
it("prefers the resolved local runtime url in local mode", () => {
const connection = getRuntimeConnection(createSnapshot({
resolved_url: "http://127.0.0.1:9000",
config: {
connection_mode: "local",
start_policy: "manual",
server_url: "http://example.com:9999",
api_key: "",
model_path: null,
models_dir: null,
addr: "127.0.0.1:9000",
gpu_layers: 0,
ctx_size: 4096,
extra_model_paths: [],
},
}));
expect(connection.serverUrl).toBe("http://127.0.0.1:9000");
expect(connection.mode).toBe("local");
});
it("uses remote settings in remote mode", () => {
const connection = getRuntimeConnection(createSnapshot({
config: {
connection_mode: "remote",
start_policy: "manual",
server_url: "https://api.example.com",
api_key: "sk-demo",
model_path: null,
models_dir: null,
addr: "127.0.0.1:8080",
gpu_layers: 0,
ctx_size: 4096,
extra_model_paths: [],
},
}));
expect(connection.serverUrl).toBe("https://api.example.com");
expect(connection.apiKey).toBe("sk-demo");
expect(connection.mode).toBe("remote");
});
});
describe("getRuntimeStateLabel", () => {
it("reports ready snapshots as ready", () => {
expect(getRuntimeStateLabel(createSnapshot({ status: "ready", ready: true, process_running: true }))).toBe("Ready");
});
it("reports local background start progress", () => {
expect(getRuntimeStateLabel(createSnapshot({ status: "starting" }))).toBe("Starting local API");
});
it("reports remote mode without runtime ownership", () => {
expect(getRuntimeStateLabel(createSnapshot({
status: "stopped",
config: {
connection_mode: "remote",
start_policy: "manual",
server_url: "https://api.example.com",
api_key: "",
model_path: null,
models_dir: null,
addr: "127.0.0.1:8080",
gpu_layers: 0,
ctx_size: 4096,
extra_model_paths: [],
},
}))).toBe("Remote API");
});
});
describe("getRuntimeStartGuard", () => {
it("requires a saved model before starting the local runtime", () => {
const guard = getRuntimeStartGuard(createSnapshot({
binary_path: "D:/bin/localllm.exe",
config: {
connection_mode: "local",
start_policy: "manual",
server_url: "",
api_key: "",
model_path: null,
models_dir: null,
addr: "127.0.0.1:8080",
gpu_layers: 0,
ctx_size: 4096,
extra_model_paths: [],
},
}));
expect(guard.canStart).toBe(false);
expect(guard.reason.toLowerCase()).toContain("model");
});
it("allows starting when the saved local runtime config is complete", () => {
const guard = getRuntimeStartGuard(createSnapshot({
binary_path: "D:/bin/localllm.exe",
config: {
connection_mode: "local",
start_policy: "manual",
server_url: "",
api_key: "",
model_path: "D:/models/demo.gguf",
models_dir: "D:/models",
addr: "127.0.0.1:8080",
gpu_layers: 24,
ctx_size: 8192,
extra_model_paths: [],
},
}));
expect(guard.canStart).toBe(true);
expect(guard.reason).toBe("");
});
it("never offers managed start controls in remote mode", () => {
const guard = getRuntimeStartGuard(createSnapshot({
binary_path: "D:/bin/localllm.exe",
config: {
connection_mode: "remote",
start_policy: "manual",
server_url: "https://api.example.com",
api_key: "sk-demo",
model_path: null,
models_dir: null,
addr: "127.0.0.1:8080",
gpu_layers: 0,
ctx_size: 4096,
extra_model_paths: [],
},
}));
expect(guard.canStart).toBe(false);
expect(guard.reason.toLowerCase()).toContain("remote");
});
});
@@ -0,0 +1,446 @@
import type {
ConnectionMode,
RuntimeApplyImpact,
RuntimeConfig,
RuntimeConnection,
RuntimeDraftFieldKey,
RuntimeLifecycleCommand,
RuntimeLifecycleResult,
RuntimeModelFile,
RuntimeSnapshot,
RuntimeWorkspaceHealth,
RuntimeWorkspaceHealthTone,
RuntimeWorkspaceSummary,
} from "./types";
import { scanRuntimeModels } from "./api";
const DEFAULT_LOCAL_URL = "http://127.0.0.1:8080";
// === Phase 4: Source-Mode Validation Types ===
export type ValidationSeverity = "error" | "warning";
export interface FieldValidation {
valid: boolean;
severity: ValidationSeverity;
message: string | null;
}
export interface SourceModeValidation {
mode: ConnectionMode;
valid: boolean;
fieldValidations: Record<string, FieldValidation>;
}
export interface PathValidation {
path: string;
exists: boolean;
isValid: boolean;
message: string | null;
}
// === Phase 4: Source-Mode Validation Functions ===
/**
* Validates source-mode configuration based on connection mode.
* For local mode, requires model_path. For remote mode, requires server_url.
*/
export function validateSourceMode(config: RuntimeConfig): SourceModeValidation {
const fieldValidations: Record<string, FieldValidation> = {};
let valid = true;
if (config.connection_mode === "local") {
// Validate local mode requirements
if (!config.model_path) {
fieldValidations.model_path = {
valid: false,
severity: "error",
message: "Select a model file for local runtime",
};
valid = false;
}
// models_dir is optional for local mode
} else if (config.connection_mode === "remote") {
// Validate remote mode requirements
if (!config.server_url) {
fieldValidations.server_url = {
valid: false,
severity: "error",
message: "Server URL is required for remote runtime",
};
valid = false;
}
// Validate URL format for remote mode
if (config.server_url) {
try {
const url = new URL(config.server_url);
if (!["http:", "https:"].includes(url.protocol)) {
fieldValidations.server_url = {
valid: false,
severity: "error",
message: "Server URL must use http or https protocol",
};
valid = false;
}
} catch {
fieldValidations.server_url = {
valid: false,
severity: "error",
message: "Invalid URL format",
};
valid = false;
}
}
}
return {
mode: config.connection_mode,
valid,
fieldValidations,
};
}
/**
* Validates a model path. For now, returns pending status.
* Actual existence check happens via scan in plan 04-02.
*/
export function validateModelPath(path: string | null): PathValidation {
if (!path) {
return { path: "", exists: false, isValid: false, message: null };
}
// For now, return valid - actual existence check happens via scan
return { path, exists: true, isValid: true, message: null };
}
/**
* Get field keys that require validation based on connection mode
*/
export function getValidatedFields(mode: ConnectionMode): RuntimeDraftFieldKey[] {
if (mode === "local") {
return ["model_path", "models_dir"];
}
return ["server_url", "api_key"];
}
/**
* Check if draft has any validation errors
*/
export function hasValidationErrors(fieldValidations: Record<string, FieldValidation>): boolean {
return Object.values(fieldValidations).some((fv) => !fv.valid);
}
// === Phase 4: Path and Model File Validation (Async) ===
export interface DirectoryValidation {
valid: boolean;
modelCount: number;
error: string | null;
}
export interface ModelFileValidation {
valid: boolean;
exists: boolean;
error: string | null;
}
const RESTART_REQUIRED_FIELDS: Array<keyof RuntimeConfig> = [
"connection_mode",
"model_path",
"addr",
"gpu_layers",
"ctx_size",
];
function normalizeUrl(value: string | null | undefined): string {
const trimmed = (value || "").trim();
return trimmed ? trimmed.replace(/\/+$/, "") : "";
}
export function getRuntimeConnection(snapshot: RuntimeSnapshot): RuntimeConnection {
if (snapshot.config.connection_mode === "remote") {
return {
mode: "remote",
serverUrl: normalizeUrl(snapshot.config.server_url),
apiKey: snapshot.config.api_key || "",
managed: false,
};
}
return {
mode: "local",
serverUrl: normalizeUrl(snapshot.resolved_url) || DEFAULT_LOCAL_URL,
apiKey: snapshot.config.api_key || "",
managed: true,
};
}
export function getRuntimeStateLabel(snapshot: RuntimeSnapshot): string {
if (snapshot.config.connection_mode === "remote") {
return "Remote API";
}
switch (snapshot.status) {
case "ready":
return "Ready";
case "starting":
return "Starting local API";
case "stopping":
return "Stopping local API";
case "error":
return "Local API error";
case "degraded":
return "Attention needed";
default:
return "Local API offline";
}
}
export function getRuntimeStatusTone(snapshot: RuntimeSnapshot): "neutral" | "success" | "warning" | "danger" {
if (snapshot.config.connection_mode === "remote") {
return "neutral";
}
switch (snapshot.status) {
case "ready":
return "success";
case "starting":
case "stopping":
case "degraded":
return "warning";
case "error":
return "danger";
default:
return "neutral";
}
}
export interface RuntimeStartGuard {
canStart: boolean;
reason: string;
}
export interface RuntimeApplyImpactOptions {
savedBaseline: RuntimeConfig;
draftConfig: RuntimeConfig;
processRunning: boolean;
}
export function createRuntimeLifecycleResult(
command: RuntimeLifecycleCommand = "idle",
message = "Runtime idle.",
outcome: RuntimeLifecycleResult["outcome"] = "succeeded",
): RuntimeLifecycleResult {
return {
command,
outcome,
message,
};
}
export function getRuntimeApplyImpact({
savedBaseline,
draftConfig,
processRunning,
}: RuntimeApplyImpactOptions): RuntimeApplyImpact {
if (savedBaseline.connection_mode !== draftConfig.connection_mode) {
return "save_before_apply";
}
const needsRestart = RESTART_REQUIRED_FIELDS.some((field) => savedBaseline[field] !== draftConfig[field]);
if (processRunning && needsRestart) {
return "restart_required";
}
return "immediate";
}
export function getRuntimeStartGuard(snapshot: RuntimeSnapshot): RuntimeStartGuard {
if (snapshot.config.connection_mode === "remote") {
return {
canStart: false,
reason: "Remote mode uses an external API and does not launch the local runtime.",
};
}
if (!snapshot.binary_path) {
return {
canStart: false,
reason: "Build or bundle the local API binary before starting the managed runtime.",
};
}
if (!snapshot.config.model_path?.trim()) {
return {
canStart: false,
reason: "Choose a local model before starting the managed runtime.",
};
}
return { canStart: true, reason: "" };
}
export function createDefaultRuntimeSnapshot(): RuntimeSnapshot {
return {
status: "stopped",
ready: false,
process_running: false,
resolved_url: DEFAULT_LOCAL_URL,
binary_path: null,
last_error: null,
active_command: "idle",
last_command_result: createRuntimeLifecycleResult(),
config: {
connection_mode: "local",
start_policy: "manual",
server_url: "",
api_key: "",
model_path: null,
models_dir: null,
addr: "127.0.0.1:8080",
gpu_layers: 0,
ctx_size: 4096,
extra_model_paths: [],
selected_model_id: null,
remote_presets: [],
hugging_face_token: "",
},
};
}
// === Phase 3: Unified Runtime Workspace Helpers ===
function getModelLabel(modelPath: string | null): string | null {
if (!modelPath) return null;
const parts = modelPath.replace(/\\/g, "/").split("/");
return parts[parts.length - 1] || null;
}
export function getRuntimeWorkspaceSummary(snapshot: RuntimeSnapshot): RuntimeWorkspaceSummary {
const connection = getRuntimeConnection(snapshot);
const statusLabel = getRuntimeStateLabel(snapshot);
const isRemote = snapshot.config.connection_mode === "remote";
return {
statusLabel,
modeLabel: isRemote ? "remote" : "local",
endpointLabel: connection.serverUrl,
modelLabel: getModelLabel(snapshot.config.model_path),
isReady: snapshot.ready,
isRunning: snapshot.process_running,
hasError: snapshot.status === "error" || (snapshot.last_error !== null && snapshot.last_error.code !== "poll_warning"),
};
}
function deriveHealthTone(snapshot: RuntimeSnapshot): RuntimeWorkspaceHealthTone {
if (snapshot.config.connection_mode === "remote") {
return "neutral";
}
switch (snapshot.status) {
case "ready":
return "success";
case "starting":
case "stopping":
case "degraded":
return "warning";
case "error":
return "danger";
default:
return "neutral";
}
}
function deriveHealthHeadline(snapshot: RuntimeSnapshot): string {
if (snapshot.config.connection_mode === "remote") {
return "Remote API connected";
}
switch (snapshot.status) {
case "ready":
return "Local API ready";
case "starting":
return "Starting local API...";
case "stopping":
return "Stopping local API...";
case "degraded":
return "Attention needed";
case "error":
return "Local API error";
default:
return "Local API offline";
}
}
function deriveHealthDetail(snapshot: RuntimeSnapshot): string {
if (snapshot.config.connection_mode === "remote") {
return snapshot.config.server_url || "Configure remote endpoint";
}
if (snapshot.last_error) {
return snapshot.last_error.message;
}
if (snapshot.status === "ready" && snapshot.process_running) {
return snapshot.resolved_url || "http://127.0.0.1:8080";
}
if (snapshot.status === "stopped") {
return "Start the local API to begin";
}
return "";
}
export function getRuntimeHealthSummary(snapshot: RuntimeSnapshot): RuntimeWorkspaceHealth {
return {
headline: deriveHealthHeadline(snapshot),
detail: deriveHealthDetail(snapshot),
tone: deriveHealthTone(snapshot),
};
}
// === Phase 4: Async Path and Model Validation ===
/**
* Validate models directory by scanning it
*/
export async function validateModelsDirectory(
path: string | null,
): Promise<DirectoryValidation> {
if (!path) {
return { valid: false, modelCount: 0, error: "No models directory selected" };
}
try {
const models = await scanRuntimeModels(path);
return {
valid: true,
modelCount: models.length,
error: null,
};
} catch (e) {
return {
valid: false,
modelCount: 0,
error: e instanceof Error ? e.message : String(e),
};
}
}
/**
* Validate that a model path exists in the scanned models list
*/
export function validateModelFile(
path: string | null,
scannedModels: RuntimeModelFile[],
): ModelFileValidation {
if (!path) {
return { valid: false, exists: false, error: "No model selected" };
}
const exists = scannedModels.some((m) => m.path === path);
return {
valid: exists,
exists,
error: exists ? null : "Model file not found in current models directory",
};
}
+180
View File
@@ -0,0 +1,180 @@
export type ConnectionMode = "local" | "remote";
export type StartPolicy = "manual" | "on_app_launch";
export type RuntimeStatus = "stopped" | "starting" | "ready" | "degraded" | "stopping" | "error";
export type RuntimeLifecycleCommand = "idle" | "save" | "start" | "stop" | "save_and_start";
export type RuntimeLifecycleOutcome = "succeeded" | "blocked" | "failed";
export type RuntimeApplyImpact = "immediate" | "restart_required" | "save_before_apply";
export type RemoteProviderKind = "open_ai" | "open_router" | "anthropic" | "custom" | "unknown";
export interface RemotePreset {
id: string;
label: string;
provider: RemoteProviderKind;
base_url: string;
default_model: string;
}
export interface RuntimeConfig {
connection_mode: ConnectionMode;
start_policy: StartPolicy;
server_url: string;
api_key: string;
model_path: string | null;
models_dir: string | null;
addr: string;
gpu_layers: number;
ctx_size: number;
extra_model_paths: string[];
selected_model_id: string | null;
remote_presets: RemotePreset[];
hugging_face_token: string;
}
export interface RuntimeErrorInfo {
code: string;
message: string;
}
export interface RuntimeLifecycleResult {
command: RuntimeLifecycleCommand;
outcome: RuntimeLifecycleOutcome;
message: string;
}
export interface RuntimeSnapshot {
status: RuntimeStatus;
ready: boolean;
process_running: boolean;
resolved_url: string;
binary_path: string | null;
last_error: RuntimeErrorInfo | null;
active_command: RuntimeLifecycleCommand;
last_command_result: RuntimeLifecycleResult;
config: RuntimeConfig;
}
export const RUNTIME_DRAFT_FIELD_KEYS = [
"models_dir",
"model_path",
"selected_model_id",
"server_url",
"api_key",
] as const;
export type RuntimeDraftFieldKey = (typeof RUNTIME_DRAFT_FIELD_KEYS)[number];
export interface RuntimeDraftFieldMeta {
invalid: boolean;
pending: boolean;
message: string | null;
}
export type RuntimeDraftFieldState = Partial<Record<RuntimeDraftFieldKey, RuntimeDraftFieldMeta>>;
export interface RuntimeDraftState {
liveSnapshot: RuntimeSnapshot;
savedBaseline: RuntimeConfig;
draftConfig: RuntimeConfig;
isDirty: boolean;
isOpen: boolean;
fields: RuntimeDraftFieldState;
// Phase 4: Scanned models for validation
scannedModels: RuntimeModelFile[];
isScanning: boolean;
}
export function createRuntimeDraftFieldState(): Record<RuntimeDraftFieldKey, RuntimeDraftFieldMeta> {
return {
models_dir: { invalid: false, pending: false, message: null },
model_path: { invalid: false, pending: false, message: null },
selected_model_id: { invalid: false, pending: false, message: null },
server_url: { invalid: false, pending: false, message: null },
api_key: { invalid: false, pending: false, message: null },
};
}
export function createRuntimeDraftState(snapshot: RuntimeSnapshot): RuntimeDraftState {
return {
liveSnapshot: snapshot,
savedBaseline: snapshot.config,
draftConfig: snapshot.config,
isDirty: false,
isOpen: false,
fields: createRuntimeDraftFieldState(),
// Phase 4: Scanned models for validation
scannedModels: [],
isScanning: false,
};
}
export interface RuntimeConnection {
mode: ConnectionMode;
serverUrl: string;
apiKey: string;
managed: boolean;
}
export interface RuntimeModelFile {
name: string;
path: string;
}
export interface RuntimePickDirResult {
dir: string;
models: RuntimeModelFile[];
}
export interface RuntimeGpuInfo {
name: string;
vram_mib: number;
recommended_layers: number;
}
export interface HuggingFaceVerifiedFile {
path: string;
size: number | null;
quantization: string | null;
}
export interface HuggingFaceModelResult {
repo_id: string;
author: string;
verified_files: HuggingFaceVerifiedFile[];
}
export interface ModelLibraryEntry {
id: string;
label: string;
source: string;
path: string;
}
export interface ModelLibraryState {
entries: ModelLibraryEntry[];
managed_dir: string;
}
// === Phase 3: Unified Runtime Workspace Types ===
export type RuntimeWorkspaceHealthTone = "neutral" | "success" | "warning" | "danger";
export interface RuntimeWorkspaceSectionValue {
label: string;
value: string;
}
export interface RuntimeWorkspaceHealth {
headline: string;
detail: string;
tone: RuntimeWorkspaceHealthTone;
}
export interface RuntimeWorkspaceSummary {
statusLabel: string;
modeLabel: "local" | "remote";
endpointLabel: string;
modelLabel: string | null;
isReady: boolean;
isRunning: boolean;
hasError: boolean;
}
@@ -0,0 +1,541 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { bootstrapRuntime, getRuntimeSnapshot, saveRuntimeConfig, scanRuntimeModels, startRuntime, stopRuntime } from "./api";
import {
createDefaultRuntimeSnapshot,
createRuntimeLifecycleResult,
getRuntimeApplyImpact,
getRuntimeConnection,
getRuntimeHealthSummary,
getRuntimeWorkspaceSummary,
validateModelFile,
validateModelsDirectory,
validateSourceMode,
} from "./runtime-utils";
import { createRuntimeDraftFieldState, createRuntimeDraftState } from "./types";
import type {
RuntimeApplyImpact,
RuntimeConfig,
RuntimeDraftFieldKey,
RuntimeDraftFieldMeta,
RuntimeDraftState,
RuntimeLifecycleCommand,
RuntimeLifecycleResult,
RuntimeModelFile,
RuntimeSnapshot,
RuntimeWorkspaceHealth,
RuntimeWorkspaceSummary,
} from "./types";
function areConfigsEqual(left: RuntimeConfig, right: RuntimeConfig): boolean {
return JSON.stringify(left) === JSON.stringify(right);
}
function cloneConfig(config: RuntimeConfig): RuntimeConfig {
return {
...config,
extra_model_paths: [...config.extra_model_paths],
remote_presets: config.remote_presets.map((preset) => ({ ...preset })),
};
}
function applyOperationalSnapshot(current: RuntimeSnapshot, next: RuntimeSnapshot): RuntimeSnapshot {
return {
...current,
status: next.status,
ready: next.ready,
process_running: next.process_running,
resolved_url: next.resolved_url,
binary_path: next.binary_path,
last_error: next.last_error,
active_command: next.active_command,
last_command_result: next.last_command_result,
};
}
function normalizeSnapshot(snapshot: RuntimeSnapshot): RuntimeSnapshot {
return {
...createDefaultRuntimeSnapshot(),
...snapshot,
config: {
...createDefaultRuntimeSnapshot().config,
...snapshot.config,
},
last_command_result:
snapshot.last_command_result ?? createRuntimeLifecycleResult(snapshot.active_command ?? "idle"),
active_command: snapshot.active_command ?? "idle",
};
}
function reconcileDraftState(current: RuntimeDraftState, next: RuntimeSnapshot): RuntimeDraftState {
const normalizedNext = normalizeSnapshot(next);
if (current.isDirty) {
return {
...current,
liveSnapshot: applyOperationalSnapshot(current.liveSnapshot, normalizedNext),
};
}
// Run source-mode validation when reconciling to baseline
const validation = validateSourceMode(normalizedNext.config);
const updatedFields = createRuntimeDraftFieldState();
for (const [fieldName, fieldValidation] of Object.entries(validation.fieldValidations)) {
updatedFields[fieldName as RuntimeDraftFieldKey] = {
invalid: !fieldValidation.valid,
pending: false,
message: fieldValidation.message,
};
}
return {
...current,
liveSnapshot: normalizedNext,
savedBaseline: cloneConfig(normalizedNext.config),
draftConfig: cloneConfig(normalizedNext.config),
fields: updatedFields,
// Preserve scanned models state during reconciliation
scannedModels: current.scannedModels,
isScanning: current.isScanning,
};
}
function blockWhileLifecycleBusy(
snapshot: RuntimeSnapshot,
command: RuntimeLifecycleCommand,
): RuntimeSnapshot | null {
if (snapshot.active_command === "idle") {
return null;
}
const blockedBy = snapshot.active_command;
const message =
blockedBy === "start"
? "Local API start already in progress."
: blockedBy === "stop"
? "Local API stop already in progress."
: blockedBy === "save"
? "Runtime settings save already in progress."
: "Runtime save and start already in progress.";
return {
...snapshot,
last_command_result: createRuntimeLifecycleResult(command, message, "blocked"),
};
}
function startLifecycleSnapshot(
snapshot: RuntimeSnapshot,
command: RuntimeLifecycleCommand,
status: RuntimeSnapshot["status"],
): RuntimeSnapshot {
return {
...snapshot,
status,
process_running: command === "start" ? true : snapshot.process_running,
ready: false,
last_error: null,
active_command: command,
};
}
function finalizeLifecycleSnapshot(
fallbackCommand: RuntimeLifecycleCommand,
fallbackMessage: string,
next: RuntimeSnapshot,
): RuntimeSnapshot {
const normalizedNext = normalizeSnapshot(next);
if (normalizedNext.last_command_result.command !== "idle") {
return normalizedNext;
}
return {
...normalizedNext,
last_command_result: createRuntimeLifecycleResult(fallbackCommand, fallbackMessage),
};
}
export function useRuntimeManager() {
const [draftState, setDraftState] = useState<RuntimeDraftState>(() =>
createRuntimeDraftState(createDefaultRuntimeSnapshot()),
);
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const refresh = useCallback(async () => {
const next = await getRuntimeSnapshot();
setDraftState((current) => reconcileDraftState(current, next));
return next;
}, []);
useEffect(() => {
let active = true;
bootstrapRuntime()
.then((next) => {
if (active) {
setDraftState((current) => reconcileDraftState(current, next));
setLoading(false);
}
})
.catch(() => {
if (active) {
setLoading(false);
}
});
const id = window.setInterval(() => {
refresh().catch(() => undefined);
}, 3000);
return () => {
active = false;
window.clearInterval(id);
};
}, [refresh]);
const openDraft = useCallback(() => {
setDraftState((current) => {
const draftConfig = current.isDirty ? current.draftConfig : cloneConfig(current.savedBaseline);
// Run source-mode validation when opening draft
const validation = validateSourceMode(draftConfig);
// Initialize field validations based on validation results
const updatedFields = { ...current.fields };
for (const [fieldName, fieldValidation] of Object.entries(validation.fieldValidations)) {
updatedFields[fieldName as RuntimeDraftFieldKey] = {
invalid: !fieldValidation.valid,
pending: false,
message: fieldValidation.message,
};
}
return {
...current,
isOpen: true,
draftConfig,
fields: updatedFields,
};
});
}, []);
const closeDraft = useCallback(() => {
setDraftState((current) => ({
...current,
isOpen: false,
}));
}, []);
const resetDraft = useCallback(() => {
setDraftState((current) => {
const resetConfig = cloneConfig(current.savedBaseline);
// Run source-mode validation when resetting
const validation = validateSourceMode(resetConfig);
// Initialize field validations based on validation results
const updatedFields = createRuntimeDraftFieldState();
for (const [fieldName, fieldValidation] of Object.entries(validation.fieldValidations)) {
updatedFields[fieldName as RuntimeDraftFieldKey] = {
invalid: !fieldValidation.valid,
pending: false,
message: fieldValidation.message,
};
}
return {
...current,
draftConfig: resetConfig,
isDirty: false,
fields: updatedFields,
};
});
}, []);
const updateDraft = useCallback((patch: Partial<RuntimeConfig>) => {
setDraftState((current) => {
const nextDraft = {
...current.draftConfig,
...patch,
};
return {
...current,
isOpen: true,
isDirty: !areConfigsEqual(nextDraft, current.savedBaseline),
draftConfig: nextDraft,
};
});
}, []);
const updateDraftField = useCallback(<K extends RuntimeDraftFieldKey>(field: K, value: RuntimeConfig[K]) => {
setDraftState((current) => {
const nextDraft = {
...current.draftConfig,
[field]: value,
};
// Run source-mode validation
const validation = validateSourceMode(nextDraft);
// Update field validations based on validation results
const updatedFields = { ...current.fields };
for (const [fieldName, fieldValidation] of Object.entries(validation.fieldValidations)) {
updatedFields[fieldName as RuntimeDraftFieldKey] = {
invalid: !fieldValidation.valid,
pending: false,
message: fieldValidation.message,
};
}
return {
...current,
isOpen: true,
isDirty: !areConfigsEqual(nextDraft, current.savedBaseline),
draftConfig: nextDraft,
fields: updatedFields,
};
});
}, []);
const updateDraftFieldMeta = useCallback(
(field: RuntimeDraftFieldKey, patch: Partial<RuntimeDraftFieldMeta>) => {
setDraftState((current) => ({
...current,
fields: {
...current.fields,
[field]: {
...current.fields[field],
invalid: false,
pending: false,
message: null,
...patch,
},
},
}));
},
[],
);
// Phase 4: Model scanning for validation
const scanModels = useCallback(async (dirPath: string | null) => {
if (!dirPath) {
setDraftState((current) => ({
...current,
scannedModels: [],
isScanning: false,
}));
return;
}
// Set scanning state first
setDraftState((current) => ({
...current,
isScanning: true,
}));
try {
const models = await scanRuntimeModels(dirPath);
// Now update with results
setDraftState((current) => {
const modelPathInvalid = current.draftConfig.model_path
? !models.some((m) => m.path === current.draftConfig.model_path)
: false;
const modelPathMessage = current.draftConfig.model_path
? models.some((m) => m.path === current.draftConfig.model_path)
? null
: "Model file not found in current models directory"
: null;
return {
...current,
scannedModels: models,
isScanning: false,
// Validate model_path against scanned models
fields: {
...current.fields,
model_path: {
invalid: modelPathInvalid,
pending: false,
message: modelPathMessage,
},
},
};
});
} catch (error) {
setDraftState((current) => ({
...current,
scannedModels: [],
isScanning: false,
fields: {
...current.fields,
models_dir: {
invalid: true,
pending: false,
message: error instanceof Error ? error.message : String(error),
},
},
}));
}
}, []);
const commitSavedSnapshot = useCallback((next: RuntimeSnapshot, keepOpen: boolean) => {
setDraftState((current) => ({
...current,
liveSnapshot: next,
savedBaseline: cloneConfig(next.config),
draftConfig: cloneConfig(next.config),
isDirty: false,
isOpen: keepOpen,
fields: createRuntimeDraftFieldState(),
}));
}, []);
const saveDraft = useCallback(async () => {
const blockedSnapshot = blockWhileLifecycleBusy(draftState.liveSnapshot, "save");
if (blockedSnapshot) {
setDraftState((current) => ({ ...current, liveSnapshot: blockedSnapshot }));
return blockedSnapshot;
}
setBusy(true);
setDraftState((current) => ({
...current,
liveSnapshot: startLifecycleSnapshot(current.liveSnapshot, "save", current.liveSnapshot.status),
}));
try {
const next = await saveRuntimeConfig(draftState.draftConfig);
const resolvedNext = finalizeLifecycleSnapshot("save", "Save completed.", next);
commitSavedSnapshot(resolvedNext, true);
return resolvedNext;
} finally {
setBusy(false);
}
}, [commitSavedSnapshot, draftState.draftConfig]);
const saveConfig = useCallback(async (patch: Partial<RuntimeConfig>) => {
const blockedSnapshot = blockWhileLifecycleBusy(draftState.liveSnapshot, "save");
if (blockedSnapshot) {
setDraftState((current) => ({ ...current, liveSnapshot: blockedSnapshot }));
return blockedSnapshot;
}
setBusy(true);
setDraftState((current) => ({
...current,
liveSnapshot: startLifecycleSnapshot(current.liveSnapshot, "save", current.liveSnapshot.status),
}));
try {
const next = await saveRuntimeConfig({ ...draftState.draftConfig, ...patch });
const resolvedNext = finalizeLifecycleSnapshot("save", "Save completed.", next);
commitSavedSnapshot(resolvedNext, draftState.isOpen);
return resolvedNext;
} finally {
setBusy(false);
}
}, [commitSavedSnapshot, draftState.draftConfig, draftState.isOpen]);
const startLocal = useCallback(async () => {
const blockedSnapshot = blockWhileLifecycleBusy(draftState.liveSnapshot, "start");
if (blockedSnapshot) {
setDraftState((current) => ({ ...current, liveSnapshot: blockedSnapshot }));
return blockedSnapshot;
}
setBusy(true);
setDraftState((current) => ({
...current,
liveSnapshot: startLifecycleSnapshot(current.liveSnapshot, "start", "starting"),
}));
try {
const next = await startRuntime();
const resolvedNext = finalizeLifecycleSnapshot("start", "Local API started.", next);
setDraftState((current) => reconcileDraftState(current, resolvedNext));
return resolvedNext;
} finally {
setBusy(false);
}
}, [draftState.liveSnapshot]);
const stopLocal = useCallback(async () => {
const blockedSnapshot = blockWhileLifecycleBusy(draftState.liveSnapshot, "stop");
if (blockedSnapshot) {
setDraftState((current) => ({ ...current, liveSnapshot: blockedSnapshot }));
return blockedSnapshot;
}
setBusy(true);
setDraftState((current) => ({
...current,
liveSnapshot: startLifecycleSnapshot(current.liveSnapshot, "stop", "stopping"),
}));
try {
const next = await stopRuntime();
const resolvedNext = finalizeLifecycleSnapshot("stop", "Local API stopped.", next);
setDraftState((current) => reconcileDraftState(current, resolvedNext));
return resolvedNext;
} finally {
setBusy(false);
}
}, [draftState.liveSnapshot]);
const connection = useMemo(() => getRuntimeConnection(draftState.liveSnapshot), [draftState.liveSnapshot]);
const activeLifecycleCommand = draftState.liveSnapshot.active_command;
const lastLifecycleResult: RuntimeLifecycleResult = draftState.liveSnapshot.last_command_result;
const lifecycleBusy = activeLifecycleCommand !== "idle";
const applyImpact: RuntimeApplyImpact = useMemo(
() =>
getRuntimeApplyImpact({
savedBaseline: draftState.savedBaseline,
draftConfig: draftState.draftConfig,
processRunning: draftState.liveSnapshot.process_running,
}),
[draftState.draftConfig, draftState.liveSnapshot.process_running, draftState.savedBaseline],
);
// Phase 3: Unified workspace contract
const workspaceSummary: RuntimeWorkspaceSummary = useMemo(
() => getRuntimeWorkspaceSummary(draftState.liveSnapshot),
[draftState.liveSnapshot],
);
const workspaceHealth: RuntimeWorkspaceHealth = useMemo(
() => getRuntimeHealthSummary(draftState.liveSnapshot),
[draftState.liveSnapshot],
);
return {
snapshot: draftState.liveSnapshot,
liveSnapshot: draftState.liveSnapshot,
savedBaseline: draftState.savedBaseline,
draftConfig: draftState.draftConfig,
draftDirty: draftState.isDirty,
draftOpen: draftState.isOpen,
draftFields: draftState.fields,
connection,
loading,
busy,
activeLifecycleCommand,
lastLifecycleResult,
lifecycleBusy,
applyImpact,
// Phase 3: Unified workspace contract
workspaceSummary,
workspaceHealth,
refresh,
openDraft,
closeDraft,
resetDraft,
updateDraft,
updateDraftField,
updateDraftFieldMeta,
saveDraft,
saveConfig,
startLocal,
stopLocal,
// Phase 4: Model scanning for validation
scannedModels: draftState.scannedModels,
isScanningModels: draftState.isScanning,
scanModels,
};
}
@@ -0,0 +1,82 @@
/* @vitest-environment jsdom */
import { createRoot, type Root } from "react-dom/client";
import { act } from "react-dom/test-utils";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { RuntimeStatus } from "./types";
import { useRuntimeReadyModelRefresh } from "./useRuntimeReadyModelRefresh";
function Harness({ status, onRefresh }: { status: RuntimeStatus; onRefresh: () => void }) {
useRuntimeReadyModelRefresh(status, onRefresh);
return null;
}
describe("useRuntimeReadyModelRefresh", () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
// @ts-expect-error React test environment flag
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
// @ts-expect-error React test environment flag
globalThis.IS_REACT_ACT_ENVIRONMENT = false;
});
it("refreshes once when the runtime transitions into ready", async () => {
const onRefresh = vi.fn();
await act(async () => {
root.render(<Harness status="stopped" onRefresh={onRefresh} />);
});
await act(async () => {
root.render(<Harness status="ready" onRefresh={onRefresh} />);
});
await act(async () => {
root.render(<Harness status="ready" onRefresh={onRefresh} />);
});
expect(onRefresh).toHaveBeenCalledTimes(1);
});
it("refreshes again after leaving ready and coming back", async () => {
const onRefresh = vi.fn();
await act(async () => {
root.render(<Harness status="ready" onRefresh={onRefresh} />);
});
await act(async () => {
root.render(<Harness status="degraded" onRefresh={onRefresh} />);
});
await act(async () => {
root.render(<Harness status="ready" onRefresh={onRefresh} />);
});
expect(onRefresh).toHaveBeenCalledTimes(1);
});
it("ignores non-ready transitions", async () => {
const onRefresh = vi.fn();
await act(async () => {
root.render(<Harness status="starting" onRefresh={onRefresh} />);
});
await act(async () => {
root.render(<Harness status="stopping" onRefresh={onRefresh} />);
});
expect(onRefresh).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,14 @@
import { useEffect, useRef } from "react";
import type { RuntimeStatus } from "./types";
export function useRuntimeReadyModelRefresh(status: RuntimeStatus, onRefresh: () => void) {
const previousStatus = useRef<RuntimeStatus>(status);
useEffect(() => {
if (status === "ready" && previousStatus.current !== "ready") {
onRefresh();
}
previousStatus.current = status;
}, [status, onRefresh]);
}