refactor: organize frontend components into features/ directories

Moved 43 component files into 9 feature domains:
- features/git/ — commit-dialog, commit-panel, file-editor, git-mount-editor,
  git-toolbar, merge-dialog
- features/project/ — repositories-settings-tab, repository-create-dialog
- features/terminal/ — special-keys-panel, special-keys-strip,
  terminal-session-tabs, terminal
- features/workspace/ — workspace-card, workspace-create-form,
  workspace-header, workspace-instance-chips
- features/session/ — create-session-form, session-card, session-list
- features/tool/ — instance-list, manifest-editor, start-tool-fab,
  start-tool-modal, tool-starter, tools-bottom-sheet
- features/notification/ — event-toast-bridge, notification-center,
  notification-item
- features/settings/ — settings-tab-layout
- features/mobile/ — mobile-action-sheet, mobile-detail-view, mobile-edit-view,
  mobile-fab, mobile-list-view, mobile-nav, mobile-page-header,
  mobile-terminal-header, mobile-terminal-wrapper

Updated all imports across pages and components.
Root components/ now only contains generic UI pieces:
app-shell, code-editor, data-states, icon, protected-route, syntax-highlighter.

Quality gates: verified no remaining old imports.
This commit is contained in:
2026-06-04 12:37:24 +02:00
parent 7224afafd1
commit 1021d61be3
54 changed files with 31 additions and 31 deletions
@@ -0,0 +1,608 @@
import { useCallback, useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { Icon } from "./icon";
import type { ToolInstance } from "../api/sessions";
import {
deleteInstance,
listInstances,
restartInstance,
startInstance,
stopInstance,
} from "../api/sessions";
import type { ToolType } from "../api/tool-types";
import { CreateSessionForm } from "./create-session-form";
import { listConfigProfiles, type ConfigProfile } from "../api/config_profiles";
import { listSSHKeys, type SSHKey } from "../api/ssh-keys";
import { useEventContext } from "../state/events";
const API_BASE_URL =
import.meta.env.VITE_API_BASE_URL ?? "http://localhost:8000";
interface InstanceListProps {
projectId: string;
repoId: string;
projectName?: string;
repoName?: string;
toolTypes: ToolType[];
}
export const InstanceList = ({
projectId,
repoId,
projectName,
repoName,
toolTypes,
}: InstanceListProps) => {
const navigate = useNavigate();
const [instances, setInstances] = useState<ToolInstance[]>([]);
const [loading, setLoading] = useState(false);
const [showCreate, setShowCreate] = useState(false);
const [error, setError] = useState<string | null>(null);
// Stop confirmation
const [stopConfirmId, setStopConfirmId] = useState<string | null>(null);
// Config profile selection for start/restart
const [configProfiles, setConfigProfiles] = useState<ConfigProfile[]>([]);
const [profileSelectInstanceId, setProfileSelectInstanceId] = useState<
string | null
>(null);
const [selectedProfileForAction, setSelectedProfileForAction] = useState("");
const [selectedSshKeyIdsForAction, setSelectedSshKeyIdsForAction] = useState<
string[]
>([]);
const [sshKeys, setSshKeys] = useState<SSHKey[]>([]);
// Per-instance busy state for actions
const [busyInstanceId, setBusyInstanceId] = useState<string | null>(null);
const loadInstances = useCallback(async () => {
setLoading(true);
try {
const data = await listInstances(projectId, repoId);
setInstances(data);
} catch {
setError("Failed to load instances");
} finally {
setLoading(false);
}
}, [projectId, repoId]);
const { events } = useEventContext();
useEffect(() => {
void loadInstances();
}, [loadInstances]);
// Lightweight list refresh every 60 seconds for resilience
useEffect(() => {
const interval = setInterval(() => void loadInstances(), 60000);
return () => clearInterval(interval);
}, [loadInstances]);
// Real-time status updates from SSE events
useEffect(() => {
if (events.length === 0) return;
const latestEvent = events[events.length - 1];
const statusEvents = [
"instance.started",
"instance.health_changed",
"instance.error",
"instance.stopped",
"instance.restarted",
];
if (!statusEvents.includes(latestEvent.event)) return;
setInstances((prev) =>
prev.map((inst) =>
inst.id === latestEvent.instance_id
? { ...inst, status: latestEvent.status ?? inst.status }
: inst,
),
);
}, [events]);
const handleCreateSuccess = async () => {
setShowCreate(false);
await loadInstances();
};
const loadConfigProfiles = useCallback(
async (toolTypeId: string) => {
try {
const [profiles, keys] = await Promise.all([
listConfigProfiles(projectId, toolTypeId),
listSSHKeys(),
]);
setConfigProfiles(profiles);
setSshKeys(keys);
} catch {
// ignore
}
},
[projectId],
);
const handleStart = async (
instanceId: string,
configProfileId?: string,
sshKeyIds?: string[],
) => {
setBusyInstanceId(instanceId);
try {
await startInstance(
projectId,
repoId,
instanceId,
configProfileId,
sshKeyIds,
);
setProfileSelectInstanceId(null);
setSelectedProfileForAction("");
setSelectedSshKeyIdsForAction([]);
await loadInstances();
} catch {
setError("Failed to start instance");
} finally {
setBusyInstanceId(null);
}
};
const handleStop = async (instanceId: string) => {
setBusyInstanceId(instanceId);
try {
await stopInstance(projectId, repoId, instanceId);
setStopConfirmId(null);
await loadInstances();
} catch {
setError("Failed to stop instance");
} finally {
setBusyInstanceId(null);
}
};
const handleRestart = async (
instanceId: string,
configProfileId?: string,
sshKeyIds?: string[],
) => {
setBusyInstanceId(instanceId);
try {
await restartInstance(
projectId,
repoId,
instanceId,
configProfileId,
sshKeyIds,
);
setProfileSelectInstanceId(null);
setSelectedProfileForAction("");
setSelectedSshKeyIdsForAction([]);
await loadInstances();
} catch {
setError("Failed to restart instance");
} finally {
setBusyInstanceId(null);
}
};
const handleDelete = async (instanceId: string) => {
if (!confirm("Are you sure you want to delete this instance?")) return;
setBusyInstanceId(instanceId);
try {
await deleteInstance(projectId, repoId, instanceId);
// Update state immediately instead of reloading
setInstances((prev) => prev.filter((i) => i.id !== instanceId));
} catch {
setError("Failed to delete instance");
} finally {
setBusyInstanceId(null);
}
};
const getStatusColor = (status: string) => {
switch (status) {
case "running":
return "var(--success)";
case "starting":
case "probing":
return "var(--info)";
case "unhealthy":
return "var(--warning)";
case "error":
return "var(--danger)";
case "pending":
case "building":
return "var(--warning)";
default:
return "var(--muted)";
}
};
return (
<div className="instance-list">
<div className="instance-list-header">
<h3>Tool Instances</h3>
<button
className="secondary-button small"
onClick={() => setShowCreate(true)}
type="button"
>
<Icon name="add" size="sm" />
Launch Tool
</button>
</div>
{error && <div className="error-message">{error}</div>}
{loading ? (
<p className="muted">Loading instances...</p>
) : instances.length === 0 ? (
<p className="muted">No instances yet. Launch a tool to get started.</p>
) : (
<div className="instance-grid">
{instances.map((instance) => (
<div
key={instance.id}
className={`instance-card ${busyInstanceId === instance.id ? "busy" : ""}`}
>
{busyInstanceId === instance.id && (
<div className="instance-busy-overlay">
<Icon name="loading" size="md" />
</div>
)}
<div className="instance-info">
<div className="instance-name">{instance.display_name}</div>
<div className="instance-meta">
<span
className="status-dot"
style={{ backgroundColor: getStatusColor(instance.status) }}
/>
{instance.status}
</div>
{instance.selected_config_profile_id && (
<div className="instance-profile">
<span className="badge">
Profile:{" "}
{configProfiles.find(
(p) => p.id === instance.selected_config_profile_id,
)?.name || instance.selected_config_profile_id}
</span>
</div>
)}
</div>
<div className="instance-actions">
{instance.status === "running" &&
instance.url &&
instance.tool_type_interfaces.includes("web") && (
<>
<a
href={
instance.url.startsWith("http")
? instance.url
: `${API_BASE_URL}${instance.url}`
}
target="_blank"
rel="noopener noreferrer"
className="secondary-button small"
>
<Icon name="external" size="sm" />
Open
</a>
</>
)}
{instance.status === "running" &&
instance.tool_type_interfaces.includes("terminal") && (
<button
className="secondary-button small"
onClick={() =>
navigate(`/instances/${instance.id}/terminal`)
}
type="button"
disabled={busyInstanceId === instance.id}
>
<Icon name="terminal" size="sm" />
Terminal
</button>
)}
{instance.status !== "running" && (
<>
{profileSelectInstanceId === instance.id ? (
<div className="inline-profile-select">
<select
value={selectedProfileForAction}
onChange={(e) =>
setSelectedProfileForAction(e.target.value)
}
>
<option value="">Default (none)</option>
{configProfiles.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
<div
style={{
display: "flex",
flexWrap: "wrap",
gap: "0.25rem",
marginTop: "0.25rem",
}}
>
{sshKeys.map((key) => (
<label
key={key.id}
className="checkbox-label"
style={{
fontSize: "0.75rem",
display: "flex",
alignItems: "center",
gap: "0.25rem",
}}
>
<input
type="checkbox"
checked={selectedSshKeyIdsForAction.includes(
key.id,
)}
onChange={(e) => {
if (e.target.checked) {
setSelectedSshKeyIdsForAction(
(prev) => [...prev, key.id],
);
} else {
setSelectedSshKeyIdsForAction(
(prev) =>
prev.filter(
(id) =>
id !== key.id,
),
);
}
}}
/>
{key.name}
</label>
))}
</div>
<button
className="primary-button small"
onClick={() =>
void handleStart(
instance.id,
selectedProfileForAction || undefined,
selectedSshKeyIdsForAction.length > 0
? selectedSshKeyIdsForAction
: undefined,
)
}
type="button"
disabled={busyInstanceId === instance.id}
>
<Icon name="play" size="sm" />
Start
</button>
<button
className="ghost-button small"
onClick={() => {
setProfileSelectInstanceId(null);
setSelectedProfileForAction("");
setSelectedSshKeyIdsForAction([]);
}}
type="button"
disabled={busyInstanceId === instance.id}
>
Cancel
</button>
</div>
) : (
<button
className="secondary-button small"
onClick={() => {
const toolType = toolTypes.find(
(t) => t.id === instance.tool_type_id,
);
if (toolType) {
void loadConfigProfiles(toolType.id);
}
setProfileSelectInstanceId(instance.id);
setSelectedProfileForAction(
instance.selected_config_profile_id || "",
);
setSelectedSshKeyIdsForAction(
instance.ssh_key_ids || [],
);
}}
type="button"
disabled={busyInstanceId === instance.id}
>
<Icon name="play" size="sm" />
Start
</button>
)}
</>
)}
{instance.status === "running" && (
<>
{stopConfirmId === instance.id ? (
<div className="inline-confirm">
<span>Stop?</span>
<button
className="ghost-button small danger-text"
onClick={() => void handleStop(instance.id)}
type="button"
disabled={busyInstanceId === instance.id}
>
Yes
</button>
<button
className="ghost-button small"
onClick={() => setStopConfirmId(null)}
type="button"
disabled={busyInstanceId === instance.id}
>
No
</button>
</div>
) : (
<button
className="ghost-button small"
onClick={() => setStopConfirmId(instance.id)}
type="button"
disabled={busyInstanceId === instance.id}
>
<Icon name="stop" size="sm" />
</button>
)}
{profileSelectInstanceId === instance.id ? (
<div className="inline-profile-select">
<select
value={selectedProfileForAction}
onChange={(e) =>
setSelectedProfileForAction(e.target.value)
}
>
<option value="">Default (none)</option>
{configProfiles.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
<div
style={{
display: "flex",
flexWrap: "wrap",
gap: "0.25rem",
marginTop: "0.25rem",
}}
>
{sshKeys.map((key) => (
<label
key={key.id}
className="checkbox-label"
style={{
fontSize: "0.75rem",
display: "flex",
alignItems: "center",
gap: "0.25rem",
}}
>
<input
type="checkbox"
checked={selectedSshKeyIdsForAction.includes(
key.id,
)}
onChange={(e) => {
if (e.target.checked) {
setSelectedSshKeyIdsForAction(
(prev) => [...prev, key.id],
);
} else {
setSelectedSshKeyIdsForAction(
(prev) =>
prev.filter(
(id) =>
id !== key.id,
),
);
}
}}
/>
{key.name}
</label>
))}
</div>
<button
className="primary-button small"
onClick={() =>
void handleRestart(
instance.id,
selectedProfileForAction || undefined,
selectedSshKeyIdsForAction.length > 0
? selectedSshKeyIdsForAction
: undefined,
)
}
type="button"
disabled={busyInstanceId === instance.id}
>
<Icon name="refresh" size="sm" />
Restart
</button>
<button
className="ghost-button small"
onClick={() => {
setProfileSelectInstanceId(null);
setSelectedProfileForAction("");
setSelectedSshKeyIdsForAction([]);
}}
type="button"
disabled={busyInstanceId === instance.id}
>
Cancel
</button>
</div>
) : (
<button
className="ghost-button small"
onClick={() => {
const toolType = toolTypes.find(
(t) => t.id === instance.tool_type_id,
);
if (toolType) {
void loadConfigProfiles(toolType.id);
}
setProfileSelectInstanceId(instance.id);
setSelectedProfileForAction(
instance.selected_config_profile_id || "",
);
setSelectedSshKeyIdsForAction(
instance.ssh_key_ids || [],
);
}}
type="button"
disabled={busyInstanceId === instance.id}
>
<Icon name="refresh" size="sm" />
</button>
)}
</>
)}
<button
className="ghost-button small danger-text"
onClick={() => void handleDelete(instance.id)}
type="button"
disabled={busyInstanceId === instance.id}
>
<Icon name="delete" size="sm" />
</button>
</div>
</div>
))}
</div>
)}
{showCreate && (
<div className="dialog-overlay" role="dialog" aria-modal="true">
<div className="dialog">
<h2>Launch Tool</h2>
<CreateSessionForm
projects={[]}
repositories={[]}
toolTypes={toolTypes}
fixedProjectId={projectId}
fixedRepoId={repoId}
projectName={projectName}
repoName={repoName}
onSuccess={handleCreateSuccess}
onCancel={() => setShowCreate(false)}
submitLabel="Launch"
/>
</div>
</div>
)}
</div>
);
};
@@ -0,0 +1,858 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { Icon } from "./icon";
import { extractErrorMessage } from "../utils/errors";
import {
compileToolDefinition,
type ToolDefinitionManifest,
} from "../api/tool_definitions";
interface PackageEntry {
name: string;
}
interface MountEntry {
name: string;
target: string;
source_type: string;
writable: boolean;
owner: string;
mode: string;
file_mode: string;
readonly: boolean;
git_mount_ref: string;
}
interface ManifestEditorProps {
manifest: Record<string, unknown> | null;
baseDefinitions: ToolDefinitionManifest[];
onChange: (manifest: Record<string, unknown>) => void;
definitionId?: string | null;
}
export const ManifestEditor = ({
manifest,
baseDefinitions,
onChange,
definitionId,
}: ManifestEditorProps) => {
const [baseImage, setBaseImage] = useState("");
const [baseDefinitionId, setBaseDefinitionId] = useState("");
const [aptPackages, setAptPackages] = useState<PackageEntry[]>([]);
const [npmPackages, setNpmPackages] = useState<PackageEntry[]>([]);
const [pipPackages, setPipPackages] = useState<PackageEntry[]>([]);
const [nodeVersion, setNodeVersion] = useState("");
const [userName, setUserName] = useState("user");
const [userUid, setUserUid] = useState("1000");
const [userGid, setUserGid] = useState("1000");
const [envVars, setEnvVars] = useState<{ key: string; value: string }[]>([]);
const [buildScripts, setBuildScripts] = useState<string[]>([""]);
const [startupScripts, setStartupScripts] = useState<string[]>([""]);
const [mounts, setMounts] = useState<MountEntry[]>([]);
const [command, setCommand] = useState<string[]>([""]);
const [workingDir, setWorkingDir] = useState("/workspace");
const [stdinOpen, setStdinOpen] = useState(true);
const [tty, setTty] = useState(true);
const [preview, setPreview] = useState<{
dockerfile: string;
compose: string;
entrypoint: string;
} | null>(null);
const [previewLoading, setPreviewLoading] = useState(false);
const [previewError, setPreviewError] = useState<string | null>(null);
// Load manifest into form
useEffect(() => {
if (!manifest) return;
const pkgs = (manifest.packages as Record<string, unknown>) || {};
setBaseImage((manifest.base_image as string) || "");
setBaseDefinitionId((manifest.base_definition_id as string) || "");
setAptPackages(((pkgs.apt as string[]) || []).map((p) => ({ name: p })));
setNpmPackages(
((pkgs.npm_global as string[]) || []).map((p) => ({ name: p })),
);
setPipPackages(((pkgs.pip as string[]) || []).map((p) => ({ name: p })));
setNodeVersion((pkgs.node as Record<string, string>)?.version || "");
const user = (manifest.user as Record<string, unknown>) || {};
setUserName((user.name as string) || "user");
setUserUid(String(user.uid || "1000"));
setUserGid(String(user.gid || "1000"));
const env = (manifest.env as Record<string, string>) || {};
setEnvVars(Object.entries(env).map(([key, value]) => ({ key, value })));
const scripts = (manifest.scripts as Record<string, string[]>) || {};
setBuildScripts((scripts.build || []).length > 0 ? scripts.build : [""]);
setStartupScripts(
(scripts.startup || []).length > 0 ? scripts.startup : [""],
);
const mts = (manifest.mounts as MountEntry[]) || [];
setMounts(mts);
const runtime = (manifest.runtime as Record<string, unknown>) || {};
setCommand((runtime.command as string[]) || [""]);
setWorkingDir((runtime.working_dir as string) || "/workspace");
setStdinOpen((runtime.stdin_open as boolean) ?? true);
setTty((runtime.tty as boolean) ?? true);
}, [manifest]);
// Build manifest from form state
const buildManifest = useCallback((): Record<string, unknown> => {
const packages: Record<string, unknown> = {};
const apt = aptPackages.map((p) => p.name).filter(Boolean);
if (apt.length) packages.apt = apt;
const npm = npmPackages.map((p) => p.name).filter(Boolean);
if (npm.length) packages.npm_global = npm;
const pip = pipPackages.map((p) => p.name).filter(Boolean);
if (pip.length) packages.pip = pip;
if (nodeVersion) packages.node = { version: nodeVersion };
const env: Record<string, string> = {};
envVars.forEach(({ key, value }) => {
if (key) env[key] = value;
});
const scripts: Record<string, string[]> = {};
const build = buildScripts.filter(Boolean);
if (build.length) scripts.build = build;
const startup = startupScripts.filter(Boolean);
if (startup.length) scripts.startup = startup;
const mts = mounts.filter((m) => m.name && m.target);
const result: Record<string, unknown> = {
packages,
user: {
name: userName,
uid: parseInt(userUid) || 1000,
gid: parseInt(userGid) || 1000,
create_home: true,
shell: "/bin/bash",
},
env,
scripts,
mounts: mts,
runtime: {
command:
command.filter(Boolean).length > 0
? command.filter(Boolean)
: ["/bin/bash"],
stdin_open: stdinOpen,
tty: tty,
working_dir: workingDir,
},
};
if (baseImage) result.base_image = baseImage;
if (baseDefinitionId) result.base_definition_id = baseDefinitionId;
return result;
}, [
aptPackages,
npmPackages,
pipPackages,
nodeVersion,
userName,
userUid,
userGid,
envVars,
buildScripts,
startupScripts,
mounts,
command,
workingDir,
stdinOpen,
tty,
baseImage,
baseDefinitionId,
]);
// Notify parent of changes — only when built manifest actually differs
// from what we last sent, to avoid feedback loops with the manifest prop.
const lastSentRef = useRef<string>("");
const onChangeRef = useRef(onChange);
onChangeRef.current = onChange;
useEffect(() => {
const m = buildManifest();
const serialized = JSON.stringify(m);
if (serialized !== lastSentRef.current) {
lastSentRef.current = serialized;
onChangeRef.current(m);
}
}, [buildManifest]);
const handlePreview = async () => {
if (!definitionId) {
setPreviewError("Save the tool definition first to preview");
return;
}
setPreviewLoading(true);
setPreviewError(null);
try {
const result = await compileToolDefinition(definitionId);
setPreview({
dockerfile: result.dockerfile,
compose: result.compose,
entrypoint: result.entrypoint,
});
} catch (err) {
setPreviewError(extractErrorMessage(err));
} finally {
setPreviewLoading(false);
}
};
const addAptPackage = () => setAptPackages([...aptPackages, { name: "" }]);
const removeAptPackage = (idx: number) =>
setAptPackages(aptPackages.filter((_, i) => i !== idx));
const updateAptPackage = (idx: number, name: string) => {
const copy = [...aptPackages];
copy[idx] = { name };
setAptPackages(copy);
};
const addNpmPackage = () => setNpmPackages([...npmPackages, { name: "" }]);
const removeNpmPackage = (idx: number) =>
setNpmPackages(npmPackages.filter((_, i) => i !== idx));
const updateNpmPackage = (idx: number, name: string) => {
const copy = [...npmPackages];
copy[idx] = { name };
setNpmPackages(copy);
};
const addPipPackage = () => setPipPackages([...pipPackages, { name: "" }]);
const removePipPackage = (idx: number) =>
setPipPackages(pipPackages.filter((_, i) => i !== idx));
const updatePipPackage = (idx: number, name: string) => {
const copy = [...pipPackages];
copy[idx] = { name };
setPipPackages(copy);
};
const addEnvVar = () => setEnvVars([...envVars, { key: "", value: "" }]);
const removeEnvVar = (idx: number) =>
setEnvVars(envVars.filter((_, i) => i !== idx));
const updateEnvVar = (idx: number, field: "key" | "value", val: string) => {
const copy = [...envVars];
copy[idx] = { ...copy[idx], [field]: val };
setEnvVars(copy);
};
const addBuildScript = () => setBuildScripts([...buildScripts, ""]);
const removeBuildScript = (idx: number) =>
setBuildScripts(buildScripts.filter((_, i) => i !== idx));
const updateBuildScript = (idx: number, val: string) => {
const copy = [...buildScripts];
copy[idx] = val;
setBuildScripts(copy);
};
const addStartupScript = () => setStartupScripts([...startupScripts, ""]);
const removeStartupScript = (idx: number) =>
setStartupScripts(startupScripts.filter((_, i) => i !== idx));
const updateStartupScript = (idx: number, val: string) => {
const copy = [...startupScripts];
copy[idx] = val;
setStartupScripts(copy);
};
const addMount = () =>
setMounts([
...mounts,
{
name: "",
target: "",
source_type: "repo",
writable: true,
owner: "",
mode: "",
file_mode: "",
readonly: false,
git_mount_ref: "",
},
]);
const removeMount = (idx: number) =>
setMounts(mounts.filter((_, i) => i !== idx));
const updateMount = (idx: number, field: keyof MountEntry, val: unknown) => {
const copy = [...mounts];
copy[idx] = { ...copy[idx], [field]: val };
setMounts(copy);
};
return (
<div className="stack" style={{ gap: "1.5rem" }}>
{/* Base Image */}
<div className="card stack" style={{ gap: "1rem", padding: "1rem" }}>
<h4 style={{ margin: 0 }}>Base Image</h4>
<div className="row" style={{ gap: "1rem" }}>
<div className="form-group" style={{ flex: 1 }}>
<label>Base Definition</label>
<select
value={baseDefinitionId}
onChange={(e) => {
setBaseDefinitionId(e.target.value);
setBaseImage("");
}}
className="form-input"
>
<option value="">Custom image...</option>
{baseDefinitions.map((b) => (
<option key={b.id} value={b.id}>
{b.display_name} ({b.version})
</option>
))}
</select>
</div>
<div className="form-group" style={{ flex: 1 }}>
<label>Custom Base Image</label>
<input
type="text"
value={baseImage}
onChange={(e) => {
setBaseImage(e.target.value);
setBaseDefinitionId("");
}}
placeholder="e.g., ubuntu:24.04"
className="form-input"
disabled={!!baseDefinitionId}
/>
</div>
</div>
</div>
{/* Packages */}
<div className="card stack" style={{ gap: "1rem", padding: "1rem" }}>
<h4 style={{ margin: 0 }}>Packages</h4>
<div className="form-group">
<label>Node.js Version</label>
<input
type="text"
value={nodeVersion}
onChange={(e) => setNodeVersion(e.target.value)}
placeholder="e.g., 20"
className="form-input"
style={{ width: "120px" }}
/>
</div>
<div>
<label>APT Packages</label>
<div className="stack" style={{ gap: "0.5rem", marginTop: "0.5rem" }}>
{aptPackages.map((pkg, idx) => (
<div key={idx} className="row" style={{ gap: "0.5rem" }}>
<input
type="text"
value={pkg.name}
onChange={(e) => updateAptPackage(idx, e.target.value)}
placeholder="e.g., neovim"
className="form-input"
/>
<button
type="button"
onClick={() => removeAptPackage(idx)}
className="button-icon"
title="Remove"
>
<Icon name="delete" size="sm" />
</button>
</div>
))}
<button
type="button"
onClick={addAptPackage}
className="button-secondary"
style={{ width: "fit-content" }}
>
<Icon name="add" size="sm" /> Add APT Package
</button>
</div>
</div>
<div>
<label>NPM Global Packages</label>
<div className="stack" style={{ gap: "0.5rem", marginTop: "0.5rem" }}>
{npmPackages.map((pkg, idx) => (
<div key={idx} className="row" style={{ gap: "0.5rem" }}>
<input
type="text"
value={pkg.name}
onChange={(e) => updateNpmPackage(idx, e.target.value)}
placeholder="e.g., @scope/pkg"
className="form-input"
/>
<button
type="button"
onClick={() => removeNpmPackage(idx)}
className="button-icon"
title="Remove"
>
<Icon name="delete" size="sm" />
</button>
</div>
))}
<button
type="button"
onClick={addNpmPackage}
className="button-secondary"
style={{ width: "fit-content" }}
>
<Icon name="add" size="sm" /> Add NPM Package
</button>
</div>
</div>
<div>
<label>Pip Packages</label>
<div className="stack" style={{ gap: "0.5rem", marginTop: "0.5rem" }}>
{pipPackages.map((pkg, idx) => (
<div key={idx} className="row" style={{ gap: "0.5rem" }}>
<input
type="text"
value={pkg.name}
onChange={(e) => updatePipPackage(idx, e.target.value)}
placeholder="e.g., requests"
className="form-input"
/>
<button
type="button"
onClick={() => removePipPackage(idx)}
className="button-icon"
title="Remove"
>
<Icon name="delete" size="sm" />
</button>
</div>
))}
<button
type="button"
onClick={addPipPackage}
className="button-secondary"
style={{ width: "fit-content" }}
>
<Icon name="add" size="sm" /> Add Pip Package
</button>
</div>
</div>
</div>
{/* Runtime User */}
<div className="card stack" style={{ gap: "1rem", padding: "1rem" }}>
<h4 style={{ margin: 0 }}>Runtime User</h4>
<div className="row" style={{ gap: "1rem" }}>
<div className="form-group" style={{ flex: 1 }}>
<label>User Name</label>
<input
type="text"
value={userName}
onChange={(e) => setUserName(e.target.value)}
className="form-input"
/>
</div>
<div className="form-group" style={{ flex: 1 }}>
<label>UID</label>
<input
type="number"
value={userUid}
onChange={(e) => setUserUid(e.target.value)}
className="form-input"
/>
</div>
<div className="form-group" style={{ flex: 1 }}>
<label>GID</label>
<input
type="number"
value={userGid}
onChange={(e) => setUserGid(e.target.value)}
className="form-input"
/>
</div>
</div>
</div>
{/* Environment */}
<div className="card stack" style={{ gap: "1rem", padding: "1rem" }}>
<h4 style={{ margin: 0 }}>Environment Variables</h4>
<div className="stack" style={{ gap: "0.5rem" }}>
{envVars.map((ev, idx) => (
<div key={idx} className="row" style={{ gap: "0.5rem" }}>
<input
type="text"
value={ev.key}
onChange={(e) => updateEnvVar(idx, "key", e.target.value)}
placeholder="KEY"
className="form-input"
/>
<input
type="text"
value={ev.value}
onChange={(e) => updateEnvVar(idx, "value", e.target.value)}
placeholder="value"
className="form-input"
/>
<button
type="button"
onClick={() => removeEnvVar(idx)}
className="button-icon"
title="Remove"
>
<Icon name="delete" size="sm" />
</button>
</div>
))}
<button
type="button"
onClick={addEnvVar}
className="button-secondary"
style={{ width: "fit-content" }}
>
<Icon name="add" size="sm" /> Add Env Var
</button>
</div>
</div>
{/* Build Scripts */}
<div className="card stack" style={{ gap: "1rem", padding: "1rem" }}>
<h4 style={{ margin: 0 }}>Build Scripts (run during docker build)</h4>
<div className="stack" style={{ gap: "0.5rem" }}>
{buildScripts.map((script, idx) => (
<div
key={idx}
className="row"
style={{ gap: "0.5rem", alignItems: "flex-start" }}
>
<textarea
value={script}
onChange={(e) => updateBuildScript(idx, e.target.value)}
placeholder="git config --global user.email 'dev@example.com'"
className="form-input"
rows={2}
style={{
fontFamily: "monospace",
fontSize: "0.8125rem",
flex: 1,
}}
/>
<button
type="button"
onClick={() => removeBuildScript(idx)}
className="button-icon"
title="Remove"
>
<Icon name="delete" size="sm" />
</button>
</div>
))}
<button
type="button"
onClick={addBuildScript}
className="button-secondary"
style={{ width: "fit-content" }}
>
<Icon name="add" size="sm" /> Add Build Script
</button>
</div>
</div>
{/* Startup Scripts */}
<div className="card stack" style={{ gap: "1rem", padding: "1rem" }}>
<h4 style={{ margin: 0 }}>
Startup Scripts (run when container starts)
</h4>
<div className="stack" style={{ gap: "0.5rem" }}>
{startupScripts.map((script, idx) => (
<div
key={idx}
className="row"
style={{ gap: "0.5rem", alignItems: "flex-start" }}
>
<textarea
value={script}
onChange={(e) => updateStartupScript(idx, e.target.value)}
placeholder="chown -R user:user /workspace"
className="form-input"
rows={2}
style={{
fontFamily: "monospace",
fontSize: "0.8125rem",
flex: 1,
}}
/>
<button
type="button"
onClick={() => removeStartupScript(idx)}
className="button-icon"
title="Remove"
>
<Icon name="delete" size="sm" />
</button>
</div>
))}
<button
type="button"
onClick={addStartupScript}
className="button-secondary"
style={{ width: "fit-content" }}
>
<Icon name="add" size="sm" /> Add Startup Script
</button>
</div>
</div>
{/* Mounts */}
<div className="card stack" style={{ gap: "1rem", padding: "1rem" }}>
<h4 style={{ margin: 0 }}>Mount Schema</h4>
<div className="stack" style={{ gap: "1rem" }}>
{mounts.map((mount, idx) => (
<div
key={idx}
className="stack"
style={{
gap: "0.5rem",
padding: "0.75rem",
border: "1px solid var(--border)",
borderRadius: "0.375rem",
}}
>
<div className="row" style={{ gap: "0.5rem" }}>
<input
type="text"
value={mount.name}
onChange={(e) => updateMount(idx, "name", e.target.value)}
placeholder="Name (e.g., workspace)"
className="form-input"
/>
<input
type="text"
value={mount.target}
onChange={(e) => updateMount(idx, "target", e.target.value)}
placeholder="Target (e.g., /workspace)"
className="form-input"
/>
<select
value={mount.source_type}
onChange={(e) =>
updateMount(idx, "source_type", e.target.value)
}
className="form-input"
>
<option value="repo">Repository</option>
<option value="ssh_key">SSH Key</option>
<option value="instance">Instance</option>
<option value="git_mount">Git Mount</option>
<option value="host_path">Host Path</option>
</select>
<button
type="button"
onClick={() => removeMount(idx)}
className="button-icon"
title="Remove"
>
<Icon name="delete" size="sm" />
</button>
</div>
<div className="row" style={{ gap: "0.5rem" }}>
<label
style={{
display: "flex",
alignItems: "center",
gap: "0.25rem",
fontSize: "0.875rem",
}}
>
<input
type="checkbox"
checked={mount.writable}
onChange={(e) =>
updateMount(idx, "writable", e.target.checked)
}
/>
Writable
</label>
<label
style={{
display: "flex",
alignItems: "center",
gap: "0.25rem",
fontSize: "0.875rem",
}}
>
<input
type="checkbox"
checked={mount.readonly}
onChange={(e) =>
updateMount(idx, "readonly", e.target.checked)
}
/>
Read-only
</label>
<input
type="text"
value={mount.owner}
onChange={(e) => updateMount(idx, "owner", e.target.value)}
placeholder="Owner (e.g., user)"
className="form-input"
style={{ width: "120px" }}
/>
<input
type="text"
value={mount.mode}
onChange={(e) => updateMount(idx, "mode", e.target.value)}
placeholder="Mode (e.g., 0755)"
className="form-input"
style={{ width: "100px" }}
/>
<input
type="text"
value={mount.file_mode}
onChange={(e) =>
updateMount(idx, "file_mode", e.target.value)
}
placeholder="File mode (e.g., 0644)"
className="form-input"
style={{ width: "120px" }}
/>
{mount.source_type === "git_mount" && (
<input
type="text"
value={mount.git_mount_ref}
onChange={(e) =>
updateMount(idx, "git_mount_ref", e.target.value)
}
placeholder="Git mount ref"
className="form-input"
style={{ width: "120px" }}
/>
)}
</div>
</div>
))}
<button
type="button"
onClick={addMount}
className="button-secondary"
style={{ width: "fit-content" }}
>
<Icon name="add" size="sm" /> Add Mount
</button>
</div>
</div>
{/* Runtime */}
<div className="card stack" style={{ gap: "1rem", padding: "1rem" }}>
<h4 style={{ margin: 0 }}>Runtime</h4>
<div className="row" style={{ gap: "1rem" }}>
<div className="form-group" style={{ flex: 1 }}>
<label>Command</label>
<input
type="text"
value={command.join(" ")}
onChange={(e) => setCommand(e.target.value.split(" "))}
placeholder="/bin/bash"
className="form-input"
/>
</div>
<div className="form-group" style={{ flex: 1 }}>
<label>Working Directory</label>
<input
type="text"
value={workingDir}
onChange={(e) => setWorkingDir(e.target.value)}
placeholder="/workspace"
className="form-input"
/>
</div>
</div>
<div className="row" style={{ gap: "1rem" }}>
<label
style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}
>
<input
type="checkbox"
checked={stdinOpen}
onChange={(e) => setStdinOpen(e.target.checked)}
/>
stdin_open
</label>
<label
style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}
>
<input
type="checkbox"
checked={tty}
onChange={(e) => setTty(e.target.checked)}
/>
tty
</label>
</div>
</div>
{/* Preview */}
<div className="card stack" style={{ gap: "1rem", padding: "1rem" }}>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
<h4 style={{ margin: 0 }}>Live Preview</h4>
<button
type="button"
onClick={handlePreview}
disabled={previewLoading}
className="button-secondary"
>
<Icon name="refresh" size="sm" />
{previewLoading ? "Compiling..." : "Preview"}
</button>
</div>
{previewError && <p className="text-error">{previewError}</p>}
{preview && (
<div className="stack" style={{ gap: "1rem" }}>
<div>
<label style={{ fontWeight: 600, fontSize: "0.875rem" }}>
Dockerfile
</label>
<pre
style={{
background: "var(--code-bg, #1e1e1e)",
color: "var(--code-fg, #d4d4d4)",
padding: "1rem",
borderRadius: "0.375rem",
overflow: "auto",
fontSize: "0.8125rem",
maxHeight: "300px",
}}
>
{preview.dockerfile}
</pre>
</div>
<div>
<label style={{ fontWeight: 600, fontSize: "0.875rem" }}>
Compose
</label>
<pre
style={{
background: "var(--code-bg, #1e1e1e)",
color: "var(--code-fg, #d4d4d4)",
padding: "1rem",
borderRadius: "0.375rem",
overflow: "auto",
fontSize: "0.8125rem",
maxHeight: "200px",
}}
>
{preview.compose}
</pre>
</div>
</div>
)}
</div>
</div>
);
};
@@ -0,0 +1,118 @@
/** Floating action button to start a tool from any page. */
import { useState } from "react";
import { Icon } from "./icon";
import { ToolStarter } from "./tool-starter";
import type { Workspace } from "../types/workspace";
import { listAllWorkspaces } from "../api/workspaces";
export function StartToolFAB() {
const [open, setOpen] = useState(false);
const [workspaces, setWorkspaces] = useState<Workspace[]>([]);
const [workspacesLoading, setWorkspacesLoading] = useState(false);
const [selectedWorkspace, setSelectedWorkspace] = useState<Workspace | null>(
null,
);
const handleOpen = async () => {
setOpen(true);
setWorkspacesLoading(true);
try {
const data = await listAllWorkspaces();
setWorkspaces(data);
} catch {
// ignore
} finally {
setWorkspacesLoading(false);
}
};
const handleClose = () => {
setOpen(false);
setSelectedWorkspace(null);
};
return (
<>
<button
className="start-tool-fab"
onClick={handleOpen}
title="Start a new tool"
type="button"
aria-label="Start a new tool"
>
<Icon name="play" size="md" />
</button>
{open && (
<div className="modal-overlay" onClick={handleClose}>
<div
className="modal-content start-tool-modal"
onClick={(e) => e.stopPropagation()}
>
<div className="modal-header">
<h3>Start Tool</h3>
<button
className="ghost-button small"
onClick={handleClose}
type="button"
aria-label="Close"
>
<Icon name="close" size="sm" />
</button>
</div>
{workspacesLoading ? (
<p className="muted">Loading workspaces...</p>
) : workspaces.length === 0 ? (
<p className="muted">
No workspaces yet.{" "}
<a href="/workspaces">Create a workspace first</a>.
</p>
) : !selectedWorkspace ? (
<div className="form-group">
<label htmlFor="fab-workspace-select">Select a workspace</label>
<select
id="fab-workspace-select"
value=""
onChange={(e) => {
const ws = workspaces.find((w) => w.id === e.target.value);
if (ws) setSelectedWorkspace(ws);
}}
>
<option value="">Choose a workspace...</option>
{workspaces.map((ws) => (
<option key={ws.id} value={ws.id}>
{ws.project_name} / {ws.repo_name} / {ws.name}
</option>
))}
</select>
</div>
) : (
<>
<div className="tool-starter-header">
<h4>
{selectedWorkspace.project_name} /{" "}
{selectedWorkspace.repo_name} / {selectedWorkspace.name}
</h4>
<button
className="ghost-button small"
onClick={() => setSelectedWorkspace(null)}
type="button"
>
Change
</button>
</div>
<ToolStarter
workspace={selectedWorkspace}
onStarted={handleClose}
onCancel={() => setSelectedWorkspace(null)}
/>
</>
)}
</div>
</div>
)}
</>
);
}
@@ -0,0 +1,114 @@
/** Modal for starting a tool on a workspace. */
import { useState } from "react";
import { Icon } from "./icon";
import { listToolTypes, type ToolType } from "../api/tool-types";
import { useAsyncData } from "../hooks/use-async-data";
import type { Workspace } from "../types/workspace";
export interface StartToolModalProps {
workspace: Workspace;
onClose: () => void;
onStart: (toolTypeId: string, configProfileId?: string) => Promise<void>;
}
export function StartToolModal({
workspace,
onClose,
onStart,
}: StartToolModalProps) {
const [toolTypeId, setToolTypeId] = useState("");
const [configProfileId, setConfigProfileId] = useState("");
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const {
data: toolTypes,
status,
error: loadError,
} = useAsyncData<ToolType[]>(listToolTypes, []);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!toolTypeId) {
setError("Please select a tool type");
return;
}
setSubmitting(true);
setError(null);
try {
await onStart(toolTypeId, configProfileId || undefined);
onClose();
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to start tool");
} finally {
setSubmitting(false);
}
};
return (
<div className="modal-overlay" onClick={onClose}>
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
<div className="modal-header">
<h3>
<Icon name="play" size="sm" /> Start Tool on {workspace.name}
</h3>
<button className="btn btn-icon" onClick={onClose}>
<Icon name="cancel" size="sm" />
</button>
</div>
<form onSubmit={handleSubmit}>
<div className="form-group">
<label htmlFor="tool-type">Tool Type</label>
<select
id="tool-type"
value={toolTypeId}
onChange={(e) => setToolTypeId(e.target.value)}
disabled={submitting || status === "loading"}
>
<option value="">Select a tool...</option>
{toolTypes?.map((tt) => (
<option key={tt.id} value={tt.id}>
{tt.display_name}
</option>
))}
</select>
{status === "loading" && (
<span className="muted">Loading tools...</span>
)}
{loadError && <span className="error-text">{loadError}</span>}
</div>
<div className="form-group">
<label htmlFor="config-profile">Config Profile (optional)</label>
<input
id="config-profile"
type="text"
value={configProfileId}
onChange={(e) => setConfigProfileId(e.target.value)}
placeholder="Profile ID"
disabled={submitting}
/>
</div>
{error && <p className="form-error">{error}</p>}
<div className="form-actions">
<button
type="button"
className="btn btn-secondary"
onClick={onClose}
disabled={submitting}
>
Cancel
</button>
<button
type="submit"
className="btn btn-primary"
disabled={submitting || status !== "ready"}
>
{submitting ? "Starting..." : "Start Tool"}
</button>
</div>
</form>
</div>
</div>
);
}
@@ -0,0 +1,299 @@
/** Unified tool starter — workspace-first, fetches real tool types and config profiles. */
import { useState, useEffect, useCallback } from "react";
import { Icon } from "./icon";
import { listToolTypes, type ToolType } from "../api/tool-types";
import { listConfigProfiles, type ConfigProfile } from "../api/config_profiles";
import { listSSHKeys, type SSHKey } from "../api/ssh-keys";
import type { Workspace } from "../types/workspace";
import type { ToolInstance } from "../api/sessions";
export interface ToolStarterProps {
workspace: Workspace;
onStarted: (instance: ToolInstance) => void;
onCancel?: () => void;
}
export function ToolStarter({
workspace,
onStarted,
onCancel,
}: ToolStarterProps) {
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
const [toolTypesLoading, setToolTypesLoading] = useState(true);
const [toolTypesError, setToolTypesError] = useState<string | null>(null);
const [selectedToolTypeId, setSelectedToolTypeId] = useState("");
const [profiles, setProfiles] = useState<ConfigProfile[]>([]);
const [profilesLoading, setProfilesLoading] = useState(false);
const [selectedProfileId, setSelectedProfileId] = useState("");
const [sshKeys, setSshKeys] = useState<SSHKey[]>([]);
const [sshKeysLoading, setSshKeysLoading] = useState(true);
const [selectedSshKeyIds, setSelectedSshKeyIds] = useState<string[]>([]);
const [starting, setStarting] = useState(false);
const [error, setError] = useState<string | null>(null);
// Fetch tool types on mount
useEffect(() => {
const load = async () => {
try {
const data = await listToolTypes();
setToolTypes(data);
} catch (err) {
setToolTypesError(
err instanceof Error ? err.message : "Failed to load tool types",
);
} finally {
setToolTypesLoading(false);
}
};
void load();
}, []);
// Fetch config profiles when tool type changes
useEffect(() => {
if (!selectedToolTypeId) {
setProfiles([]);
setSelectedProfileId("");
return;
}
const load = async () => {
setProfilesLoading(true);
try {
const data = await listConfigProfiles(
workspace.project_id,
selectedToolTypeId,
);
setProfiles(data);
// Auto-select default profile if available
const defaultProfile = data.find((p) => p.is_default);
if (defaultProfile) {
setSelectedProfileId(defaultProfile.id);
} else {
setSelectedProfileId("");
}
} catch {
setProfiles([]);
} finally {
setProfilesLoading(false);
}
};
void load();
}, [selectedToolTypeId, workspace.project_id]);
// Fetch SSH keys on mount
useEffect(() => {
const load = async () => {
try {
const data = await listSSHKeys();
setSshKeys(data);
// Auto-select the repository's SSH key if available
if (workspace.repo_ssh_key_id) {
setSelectedSshKeyIds([workspace.repo_ssh_key_id]);
}
} catch (err) {
console.error("Failed to load SSH keys:", err);
} finally {
setSshKeysLoading(false);
}
};
void load();
}, [workspace.repo_ssh_key_id]);
const repoHasSshKey = !!workspace.repo_ssh_key_id;
const repoSshKey = sshKeys.find((k) => k.id === workspace.repo_ssh_key_id);
const handleStart = useCallback(async () => {
if (!selectedToolTypeId) {
setError("Please select a tool type");
return;
}
setStarting(true);
setError(null);
try {
const { createInstance, startInstance } = await import("../api/sessions");
const instance = await createInstance(
workspace.project_id,
workspace.repo_id,
selectedToolTypeId,
workspace.name,
undefined,
undefined,
undefined,
selectedProfileId || undefined,
selectedSshKeyIds.length > 0 ? selectedSshKeyIds : undefined,
workspace.id,
);
await startInstance(
workspace.project_id,
workspace.repo_id,
instance.id,
selectedProfileId || undefined,
selectedSshKeyIds.length > 0 ? selectedSshKeyIds : undefined,
);
onStarted(instance);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to start tool");
} finally {
setStarting(false);
}
}, [selectedToolTypeId, selectedProfileId, workspace, onStarted]);
return (
<div className="tool-starter">
{/* Context header — read-only workspace info */}
<div className="tool-starter-context">
<div className="context-row">
<span className="context-label">Project</span>
<span className="context-value">{workspace.project_name}</span>
</div>
<div className="context-row">
<span className="context-label">Repository</span>
<span className="context-value">{workspace.repo_name}</span>
</div>
<div className="context-row">
<span className="context-label">Workspace</span>
<span className="context-value">{workspace.name}</span>
<span className="branch-badge">
<Icon name="branch" size="sm" /> {workspace.branch}
</span>
</div>
</div>
{/* Tool Type */}
<div className="form-group">
<label htmlFor="tool-type">Tool Type</label>
<select
id="tool-type"
value={selectedToolTypeId}
onChange={(e) => {
setSelectedToolTypeId(e.target.value);
setError(null);
}}
disabled={toolTypesLoading || starting}
>
<option value="">Select a tool...</option>
{toolTypes.map((tt) => (
<option key={tt.id} value={tt.id}>
{tt.display_name}
{tt.category && ` (${tt.category})`}
</option>
))}
</select>
{toolTypesLoading && <span className="muted">Loading tools...</span>}
{toolTypesError && <span className="error-text">{toolTypesError}</span>}
</div>
{/* Config Profile */}
{selectedToolTypeId && (
<div className="form-group">
<label htmlFor="config-profile">Config Profile</label>
<select
id="config-profile"
value={selectedProfileId}
onChange={(e) => setSelectedProfileId(e.target.value)}
disabled={profilesLoading || starting}
>
<option value="">Default (no profile)</option>
{profiles.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
{p.is_default && " (default)"}
</option>
))}
</select>
{profilesLoading && (
<span className="muted">Loading profiles...</span>
)}
{profiles.length === 0 && !profilesLoading && (
<span className="muted">No custom profiles for this tool.</span>
)}
</div>
)}
{/* SSH Key Selection */}
<div className="form-group ssh-key-selection">
<label>SSH Keys</label>
{sshKeysLoading ? (
<span className="muted">Loading SSH keys...</span>
) : sshKeys.length === 0 ? (
<span className="muted">No SSH keys configured.</span>
) : (
<div style={{ display: "flex", flexWrap: "wrap", gap: "0.5rem" }}>
{sshKeys.map((key) => (
<label
key={key.id}
className="checkbox-label"
style={{
display: "flex",
alignItems: "center",
gap: "0.25rem",
padding: "0.375rem 0.75rem",
background: "var(--panel)",
borderRadius: "0.375rem",
border: "1px solid var(--border)",
cursor: "pointer",
}}
>
<input
type="checkbox"
checked={selectedSshKeyIds.includes(key.id)}
onChange={(e) => {
if (e.target.checked) {
setSelectedSshKeyIds((prev) => [...prev, key.id]);
} else {
setSelectedSshKeyIds((prev) =>
prev.filter((id) => id !== key.id),
);
}
}}
disabled={starting}
/>
{key.name}
</label>
))}
</div>
)}
{!sshKeysLoading && repoHasSshKey && repoSshKey && (
<div className="hint" style={{ marginTop: "0.5rem" }}>
Repository key <strong>{repoSshKey.name}</strong> is pre-selected.
</div>
)}
</div>
{error && <p className="form-error">{error}</p>}
<div className="form-actions">
{onCancel && (
<button
type="button"
className="btn btn-secondary"
onClick={onCancel}
disabled={starting}
>
Cancel
</button>
)}
<button
type="button"
className="btn btn-primary"
onClick={handleStart}
disabled={!selectedToolTypeId || toolTypesLoading || starting}
>
{starting ? (
<>
<Icon name="loading" size="sm" /> Starting...
</>
) : (
<>
<Icon name="play" size="sm" /> Start Tool
</>
)}
</button>
</div>
</div>
);
}
@@ -0,0 +1,62 @@
import { useLocation, useNavigate } from "react-router-dom";
import { Icon } from "./icon";
interface ToolsBottomSheetProps {
isOpen: boolean;
onClose: () => void;
}
const TOOLS_ITEMS = [
{ to: "/tool-workshop", label: "Tool Workshop" },
{ to: "/config-profiles", label: "Config Profiles" },
];
export const ToolsBottomSheet: React.FC<ToolsBottomSheetProps> = ({
isOpen,
onClose,
}) => {
const location = useLocation();
const navigate = useNavigate();
if (!isOpen) return null;
const handleSelect = (to: string) => {
onClose();
navigate(to);
};
return (
<div
className="mobile-bottom-sheet-overlay"
onClick={onClose}
role="presentation"
>
<div
className="mobile-bottom-sheet"
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-label="Tools menu"
>
<div className="mobile-bottom-sheet-header">
<div className="mobile-bottom-sheet-handle" />
<h3 className="mobile-bottom-sheet-title">Tools</h3>
</div>
<div className="mobile-bottom-sheet-content">
{TOOLS_ITEMS.map((item) => (
<button
key={item.to}
className={`mobile-bottom-sheet-item ${
location.pathname === item.to ? "active" : ""
}`}
onClick={() => handleSelect(item.to)}
type="button"
>
<span className="mobile-bottom-sheet-item-label">{item.label}</span>
{location.pathname === item.to && <Icon name="success" size="sm" />}
</button>
))}
</div>
</div>
</div>
);
};