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:
@@ -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))
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
Reference in New Issue
Block a user