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:
@@ -29,6 +29,9 @@ import {
|
|||||||
Image,
|
Image,
|
||||||
Binary,
|
Binary,
|
||||||
Code,
|
Code,
|
||||||
|
ArrowSquareOut,
|
||||||
|
Play,
|
||||||
|
Stop,
|
||||||
} from "@phosphor-icons/react";
|
} from "@phosphor-icons/react";
|
||||||
|
|
||||||
export type IconName =
|
export type IconName =
|
||||||
@@ -65,7 +68,10 @@ export type IconName =
|
|||||||
| "code"
|
| "code"
|
||||||
| "document"
|
| "document"
|
||||||
| "image"
|
| "image"
|
||||||
| "binary";
|
| "binary"
|
||||||
|
| "external"
|
||||||
|
| "play"
|
||||||
|
| "stop";
|
||||||
|
|
||||||
const iconMap: Record<IconName, React.ComponentType<{ size?: number | string; weight?: "thin" | "light" | "regular" | "bold" | "fill" | "duotone" }>> = {
|
const iconMap: Record<IconName, React.ComponentType<{ size?: number | string; weight?: "thin" | "light" | "regular" | "bold" | "fill" | "duotone" }>> = {
|
||||||
dashboard: House,
|
dashboard: House,
|
||||||
@@ -102,6 +108,9 @@ const iconMap: Record<IconName, React.ComponentType<{ size?: number | string; we
|
|||||||
document: FileText,
|
document: FileText,
|
||||||
image: Image,
|
image: Image,
|
||||||
binary: Binary,
|
binary: Binary,
|
||||||
|
external: ArrowSquareOut,
|
||||||
|
play: Play,
|
||||||
|
stop: Stop,
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface IconProps {
|
export interface IconProps {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -13,7 +13,10 @@ import {
|
|||||||
import { CommitPanel } from "../components/commit-panel";
|
import { CommitPanel } from "../components/commit-panel";
|
||||||
import { FileEditor } from "../components/file-editor";
|
import { FileEditor } from "../components/file-editor";
|
||||||
import { GitToolbar } from "../components/git-toolbar";
|
import { GitToolbar } from "../components/git-toolbar";
|
||||||
|
import { InstanceList } from "../components/instance-list";
|
||||||
import { WorkspaceHeader } from "../components/workspace-header";
|
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";
|
type WorkspaceStatus = "loading" | "ready" | "error" | "empty";
|
||||||
|
|
||||||
@@ -50,6 +53,7 @@ export const RepoWorkspace = () => {
|
|||||||
const [branches, setBranches] = useState<string[]>([]);
|
const [branches, setBranches] = useState<string[]>([]);
|
||||||
const [currentBranch, setCurrentBranch] = useState<string>("main");
|
const [currentBranch, setCurrentBranch] = useState<string>("main");
|
||||||
const [gitStatus, setGitStatus] = useState<GitStatus | null>(null);
|
const [gitStatus, setGitStatus] = useState<GitStatus | null>(null);
|
||||||
|
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
|
||||||
|
|
||||||
const loadProject = useCallback(async () => {
|
const loadProject = useCallback(async () => {
|
||||||
if (!projectId) return;
|
if (!projectId) return;
|
||||||
@@ -114,10 +118,20 @@ export const RepoWorkspace = () => {
|
|||||||
}
|
}
|
||||||
}, [projectId, selectedRepoId]);
|
}, [projectId, selectedRepoId]);
|
||||||
|
|
||||||
|
const loadToolTypes = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const data = await listToolTypes();
|
||||||
|
setToolTypes(data);
|
||||||
|
} catch {
|
||||||
|
setToolTypes([]);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void loadProject();
|
void loadProject();
|
||||||
void loadRepositories();
|
void loadRepositories();
|
||||||
}, [loadProject, loadRepositories]);
|
void loadToolTypes();
|
||||||
|
}, [loadProject, loadRepositories, loadToolTypes]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void loadBranches();
|
void loadBranches();
|
||||||
@@ -234,6 +248,13 @@ export const RepoWorkspace = () => {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{selectedRepoId && (
|
||||||
|
<InstanceList
|
||||||
|
projectId={projectId!}
|
||||||
|
repoId={selectedRepoId}
|
||||||
|
toolTypes={toolTypes}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</aside>
|
</aside>
|
||||||
|
|||||||
@@ -2260,3 +2260,64 @@ a.nav-item,
|
|||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.05em;
|
||||||
color: var(--muted);
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -28,6 +28,9 @@ import {
|
|||||||
Image,
|
Image,
|
||||||
Binary,
|
Binary,
|
||||||
Code,
|
Code,
|
||||||
|
ArrowSquareOut,
|
||||||
|
Play,
|
||||||
|
Stop,
|
||||||
} from "@phosphor-icons/react";
|
} from "@phosphor-icons/react";
|
||||||
|
|
||||||
export type IconName =
|
export type IconName =
|
||||||
@@ -64,7 +67,10 @@ export type IconName =
|
|||||||
| "code"
|
| "code"
|
||||||
| "document"
|
| "document"
|
||||||
| "image"
|
| "image"
|
||||||
| "binary";
|
| "binary"
|
||||||
|
| "external"
|
||||||
|
| "play"
|
||||||
|
| "stop";
|
||||||
|
|
||||||
export const iconRegistry: Record<
|
export const iconRegistry: Record<
|
||||||
IconName,
|
IconName,
|
||||||
@@ -113,6 +119,11 @@ export const iconRegistry: Record<
|
|||||||
document: FileText,
|
document: FileText,
|
||||||
image: Image,
|
image: Image,
|
||||||
binary: Binary,
|
binary: Binary,
|
||||||
|
|
||||||
|
// Instance actions
|
||||||
|
external: ArrowSquareOut,
|
||||||
|
play: Play,
|
||||||
|
stop: Stop,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const iconCategories = {
|
export const iconCategories = {
|
||||||
|
|||||||
Reference in New Issue
Block a user