Files
ThothII/frontend/src/shell/PiManagement.tsx
T

534 lines
31 KiB
TypeScript

import { useEffect, useMemo, useRef, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { CheckCircle2, CircleAlert, ClipboardCheck, FlaskConical, LoaderCircle, ScrollText } from "lucide-react";
import {
asPiManagementApiError,
getPiManagementLogs,
getPiManagementOptions,
getPiManagementStatus,
runPiManagementTest,
savePiManagementConfig,
type PiInstallationConfig,
type PiManagementOptions,
} from "../api/pi-management";
import { Button } from "../components/ui/button";
import { WorkAreaPanel } from "./WorkAreaPanel";
const fieldClass = "h-9 w-full rounded-md border border-input bg-background px-2.5 text-sm shadow-xs outline-none focus-visible:ring-3 focus-visible:ring-ring/25 disabled:cursor-not-allowed disabled:opacity-60";
type Feedback = { tone: "success" | "error"; message: string } | undefined;
type SmokeState = "passed" | "failed" | undefined;
function suggestedConfig(status: { config: Partial<PiInstallationConfig> }, options: PiManagementOptions): PiInstallationConfig | undefined {
const provider = status.config.provider && options.providers.includes(status.config.provider)
? status.config.provider
: options.providers[0];
const model = status.config.model && options.models.some((item) => item.provider === provider && item.id === status.config.model)
? status.config.model
: options.models.find((item) => item.provider === provider)?.id;
const reasoning = status.config.reasoning && options.reasoning.includes(status.config.reasoning)
? status.config.reasoning
: options.reasoning[0];
return provider && model && reasoning ? { provider, model, reasoning } : undefined;
}
function isSupportedConfig(
config: Partial<PiInstallationConfig> | undefined,
options: PiManagementOptions | undefined,
): config is PiInstallationConfig {
return Boolean(
config?.provider
&& config.model
&& config.reasoning
&& options?.providers.includes(config.provider)
&& options.models.some((model) => model.provider === config.provider && model.id === config.model)
&& options.reasoning.includes(config.reasoning),
);
}
function sameConfig(a: Partial<PiInstallationConfig> | undefined, b: PiInstallationConfig | undefined): boolean {
return Boolean(
a?.provider === b?.provider
&& a?.model === b?.model
&& a?.reasoning === b?.reasoning,
);
}
function errorMessage(error: unknown, fallback: string): string {
return asPiManagementApiError(error)?.message ?? fallback;
}
function ReadinessRail({ ready, configured, credentials, smokeState }: {
ready: boolean;
configured: boolean;
credentials: "present" | "missing";
smokeState: SmokeState;
}) {
return (
<section
data-testid="pi-readiness-rail"
aria-label="Pi readiness sequence"
className="grid gap-3 border-l-2 border-border pl-4 sm:grid-cols-4"
>
<RailItem label="Runtime" value={ready ? "Runtime ready" : "Runtime needs attention"} state={ready ? "ready" : "attention"} />
<RailItem label="Defaults" value={configured ? "Defaults ready" : "Defaults incomplete"} state={configured ? "ready" : "attention"} />
<RailItem
label="Credentials"
value={`Credentials ${credentials}`}
state={credentials === "present" ? "ready" : "attention"}
/>
<RailItem
label="Configuration test"
value={smokeState === "passed" ? "Saved configuration test passed" : smokeState === "failed" ? "Saved configuration test failed" : "Saved configuration test not run"}
state={smokeState === "passed" ? "ready" : smokeState === "failed" ? "attention" : "idle"}
/>
</section>
);
}
function RailItem({ label, value, state }: { label: string; value: string; state: "ready" | "attention" | "idle" }) {
const iconClass = state === "ready" ? "text-[oklch(var(--success))]" : state === "attention" ? "text-amber-700 dark:text-amber-400" : "text-muted-foreground";
return <div className="min-w-0">
<p className="thot-label">{label}</p>
<p className={`mt-1 flex items-start gap-1.5 text-sm font-semibold ${iconClass}`}>
{state === "ready" ? <CheckCircle2 className="mt-0.5 size-3.5 shrink-0" /> : state === "attention" ? <CircleAlert className="mt-0.5 size-3.5 shrink-0" /> : <span className="mt-1.5 size-1.5 shrink-0 rounded-full bg-current" />}
<span>{value}</span>
</p>
</div>;
}
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return <label className="grid gap-1.5 text-xs font-semibold text-foreground"><span>{label}</span>{children}</label>;
}
type PiPlatform = "linux" | "macos" | "windows";
type PiPlatformDetails = {
modelsPath: string;
settingsPath: string;
terminal: string;
changeDirectoryCommand: string;
credentialProtection: string;
restartCommand: string;
updateCommand: string;
pullCommand: string;
diagnosticCommands: string;
rollbackCommand: string;
recoverCommand: string;
verificationCommands: string;
};
const piPlatforms: Array<{ id: PiPlatform; label: string; details: PiPlatformDetails }> = [
{
id: "linux",
label: "Linux",
details: {
modelsPath: "deploy/pi/models.json",
settingsPath: "deploy/pi/settings.json",
terminal: "a terminal",
changeDirectoryCommand: "cd /absolute/path/to/ThothII",
credentialProtection: "a protected host file with mode 0600",
restartCommand: "tht pi restart --yes --drain",
updateCommand: "tht pi update",
pullCommand: "tht pi update --version <VERSION> --source pull --image <IMAGE>@sha256:<DIGEST> --yes --drain",
diagnosticCommands: "tht pi maintenance status\ntht pi status\ntht pi doctor\ntht pi logs",
rollbackCommand: "tht pi rollback --yes",
recoverCommand: "tht pi maintenance recover --yes",
verificationCommands: "tht pi maintenance status\ntht pi doctor\ntht pi test",
},
},
{
id: "macos",
label: "macOS",
details: {
modelsPath: "deploy/pi/models.json",
settingsPath: "deploy/pi/settings.json",
terminal: "Terminal",
changeDirectoryCommand: "cd /absolute/path/to/ThothII",
credentialProtection: "a protected host file with mode 0600",
restartCommand: "tht pi restart --yes --drain",
updateCommand: "tht pi update",
pullCommand: "tht pi update --version <VERSION> --source pull --image <IMAGE>@sha256:<DIGEST> --yes --drain",
diagnosticCommands: "tht pi maintenance status\ntht pi status\ntht pi doctor\ntht pi logs",
rollbackCommand: "tht pi rollback --yes",
recoverCommand: "tht pi maintenance recover --yes",
verificationCommands: "tht pi maintenance status\ntht pi doctor\ntht pi test",
},
},
{
id: "windows",
label: "Windows",
details: {
modelsPath: "deploy\\pi\\models.json",
settingsPath: "deploy\\pi\\settings.json",
terminal: "PowerShell",
changeDirectoryCommand: "Set-Location C:\\absolute\\path\\to\\ThothII",
credentialProtection: "a protected host file with a user-only ACL",
restartCommand: "tht pi restart --yes --drain",
updateCommand: "tht pi update",
pullCommand: "tht pi update --version <VERSION> --source pull --image <IMAGE>@sha256:<DIGEST> --yes --drain",
diagnosticCommands: "tht pi maintenance status\ntht pi status\ntht pi doctor\ntht pi logs",
rollbackCommand: "tht pi rollback --yes",
recoverCommand: "tht pi maintenance recover --yes",
verificationCommands: "tht pi maintenance status\ntht pi doctor\ntht pi test",
},
},
];
function PiCodeBlock({ children, className = "" }: { children: string; className?: string }) {
return <pre className={`overflow-x-auto rounded bg-muted p-2 ${className}`}><code>{children}</code></pre>;
}
function PiInstructionSteps({ details }: { details: PiPlatformDetails }) {
return <ol className="grid gap-4 pl-5 marker:font-semibold marker:text-muted-foreground">
<li>
<h4 className="font-semibold text-foreground">Open the project root</h4>
<p className="mt-1 text-muted-foreground">Using {details.terminal}, before editing files or running any lifecycle command, enter the exact checkout or worktree root. Run the platform command below, then run every command in this list from that directory: <code>tht</code> resolves the installation descriptor and configuration from the selected project. A worktree is an independent checkout, so this prevents changing a different copy by mistake.</p>
<PiCodeBlock className="mt-2">{details.changeDirectoryCommand}</PiCodeBlock>
<p className="mt-2 text-muted-foreground">The relevant layout is:</p>
<PiCodeBlock className="mt-1">project-root/
├── compose.yaml
├── deploy/
│ └── pi/
│ ├── models.json
│ └── settings.json
└── docker/</PiCodeBlock>
<p className="mt-2 text-muted-foreground">The <code>deploy/</code> directory contains the selected installation descriptor and profile files. You do not need to create or manage a <code>bin/</code> directory: <code>tht</code> is the installed host CLI. If discovery finds multiple descriptors, pass the intended one explicitly with <code>--installation &lt;absolute-path&gt;/thothii-installation.yaml</code>. If <code>tht</code> is not on your <code>PATH</code>, install it using the installation guide.</p>
</li>
<li>
<h4 className="font-semibold text-foreground">Edit the provider catalog</h4>
<p className="mt-1 text-muted-foreground">Edit <code>{details.modelsPath}</code> only when adding or correcting a provider/model definition. The provider catalog is an address book/map of the services Pi can call: each entry supplies the API endpoint and format, and lists the model identifiers offered there. It does not enable a model and never contains credentials. Provider integrations must remain declarative; do not add model-provider code under <code>harness/.pi/extensions/</code>.</p>
<dl className="mt-2 grid grid-cols-[auto_1fr] gap-x-2 gap-y-1 text-muted-foreground">
<dt className="font-mono text-foreground">baseUrl </dt><dd>is the provider API endpoint.</dd>
<dt className="font-mono text-foreground">api </dt><dd>selects the provider API format.</dd>
<dt className="font-mono text-foreground">models </dt><dd>lists that provider&apos;s available models.</dd>
<dt className="font-mono text-foreground">id </dt><dd>is the model identifier.</dd>
<dt className="font-mono text-foreground">name </dt><dd>is the model name shown to operators.</dd>
</dl>
</li>
<li>
<h4 className="font-semibold text-foreground">Enable the model</h4>
<p className="mt-1 text-muted-foreground">Enable a model after the provider setup is ready. For a custom provider, first define the provider and model ID in <code>{details.modelsPath}</code>; built-in Pi models may already be known without a local catalog entry. Then edit <code>{details.settingsPath}</code> in the project root, add the exact <code>provider/model</code> identifier to <code>enabledModels</code>, and reload Pi. Do not edit it merely to choose the default provider/model: use this page or <code>tht pi configure</code>.</p>
<dl className="mt-2 grid grid-cols-[auto_1fr] gap-x-2 gap-y-1 text-muted-foreground">
<dt className="font-mono text-foreground">enabledModels </dt><dd>uses provider/model identifiers to choose the models available for new Pi work.</dd>
</dl>
</li>
<li>
<h4 className="font-semibold text-foreground">Complete the provider setup</h4>
<p className="mt-1 text-muted-foreground">During the initial installation, run <code>tht setup</code> and complete the prompt named “Pi credentials file location”; it can create the empty protected template. On an existing installation with a missing credential, correct the protected credential file selected during setup by following the installation guide. Keep {details.credentialProtection}, then restart and run <code>tht pi doctor</code> and <code>tht pi test</code>. Never paste credentials into either JSON file, the page, a command, or a log.</p>
</li>
<li>
<h4 className="font-semibold text-foreground">Reload Pi configuration</h4>
<p className="mt-1 text-muted-foreground">After changing <code>models.json</code>, <code>settings.json</code>, or the provider credential, reload the running <code>core</code> service so it reads the new files. This is a configuration reload, not a Pi version update; it keeps the current image.</p>
<PiCodeBlock className="mt-2">{details.restartCommand}</PiCodeBlock>
</li>
<li>
<h4 className="font-semibold text-foreground">Update the Pi version</h4>
<p className="mt-1 text-muted-foreground">Use <code>tht pi update</code> for the routine build: it resolves the latest stable Pi release from the registry, builds it locally, drains active sessions, and recreates only <code>core</code>. Add <code>--version &lt;VERSION&gt;</code> when an explicitly reviewed version is required. The digest-pinned <code>--source pull</code> form is for an already-built, reviewed registry image; its declared version must match the digest-pinned image. Configuration changes use Reload above, not update.</p>
<PiCodeBlock className="mt-2">{details.updateCommand}</PiCodeBlock>
<p className="mt-2 text-[11px] text-muted-foreground">Advanced: pull an immutable, digest-pinned image.</p>
<PiCodeBlock className="mt-1 text-[11px] text-muted-foreground">{details.pullCommand}</PiCodeBlock>
</li>
<li>
<h4 className="font-semibold text-foreground">Diagnose and recover a failed operation</h4>
<p className="mt-1 text-muted-foreground">Start with these diagnostics, in order:</p>
<PiCodeBlock className="mt-2 text-[11px] text-muted-foreground">{details.diagnosticCommands}</PiCodeBlock>
<dl className="mt-2 grid gap-2 text-muted-foreground">
<dt className="font-semibold text-foreground">Configuration</dt><dd>Invalid JSON: fix the reported file and validate it. A provider/model ID mismatch or a model missing from <code>enabledModels</code>: correct the IDs or policy, then reload.</dd>
<dt className="font-semibold text-foreground">Credentials</dt><dd>Missing or unreadable credential: correct the protected credential file selected during setup and its permissions, without printing the file.</dd>
<dt className="font-semibold text-foreground">Provider connectivity</dt><dd>Wrong <code>baseUrl</code>, network, or provider error: correct the endpoint or network, then retry.</dd>
<dt className="font-semibold text-foreground">Docker and disk</dt><dd>Unhealthy Docker or insufficient disk: restore Docker health or free space before retrying.</dd>
</dl>
<p className="mt-2 text-muted-foreground">If maintenance is inactive, no recovery is needed: correct the cause and retry the original command.</p>
<p className="mt-2 text-muted-foreground">If maintenance is active after an update, rollback the previous image:</p>
<PiCodeBlock className="mt-1 text-[11px] text-muted-foreground">{details.rollbackCommand}</PiCodeBlock>
<p className="mt-2 text-muted-foreground">If maintenance is active after a restart, fix the cause first, then recover the captured lifecycle state:</p>
<PiCodeBlock className="mt-1 text-[11px] text-muted-foreground">{details.recoverCommand}</PiCodeBlock>
<p className="mt-2 text-muted-foreground">After the selected remedy, verify the installation:</p>
<PiCodeBlock className="mt-1 text-[11px] text-muted-foreground">{details.verificationCommands}</PiCodeBlock>
</li>
</ol>;
}
function PiPlatformInstructions() {
const [activePlatform, setActivePlatform] = useState<PiPlatform>();
const [focusedPlatform, setFocusedPlatform] = useState<PiPlatform>("linux");
const tabRefs = useRef<Record<PiPlatform, HTMLButtonElement | null>>({ linux: null, macos: null, windows: null });
function activateAndFocus(platform: PiPlatform) {
setFocusedPlatform(platform);
setActivePlatform(platform);
tabRefs.current[platform]?.focus();
}
function handleTabKeyDown(event: React.KeyboardEvent<HTMLButtonElement>, platform: PiPlatform) {
const index = piPlatforms.findIndex((item) => item.id === platform);
const nextPlatform = event.key === "ArrowRight"
? piPlatforms[(index + 1) % piPlatforms.length]
: event.key === "ArrowLeft"
? piPlatforms[(index - 1 + piPlatforms.length) % piPlatforms.length]
: event.key === "Home"
? piPlatforms[0]
: event.key === "End"
? piPlatforms[piPlatforms.length - 1]
: undefined;
if (!nextPlatform) return;
event.preventDefault();
activateAndFocus(nextPlatform.id);
}
return (
<section aria-label="Pi operator workflow" className="mt-2 grid gap-2 rounded-md border border-amber-500/30 bg-amber-500/5 p-3 text-xs">
<p className="text-muted-foreground">Using the host terminal:</p>
<div role="tablist" aria-label="Pi host platform" className="grid grid-cols-3 gap-1 rounded-md bg-muted p-1">
{piPlatforms.map((platform) => (
<button
key={platform.id}
id={`pi-${platform.id}-tab`}
ref={(element) => { tabRefs.current[platform.id] = element; }}
type="button"
role="tab"
aria-selected={activePlatform === platform.id}
aria-controls={`pi-${platform.id}-panel`}
tabIndex={focusedPlatform === platform.id ? 0 : -1}
className={activePlatform === platform.id ? "rounded bg-background px-2 py-1.5 font-semibold text-foreground shadow-sm" : "rounded px-2 py-1.5 font-semibold text-muted-foreground hover:bg-background/70 hover:text-foreground"}
onFocus={() => setFocusedPlatform(platform.id)}
onKeyDown={(event) => handleTabKeyDown(event, platform.id)}
onClick={() => {
setFocusedPlatform(platform.id);
setActivePlatform((current) => current === platform.id ? undefined : platform.id);
}}
>
{platform.label}
</button>
))}
</div>
<div data-testid="pi-platform-instructions-scroll" className="min-h-0 max-h-[min(34vh,22rem)] overflow-y-scroll overscroll-contain pr-2">
{piPlatforms.map((platform) => (
<div
key={platform.id}
id={`pi-${platform.id}-panel`}
role="tabpanel"
aria-label={platform.label}
aria-labelledby={`pi-${platform.id}-tab`}
tabIndex={activePlatform === platform.id ? 0 : -1}
hidden={activePlatform !== platform.id}
className="outline-none"
>
<PiInstructionSteps details={platform.details} />
</div>
))}
</div>
</section>
);
}
export function PiManagement({ open, onClose }: { open: boolean; onClose: () => void }) {
const queryClient = useQueryClient();
const [draft, setDraft] = useState<PiInstallationConfig>();
const [feedback, setFeedback] = useState<Feedback>();
const [smokeState, setSmokeState] = useState<SmokeState>();
const [logsRequested, setLogsRequested] = useState(false);
const statusQuery = useQuery({ queryKey: ["pi-management", "status"], queryFn: getPiManagementStatus, enabled: open });
const optionsQuery = useQuery({ queryKey: ["pi-management", "options"], queryFn: getPiManagementOptions, enabled: open });
const logsQuery = useQuery({ queryKey: ["pi-management", "logs"], queryFn: getPiManagementLogs, enabled: open && logsRequested });
const models = useMemo(
() => optionsQuery.data?.models.filter((model) => model.provider === draft?.provider) ?? [],
[draft?.provider, optionsQuery.data?.models],
);
const initialDraft = useMemo(
() => statusQuery.data && optionsQuery.data
? suggestedConfig(statusQuery.data, optionsQuery.data)
: undefined,
[optionsQuery.data, statusQuery.data],
);
const persistedReady = isSupportedConfig(statusQuery.data?.config, optionsQuery.data);
const validDraft = isSupportedConfig(draft, optionsQuery.data);
const dirty = Boolean(draft && !sameConfig(statusQuery.data?.config, draft));
useEffect(() => {
if (!open) {
setDraft(undefined);
setFeedback(undefined);
setSmokeState(undefined);
setLogsRequested(false);
queryClient.removeQueries({ queryKey: ["pi-management"] });
}
}, [open, queryClient]);
useEffect(() => {
if (draft || !initialDraft) return;
setDraft(initialDraft);
}, [draft, initialDraft]);
const saveMutation = useMutation({
mutationFn: savePiManagementConfig,
onSuccess: async (saved) => {
const config = { provider: saved.provider, model: saved.model, reasoning: saved.reasoning };
setDraft(config);
setSmokeState(undefined);
await queryClient.invalidateQueries({ queryKey: ["pi-management", "status"] });
setFeedback({ tone: "success", message: "Defaults saved." });
},
onError: (error) => setFeedback({ tone: "error", message: errorMessage(error, "Could not save Pi defaults.") }),
});
const smokeMutation = useMutation({
mutationFn: runPiManagementTest,
onSuccess: (result) => {
setSmokeState(result.ready ? "passed" : "failed");
setFeedback({
tone: result.ready ? "success" : "error",
message: result.ready
? "Saved configuration test passed."
: `Saved configuration test failed.${result.message ? ` ${result.message}` : ""}`,
});
},
onError: (error) => {
setSmokeState("failed");
setFeedback({ tone: "error", message: errorMessage(error, "Could not test the saved Pi defaults.") });
},
});
function saveDefaults() {
if (!draft || !validDraft) {
setFeedback({ tone: "error", message: "Choose a supported provider, model, and reasoning level." });
return;
}
saveMutation.mutate(draft);
}
function updateDraft(update: (current: PiInstallationConfig) => PiInstallationConfig) {
setDraft((current) => current ? update(current) : current);
setSmokeState(undefined);
setFeedback(undefined);
}
function testSavedDefaults() {
if (!persistedReady || dirty) {
setFeedback({ tone: "error", message: "Save supported defaults before running the test." });
return;
}
smokeMutation.mutate();
}
const forbidden = asPiManagementApiError(statusQuery.error)?.code === "pi_management_forbidden";
const loading = statusQuery.isLoading || optionsQuery.isLoading || Boolean(!draft && initialDraft);
const unavailable = statusQuery.isError || optionsQuery.isError;
return (
<WorkAreaPanel
open={open}
ariaLabel="Pi management"
eyebrow="Installation controls"
title="Pi management"
description="Review the bundled runtime, set safe defaults, and test the saved provider configuration."
onClose={onClose}
closeLabel="Close Pi management"
>
<PiPlatformInstructions />
<div className="mt-5">
{forbidden ? (
<div role="alert" aria-label="Pi management unavailable" className="rounded-md border border-destructive/30 bg-destructive/5 p-4 text-sm">
<p className="font-semibold">Pi management is not permitted</p>
<p className="mt-1 text-muted-foreground">Ask an installation administrator to manage Pi defaults and diagnostics.</p>
</div>
) : unavailable ? (
<div role="alert" aria-label="Pi management unavailable" className="rounded-md border border-destructive/30 bg-destructive/5 p-4 text-sm">
<p className="font-semibold">{errorMessage(statusQuery.error ?? optionsQuery.error, "Pi management is unavailable")}</p>
<Button className="mt-3" size="sm" variant="outline" onClick={() => { void statusQuery.refetch(); void optionsQuery.refetch(); }}>Retry</Button>
</div>
) : loading ? <p className="text-sm text-muted-foreground">Loading Pi management…</p> : statusQuery.data && optionsQuery.data && (
<div className="grid gap-6">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<p className="thot-label">Bundled runtime</p>
<p className="mt-1 font-mono text-sm font-semibold">Pi {statusQuery.data.version ?? "version unavailable"}</p>
</div>
<p role="status" aria-label="Pi readiness" className={`rounded-md border px-2.5 py-1 text-xs font-semibold ${statusQuery.data.ready ? "border-[oklch(var(--success)/0.4)] bg-[oklch(var(--success)/0.1)] text-foreground" : "border-amber-500/35 bg-amber-500/10 text-amber-800 dark:text-amber-300"}`}>
{statusQuery.data.ready ? "Ready" : "Needs attention"}
</p>
</div>
<ReadinessRail
ready={statusQuery.data.ready}
configured={persistedReady}
credentials={statusQuery.data.credentials ?? "missing"}
smokeState={smokeState}
/>
{statusQuery.data.message && <p role="status" aria-live="polite" className="rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-sm text-amber-900 dark:text-amber-200">{statusQuery.data.message}</p>}
{feedback && <p role="status" aria-label="Pi management feedback" aria-live="polite" className={`flex items-start gap-2 rounded-md border px-3 py-2 text-sm ${feedback.tone === "error" ? "border-destructive/30 bg-destructive/5" : "border-border bg-muted/50"}`}>
{feedback.tone === "error" ? <CircleAlert className="mt-0.5 size-4 shrink-0 text-destructive" /> : <CheckCircle2 className="mt-0.5 size-4 shrink-0 text-[oklch(var(--success))]" />}
{feedback.message}
</p>}
<section aria-label="Installation defaults" className="border-t border-border/70 pt-5">
<div className="flex flex-wrap items-baseline justify-between gap-2">
<div><h3 className="font-heading text-sm font-semibold">Installation defaults</h3><p className="mt-1 text-sm text-muted-foreground">Select the provider, model, and reasoning used for new Pi work. Credentials stay in protected host files.</p></div>
</div>
{draft ? (
<>
<div className="mt-4 grid gap-3 sm:grid-cols-3">
<Field label="Provider">
<select aria-label="Provider" className={fieldClass} value={draft.provider} onChange={(event) => {
const provider = event.target.value;
updateDraft((current) => ({
...current,
provider,
model: optionsQuery.data.models.find((model) => model.provider === provider)?.id ?? "",
}));
}}>
{optionsQuery.data.providers.map((provider) => <option key={provider} value={provider}>{provider}</option>)}
</select>
</Field>
<Field label="Model">
<select aria-label="Model" className={fieldClass} value={draft.model} onChange={(event) => updateDraft((current) => ({ ...current, model: event.target.value }))}>
{models.map((model) => <option key={model.id} value={model.id}>{model.id === "glm-5.2" ? "GLM 5.2" : model.id}</option>)}
</select>
</Field>
<Field label="Reasoning level">
<select aria-label="Reasoning level" className={fieldClass} value={draft.reasoning} onChange={(event) => updateDraft((current) => ({ ...current, reasoning: event.target.value as PiInstallationConfig["reasoning"] }))}>
{optionsQuery.data.reasoning.map((reasoning) => <option key={reasoning} value={reasoning}>{reasoning}</option>)}
</select>
</Field>
</div>
{dirty && <p className="mt-3 text-sm text-amber-800 dark:text-amber-300">
{persistedReady ? "Changes are not saved yet." : "Suggested choices are not saved yet."}
</p>}
<div className="mt-4 flex flex-wrap gap-2">
<Button size="sm" variant="outline" disabled={saveMutation.isPending || !validDraft || !dirty} onClick={saveDefaults}>{saveMutation.isPending ? <LoaderCircle className="animate-spin motion-reduce:animate-none" /> : <ClipboardCheck />}Save defaults</Button>
<Button size="sm" variant="outline" disabled={smokeMutation.isPending || dirty || !persistedReady} onClick={testSavedDefaults}>{smokeMutation.isPending ? <LoaderCircle className="animate-spin motion-reduce:animate-none" /> : <FlaskConical />}Test saved defaults</Button>
</div>
{dirty && <p className="mt-2 text-xs text-muted-foreground">Save these changes before testing. The test always uses saved defaults.</p>}
{!dirty && !persistedReady && <p className="mt-2 text-xs text-muted-foreground">Save supported defaults before testing. The test always uses saved defaults.</p>}
</>
) : (
<div className="mt-4">
<div role="alert" aria-label="Pi configuration incomplete" className="rounded-md border border-amber-500/30 bg-amber-500/10 p-4 text-sm text-amber-900 dark:text-amber-200">
<p className="font-semibold">No provider or model options are available.</p>
<p className="mt-1">Check the host-managed Pi model configuration, then retry this panel.</p>
</div>
<div className="mt-4 flex flex-wrap gap-2">
<Button size="sm" variant="outline" disabled><ClipboardCheck />Save defaults</Button>
<Button size="sm" variant="outline" disabled><FlaskConical />Test saved defaults</Button>
<Button size="sm" variant="ghost" onClick={() => { void statusQuery.refetch(); void optionsQuery.refetch(); }}>Retry choices</Button>
</div>
</div>
)}
</section>
<section aria-label="Sanitized diagnostics" className="border-t border-border/70 pt-5">
<div className="flex flex-wrap items-center justify-between gap-3">
<div><h3 className="font-heading text-sm font-semibold">Sanitized diagnostics</h3><p className="mt-1 text-sm text-muted-foreground">Shows at most 200 recent lines with declared secret values removed.</p></div>
<Button size="sm" variant="outline" onClick={() => setLogsRequested(true)} disabled={logsQuery.isFetching}>{logsQuery.isFetching ? <LoaderCircle className="animate-spin motion-reduce:animate-none" /> : <ScrollText />}Show sanitized logs</Button>
</div>
{logsQuery.isError && <p role="alert" className="mt-3 text-sm text-destructive">Could not load sanitized Pi diagnostics.</p>}
{logsQuery.data && <pre aria-label="Sanitized Pi diagnostics" className="mt-3 max-h-48 overflow-auto rounded-md border border-border bg-muted/50 p-3 font-mono text-xs leading-relaxed whitespace-pre-wrap">{logsQuery.data.lines.join("\n") || "No diagnostic lines are available."}</pre>}
</section>
</div>
)}
</div>
</WorkAreaPanel>
);
}