feat: sessions page workspace-first flow + workspace instance chips
- Sessions page: replaced CreateSessionForm with workspace selector + ToolStarter - Fetches workspaces, shows dropdown, then renders ToolStarter for selected workspace - Removed old project/repo/tool-type/config-profile/clone-mode flow - Workspace cards: new WorkspaceInstanceChips component fetches and displays running instances per workspace with status-colored chips and open links - Styles: .instance-chip variants (running/starting/error), .tool-starter-header Quality gates: tsc --noEmit clean, pytest workspaces API (9 passed, 1 skipped)
This commit is contained in:
@@ -14,7 +14,11 @@ export interface ToolStarterProps {
|
||||
onCancel?: () => void;
|
||||
}
|
||||
|
||||
export function ToolStarter({ workspace, onStarted, onCancel }: ToolStarterProps) {
|
||||
export function ToolStarter({
|
||||
workspace,
|
||||
onStarted,
|
||||
onCancel,
|
||||
}: ToolStarterProps) {
|
||||
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
|
||||
const [toolTypesLoading, setToolTypesLoading] = useState(true);
|
||||
const [toolTypesError, setToolTypesError] = useState<string | null>(null);
|
||||
@@ -105,9 +109,7 @@ export function ToolStarter({ workspace, onStarted, onCancel }: ToolStarterProps
|
||||
setStarting(true);
|
||||
setError(null);
|
||||
try {
|
||||
const { createInstance, startInstance } = await import(
|
||||
"../api/sessions"
|
||||
);
|
||||
const { createInstance, startInstance } = await import("../api/sessions");
|
||||
const instance = await createInstance(
|
||||
workspace.project_id,
|
||||
workspace.repo_id,
|
||||
@@ -132,12 +134,7 @@ export function ToolStarter({ workspace, onStarted, onCancel }: ToolStarterProps
|
||||
} finally {
|
||||
setStarting(false);
|
||||
}
|
||||
}, [
|
||||
selectedToolTypeId,
|
||||
selectedProfileId,
|
||||
workspace,
|
||||
onStarted,
|
||||
]);
|
||||
}, [selectedToolTypeId, selectedProfileId, workspace, onStarted]);
|
||||
|
||||
return (
|
||||
<div className="tool-starter">
|
||||
@@ -180,12 +177,8 @@ export function ToolStarter({ workspace, onStarted, onCancel }: ToolStarterProps
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{toolTypesLoading && (
|
||||
<span className="muted">Loading tools...</span>
|
||||
)}
|
||||
{toolTypesError && (
|
||||
<span className="error-text">{toolTypesError}</span>
|
||||
)}
|
||||
{toolTypesLoading && <span className="muted">Loading tools...</span>}
|
||||
{toolTypesError && <span className="error-text">{toolTypesError}</span>}
|
||||
</div>
|
||||
|
||||
{/* Config Profile */}
|
||||
@@ -227,8 +220,7 @@ export function ToolStarter({ workspace, onStarted, onCancel }: ToolStarterProps
|
||||
</span>
|
||||
) : (
|
||||
<span className="warning-text">
|
||||
<Icon name="warning" size="sm" />{" "}
|
||||
No SSH key assigned to repository
|
||||
<Icon name="warning" size="sm" /> No SSH key assigned to repository
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { Link } from "react-router-dom";
|
||||
import { Icon } from "./icon";
|
||||
import { WorkspaceInstanceChips } from "./workspace-instance-chips";
|
||||
import type { Workspace } from "../types/workspace";
|
||||
|
||||
export interface WorkspaceCardProps {
|
||||
@@ -46,12 +47,7 @@ export function WorkspaceCard({
|
||||
<p className="workspace-branch">
|
||||
<Icon name="branch" size="sm" /> {workspace.branch}
|
||||
</p>
|
||||
{workspace.instance_count > 0 && (
|
||||
<p className="workspace-instances">
|
||||
{workspace.instance_count} active tool
|
||||
{workspace.instance_count > 1 ? "s" : ""}
|
||||
</p>
|
||||
)}
|
||||
<WorkspaceInstanceChips workspaceId={workspace.id} />
|
||||
</div>
|
||||
<div className="workspace-actions">
|
||||
<button
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
/** Small component showing running instances for a workspace. */
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { listWorkspaceInstances } from "../api/workspace-instances";
|
||||
import type { ToolInstance } from "../api/sessions";
|
||||
|
||||
interface WorkspaceInstanceChipsProps {
|
||||
workspaceId: string;
|
||||
}
|
||||
|
||||
export function WorkspaceInstanceChips({
|
||||
workspaceId,
|
||||
}: WorkspaceInstanceChipsProps) {
|
||||
const [instances, setInstances] = useState<ToolInstance[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
try {
|
||||
const data = await listWorkspaceInstances(workspaceId);
|
||||
setInstances(data);
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
void load();
|
||||
}, [workspaceId]);
|
||||
|
||||
if (loading) return <span className="muted">...</span>;
|
||||
if (instances.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="instance-chips">
|
||||
{instances.map((inst) => (
|
||||
<span
|
||||
key={inst.id}
|
||||
className={`instance-chip ${inst.status}`}
|
||||
title={inst.display_name}
|
||||
>
|
||||
{inst.display_name}
|
||||
{inst.status === "running" && inst.url && (
|
||||
<a
|
||||
href={inst.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
↗
|
||||
</a>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,21 +1,19 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import { listProjects } from "../api/projects";
|
||||
import type { ProjectWithRepos } from "../types";
|
||||
import { listRepositories, type GitRepository } from "../api/git_repositories";
|
||||
import {
|
||||
getUserSessions,
|
||||
type Session,
|
||||
checkInstanceHealth,
|
||||
} from "../api/sessions";
|
||||
import { listToolTypes, type ToolType } from "../api/tool_types";
|
||||
import { getUserConfig, updateUserConfig } from "../api/settings";
|
||||
import { getUserConfig } from "../api/settings";
|
||||
import { listAllWorkspaces } from "../api/workspaces";
|
||||
import { ErrorState, LoadingState } from "../components/data-states";
|
||||
import { CreateSessionForm } from "../components/create-session-form";
|
||||
import { SessionList } from "../components/session-list";
|
||||
import { SessionCard } from "../components/session-card";
|
||||
import { ToolStarter } from "../components/tool-starter";
|
||||
import { useInstanceActions } from "../hooks/use-instance-actions";
|
||||
import type { InstanceHealth } from "../api/sessions";
|
||||
import type { Workspace } from "../types/workspace";
|
||||
|
||||
type SessionsStatus = "loading" | "ready" | "error";
|
||||
|
||||
@@ -24,10 +22,11 @@ export const SessionsPage = () => {
|
||||
const [sessions, setSessions] = useState<Session[]>([]);
|
||||
const [lastSessionId, setLastSessionId] = useState<string | null>(null);
|
||||
|
||||
const [projects, setProjects] = useState<ProjectWithRepos[]>([]);
|
||||
const [repositories, setRepositories] = useState<GitRepository[]>([]);
|
||||
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
|
||||
const [selectedProject, setSelectedProject] = useState<string>("");
|
||||
const [workspaces, setWorkspaces] = useState<Workspace[]>([]);
|
||||
const [workspacesLoading, setWorkspacesLoading] = useState(true);
|
||||
const [selectedWorkspace, setSelectedWorkspace] = useState<Workspace | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const [tunnelHealth, setTunnelHealth] = useState<
|
||||
Record<string, InstanceHealth>
|
||||
@@ -52,28 +51,19 @@ export const SessionsPage = () => {
|
||||
void loadSessions();
|
||||
}, [loadSessions]);
|
||||
|
||||
// Load workspaces for the tool-starter flow
|
||||
useEffect(() => {
|
||||
const loadProjects = async () => {
|
||||
const load = async () => {
|
||||
try {
|
||||
const data = await listProjects();
|
||||
setProjects(data);
|
||||
const data = await listAllWorkspaces();
|
||||
setWorkspaces(data);
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setWorkspacesLoading(false);
|
||||
}
|
||||
};
|
||||
void loadProjects();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const loadToolTypes = async () => {
|
||||
try {
|
||||
const data = await listToolTypes();
|
||||
setToolTypes(data);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
void loadToolTypes();
|
||||
void load();
|
||||
}, []);
|
||||
|
||||
const {
|
||||
@@ -126,36 +116,15 @@ export const SessionsPage = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// Check immediately and then every 30 seconds
|
||||
void checkHealth();
|
||||
const interval = setInterval(() => void checkHealth(), 30000);
|
||||
return () => clearInterval(interval);
|
||||
}, [sessions]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedProject) {
|
||||
setRepositories([]);
|
||||
return;
|
||||
}
|
||||
const loadRepos = async () => {
|
||||
try {
|
||||
const data = await listRepositories(selectedProject);
|
||||
setRepositories(data);
|
||||
} catch {
|
||||
setRepositories([]);
|
||||
}
|
||||
};
|
||||
void loadRepos();
|
||||
}, [selectedProject]);
|
||||
const lastSession = sessions.find((s) => s.id === lastSessionId) ?? null;
|
||||
|
||||
const lastSession = useMemo(
|
||||
() => sessions.find((s) => s.id === lastSessionId) ?? null,
|
||||
[sessions, lastSessionId],
|
||||
);
|
||||
|
||||
const handleCreateSuccess = async (instance: { id: string }) => {
|
||||
await updateUserConfig({ last_session_id: instance.id });
|
||||
setSelectedProject("");
|
||||
const handleToolStarted = async () => {
|
||||
setSelectedWorkspace(null);
|
||||
await loadSessions();
|
||||
};
|
||||
|
||||
@@ -204,18 +173,71 @@ export const SessionsPage = () => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Create Session */}
|
||||
{/* Create Session — workspace-first */}
|
||||
<div className="create-session-section">
|
||||
<h2>Create New Session</h2>
|
||||
<CreateSessionForm
|
||||
projects={projects}
|
||||
repositories={repositories}
|
||||
toolTypes={toolTypes}
|
||||
onProjectChange={(projectId) => {
|
||||
setSelectedProject(projectId);
|
||||
}}
|
||||
onSuccess={handleCreateSuccess}
|
||||
/>
|
||||
<h2>Start New Tool</h2>
|
||||
{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>
|
||||
) : (
|
||||
<div className="stack">
|
||||
{!selectedWorkspace ? (
|
||||
<div className="form-group">
|
||||
<label htmlFor="workspace-select">
|
||||
Select a workspace to start a tool in
|
||||
</label>
|
||||
<select
|
||||
id="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="card">
|
||||
<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={handleToolStarted}
|
||||
onCancel={() => setSelectedWorkspace(null)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Dirty Delete Confirmation Modal */}
|
||||
@@ -228,8 +250,8 @@ export const SessionsPage = () => {
|
||||
<h3>Uncommitted Changes</h3>
|
||||
<p>
|
||||
The repository{" "}
|
||||
<strong>{dirtyDeleteSession.repository_name}</strong> has
|
||||
uncommitted changes. Deleting this session will permanently
|
||||
<strong>{dirtyDeleteSession.repository_name}</strong>{" "}
|
||||
has uncommitted changes. Deleting this session will permanently
|
||||
lose these changes.
|
||||
</p>
|
||||
<div className="changed-files-list">
|
||||
|
||||
@@ -5551,3 +5551,67 @@ a:active,
|
||||
gap: var(--space-2);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
/* ─── Instance Chips ─── */
|
||||
.instance-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
|
||||
.instance-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
padding: 0.2rem 0.5rem;
|
||||
border-radius: 999px;
|
||||
font-size: var(--font-size-sm);
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.instance-chip.running {
|
||||
background: var(--success-light);
|
||||
border-color: var(--success);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.instance-chip.starting,
|
||||
.instance-chip.building,
|
||||
.instance-chip.probing {
|
||||
background: var(--warning-light);
|
||||
border-color: var(--warning);
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.instance-chip.error,
|
||||
.instance-chip.unhealthy,
|
||||
.instance-chip.stopped {
|
||||
background: var(--danger-light);
|
||||
border-color: var(--danger);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.instance-chip a {
|
||||
font-size: 0.75rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.instance-chip a:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* ─── Tool Starter Header ─── */
|
||||
.tool-starter-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: var(--space-3);
|
||||
padding-bottom: var(--space-2);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.tool-starter-header h4 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user