feat: add instance list component and integrate into workspace

- Create InstanceList component with create/start/stop/restart/delete
- Add tool instance icons (external, play, stop)
- Integrate InstanceList into RepoWorkspace sidebar
- Load tool types for instance creation
- Add instance-specific CSS styles

Quality gates: typecheck ✓, lint ✓, build ✓
This commit is contained in:
Fusion
2026-05-19 20:50:08 +02:00
parent c795f8f873
commit f997b3f7c5
5 changed files with 354 additions and 3 deletions
+10 -1
View File
@@ -29,6 +29,9 @@ import {
Image,
Binary,
Code,
ArrowSquareOut,
Play,
Stop,
} from "@phosphor-icons/react";
export type IconName =
@@ -65,7 +68,10 @@ export type IconName =
| "code"
| "document"
| "image"
| "binary";
| "binary"
| "external"
| "play"
| "stop";
const iconMap: Record<IconName, React.ComponentType<{ size?: number | string; weight?: "thin" | "light" | "regular" | "bold" | "fill" | "duotone" }>> = {
dashboard: House,
@@ -102,6 +108,9 @@ const iconMap: Record<IconName, React.ComponentType<{ size?: number | string; we
document: FileText,
image: Image,
binary: Binary,
external: ArrowSquareOut,
play: Play,
stop: Stop,
};
export interface IconProps {
+249
View File
@@ -0,0 +1,249 @@
import { useCallback, useEffect, useState } from "react";
import { Icon } from "./icon";
import type { ToolInstance } from "../api/sessions";
import {
createInstance,
deleteInstance,
listInstances,
restartInstance,
startInstance,
stopInstance,
} from "../api/sessions";
import type { ToolType } from "../api/tool_types";
interface InstanceListProps {
projectId: string;
repoId: string;
toolTypes: ToolType[];
}
export const InstanceList = ({ projectId, repoId, toolTypes }: InstanceListProps) => {
const [instances, setInstances] = useState<ToolInstance[]>([]);
const [loading, setLoading] = useState(false);
const [showCreate, setShowCreate] = useState(false);
const [selectedToolType, setSelectedToolType] = useState("");
const [displayName, setDisplayName] = useState("");
const [error, setError] = 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]);
useEffect(() => {
void loadInstances();
}, [loadInstances]);
const handleCreate = async () => {
if (!selectedToolType) return;
setError(null);
try {
await createInstance(projectId, repoId, selectedToolType, displayName || undefined);
setShowCreate(false);
setSelectedToolType("");
setDisplayName("");
await loadInstances();
} catch {
setError("Failed to create instance");
}
};
const handleStart = async (instanceId: string) => {
try {
await startInstance(projectId, repoId, instanceId);
await loadInstances();
} catch {
setError("Failed to start instance");
}
};
const handleStop = async (instanceId: string) => {
try {
await stopInstance(projectId, repoId, instanceId);
await loadInstances();
} catch {
setError("Failed to stop instance");
}
};
const handleRestart = async (instanceId: string) => {
try {
await restartInstance(projectId, repoId, instanceId);
await loadInstances();
} catch {
setError("Failed to restart instance");
}
};
const handleDelete = async (instanceId: string) => {
if (!confirm("Are you sure you want to delete this instance?")) return;
try {
await deleteInstance(projectId, repoId, instanceId);
await loadInstances();
} catch {
setError("Failed to delete instance");
}
};
const getStatusColor = (status: string) => {
switch (status) {
case "running":
return "var(--success)";
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">
<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>
</div>
<div className="instance-actions">
{instance.status === "running" && instance.url && (
<a
href={instance.url}
target="_blank"
rel="noopener noreferrer"
className="secondary-button small"
>
<Icon name="external" size="sm" />
Open
</a>
)}
{instance.status !== "running" && (
<button
className="secondary-button small"
onClick={() => void handleStart(instance.id)}
type="button"
>
<Icon name="play" size="sm" />
Start
</button>
)}
{instance.status === "running" && (
<>
<button
className="ghost-button small"
onClick={() => void handleStop(instance.id)}
type="button"
>
<Icon name="stop" size="sm" />
</button>
<button
className="ghost-button small"
onClick={() => void handleRestart(instance.id)}
type="button"
>
<Icon name="refresh" size="sm" />
</button>
</>
)}
<button
className="ghost-button small danger-text"
onClick={() => void handleDelete(instance.id)}
type="button"
>
<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>
<div className="stack">
<label className="form-field">
Tool Type
<select
value={selectedToolType}
onChange={(e) => setSelectedToolType(e.target.value)}
>
<option value="">Select a tool...</option>
{toolTypes.map((tool) => (
<option key={tool.id} value={tool.id}>
{tool.display_name}
</option>
))}
</select>
</label>
<label className="form-field">
Display Name (optional)
<input
type="text"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
placeholder="My Development Environment"
/>
</label>
<div className="dialog-actions">
<button
className="secondary-button"
onClick={() => setShowCreate(false)}
type="button"
>
Cancel
</button>
<button
className="primary-button"
onClick={() => void handleCreate()}
disabled={!selectedToolType}
type="button"
>
Launch
</button>
</div>
</div>
</div>
</div>
)}
</div>
);
};
+22 -1
View File
@@ -13,7 +13,10 @@ import {
import { CommitPanel } from "../components/commit-panel";
import { FileEditor } from "../components/file-editor";
import { GitToolbar } from "../components/git-toolbar";
import { InstanceList } from "../components/instance-list";
import { WorkspaceHeader } from "../components/workspace-header";
import { listToolTypes } from "../api/tool_types";
import type { ToolType } from "../api/tool_types";
type WorkspaceStatus = "loading" | "ready" | "error" | "empty";
@@ -50,6 +53,7 @@ export const RepoWorkspace = () => {
const [branches, setBranches] = useState<string[]>([]);
const [currentBranch, setCurrentBranch] = useState<string>("main");
const [gitStatus, setGitStatus] = useState<GitStatus | null>(null);
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
const loadProject = useCallback(async () => {
if (!projectId) return;
@@ -114,10 +118,20 @@ export const RepoWorkspace = () => {
}
}, [projectId, selectedRepoId]);
const loadToolTypes = useCallback(async () => {
try {
const data = await listToolTypes();
setToolTypes(data);
} catch {
setToolTypes([]);
}
}, []);
useEffect(() => {
void loadProject();
void loadRepositories();
}, [loadProject, loadRepositories]);
void loadToolTypes();
}, [loadProject, loadRepositories, loadToolTypes]);
useEffect(() => {
void loadBranches();
@@ -234,6 +248,13 @@ export const RepoWorkspace = () => {
}}
/>
)}
{selectedRepoId && (
<InstanceList
projectId={projectId!}
repoId={selectedRepoId}
toolTypes={toolTypes}
/>
)}
</>
)}
</aside>
+61
View File
@@ -2260,3 +2260,64 @@ a.nav-item,
letter-spacing: 0.05em;
color: var(--muted);
}
/* Instance List */
.instance-list {
margin-top: var(--space-4);
}
.instance-list-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--space-4);
}
.instance-list-header h3 {
margin: 0;
}
.instance-grid {
display: grid;
gap: var(--space-3);
}
.instance-card {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--space-4);
background: var(--bg);
border: 1px solid var(--border);
border-radius: 10px;
}
.instance-info {
display: flex;
flex-direction: column;
gap: var(--space-1);
}
.instance-name {
font-weight: 600;
}
.instance-meta {
display: flex;
align-items: center;
gap: var(--space-2);
font-size: var(--text-sm);
color: var(--muted);
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
}
.instance-actions {
display: flex;
gap: var(--space-2);
align-items: center;
}
+12 -1
View File
@@ -28,6 +28,9 @@ import {
Image,
Binary,
Code,
ArrowSquareOut,
Play,
Stop,
} from "@phosphor-icons/react";
export type IconName =
@@ -64,7 +67,10 @@ export type IconName =
| "code"
| "document"
| "image"
| "binary";
| "binary"
| "external"
| "play"
| "stop";
export const iconRegistry: Record<
IconName,
@@ -113,6 +119,11 @@ export const iconRegistry: Record<
document: FileText,
image: Image,
binary: Binary,
// Instance actions
external: ArrowSquareOut,
play: Play,
stop: Stop,
};
export const iconCategories = {