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:
2026-06-01 21:53:48 +02:00
parent c1445976d7
commit 78e808bc54
5 changed files with 219 additions and 88 deletions
+10 -18
View File
@@ -14,7 +14,11 @@ export interface ToolStarterProps {
onCancel?: () => void; onCancel?: () => void;
} }
export function ToolStarter({ workspace, onStarted, onCancel }: ToolStarterProps) { export function ToolStarter({
workspace,
onStarted,
onCancel,
}: ToolStarterProps) {
const [toolTypes, setToolTypes] = useState<ToolType[]>([]); const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
const [toolTypesLoading, setToolTypesLoading] = useState(true); const [toolTypesLoading, setToolTypesLoading] = useState(true);
const [toolTypesError, setToolTypesError] = useState<string | null>(null); const [toolTypesError, setToolTypesError] = useState<string | null>(null);
@@ -105,9 +109,7 @@ export function ToolStarter({ workspace, onStarted, onCancel }: ToolStarterProps
setStarting(true); setStarting(true);
setError(null); setError(null);
try { try {
const { createInstance, startInstance } = await import( const { createInstance, startInstance } = await import("../api/sessions");
"../api/sessions"
);
const instance = await createInstance( const instance = await createInstance(
workspace.project_id, workspace.project_id,
workspace.repo_id, workspace.repo_id,
@@ -132,12 +134,7 @@ export function ToolStarter({ workspace, onStarted, onCancel }: ToolStarterProps
} finally { } finally {
setStarting(false); setStarting(false);
} }
}, [ }, [selectedToolTypeId, selectedProfileId, workspace, onStarted]);
selectedToolTypeId,
selectedProfileId,
workspace,
onStarted,
]);
return ( return (
<div className="tool-starter"> <div className="tool-starter">
@@ -180,12 +177,8 @@ export function ToolStarter({ workspace, onStarted, onCancel }: ToolStarterProps
</option> </option>
))} ))}
</select> </select>
{toolTypesLoading && ( {toolTypesLoading && <span className="muted">Loading tools...</span>}
<span className="muted">Loading tools...</span> {toolTypesError && <span className="error-text">{toolTypesError}</span>}
)}
{toolTypesError && (
<span className="error-text">{toolTypesError}</span>
)}
</div> </div>
{/* Config Profile */} {/* Config Profile */}
@@ -227,8 +220,7 @@ export function ToolStarter({ workspace, onStarted, onCancel }: ToolStarterProps
</span> </span>
) : ( ) : (
<span className="warning-text"> <span className="warning-text">
<Icon name="warning" size="sm" />{" "} <Icon name="warning" size="sm" /> No SSH key assigned to repository
No SSH key assigned to repository
</span> </span>
)} )}
</div> </div>
+2 -6
View File
@@ -2,6 +2,7 @@
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { Icon } from "./icon"; import { Icon } from "./icon";
import { WorkspaceInstanceChips } from "./workspace-instance-chips";
import type { Workspace } from "../types/workspace"; import type { Workspace } from "../types/workspace";
export interface WorkspaceCardProps { export interface WorkspaceCardProps {
@@ -46,12 +47,7 @@ export function WorkspaceCard({
<p className="workspace-branch"> <p className="workspace-branch">
<Icon name="branch" size="sm" /> {workspace.branch} <Icon name="branch" size="sm" /> {workspace.branch}
</p> </p>
{workspace.instance_count > 0 && ( <WorkspaceInstanceChips workspaceId={workspace.id} />
<p className="workspace-instances">
{workspace.instance_count} active tool
{workspace.instance_count > 1 ? "s" : ""}
</p>
)}
</div> </div>
<div className="workspace-actions"> <div className="workspace-actions">
<button <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>
);
}
+84 -62
View File
@@ -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 { import {
getUserSessions, getUserSessions,
type Session, type Session,
checkInstanceHealth, checkInstanceHealth,
} from "../api/sessions"; } from "../api/sessions";
import { listToolTypes, type ToolType } from "../api/tool_types"; import { getUserConfig } from "../api/settings";
import { getUserConfig, updateUserConfig } from "../api/settings"; import { listAllWorkspaces } from "../api/workspaces";
import { ErrorState, LoadingState } from "../components/data-states"; import { ErrorState, LoadingState } from "../components/data-states";
import { CreateSessionForm } from "../components/create-session-form";
import { SessionList } from "../components/session-list"; import { SessionList } from "../components/session-list";
import { SessionCard } from "../components/session-card"; import { SessionCard } from "../components/session-card";
import { ToolStarter } from "../components/tool-starter";
import { useInstanceActions } from "../hooks/use-instance-actions"; import { useInstanceActions } from "../hooks/use-instance-actions";
import type { InstanceHealth } from "../api/sessions"; import type { InstanceHealth } from "../api/sessions";
import type { Workspace } from "../types/workspace";
type SessionsStatus = "loading" | "ready" | "error"; type SessionsStatus = "loading" | "ready" | "error";
@@ -24,10 +22,11 @@ export const SessionsPage = () => {
const [sessions, setSessions] = useState<Session[]>([]); const [sessions, setSessions] = useState<Session[]>([]);
const [lastSessionId, setLastSessionId] = useState<string | null>(null); const [lastSessionId, setLastSessionId] = useState<string | null>(null);
const [projects, setProjects] = useState<ProjectWithRepos[]>([]); const [workspaces, setWorkspaces] = useState<Workspace[]>([]);
const [repositories, setRepositories] = useState<GitRepository[]>([]); const [workspacesLoading, setWorkspacesLoading] = useState(true);
const [toolTypes, setToolTypes] = useState<ToolType[]>([]); const [selectedWorkspace, setSelectedWorkspace] = useState<Workspace | null>(
const [selectedProject, setSelectedProject] = useState<string>(""); null,
);
const [tunnelHealth, setTunnelHealth] = useState< const [tunnelHealth, setTunnelHealth] = useState<
Record<string, InstanceHealth> Record<string, InstanceHealth>
@@ -52,28 +51,19 @@ export const SessionsPage = () => {
void loadSessions(); void loadSessions();
}, [loadSessions]); }, [loadSessions]);
// Load workspaces for the tool-starter flow
useEffect(() => { useEffect(() => {
const loadProjects = async () => { const load = async () => {
try { try {
const data = await listProjects(); const data = await listAllWorkspaces();
setProjects(data); setWorkspaces(data);
} catch { } catch {
// ignore // ignore
} finally {
setWorkspacesLoading(false);
} }
}; };
void loadProjects(); void load();
}, []);
useEffect(() => {
const loadToolTypes = async () => {
try {
const data = await listToolTypes();
setToolTypes(data);
} catch {
// ignore
}
};
void loadToolTypes();
}, []); }, []);
const { const {
@@ -126,36 +116,15 @@ export const SessionsPage = () => {
} }
}; };
// Check immediately and then every 30 seconds
void checkHealth(); void checkHealth();
const interval = setInterval(() => void checkHealth(), 30000); const interval = setInterval(() => void checkHealth(), 30000);
return () => clearInterval(interval); return () => clearInterval(interval);
}, [sessions]); }, [sessions]);
useEffect(() => { const lastSession = sessions.find((s) => s.id === lastSessionId) ?? null;
if (!selectedProject) {
setRepositories([]);
return;
}
const loadRepos = async () => {
try {
const data = await listRepositories(selectedProject);
setRepositories(data);
} catch {
setRepositories([]);
}
};
void loadRepos();
}, [selectedProject]);
const lastSession = useMemo( const handleToolStarted = async () => {
() => sessions.find((s) => s.id === lastSessionId) ?? null, setSelectedWorkspace(null);
[sessions, lastSessionId],
);
const handleCreateSuccess = async (instance: { id: string }) => {
await updateUserConfig({ last_session_id: instance.id });
setSelectedProject("");
await loadSessions(); await loadSessions();
}; };
@@ -204,19 +173,72 @@ export const SessionsPage = () => {
/> />
</div> </div>
{/* Create Session */} {/* Create Session — workspace-first */}
<div className="create-session-section"> <div className="create-session-section">
<h2>Create New Session</h2> <h2>Start New Tool</h2>
<CreateSessionForm {workspacesLoading ? (
projects={projects} <p className="muted">Loading workspaces...</p>
repositories={repositories} ) : workspaces.length === 0 ? (
toolTypes={toolTypes} <p className="muted">
onProjectChange={(projectId) => { No workspaces yet.{" "}
setSelectedProject(projectId); <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);
}} }}
onSuccess={handleCreateSuccess} >
<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>
)}
</div>
{/* Dirty Delete Confirmation Modal */} {/* Dirty Delete Confirmation Modal */}
{dirtyDeleteSession && ( {dirtyDeleteSession && (
@@ -228,8 +250,8 @@ export const SessionsPage = () => {
<h3>Uncommitted Changes</h3> <h3>Uncommitted Changes</h3>
<p> <p>
The repository{" "} The repository{" "}
<strong>{dirtyDeleteSession.repository_name}</strong> has <strong>{dirtyDeleteSession.repository_name}</strong>{" "}
uncommitted changes. Deleting this session will permanently has uncommitted changes. Deleting this session will permanently
lose these changes. lose these changes.
</p> </p>
<div className="changed-files-list"> <div className="changed-files-list">
+64
View File
@@ -5551,3 +5551,67 @@ a:active,
gap: var(--space-2); gap: var(--space-2);
font-size: var(--font-size-sm); 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;
}