From 280a6ff2fa7d4bf199375d50d0b6d09e660fd6e2 Mon Sep 17 00:00:00 2001 From: Alex Blank Date: Mon, 1 Jun 2026 22:09:49 +0200 Subject: [PATCH] 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) --- apps/web/src/components/app-shell.tsx | 2 + apps/web/src/components/start-tool-fab.tsx | 123 +++++++++++++++++++++ apps/web/src/pages/dashboard.tsx | 99 ++--------------- apps/web/src/pages/sessions.tsx | 97 +--------------- apps/web/src/styles.css | 54 +++++++++ 5 files changed, 194 insertions(+), 181 deletions(-) create mode 100644 apps/web/src/components/start-tool-fab.tsx diff --git a/apps/web/src/components/app-shell.tsx b/apps/web/src/components/app-shell.tsx index 3d924b6..54aee87 100644 --- a/apps/web/src/components/app-shell.tsx +++ b/apps/web/src/components/app-shell.tsx @@ -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 = () => { } /> )} + diff --git a/apps/web/src/components/start-tool-fab.tsx b/apps/web/src/components/start-tool-fab.tsx new file mode 100644 index 0000000..4741fb7 --- /dev/null +++ b/apps/web/src/components/start-tool-fab.tsx @@ -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([]); + const [workspacesLoading, setWorkspacesLoading] = useState(false); + const [selectedWorkspace, setSelectedWorkspace] = useState( + 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 ( + <> + + + {open && ( +
+
e.stopPropagation()} + > +
+

Start Tool

+ +
+ + {workspacesLoading ? ( +

Loading workspaces...

+ ) : workspaces.length === 0 ? ( +

+ No workspaces yet.{" "} + Create a workspace first. +

+ ) : !selectedWorkspace ? ( +
+ + +
+ ) : ( + <> +
+

+ {selectedWorkspace.project_name} /{" "} + {selectedWorkspace.repo_name} /{" "} + {selectedWorkspace.name} +

+ +
+ setSelectedWorkspace(null)} + /> + + )} +
+
+ )} + + ); +} diff --git a/apps/web/src/pages/dashboard.tsx b/apps/web/src/pages/dashboard.tsx index b511002..aafee58 100644 --- a/apps/web/src/pages/dashboard.tsx +++ b/apps/web/src/pages/dashboard.tsx @@ -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("loading"); const [summary, setSummary] = useState(null); const [sessions, setSessions] = useState([]); - const [projects, setProjects] = useState([]); - const [repositories, setRepositories] = useState([]); - const [toolTypes, setToolTypes] = useState([]); - const [selectedProject, setSelectedProject] = useState(""); const [tunnelHealth, setTunnelHealth] = useState< Record >({}); @@ -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 (
@@ -245,59 +205,20 @@ export const HomePage = () => {
-

Available projects

-

{projects.length}

+

Workspaces

+

Quick access

- {projects.length === 0 ? ( - - ) : ( -
- {projects.map((project) => ( -
-
-

{project.name}

- {project.description && ( -

{project.description}

- )} -
- -
- ))} -
- )} -
- -
-
-
-

Quick create

-

Start a session

-
-
- setSelectedProject(projectId)} - onSuccess={handleCreateSuccess} - /> +

+ Use the floating button to start a tool in any workspace. +

{safeSessions.filter((s) => ["stopped", "error"].includes(s.status)) diff --git a/apps/web/src/pages/sessions.tsx b/apps/web/src/pages/sessions.tsx index ed50ef2..2442249 100644 --- a/apps/web/src/pages/sessions.tsx +++ b/apps/web/src/pages/sessions.tsx @@ -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([]); const [lastSessionId, setLastSessionId] = useState(null); - const [workspaces, setWorkspaces] = useState([]); - const [workspacesLoading, setWorkspacesLoading] = useState(true); - const [selectedWorkspace, setSelectedWorkspace] = useState( - null, - ); - const [tunnelHealth, setTunnelHealth] = useState< Record >({}); @@ -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 (
@@ -173,71 +144,13 @@ export const SessionsPage = () => { />
- {/* Create Session — workspace-first */} + {/* Create Session — use floating button */}

Start New Tool

- {workspacesLoading ? ( -

Loading workspaces...

- ) : workspaces.length === 0 ? ( -

- No workspaces yet.{" "} - Create a workspace first. -

- ) : ( -
- {!selectedWorkspace ? ( -
- - -
- ) : ( -
-
-

- {selectedWorkspace.project_name} /{" "} - {selectedWorkspace.repo_name} /{" "} - {selectedWorkspace.name} -

- -
- setSelectedWorkspace(null)} - /> -
- )} -
- )} +

+ Use the floating button (bottom-right) to start a tool in any + workspace. +

{/* Dirty Delete Confirmation Modal */} diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index 8428344..1f9171f 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -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; + } +}