feat: floating action button for starting tools globally

- New StartToolFAB component: fixed floating button (bottom-right) opens
  a modal with workspace selector + ToolStarter
- Added to AppShell: available on every page except mobile terminal
- Dashboard (home): removed old CreateSessionForm and 'Quick create' section,
  replaced with FAB + 'Workspaces quick access' prompt
- Sessions page: removed inline workspace selector + ToolStarter, now
  shows prompt to use the FAB
- Styles: .start-tool-fab with hover scale, shadow, mobile offset above tab bar

Quality gates: tsc --noEmit clean, pytest workspaces API (9 passed, 1 skipped)
This commit is contained in:
2026-06-01 22:09:49 +02:00
parent 78e808bc54
commit 280a6ff2fa
5 changed files with 194 additions and 181 deletions
+2
View File
@@ -14,6 +14,7 @@ import { EventToastBridge } from "./event-toast-bridge";
import { NotificationCenter } from "./notification-center";
import { Icon } from "./icon";
import { MobileNav } from "./mobile-nav";
import { StartToolFAB } from "./start-tool-fab";
import type { IconName } from "../utils/icons";
const NAV_ITEMS: {
@@ -170,6 +171,7 @@ export const AppShell = () => {
}
/>
)}
<StartToolFAB />
</div>
</NotificationProvider>
</ToastProvider>
+123
View File
@@ -0,0 +1,123 @@
/** Floating action button to start a tool from any page. */
import { useState } from "react";
import { Icon } from "./icon";
import { ToolStarter } from "./tool-starter";
import type { Workspace } from "../types/workspace";
import { listAllWorkspaces } from "../api/workspaces";
export function StartToolFAB() {
const [open, setOpen] = useState(false);
const [workspaces, setWorkspaces] = useState<Workspace[]>([]);
const [workspacesLoading, setWorkspacesLoading] = useState(false);
const [selectedWorkspace, setSelectedWorkspace] = useState<Workspace | null>(
null,
);
const handleOpen = async () => {
setOpen(true);
setWorkspacesLoading(true);
try {
const data = await listAllWorkspaces();
setWorkspaces(data);
} catch {
// ignore
} finally {
setWorkspacesLoading(false);
}
};
const handleClose = () => {
setOpen(false);
setSelectedWorkspace(null);
};
return (
<>
<button
className="start-tool-fab"
onClick={handleOpen}
title="Start a new tool"
type="button"
aria-label="Start a new tool"
>
<Icon name="play" size="md" />
</button>
{open && (
<div className="modal-overlay" onClick={handleClose}>
<div
className="modal-content start-tool-modal"
onClick={(e) => e.stopPropagation()}
>
<div className="modal-header">
<h3>Start Tool</h3>
<button
className="ghost-button small"
onClick={handleClose}
type="button"
aria-label="Close"
>
<Icon name="close" size="sm" />
</button>
</div>
{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>
) : !selectedWorkspace ? (
<div className="form-group">
<label htmlFor="fab-workspace-select">
Select a workspace
</label>
<select
id="fab-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="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={handleClose}
onCancel={() => setSelectedWorkspace(null)}
/>
</>
)}
</div>
</div>
)}
</>
);
}
+10 -89
View File
@@ -8,17 +8,10 @@ import {
type Session as SessionApi,
type InstanceHealth,
} from "../api/sessions";
import { listProjects } from "../api/projects";
import { listRepositories, type GitRepository } from "../api/git_repositories";
import { listToolTypes, type ToolType } from "../api/tool_types";
import { updateUserConfig } from "../api/settings";
import type { ProjectWithRepos } from "../types";
import {
EmptyState,
ErrorState,
LoadingState,
} from "../components/data-states";
import { CreateSessionForm } from "../components/create-session-form";
import { SessionList } from "../components/session-list";
import { useInstanceActions } from "../hooks/use-instance-actions";
@@ -37,10 +30,6 @@ export const HomePage = () => {
const [status, setStatus] = useState<HomeStatus>("loading");
const [summary, setSummary] = useState<DashboardSummary | null>(null);
const [sessions, setSessions] = useState<SessionView[]>([]);
const [projects, setProjects] = useState<ProjectWithRepos[]>([]);
const [repositories, setRepositories] = useState<GitRepository[]>([]);
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
const [selectedProject, setSelectedProject] = useState("");
const [tunnelHealth, setTunnelHealth] = useState<
Record<string, InstanceHealth>
>({});
@@ -49,17 +38,12 @@ export const HomePage = () => {
const loadHome = useCallback(async () => {
setStatus("loading");
try {
const [dashboard, sessionData, projectData, toolTypeData] =
await Promise.all([
getDashboardSummary(),
getUserSessions(),
listProjects(),
listToolTypes(),
]);
const [dashboard, sessionData] = await Promise.all([
getDashboardSummary(),
getUserSessions(),
]);
setSummary(dashboard);
setSessions(sessionData as SessionView[]);
setProjects(projectData);
setToolTypes(toolTypeData);
setStatus("ready");
} catch {
setStatus("error");
@@ -122,24 +106,6 @@ export const HomePage = () => {
return () => clearInterval(interval);
}, [safeSessions]);
useEffect(() => {
if (!selectedProject) {
setRepositories([]);
return;
}
const loadRepos = async () => {
try {
const data = await listRepositories(selectedProject);
setRepositories(data);
} catch {
setRepositories([]);
}
};
void loadRepos();
}, [selectedProject]);
const activeSessions = useMemo(
() =>
safeSessions.filter((session) =>
@@ -148,12 +114,6 @@ export const HomePage = () => {
[safeSessions],
);
const handleCreateSuccess = async (instance: { id: string }) => {
await updateUserConfig({ last_session_id: instance.id });
setSelectedProject("");
await loadHome();
};
return (
<section className="stack home-page">
<header className="home-hero card">
@@ -245,59 +205,20 @@ export const HomePage = () => {
<section className="card stack home-section">
<div className="page-header">
<div>
<p className="eyebrow">Available projects</p>
<h2>{projects.length}</h2>
<p className="eyebrow">Workspaces</p>
<h2>Quick access</h2>
</div>
<button
className="secondary-button"
type="button"
onClick={() => navigate("/projects")}
onClick={() => navigate("/workspaces")}
>
View all
</button>
</div>
{projects.length === 0 ? (
<EmptyState message="No projects yet." />
) : (
<div className="home-project-grid">
{projects.map((project) => (
<article
className="card project-card home-project-card"
key={project.id}
>
<div className="stack-sm">
<h3>{project.name}</h3>
{project.description && (
<p className="muted">{project.description}</p>
)}
</div>
<button
className="ghost-button small"
type="button"
onClick={() => navigate(`/projects/${project.id}`)}
>
Open Workspace
</button>
</article>
))}
</div>
)}
</section>
<section className="card stack home-section">
<div className="page-header">
<div>
<p className="eyebrow">Quick create</p>
<h2>Start a session</h2>
</div>
</div>
<CreateSessionForm
projects={projects}
repositories={repositories}
toolTypes={toolTypes}
onProjectChange={(projectId) => setSelectedProject(projectId)}
onSuccess={handleCreateSuccess}
/>
<p className="muted">
Use the floating button to start a tool in any workspace.
</p>
</section>
{safeSessions.filter((s) => ["stopped", "error"].includes(s.status))
+5 -92
View File
@@ -6,14 +6,11 @@ import {
checkInstanceHealth,
} from "../api/sessions";
import { getUserConfig } from "../api/settings";
import { listAllWorkspaces } from "../api/workspaces";
import { ErrorState, LoadingState } from "../components/data-states";
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";
@@ -22,12 +19,6 @@ export const SessionsPage = () => {
const [sessions, setSessions] = useState<Session[]>([]);
const [lastSessionId, setLastSessionId] = useState<string | null>(null);
const [workspaces, setWorkspaces] = useState<Workspace[]>([]);
const [workspacesLoading, setWorkspacesLoading] = useState(true);
const [selectedWorkspace, setSelectedWorkspace] = useState<Workspace | null>(
null,
);
const [tunnelHealth, setTunnelHealth] = useState<
Record<string, InstanceHealth>
>({});
@@ -51,21 +42,6 @@ export const SessionsPage = () => {
void loadSessions();
}, [loadSessions]);
// Load workspaces for the tool-starter flow
useEffect(() => {
const load = async () => {
try {
const data = await listAllWorkspaces();
setWorkspaces(data);
} catch {
// ignore
} finally {
setWorkspacesLoading(false);
}
};
void load();
}, []);
const {
loadingSessionId,
dirtyDeleteSession,
@@ -123,11 +99,6 @@ export const SessionsPage = () => {
const lastSession = sessions.find((s) => s.id === lastSessionId) ?? null;
const handleToolStarted = async () => {
setSelectedWorkspace(null);
await loadSessions();
};
return (
<section className="stack sessions-page">
<div className="page-header">
@@ -173,71 +144,13 @@ export const SessionsPage = () => {
/>
</div>
{/* Create Session — workspace-first */}
{/* Create Session — use floating button */}
<div className="create-session-section">
<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>
)}
<p className="muted">
Use the floating button (bottom-right) to start a tool in any
workspace.
</p>
</div>
{/* Dirty Delete Confirmation Modal */}
+54
View File
@@ -5615,3 +5615,57 @@ a:active,
.tool-starter-header h4 {
margin: 0;
}
/* ─── Floating Action Button ─── */
.start-tool-fab {
position: fixed;
bottom: 2rem;
right: 2rem;
z-index: 100;
width: 3.5rem;
height: 3.5rem;
border-radius: 50%;
background: var(--primary);
color: var(--primary-fg);
border: none;
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.25);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: transform 0.15s ease, box-shadow 0.15s ease;
}
.start-tool-fab:hover {
transform: scale(1.08);
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.3);
}
.start-tool-fab:active {
transform: scale(0.95);
}
.start-tool-modal {
max-width: 480px;
width: 90vw;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--space-3);
}
.modal-header h3 {
margin: 0;
}
@media (max-width: 640px) {
.start-tool-fab {
bottom: 5rem; /* above mobile tab bar */
right: 1rem;
width: 3rem;
height: 3rem;
}
}