From f997b3f7c564f6ffbbcded968f7ef39378085398 Mon Sep 17 00:00:00 2001 From: Fusion Date: Tue, 19 May 2026 20:50:08 +0200 Subject: [PATCH] feat: add instance list component and integrate into workspace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 ✓ --- apps/web/src/components/icon.tsx | 11 +- apps/web/src/components/instance-list.tsx | 249 ++++++++++++++++++++++ apps/web/src/pages/repo-workspace.tsx | 23 +- apps/web/src/styles.css | 61 ++++++ apps/web/src/utils/icons.ts | 13 +- 5 files changed, 354 insertions(+), 3 deletions(-) create mode 100644 apps/web/src/components/instance-list.tsx diff --git a/apps/web/src/components/icon.tsx b/apps/web/src/components/icon.tsx index f3c95ba..cf74252 100644 --- a/apps/web/src/components/icon.tsx +++ b/apps/web/src/components/icon.tsx @@ -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> = { dashboard: House, @@ -102,6 +108,9 @@ const iconMap: Record { + const [instances, setInstances] = useState([]); + const [loading, setLoading] = useState(false); + const [showCreate, setShowCreate] = useState(false); + const [selectedToolType, setSelectedToolType] = useState(""); + const [displayName, setDisplayName] = useState(""); + const [error, setError] = useState(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 ( +
+
+

Tool Instances

+ +
+ + {error && ( +
{error}
+ )} + + {loading ? ( +

Loading instances...

+ ) : instances.length === 0 ? ( +

No instances yet. Launch a tool to get started.

+ ) : ( +
+ {instances.map((instance) => ( +
+
+
{instance.display_name}
+
+ + {instance.status} +
+
+
+ {instance.status === "running" && instance.url && ( + + + Open + + )} + {instance.status !== "running" && ( + + )} + {instance.status === "running" && ( + <> + + + + )} + +
+
+ ))} +
+ )} + + {showCreate && ( +
+
+

Launch Tool

+
+ + +
+ + +
+
+
+
+ )} +
+ ); +}; diff --git a/apps/web/src/pages/repo-workspace.tsx b/apps/web/src/pages/repo-workspace.tsx index 0e2f259..dcb58b7 100644 --- a/apps/web/src/pages/repo-workspace.tsx +++ b/apps/web/src/pages/repo-workspace.tsx @@ -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([]); const [currentBranch, setCurrentBranch] = useState("main"); const [gitStatus, setGitStatus] = useState(null); + const [toolTypes, setToolTypes] = useState([]); 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 && ( + + )} )} diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index 8d01357..98c769e 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -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; +} diff --git a/apps/web/src/utils/icons.ts b/apps/web/src/utils/icons.ts index 6737113..45501af 100644 --- a/apps/web/src/utils/icons.ts +++ b/apps/web/src/utils/icons.ts @@ -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 = {