import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useNavigate, useParams } from "@tanstack/react-router"; import { GitBranch, LayoutDashboard, PanelRightClose, PanelRightOpen, Square } from "lucide-react"; import { useState } from "react"; import { findProjectOrchestrator, isOrchestratorSession, sessionIsActive, workerDisplayStatus, type WorkerDisplayStatus, type WorkspaceSession, } from "../types/workspace"; import { useWorkspaceQuery, workspaceQueryKey } from "../hooks/useWorkspaceQuery"; import { apiClient, apiErrorMessage } from "../lib/api-client"; import { spawnOrchestrator } from "../lib/spawn-orchestrator"; import { captureRendererEvent, captureRendererException } from "../lib/telemetry"; import { useUiStore } from "../stores/ui-store"; import { OrchestratorIcon } from "./icons"; import { cn } from "../lib/utils"; const isMac = typeof navigator !== "undefined" && /Mac|iPod|iPhone|iPad/.test(navigator.userAgent); const dragStyle = isMac ? ({ WebkitAppRegion: "drag" } as React.CSSProperties) : undefined; const noDragStyle = isMac ? ({ WebkitAppRegion: "no-drag" } as React.CSSProperties) : undefined; // Session status → pill tone, mirroring agent-orchestrator's StatusBadge // (working=orange & breathing, input=amber, fail=red, ready=green, done=neutral). // Tones are theme vars so the pill tracks the light/dark status palettes. const STATUS_PILL: Record = { working: { label: "Working", tone: "var(--orange)", breathe: true }, needs_you: { label: "Needs input", tone: "var(--amber)", breathe: false }, ci_failed: { label: "CI failed", tone: "var(--red)", breathe: false }, mergeable: { label: "Ready", tone: "var(--green)", breathe: false }, done: { label: "Done", tone: "var(--fg-muted)", breathe: false }, }; // The one app topbar (.dashboard-app-header), rendered by the shell layout // across the full window width — above both the sidebar and the route outlet — // so the crumb and actions sit at identical offsets on every screen and the // macOS traffic lights + TitlebarNav cluster live in its left inset // (.is-under-titlebar-nav pads past them). The // variant is derived from the route, not props: a sessionId in the URL swaps // the lead to the session identity (orchestrator crumb + mode badge, or worker // branch + status pill) and the actions to board/orchestrator + inspector // controls (orchestrators open the Kanban board; workers open their orchestrator); // otherwise it's the dashboard crumb plus the Orchestrator launcher when a // project is in scope. Merges the old DashboardTopbar/Topbar pair — // agent-orchestrator keeps those as two components aligned only by CSS. export function ShellTopbar() { const navigate = useNavigate(); const queryClient = useQueryClient(); const params = useParams({ strict: false }) as { projectId?: string; sessionId?: string }; const isInspectorOpen = useUiStore((state) => state.isInspectorOpen); const toggleInspector = useUiStore((state) => state.toggleInspector); const [isSpawning, setIsSpawning] = useState(false); const all = useWorkspaceQuery().data ?? []; const session = params.sessionId ? all.flatMap((workspace) => workspace.sessions).find((s) => s.id === params.sessionId) : undefined; const isSessionRoute = Boolean(params.sessionId); const isOrchestrator = session ? isOrchestratorSession(session) : false; // Project in scope: the session's workspace wins over the route param so the // cross-project /sessions/$sessionId route still resolves a crumb. A // projectId that no longer resolves (stale route after the project was // removed, or data still loading) shows an empty crumb — never the raw // route slug. "agent-orchestrator" is the root-board crumb only. const projectId = session?.workspaceId ?? params.projectId; const project = projectId ? all.find((workspace) => workspace.id === projectId) : undefined; const projectLabel = project?.name ?? session?.workspaceName ?? (projectId ? "" : "agent-orchestrator"); const orchestrator = projectId ? findProjectOrchestrator(all, projectId) : undefined; const openBoard = () => projectId ? void navigate({ to: "/projects/$projectId", params: { projectId } }) : void navigate({ to: "/" }); const openOrchestrator = async () => { if (!projectId) return; void captureRendererEvent("ao.renderer.orchestrator_open_requested", { project_id: projectId }); if (orchestrator) { void navigate({ to: "/projects/$projectId/sessions/$sessionId", params: { projectId, sessionId: orchestrator.id }, }); return; } setIsSpawning(true); try { const sessionId = await spawnOrchestrator(projectId); await queryClient.invalidateQueries({ queryKey: workspaceQueryKey }); void navigate({ to: "/projects/$projectId/sessions/$sessionId", params: { projectId, sessionId }, }); } catch (error) { void captureRendererException(error, { source: "orchestrator-open", project_id: projectId }); console.error("Failed to spawn orchestrator:", error); } finally { setIsSpawning(false); } }; return (
{isSessionRoute && isOrchestrator ? (
{projectLabel}
) : isSessionRoute ? (
{session ? : null}
) : (
{projectLabel}
)}
{isSessionRoute ? ( <> {isOrchestrator ? ( ) : ( )} {/* Kill control sits beside the orchestrator link for active workers — moved here from the inspector's Summary "Danger zone". */} {!isOrchestrator && session && sessionIsActive(session) ? : null} {/* Inspector collapse (worker sessions only — orchestrators have no rail). */} {!isOrchestrator && ( )} ) : projectId ? ( orchestrator ? ( ) : ( ) ) : null}
); } // Compact kill control for the topbar actions row. Stop a running worker and // tear down its runtime/workspace. Kill is irreversible from the UI, so the // button arms a one-step confirmation before firing POST /sessions/{id}/kill, // then invalidates the workspace query so the session drops into the board's // terminated group. export function TopbarKillButton({ session }: { session: WorkspaceSession }) { const queryClient = useQueryClient(); const [confirming, setConfirming] = useState(false); const [error, setError] = useState(null); const kill = useMutation({ mutationFn: async () => { const { error: apiError } = await apiClient.POST("/api/v1/sessions/{sessionId}/kill", { params: { path: { sessionId: session.id } }, }); if (apiError) throw new Error(apiErrorMessage(apiError)); }, onSuccess: () => { setConfirming(false); void queryClient.invalidateQueries({ queryKey: workspaceQueryKey }); }, onError: (e) => setError(e instanceof Error ? e.message : "Kill failed"), }); if (confirming) { return (
{error ? ( {error} ) : null}
); } return ( ); } // StatusBadge --pill: tinted bordered pill (inset 25%-tone hairline + 7%-tone // fill) with a 6px dot that breathes while the agent is working. function SessionStatusPill({ session }: { session: WorkspaceSession }) { const { label, tone, breathe } = STATUS_PILL[workerDisplayStatus(session)]; return ( {label} ); }