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:
@@ -14,6 +14,7 @@ import { EventToastBridge } from "./event-toast-bridge";
|
|||||||
import { NotificationCenter } from "./notification-center";
|
import { NotificationCenter } from "./notification-center";
|
||||||
import { Icon } from "./icon";
|
import { Icon } from "./icon";
|
||||||
import { MobileNav } from "./mobile-nav";
|
import { MobileNav } from "./mobile-nav";
|
||||||
|
import { StartToolFAB } from "./start-tool-fab";
|
||||||
import type { IconName } from "../utils/icons";
|
import type { IconName } from "../utils/icons";
|
||||||
|
|
||||||
const NAV_ITEMS: {
|
const NAV_ITEMS: {
|
||||||
@@ -170,6 +171,7 @@ export const AppShell = () => {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
<StartToolFAB />
|
||||||
</div>
|
</div>
|
||||||
</NotificationProvider>
|
</NotificationProvider>
|
||||||
</ToastProvider>
|
</ToastProvider>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,17 +8,10 @@ import {
|
|||||||
type Session as SessionApi,
|
type Session as SessionApi,
|
||||||
type InstanceHealth,
|
type InstanceHealth,
|
||||||
} from "../api/sessions";
|
} 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 {
|
import {
|
||||||
EmptyState,
|
|
||||||
ErrorState,
|
ErrorState,
|
||||||
LoadingState,
|
LoadingState,
|
||||||
} from "../components/data-states";
|
} from "../components/data-states";
|
||||||
import { CreateSessionForm } from "../components/create-session-form";
|
|
||||||
import { SessionList } from "../components/session-list";
|
import { SessionList } from "../components/session-list";
|
||||||
import { useInstanceActions } from "../hooks/use-instance-actions";
|
import { useInstanceActions } from "../hooks/use-instance-actions";
|
||||||
|
|
||||||
@@ -37,10 +30,6 @@ export const HomePage = () => {
|
|||||||
const [status, setStatus] = useState<HomeStatus>("loading");
|
const [status, setStatus] = useState<HomeStatus>("loading");
|
||||||
const [summary, setSummary] = useState<DashboardSummary | null>(null);
|
const [summary, setSummary] = useState<DashboardSummary | null>(null);
|
||||||
const [sessions, setSessions] = useState<SessionView[]>([]);
|
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<
|
const [tunnelHealth, setTunnelHealth] = useState<
|
||||||
Record<string, InstanceHealth>
|
Record<string, InstanceHealth>
|
||||||
>({});
|
>({});
|
||||||
@@ -49,17 +38,12 @@ export const HomePage = () => {
|
|||||||
const loadHome = useCallback(async () => {
|
const loadHome = useCallback(async () => {
|
||||||
setStatus("loading");
|
setStatus("loading");
|
||||||
try {
|
try {
|
||||||
const [dashboard, sessionData, projectData, toolTypeData] =
|
const [dashboard, sessionData] = await Promise.all([
|
||||||
await Promise.all([
|
|
||||||
getDashboardSummary(),
|
getDashboardSummary(),
|
||||||
getUserSessions(),
|
getUserSessions(),
|
||||||
listProjects(),
|
|
||||||
listToolTypes(),
|
|
||||||
]);
|
]);
|
||||||
setSummary(dashboard);
|
setSummary(dashboard);
|
||||||
setSessions(sessionData as SessionView[]);
|
setSessions(sessionData as SessionView[]);
|
||||||
setProjects(projectData);
|
|
||||||
setToolTypes(toolTypeData);
|
|
||||||
setStatus("ready");
|
setStatus("ready");
|
||||||
} catch {
|
} catch {
|
||||||
setStatus("error");
|
setStatus("error");
|
||||||
@@ -122,24 +106,6 @@ export const HomePage = () => {
|
|||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [safeSessions]);
|
}, [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(
|
const activeSessions = useMemo(
|
||||||
() =>
|
() =>
|
||||||
safeSessions.filter((session) =>
|
safeSessions.filter((session) =>
|
||||||
@@ -148,12 +114,6 @@ export const HomePage = () => {
|
|||||||
[safeSessions],
|
[safeSessions],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleCreateSuccess = async (instance: { id: string }) => {
|
|
||||||
await updateUserConfig({ last_session_id: instance.id });
|
|
||||||
setSelectedProject("");
|
|
||||||
await loadHome();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="stack home-page">
|
<section className="stack home-page">
|
||||||
<header className="home-hero card">
|
<header className="home-hero card">
|
||||||
@@ -245,59 +205,20 @@ export const HomePage = () => {
|
|||||||
<section className="card stack home-section">
|
<section className="card stack home-section">
|
||||||
<div className="page-header">
|
<div className="page-header">
|
||||||
<div>
|
<div>
|
||||||
<p className="eyebrow">Available projects</p>
|
<p className="eyebrow">Workspaces</p>
|
||||||
<h2>{projects.length}</h2>
|
<h2>Quick access</h2>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
className="secondary-button"
|
className="secondary-button"
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => navigate("/projects")}
|
onClick={() => navigate("/workspaces")}
|
||||||
>
|
>
|
||||||
View all
|
View all
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{projects.length === 0 ? (
|
<p className="muted">
|
||||||
<EmptyState message="No projects yet." />
|
Use the floating button to start a tool in any workspace.
|
||||||
) : (
|
</p>
|
||||||
<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}
|
|
||||||
/>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{safeSessions.filter((s) => ["stopped", "error"].includes(s.status))
|
{safeSessions.filter((s) => ["stopped", "error"].includes(s.status))
|
||||||
|
|||||||
@@ -6,14 +6,11 @@ import {
|
|||||||
checkInstanceHealth,
|
checkInstanceHealth,
|
||||||
} from "../api/sessions";
|
} from "../api/sessions";
|
||||||
import { getUserConfig } from "../api/settings";
|
import { getUserConfig } from "../api/settings";
|
||||||
import { listAllWorkspaces } from "../api/workspaces";
|
|
||||||
import { ErrorState, LoadingState } from "../components/data-states";
|
import { ErrorState, LoadingState } from "../components/data-states";
|
||||||
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";
|
||||||
|
|
||||||
@@ -22,12 +19,6 @@ 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 [workspaces, setWorkspaces] = useState<Workspace[]>([]);
|
|
||||||
const [workspacesLoading, setWorkspacesLoading] = useState(true);
|
|
||||||
const [selectedWorkspace, setSelectedWorkspace] = useState<Workspace | null>(
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
|
|
||||||
const [tunnelHealth, setTunnelHealth] = useState<
|
const [tunnelHealth, setTunnelHealth] = useState<
|
||||||
Record<string, InstanceHealth>
|
Record<string, InstanceHealth>
|
||||||
>({});
|
>({});
|
||||||
@@ -51,21 +42,6 @@ export const SessionsPage = () => {
|
|||||||
void loadSessions();
|
void loadSessions();
|
||||||
}, [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 {
|
const {
|
||||||
loadingSessionId,
|
loadingSessionId,
|
||||||
dirtyDeleteSession,
|
dirtyDeleteSession,
|
||||||
@@ -123,11 +99,6 @@ export const SessionsPage = () => {
|
|||||||
|
|
||||||
const lastSession = sessions.find((s) => s.id === lastSessionId) ?? null;
|
const lastSession = sessions.find((s) => s.id === lastSessionId) ?? null;
|
||||||
|
|
||||||
const handleToolStarted = async () => {
|
|
||||||
setSelectedWorkspace(null);
|
|
||||||
await loadSessions();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="stack sessions-page">
|
<section className="stack sessions-page">
|
||||||
<div className="page-header">
|
<div className="page-header">
|
||||||
@@ -173,71 +144,13 @@ export const SessionsPage = () => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Create Session — workspace-first */}
|
{/* Create Session — use floating button */}
|
||||||
<div className="create-session-section">
|
<div className="create-session-section">
|
||||||
<h2>Start New Tool</h2>
|
<h2>Start New Tool</h2>
|
||||||
{workspacesLoading ? (
|
|
||||||
<p className="muted">Loading workspaces...</p>
|
|
||||||
) : workspaces.length === 0 ? (
|
|
||||||
<p className="muted">
|
<p className="muted">
|
||||||
No workspaces yet.{" "}
|
Use the floating button (bottom-right) to start a tool in any
|
||||||
<a href="/workspaces">Create a workspace first</a>.
|
workspace.
|
||||||
</p>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Dirty Delete Confirmation Modal */}
|
{/* Dirty Delete Confirmation Modal */}
|
||||||
|
|||||||
@@ -5615,3 +5615,57 @@ a:active,
|
|||||||
.tool-starter-header h4 {
|
.tool-starter-header h4 {
|
||||||
margin: 0;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user