refactor: reorganize CSS and Tool Workshop page

- Extract styles.css into styles/ directory (tokens, global, utilities, syntax-highlight, pages)
- Extract ToolWorkshopPage into components:
  - ToolTypeListSidebar, ToolTypeEditorPanel, ToolWorkshopMobileView
  - use-tool-workshop hook for state management
- Slim ToolWorkshopPage from 1,269 to 110 lines

Quality gates: tsc --noEmit passes, npm run build passes
This commit is contained in:
Developer
2026-06-05 18:58:14 +00:00
parent 7070867393
commit 61072f4c07
16 changed files with 7079 additions and 1245 deletions
@@ -0,0 +1,373 @@
import { Icon } from "../../icon";
import { ManifestEditor } from "../tool/manifest-editor";
import type { ToolType } from "../../../api/tool-types";
import type { ToolDefinitionManifest } from "../../../api/tool-definitions";
export interface ToolTypeFormState {
name: string;
display_name: string;
description: string;
category: string;
interface_type: "web" | "terminal";
requires_port: boolean;
default_port: string;
definition_type: "compose" | "dockerfile" | "manifest";
compose_template: string;
dockerfile_template: string;
readiness_command: string;
readiness_timeout: string;
readiness_interval: string;
required_variables: string;
startup_command: string;
}
interface ToolTypeEditorPanelProps {
isCreating: boolean;
selectedToolType: ToolType | null;
form: ToolTypeFormState;
manifestData: Record<string, unknown> | null;
manifestDefinitionId: string | null;
baseDefinitions: ToolDefinitionManifest[];
toolTypeError: string | null;
toolTypeDirty: boolean;
onFormChange: (changes: Partial<ToolTypeFormState>) => void;
onManifestChange: (manifest: Record<string, unknown> | null) => void;
onSubmit: (e: React.FormEvent) => void;
onReset: () => void;
}
export const ToolTypeEditorPanel = ({
isCreating,
selectedToolType,
form,
manifestData,
manifestDefinitionId,
baseDefinitions,
toolTypeError,
toolTypeDirty,
onFormChange,
onManifestChange,
onSubmit,
onReset,
}: ToolTypeEditorPanelProps) => {
const hasSelection = isCreating || selectedToolType;
return (
<div style={{ flex: 1, overflow: "auto", padding: "1.5rem", minWidth: 0 }}>
{!hasSelection ? (
<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>
<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={onSubmit}
className="stack"
style={{ gap: "1rem", maxWidth: "800px" }}
>
<div className="form-group">
<label htmlFor="definition-type">Definition Type</label>
<select
id="definition-type"
value={form.definition_type}
onChange={(e) => {
onFormChange({
definition_type: e.target.value as
| "compose"
| "dockerfile"
| "manifest",
});
}}
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={form.name}
onChange={(e) => {
onFormChange({ name: e.target.value });
}}
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={form.display_name}
onChange={(e) => {
onFormChange({ display_name: e.target.value });
}}
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={form.description}
onChange={(e) => {
onFormChange({ description: e.target.value });
}}
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={form.category}
onChange={(e) => {
onFormChange({ category: e.target.value });
}}
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={form.interface_type}
onChange={(e) => {
const value = e.target.value as "web" | "terminal";
onFormChange({
interface_type: value,
requires_port: value === "web",
default_port:
value === "web" ? form.default_port : "",
});
}}
className="form-input"
>
<option value="web">Web</option>
<option value="terminal">Terminal</option>
</select>
</div>
</div>
{form.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={form.startup_command}
onChange={(e) => {
onFormChange({ startup_command: e.target.value });
}}
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>
)}
{form.requires_port && (
<div className="form-group">
<label htmlFor="tool-type-default-port">
Default Port *
</label>
<input
id="tool-type-default-port"
type="number"
value={form.default_port}
onChange={(e) => {
onFormChange({ default_port: e.target.value });
}}
placeholder="e.g., 8443"
className="form-input"
required
/>
</div>
)}
{form.definition_type === "manifest" ? (
<ManifestEditor
manifest={manifestData}
baseDefinitions={baseDefinitions}
onChange={(m) => {
onManifestChange(m);
}}
definitionId={manifestDefinitionId}
/>
) : (
<div className="form-group">
<label htmlFor="tool-type-template">
{form.definition_type === "compose"
? "Compose Template"
: "Dockerfile"}{" "}
*
</label>
<textarea
id="tool-type-template"
value={
form.definition_type === "compose"
? form.compose_template
: form.dockerfile_template
}
onChange={(e) => {
if (form.definition_type === "compose") {
onFormChange({ compose_template: e.target.value });
} else {
onFormChange({ dockerfile_template: e.target.value });
}
}}
rows={12}
placeholder={
form.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={form.readiness_command}
onChange={(e) => {
onFormChange({ readiness_command: e.target.value });
}}
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={form.readiness_timeout}
onChange={(e) => {
onFormChange({ readiness_timeout: e.target.value });
}}
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={form.readiness_interval}
onChange={(e) => {
onFormChange({ readiness_interval: e.target.value });
}}
className="form-input"
/>
</div>
</div>
<div className="form-group">
<label>Required Variables (comma-separated)</label>
<input
type="text"
value={form.required_variables}
onChange={(e) => {
onFormChange({ required_variables: e.target.value });
}}
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={onReset}
className="button-secondary"
>
<Icon name="cancel" size="sm" /> Discard
</button>
)}
</div>
</form>
</div>
)}
</div>
);
};
@@ -0,0 +1,161 @@
import { Icon } from "../../icon";
import type { ToolType } from "../../../api/tool-types";
interface ToolTypeListSidebarProps {
toolTypes: ToolType[];
selectedToolTypeId: string | null;
onSelect: (toolType: ToolType) => void;
onCreate: () => void;
onDelete: (id: string) => void;
}
export const ToolTypeListSidebar = ({
toolTypes,
selectedToolTypeId,
onSelect,
onCreate,
onDelete,
}: ToolTypeListSidebarProps) => {
return (
<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={() => onSelect(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();
onDelete(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={onCreate}
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>
);
};
@@ -0,0 +1,350 @@
import { MobileListView } from "../mobile/mobile-list-view";
import { MobileDetailView } from "../mobile/mobile-detail-view";
import { MobileEditView } from "../mobile/mobile-edit-view";
import { MobileFAB } from "../mobile/mobile-fab";
import type { ToolType } from "../../../api/tool-types";
import type { ToolTypeFormState } from "./ToolTypeEditorPanel";
export type MobileView = "list" | "detail" | "edit";
interface ToolWorkshopMobileViewProps {
toolTypes: ToolType[];
selectedToolType: ToolType | null;
mobileView: MobileView;
isCreating: boolean;
toolTypeForm: ToolTypeFormState;
toolTypeError: string | null;
onViewChange: (view: MobileView) => void;
onSelect: (toolType: ToolType) => void;
onCreate: () => void;
onDelete: (id: string) => void;
onFormChange: (changes: Partial<ToolTypeFormState>) => void;
onSubmit: () => void;
onCancel: () => void;
}
export const ToolWorkshopMobileView = ({
toolTypes,
selectedToolType,
mobileView,
isCreating,
toolTypeForm,
toolTypeError,
onViewChange,
onSelect,
onCreate,
onDelete,
onFormChange,
onSubmit,
onCancel,
}: ToolWorkshopMobileViewProps) => {
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) => {
const toolType = toolTypes.find((t) => t.id === id);
if (toolType) {
onSelect(toolType);
onViewChange("detail");
}
}}
emptyMessage="No tool types yet"
/>
<MobileFAB
onClick={() => {
onCreate();
onViewChange("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={() => {
onViewChange("edit");
}}
onDelete={() => {
void onDelete(selectedToolType.id);
onViewChange("list");
}}
onBack={() => {
onViewChange("list");
}}
/>
);
}
if (mobileView === "edit") {
return (
<MobileEditView
title={isCreating ? "Create Tool Type" : "Edit Tool Type"}
onCancel={onCancel}
onSave={() => {
onSubmit();
if (!toolTypeError) {
onViewChange("list");
}
}}
isSaving={false}
>
<div className="mobile-form-group">
<label className="mobile-form-label">Name *</label>
<input
type="text"
value={toolTypeForm.name}
onChange={(e) => onFormChange({ 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) => onFormChange({ 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) => onFormChange({ 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) => onFormChange({ 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) =>
onFormChange({
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) => onFormChange({ 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) => onFormChange({ 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) =>
onFormChange({
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) => onFormChange({ 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) => onFormChange({ 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) => onFormChange({ 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) => onFormChange({ 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) => onFormChange({ 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) => onFormChange({ 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) =>
onFormChange({ dockerfile_template: e.target.value })
}
className="mobile-form-textarea mobile-form-code"
placeholder="FROM ubuntu:22.04"
rows={10}
/>
</div>
)}
</MobileEditView>
);
}
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) => {
const toolType = toolTypes.find((t) => t.id === id);
if (toolType) {
onSelect(toolType);
onViewChange("detail");
}
}}
emptyMessage="No tool types yet"
/>
<MobileFAB
onClick={() => {
onCreate();
onViewChange("edit");
}}
/>
</div>
);
};