Files
headquarter/apps/web/src/pages/tool-workshop.tsx
T
Alex Blank 0a0af4e02a fix: stop manifest editor base image selection loop
The ManifestEditor had a feedback loop:
1. State change → buildManifest changes → onChange notifies parent
2. Parent updates manifestData → new manifest prop
3. Loading effect sets all state from manifest (arrays get new refs even if same content)
4. New array refs → buildManifest changes → onChange fires again → loop

Fix: track the last-sent manifest via a ref and only call onChange when the
serialized built manifest actually differs. This breaks the cycle because after
the loading effect syncs state, the rebuilt manifest is identical in content
so we skip the parent notification.
2026-05-28 21:44:49 +02:00

1270 lines
36 KiB
TypeScript

import { useCallback, useEffect, useState } from "react";
import { ErrorState, LoadingState } from "../components/data-states";
import { Icon } from "../components/icon";
import { useMobileViewport } from "../hooks/use-mobile-viewport";
import { extractErrorMessage } from "../utils/errors";
import { MobileListView } from "../components/mobile-list-view";
import { MobileDetailView } from "../components/mobile-detail-view";
import { MobileEditView } from "../components/mobile-edit-view";
import { MobileFAB } from "../components/mobile-fab";
import {
createToolType,
deleteToolType,
listToolTypes,
updateToolType,
type CreateToolTypeRequest,
type ReadinessProbe,
type ToolType,
type UpdateToolTypeRequest,
} from "../api/tool_types";
import {
createToolDefinition,
getToolDefinition,
listToolDefinitions,
updateToolDefinition,
type ToolDefinitionManifest,
} from "../api/tool_definitions";
import { ManifestEditor } from "../components/manifest-editor";
type Status = "loading" | "ready" | "error";
type MobileView = "list" | "detail" | "edit";
export const ToolWorkshopPage = () => {
const isMobile = useMobileViewport();
const [mobileView, setMobileView] = useState<MobileView>("list");
const [status, setStatus] = useState<Status>("loading");
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
const [baseDefinitions, setBaseDefinitions] = useState<
ToolDefinitionManifest[]
>([]);
const [manifestData, setManifestData] = useState<Record<
string,
unknown
> | null>(null);
const [manifestDefinitionId, setManifestDefinitionId] = useState<
string | null
>(null);
// Selection state
const [selectedToolTypeId, setSelectedToolTypeId] = useState<string | null>(
null,
);
const [isCreating, setIsCreating] = useState(false);
// Tool Type form state
const [toolTypeForm, setToolTypeForm] = useState({
name: "",
display_name: "",
description: "",
category: "",
interface_type: "web" as "web" | "terminal",
requires_port: true,
default_port: "",
definition_type: "compose" as "compose" | "dockerfile" | "manifest",
compose_template: "",
dockerfile_template: "",
readiness_command: "",
readiness_timeout: "30",
readiness_interval: "2",
required_variables: "",
startup_command: "",
});
const [toolTypeError, setToolTypeError] = useState<string | null>(null);
const [toolTypeDirty, setToolTypeDirty] = useState(false);
const selectedToolType =
(toolTypes || []).find((t) => t.id === selectedToolTypeId) || null;
const loadData = useCallback(async () => {
setStatus("loading");
try {
const [types, defs] = await Promise.all([
listToolTypes(),
listToolDefinitions(),
]);
setToolTypes(types || []);
setBaseDefinitions((defs || []).filter((d) => d.is_base));
setStatus("ready");
} catch {
setStatus("error");
}
}, []);
useEffect(() => {
void loadData();
}, [loadData]);
const resetToolTypeForm = () => {
setToolTypeForm({
name: "",
display_name: "",
description: "",
category: "",
interface_type: "web",
requires_port: true,
default_port: "",
definition_type: "compose",
compose_template: "",
dockerfile_template: "",
readiness_command: "",
readiness_timeout: "30",
readiness_interval: "2",
required_variables: "",
startup_command: "",
});
setToolTypeError(null);
setToolTypeDirty(false);
setManifestData(null);
setManifestDefinitionId(null);
};
const populateToolTypeForm = async (toolType: ToolType) => {
setToolTypeForm({
name: toolType.name,
display_name: toolType.display_name,
description: toolType.description || "",
category: toolType.category || "",
interface_type: (toolType.interface_type as "web" | "terminal") || "web",
requires_port: toolType.requires_port ?? true,
default_port: toolType.default_port?.toString() || "",
definition_type:
(toolType.definition_type as "compose" | "dockerfile" | "manifest") ||
"compose",
compose_template: toolType.compose_template || "",
dockerfile_template: toolType.dockerfile_template || "",
readiness_command: toolType.readiness_probe?.command || "",
readiness_timeout: toolType.readiness_probe?.timeout?.toString() || "30",
readiness_interval: toolType.readiness_probe?.interval?.toString() || "2",
required_variables: toolType.required_variables?.join(", ") || "",
startup_command: toolType.startup_command || "",
});
setToolTypeError(null);
setToolTypeDirty(false);
setManifestDefinitionId(toolType.manifest_id || null);
if (toolType.definition_type === "manifest" && toolType.manifest_id) {
try {
const defn = await getToolDefinition(toolType.manifest_id);
setManifestData(defn.manifest);
} catch {
setManifestData(null);
}
} else {
setManifestData(null);
}
};
const handleSelectToolType = (toolType: ToolType | null) => {
if (toolTypeDirty) {
if (!window.confirm("You have unsaved changes. Discard them?")) {
return;
}
}
if (toolType) {
setSelectedToolTypeId(toolType.id);
setIsCreating(false);
populateToolTypeForm(toolType);
} else {
setSelectedToolTypeId(null);
}
};
const handleCreateNew = () => {
if (toolTypeDirty) {
if (!window.confirm("You have unsaved changes. Discard them?")) {
return;
}
}
setSelectedToolTypeId(null);
setIsCreating(true);
resetToolTypeForm();
};
const handleToolTypeSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setToolTypeError(null);
if (!toolTypeForm.name.trim() || !toolTypeForm.display_name.trim()) {
setToolTypeError("Name and display name are required");
return;
}
if (
toolTypeForm.requires_port &&
(!toolTypeForm.default_port.trim() ||
isNaN(Number(toolTypeForm.default_port)))
) {
setToolTypeError("Default port is required and must be a number");
return;
}
if (toolTypeForm.definition_type !== "manifest") {
const template =
toolTypeForm.definition_type === "compose"
? toolTypeForm.compose_template
: toolTypeForm.dockerfile_template;
if (!template.trim()) {
setToolTypeError(
`${toolTypeForm.definition_type === "compose" ? "Compose" : "Dockerfile"} template is required`,
);
return;
}
} else if (!manifestData) {
setToolTypeError(
"Manifest data is required for manifest definition type",
);
return;
}
const variables = toolTypeForm.required_variables
.split(",")
.map((v) => v.trim())
.filter((v) => v.length > 0);
const readinessProbe: ReadinessProbe | undefined =
toolTypeForm.readiness_command.trim()
? {
command: toolTypeForm.readiness_command.trim(),
timeout: parseInt(toolTypeForm.readiness_timeout) || 30,
interval: parseInt(toolTypeForm.readiness_interval) || 2,
}
: undefined;
const template =
toolTypeForm.definition_type === "compose"
? toolTypeForm.compose_template
: toolTypeForm.dockerfile_template;
try {
if (isCreating) {
let manifestId: string | undefined;
if (toolTypeForm.definition_type === "manifest" && manifestData) {
const manifestPayload = {
name: toolTypeForm.name.trim(),
display_name: toolTypeForm.display_name.trim(),
description: toolTypeForm.description.trim() || undefined,
category: toolTypeForm.category.trim() || undefined,
interface_type: toolTypeForm.interface_type,
base_image: (manifestData.base_image as string) || undefined,
base_definition_id:
(manifestData.base_definition_id as string) || undefined,
manifest: manifestData,
};
const newManifest = await createToolDefinition(manifestPayload);
manifestId = newManifest.id;
}
const input: CreateToolTypeRequest = {
name: toolTypeForm.name.trim(),
display_name: toolTypeForm.display_name.trim(),
description: toolTypeForm.description.trim() || undefined,
category: toolTypeForm.category.trim() || undefined,
interface_type: toolTypeForm.interface_type,
requires_port: toolTypeForm.requires_port,
default_port: toolTypeForm.requires_port
? Number(toolTypeForm.default_port)
: 0,
definition_type: toolTypeForm.definition_type,
manifest_id: manifestId,
compose_template:
toolTypeForm.definition_type === "compose" ? template : undefined,
dockerfile_template:
toolTypeForm.definition_type === "dockerfile"
? template
: undefined,
readiness_probe: readinessProbe,
required_variables: variables,
startup_command: toolTypeForm.startup_command.trim() || undefined,
};
const newTool = await createToolType(input);
setIsCreating(false);
setSelectedToolTypeId(newTool.id);
setToolTypeDirty(false);
} else if (selectedToolType) {
let manifestId = selectedToolType.manifest_id || undefined;
if (toolTypeForm.definition_type === "manifest" && manifestData) {
if (manifestId) {
await updateToolDefinition(manifestId, {
display_name: toolTypeForm.display_name.trim(),
description: toolTypeForm.description.trim() || undefined,
category: toolTypeForm.category.trim() || undefined,
manifest: manifestData,
});
} else {
const manifestPayload = {
name: toolTypeForm.name.trim(),
display_name: toolTypeForm.display_name.trim(),
description: toolTypeForm.description.trim() || undefined,
category: toolTypeForm.category.trim() || undefined,
interface_type: toolTypeForm.interface_type,
base_image: (manifestData.base_image as string) || undefined,
base_definition_id:
(manifestData.base_definition_id as string) || undefined,
manifest: manifestData,
};
const newManifest = await createToolDefinition(manifestPayload);
manifestId = newManifest.id;
}
}
const input: UpdateToolTypeRequest = {
display_name: toolTypeForm.display_name.trim(),
description: toolTypeForm.description.trim() || undefined,
category: toolTypeForm.category.trim() || undefined,
interface_type: toolTypeForm.interface_type,
requires_port: toolTypeForm.requires_port,
default_port: toolTypeForm.requires_port
? Number(toolTypeForm.default_port)
: 0,
definition_type: toolTypeForm.definition_type,
manifest_id:
toolTypeForm.definition_type === "manifest"
? manifestId
: undefined,
compose_template:
toolTypeForm.definition_type === "compose" ? template : undefined,
dockerfile_template:
toolTypeForm.definition_type === "dockerfile"
? template
: undefined,
readiness_probe: readinessProbe,
required_variables: variables,
startup_command: toolTypeForm.startup_command.trim() || undefined,
};
await updateToolType(selectedToolType.id, input);
setToolTypeDirty(false);
}
await loadData();
} catch (err) {
setToolTypeError(extractErrorMessage(err));
}
};
const handleDeleteToolType = async (id: string) => {
if (
!window.confirm(
"Delete this tool type? All associated configs will be removed.",
)
)
return;
try {
await deleteToolType(id);
if (selectedToolTypeId === id) {
setSelectedToolTypeId(null);
setIsCreating(false);
resetToolTypeForm();
}
await loadData();
} catch {
alert("Failed to delete tool type");
}
};
if (status === "loading") {
return (
<div className="container">
<LoadingState message="Loading Tool Workshop..." />
</div>
);
}
if (status === "error") {
return (
<div className="container">
<ErrorState
message="Failed to load Tool Workshop."
onRetry={loadData}
/>
</div>
);
}
if (isMobile) {
if (mobileView === "list") {
return (
<div className="mobile-page">
<div className="mobile-page-header">
<h1>Tool Workshop</h1>
<span className="muted">{toolTypes.length} tool types</span>
</div>
<MobileListView
items={toolTypes.map((t) => ({
id: t.id,
title: t.display_name,
subtitle: `${t.category || "Uncategorized"} · ${t.interface_type === "web" ? `Port ${t.default_port}` : "Terminal"}`,
}))}
onItemClick={(id) => {
setSelectedToolTypeId(id);
setIsCreating(false);
setMobileView("detail");
}}
emptyMessage="No tool types yet"
/>
<MobileFAB
onClick={() => {
setSelectedToolTypeId(null);
setIsCreating(true);
resetToolTypeForm();
setMobileView("edit");
}}
/>
</div>
);
}
if (mobileView === "detail" && selectedToolType) {
return (
<MobileDetailView
title={selectedToolType.display_name}
subtitle={`${selectedToolType.name} · ${selectedToolType.definition_type} · ${selectedToolType.interface_type === "web" ? `Port ${selectedToolType.default_port}` : "Terminal"}`}
fields={[
{ label: "Name", value: selectedToolType.name },
{ label: "Display Name", value: selectedToolType.display_name },
{ label: "Description", value: selectedToolType.description },
{ label: "Category", value: selectedToolType.category },
{ label: "Interface Type", value: selectedToolType.interface_type },
{
label: "Requires Port",
value: selectedToolType.requires_port,
type: "boolean",
},
{ label: "Default Port", value: selectedToolType.default_port },
{
label: "Definition Type",
value: selectedToolType.definition_type,
},
{
label: "Startup Command",
value: selectedToolType.startup_command,
},
{
label: "Readiness Command",
value: selectedToolType.readiness_probe?.command ?? null,
},
{
label: "Readiness Timeout",
value: selectedToolType.readiness_probe?.timeout ?? null,
},
{
label: "Readiness Interval",
value: selectedToolType.readiness_probe?.interval ?? null,
},
{
label: "Required Variables",
value: selectedToolType.required_variables?.join(", ") ?? null,
},
{
label: "Compose Template",
value: selectedToolType.compose_template,
type: "code",
},
{
label: "Dockerfile Template",
value: selectedToolType.dockerfile_template,
type: "code",
},
]}
onEdit={() => {
populateToolTypeForm(selectedToolType);
setIsCreating(false);
setMobileView("edit");
}}
onDelete={() => {
handleDeleteToolType(selectedToolType.id);
setMobileView("list");
}}
onBack={() => {
setSelectedToolTypeId(null);
setMobileView("list");
}}
/>
);
}
if (mobileView === "edit") {
return (
<MobileEditView
title={isCreating ? "Create Tool Type" : "Edit Tool Type"}
onCancel={() => {
if (toolTypeDirty) {
if (!window.confirm("You have unsaved changes. Discard them?")) {
return;
}
}
setMobileView(isCreating ? "list" : "detail");
}}
onSave={() => {
// Create a synthetic form event to call handleToolTypeSubmit
const syntheticEvent = {
preventDefault: () => {},
} as React.FormEvent;
void handleToolTypeSubmit(syntheticEvent);
if (!toolTypeError) {
setMobileView("list");
}
}}
isSaving={false}
>
{/* Tool Type Form Fields */}
<div className="mobile-form-group">
<label className="mobile-form-label">Name *</label>
<input
type="text"
value={toolTypeForm.name}
onChange={(e) =>
setToolTypeForm({ ...toolTypeForm, name: e.target.value })
}
className="mobile-form-input"
placeholder="e.g., my-tool"
/>
</div>
<div className="mobile-form-group">
<label className="mobile-form-label">Display Name *</label>
<input
type="text"
value={toolTypeForm.display_name}
onChange={(e) =>
setToolTypeForm({
...toolTypeForm,
display_name: e.target.value,
})
}
className="mobile-form-input"
placeholder="e.g., My Tool"
/>
</div>
<div className="mobile-form-group">
<label className="mobile-form-label">Description</label>
<textarea
value={toolTypeForm.description}
onChange={(e) =>
setToolTypeForm({
...toolTypeForm,
description: e.target.value,
})
}
className="mobile-form-textarea"
placeholder="What does this tool do?"
rows={3}
/>
</div>
<div className="mobile-form-group">
<label className="mobile-form-label">Category</label>
<input
type="text"
value={toolTypeForm.category}
onChange={(e) =>
setToolTypeForm({ ...toolTypeForm, category: e.target.value })
}
className="mobile-form-input"
placeholder="e.g., development"
/>
</div>
<div className="mobile-form-group">
<label className="mobile-form-label">Interface Type</label>
<select
value={toolTypeForm.interface_type}
onChange={(e) =>
setToolTypeForm({
...toolTypeForm,
interface_type: e.target.value as "web" | "terminal",
})
}
className="mobile-form-select"
>
<option value="web">Web</option>
<option value="terminal">Terminal</option>
</select>
</div>
<div className="mobile-form-group">
<label className="mobile-form-label">Requires Port</label>
<input
type="checkbox"
checked={toolTypeForm.requires_port}
onChange={(e) =>
setToolTypeForm({
...toolTypeForm,
requires_port: e.target.checked,
})
}
className="mobile-form-checkbox"
/>
</div>
<div className="mobile-form-group">
<label className="mobile-form-label">Default Port</label>
<input
type="text"
value={toolTypeForm.default_port}
onChange={(e) =>
setToolTypeForm({
...toolTypeForm,
default_port: e.target.value,
})
}
className="mobile-form-input"
placeholder="e.g., 8080"
/>
</div>
<div className="mobile-form-group">
<label className="mobile-form-label">Definition Type</label>
<select
value={toolTypeForm.definition_type}
onChange={(e) =>
setToolTypeForm({
...toolTypeForm,
definition_type: e.target.value as "compose" | "dockerfile",
})
}
className="mobile-form-select"
>
<option value="compose">Compose</option>
<option value="dockerfile">Dockerfile</option>
</select>
</div>
<div className="mobile-form-group">
<label className="mobile-form-label">Startup Command</label>
<input
type="text"
value={toolTypeForm.startup_command}
onChange={(e) =>
setToolTypeForm({
...toolTypeForm,
startup_command: e.target.value,
})
}
className="mobile-form-input"
placeholder="Command to run on startup"
/>
</div>
<div className="mobile-form-group">
<label className="mobile-form-label">Readiness Command</label>
<input
type="text"
value={toolTypeForm.readiness_command}
onChange={(e) =>
setToolTypeForm({
...toolTypeForm,
readiness_command: e.target.value,
})
}
className="mobile-form-input"
placeholder="e.g., curl -f http://localhost:8080/health"
/>
</div>
<div className="mobile-form-group">
<label className="mobile-form-label">Readiness Timeout</label>
<input
type="text"
value={toolTypeForm.readiness_timeout}
onChange={(e) =>
setToolTypeForm({
...toolTypeForm,
readiness_timeout: e.target.value,
})
}
className="mobile-form-input"
placeholder="30"
/>
</div>
<div className="mobile-form-group">
<label className="mobile-form-label">Readiness Interval</label>
<input
type="text"
value={toolTypeForm.readiness_interval}
onChange={(e) =>
setToolTypeForm({
...toolTypeForm,
readiness_interval: e.target.value,
})
}
className="mobile-form-input"
placeholder="2"
/>
</div>
<div className="mobile-form-group">
<label className="mobile-form-label">Required Variables</label>
<input
type="text"
value={toolTypeForm.required_variables}
onChange={(e) =>
setToolTypeForm({
...toolTypeForm,
required_variables: e.target.value,
})
}
className="mobile-form-input"
placeholder="VAR1, VAR2, VAR3"
/>
</div>
{toolTypeForm.definition_type === "compose" && (
<div className="mobile-form-group">
<label className="mobile-form-label">Compose Template</label>
<textarea
value={toolTypeForm.compose_template}
onChange={(e) =>
setToolTypeForm({
...toolTypeForm,
compose_template: e.target.value,
})
}
className="mobile-form-textarea mobile-form-code"
placeholder="version: '3'"
rows={10}
/>
</div>
)}
{toolTypeForm.definition_type === "dockerfile" && (
<div className="mobile-form-group">
<label className="mobile-form-label">Dockerfile Template</label>
<textarea
value={toolTypeForm.dockerfile_template}
onChange={(e) =>
setToolTypeForm({
...toolTypeForm,
dockerfile_template: e.target.value,
})
}
className="mobile-form-textarea mobile-form-code"
placeholder="FROM ubuntu:22.04"
rows={10}
/>
</div>
)}
</MobileEditView>
);
}
}
return (
<div
className="container"
style={{
display: "flex",
height: "calc(100vh - 4rem)",
gap: 0,
padding: 0,
}}
>
{/* Left Sidebar - Tool List */}
<div
style={{
width: "280px",
minWidth: "280px",
borderRight: "1px solid var(--border)",
display: "flex",
flexDirection: "column",
background: "var(--panel)",
}}
>
<div
style={{ padding: "1rem", borderBottom: "1px solid var(--border)" }}
>
<h2 style={{ margin: 0, fontSize: "1.125rem" }}>Tool Workshop</h2>
<p
className="muted"
style={{ margin: "0.25rem 0 0 0", fontSize: "0.875rem" }}
>
{toolTypes.length} tool type{toolTypes.length !== 1 ? "s" : ""}
</p>
</div>
<div style={{ flex: 1, overflowY: "auto", padding: "0.5rem" }}>
{toolTypes.map((toolType) => (
<button
key={toolType.id}
onClick={() => handleSelectToolType(toolType)}
style={{
width: "100%",
textAlign: "left",
padding: "0.75rem 1rem",
marginBottom: "0.25rem",
borderRadius: "0.375rem",
border: "none",
background:
selectedToolTypeId === toolType.id
? "var(--brand)"
: "transparent",
color:
selectedToolTypeId === toolType.id ? "white" : "var(--ink)",
cursor: "pointer",
display: "flex",
alignItems: "center",
gap: "0.75rem",
transition: "background 0.15s",
}}
onMouseEnter={(e) => {
if (selectedToolTypeId !== toolType.id) {
e.currentTarget.style.background = "#ece7df";
}
}}
onMouseLeave={(e) => {
if (selectedToolTypeId !== toolType.id) {
e.currentTarget.style.background = "transparent";
}
}}
>
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
fontWeight: 600,
fontSize: "0.9375rem",
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{toolType.display_name}
</div>
<div
style={{
fontSize: "0.8125rem",
opacity: 0.8,
marginTop: "0.125rem",
}}
>
{toolType.category || "Uncategorized"} ·{" "}
{toolType.interface_type === "web"
? `Port ${toolType.default_port}`
: "Terminal"}
</div>
</div>
<button
onClick={(e) => {
e.stopPropagation();
handleDeleteToolType(toolType.id);
}}
style={{
background: "none",
border: "none",
color:
selectedToolTypeId === toolType.id
? "rgba(255,255,255,0.8)"
: "var(--muted)",
cursor: "pointer",
padding: "0.25rem",
borderRadius: "0.25rem",
flexShrink: 0,
opacity: 0,
}}
className="delete-btn"
title="Delete tool type"
>
<Icon name="delete" size="sm" />
</button>
</button>
))}
</div>
<div style={{ padding: "1rem", borderTop: "1px solid var(--border)" }}>
<button
onClick={handleCreateNew}
style={{
width: "100%",
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: "0.5rem",
padding: "0.75rem",
borderRadius: "0.5rem",
border: "2px dashed var(--border)",
background: "transparent",
color: "var(--muted)",
cursor: "pointer",
fontWeight: 600,
transition: "all 0.15s",
}}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = "var(--brand)";
e.currentTarget.style.color = "var(--brand)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = "var(--border)";
e.currentTarget.style.color = "var(--muted)";
}}
>
<Icon name="add" size="sm" /> New Tool Type
</button>
</div>
</div>
{/* Right Panel - Editor */}
<div
style={{ flex: 1, overflow: "auto", padding: "1.5rem", minWidth: 0 }}
>
{!selectedToolTypeId && !isCreating ? (
<div
style={{
textAlign: "center",
paddingTop: "4rem",
color: "var(--muted)",
}}
>
<div style={{ opacity: 0.3, marginBottom: "1rem" }}>
<Icon name="code" size="lg" />
</div>
<h3 style={{ margin: "0 0 0.5rem 0", fontWeight: 500 }}>
Select a tool type
</h3>
<p style={{ margin: 0 }}>
Choose a tool from the list to edit, or create a new one.
</p>
</div>
) : (
<div>
{/* Header */}
<div style={{ marginBottom: "1.5rem" }}>
<h1 style={{ margin: "0 0 0.5rem 0", fontSize: "1.5rem" }}>
{isCreating
? "Create Tool Type"
: selectedToolType?.display_name}
</h1>
{!isCreating && (
<p className="muted" style={{ margin: 0 }}>
{selectedToolType?.name} · {selectedToolType?.definition_type}{" "}
·{" "}
{selectedToolType?.interface_type === "web"
? `Port ${selectedToolType?.default_port}`
: "Terminal"}
</p>
)}
</div>
{
<form
onSubmit={handleToolTypeSubmit}
className="stack"
style={{ gap: "1rem", maxWidth: "800px" }}
>
<div className="form-group">
<label htmlFor="definition-type">Definition Type</label>
<select
id="definition-type"
value={toolTypeForm.definition_type}
onChange={(e) => {
setToolTypeForm({
...toolTypeForm,
definition_type: e.target.value as
| "compose"
| "dockerfile",
});
setToolTypeDirty(true);
}}
className="form-input"
disabled={!isCreating}
>
<option value="compose">Docker Compose</option>
<option value="dockerfile">Dockerfile</option>
<option value="manifest">Manifest (Declarative)</option>
</select>
</div>
<div className="row" style={{ gap: "1rem" }}>
<div className="form-group" style={{ flex: 1 }}>
<label htmlFor="tool-type-name">Name *</label>
<input
id="tool-type-name"
type="text"
value={toolTypeForm.name}
onChange={(e) => {
setToolTypeForm({
...toolTypeForm,
name: e.target.value,
});
setToolTypeDirty(true);
}}
disabled={!isCreating}
placeholder="e.g., code-server"
className="form-input"
required
/>
</div>
<div className="form-group" style={{ flex: 1 }}>
<label htmlFor="tool-type-display-name">
Display Name *
</label>
<input
id="tool-type-display-name"
type="text"
value={toolTypeForm.display_name}
onChange={(e) => {
setToolTypeForm({
...toolTypeForm,
display_name: e.target.value,
});
setToolTypeDirty(true);
}}
placeholder="e.g., VS Code Server"
className="form-input"
required
/>
</div>
</div>
<div className="form-group">
<label htmlFor="tool-type-description">Description</label>
<input
id="tool-type-description"
type="text"
value={toolTypeForm.description}
onChange={(e) => {
setToolTypeForm({
...toolTypeForm,
description: e.target.value,
});
setToolTypeDirty(true);
}}
placeholder="Optional description"
className="form-input"
/>
</div>
<div className="row" style={{ gap: "1rem" }}>
<div className="form-group" style={{ flex: 1 }}>
<label htmlFor="tool-type-category">Category</label>
<input
id="tool-type-category"
type="text"
value={toolTypeForm.category}
onChange={(e) => {
setToolTypeForm({
...toolTypeForm,
category: e.target.value,
});
setToolTypeDirty(true);
}}
placeholder="e.g., editor, notebook, ai-assistant"
className="form-input"
/>
</div>
<div className="form-group" style={{ flex: 1 }}>
<label htmlFor="tool-type-interface">Interface Type</label>
<select
id="tool-type-interface"
value={toolTypeForm.interface_type}
onChange={(e) => {
const value = e.target.value as "web" | "terminal";
setToolTypeForm({
...toolTypeForm,
interface_type: value,
requires_port: value === "web",
default_port:
value === "web" ? toolTypeForm.default_port : "",
});
setToolTypeDirty(true);
}}
className="form-input"
>
<option value="web">Web</option>
<option value="terminal">Terminal</option>
</select>
</div>
</div>
{toolTypeForm.interface_type === "terminal" && (
<div className="form-group">
<label htmlFor="tool-type-startup-command">
Startup Command
</label>
<input
id="tool-type-startup-command"
type="text"
value={toolTypeForm.startup_command}
onChange={(e) => {
setToolTypeForm({
...toolTypeForm,
startup_command: e.target.value,
});
setToolTypeDirty(true);
}}
placeholder="e.g., cd /workspace && ls"
className="form-input"
/>
<small className="form-help">
Command to run before the interactive shell for each new
terminal session.
</small>
</div>
)}
{toolTypeForm.requires_port && (
<div className="form-group">
<label htmlFor="tool-type-default-port">
Default Port *
</label>
<input
id="tool-type-default-port"
type="number"
value={toolTypeForm.default_port}
onChange={(e) => {
setToolTypeForm({
...toolTypeForm,
default_port: e.target.value,
});
setToolTypeDirty(true);
}}
placeholder="e.g., 8443"
className="form-input"
required
/>
</div>
)}
{toolTypeForm.definition_type === "manifest" ? (
<ManifestEditor
manifest={manifestData}
baseDefinitions={baseDefinitions}
onChange={(m) => {
setManifestData(m);
setToolTypeDirty(true);
}}
definitionId={manifestDefinitionId}
/>
) : (
<div className="form-group">
<label htmlFor="tool-type-template">
{toolTypeForm.definition_type === "compose"
? "Compose Template"
: "Dockerfile"}{" "}
*
</label>
<textarea
id="tool-type-template"
value={
toolTypeForm.definition_type === "compose"
? toolTypeForm.compose_template
: toolTypeForm.dockerfile_template
}
onChange={(e) => {
if (toolTypeForm.definition_type === "compose") {
setToolTypeForm({
...toolTypeForm,
compose_template: e.target.value,
});
} else {
setToolTypeForm({
...toolTypeForm,
dockerfile_template: e.target.value,
});
}
setToolTypeDirty(true);
}}
rows={12}
placeholder={
toolTypeForm.definition_type === "compose"
? "version: '3.8'\nservices:\n app:\n image: ..."
: "FROM node:18\nWORKDIR /app\n..."
}
className="form-input"
style={{ fontFamily: "monospace", fontSize: "0.875rem" }}
required
/>
</div>
)}
<div className="form-group">
<label htmlFor="readiness-command">
Readiness Probe Command
</label>
<input
id="readiness-command"
type="text"
value={toolTypeForm.readiness_command}
onChange={(e) => {
setToolTypeForm({
...toolTypeForm,
readiness_command: e.target.value,
});
setToolTypeDirty(true);
}}
placeholder="e.g., curl -f http://localhost:8080"
className="form-input"
/>
</div>
<div className="row" style={{ gap: "1rem" }}>
<div className="form-group" style={{ flex: 1 }}>
<label htmlFor="readiness-timeout">Timeout (seconds)</label>
<input
id="readiness-timeout"
type="number"
value={toolTypeForm.readiness_timeout}
onChange={(e) => {
setToolTypeForm({
...toolTypeForm,
readiness_timeout: e.target.value,
});
setToolTypeDirty(true);
}}
className="form-input"
/>
</div>
<div className="form-group" style={{ flex: 1 }}>
<label htmlFor="readiness-interval">
Interval (seconds)
</label>
<input
id="readiness-interval"
type="number"
value={toolTypeForm.readiness_interval}
onChange={(e) => {
setToolTypeForm({
...toolTypeForm,
readiness_interval: e.target.value,
});
setToolTypeDirty(true);
}}
className="form-input"
/>
</div>
</div>
<div className="form-group">
<label>Required Variables (comma-separated)</label>
<input
type="text"
value={toolTypeForm.required_variables}
onChange={(e) => {
setToolTypeForm({
...toolTypeForm,
required_variables: e.target.value,
});
setToolTypeDirty(true);
}}
placeholder="REPO_PATH, TOOL_NAME"
className="form-input"
/>
</div>
{toolTypeError && <p className="text-error">{toolTypeError}</p>}
<div className="dialog-actions" style={{ marginTop: "1rem" }}>
<button type="submit">
<Icon name={isCreating ? "add" : "save"} size="sm" />
{isCreating ? "Create Tool Type" : "Save Changes"}
</button>
{toolTypeDirty && (
<button
type="button"
onClick={() => {
if (isCreating) {
resetToolTypeForm();
} else if (selectedToolType) {
populateToolTypeForm(selectedToolType);
}
}}
className="button-secondary"
>
<Icon name="cancel" size="sm" /> Discard
</button>
)}
</div>
</form>
}
</div>
)}
</div>
</div>
);
};