18634387c7
- Move Config Profiles from settings to top-level navigation - Implement split-pane layout: profile list on left, editor on right - Add project and tool type dropdowns with live data - Keep form open after save with success feedback - Add sticky save bar at bottom of editor - Remove Config Profiles tab from Settings page OpenSpec: add-config-profiles
159 lines
4.9 KiB
TypeScript
159 lines
4.9 KiB
TypeScript
import { useCallback, useEffect, useState } from "react";
|
|
import { Link, NavLink, Outlet, useLocation } from "react-router-dom";
|
|
|
|
import { getUserSessions } from "../api/sessions";
|
|
import type { Session } from "../api/sessions";
|
|
import { useTheme } from "../hooks/use-theme";
|
|
import { useAuth } from "../state/auth";
|
|
import { useSessions } from "../state/sessions";
|
|
import { useMobileViewport } from "../hooks/use-mobile-viewport";
|
|
import { Icon } from "./icon";
|
|
import type { IconName } from "../utils/icons";
|
|
|
|
const NAV_ITEMS: { to: string; label: string; icon: IconName; badge?: "sessions" }[] = [
|
|
{ to: "/", label: "Home", icon: "dashboard" },
|
|
{ to: "/sessions", label: "Sessions", icon: "terminal", badge: "sessions" },
|
|
{ to: "/projects", label: "Projects", icon: "projects" },
|
|
{ to: "/tool-workshop", label: "Tool Workshop", icon: "settings" },
|
|
{ to: "/config-profiles", label: "Config Profiles", icon: "folder" },
|
|
{ to: "/settings", label: "Settings", icon: "settings" }
|
|
];
|
|
|
|
const SessionItem = ({ session }: { session: Session }) => {
|
|
const isRunning = session.status === "running";
|
|
|
|
return (
|
|
<a
|
|
href={session.url ?? `/projects/${session.project_id}`}
|
|
target={session.url ? "_blank" : undefined}
|
|
rel={session.url ? "noopener noreferrer" : undefined}
|
|
className="nav-item session-item"
|
|
title={`${session.display_name} (${session.status})`}
|
|
>
|
|
<span className={`session-status ${isRunning ? "running" : ""}`} />
|
|
<Icon name={session.tool_icon as IconName} size="sm" />
|
|
<span className="session-name">{session.display_name}</span>
|
|
</a>
|
|
);
|
|
};
|
|
|
|
export const AppShell = () => {
|
|
useTheme();
|
|
const { user, logout } = useAuth();
|
|
const { sessions, setAllSessions } = useSessions();
|
|
const location = useLocation();
|
|
const isMobile = useMobileViewport();
|
|
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
|
|
|
const isMobileTerminal = isMobile && location.pathname.includes("/instances/") && location.pathname.includes("/terminal");
|
|
|
|
const loadSessions = useCallback(async () => {
|
|
try {
|
|
const data = await getUserSessions();
|
|
setAllSessions(data);
|
|
} catch {
|
|
// Silently fail - sessions are optional
|
|
}
|
|
}, [setAllSessions]);
|
|
|
|
useEffect(() => {
|
|
void loadSessions();
|
|
// Poll every 10 seconds
|
|
const interval = setInterval(() => {
|
|
void loadSessions();
|
|
}, 10000);
|
|
return () => clearInterval(interval);
|
|
}, [loadSessions]);
|
|
|
|
// Close mobile menu on route change
|
|
useEffect(() => {
|
|
setMobileMenuOpen(false);
|
|
}, [location.pathname]);
|
|
|
|
if (isMobileTerminal) {
|
|
return (
|
|
<div className="shell mobile-terminal-shell">
|
|
<Outlet />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="shell">
|
|
<header className="shell-header">
|
|
<Link className="brand" to="/">
|
|
Headquarter
|
|
</Link>
|
|
<div className="header-actions">
|
|
<Link className="user-chip" to="/profile">
|
|
{user?.name ?? "User"}
|
|
</Link>
|
|
<button
|
|
className="ghost-button"
|
|
onClick={() => {
|
|
void logout();
|
|
}}
|
|
type="button"
|
|
>
|
|
<Icon name="logout" size="sm" />
|
|
Logout
|
|
</button>
|
|
</div>
|
|
</header>
|
|
|
|
<div className="shell-body">
|
|
<aside className={`shell-nav ${mobileMenuOpen ? "mobile-open" : ""}`} aria-label="Primary navigation">
|
|
{isMobile && (
|
|
<button
|
|
className="mobile-menu-close"
|
|
onClick={() => setMobileMenuOpen(false)}
|
|
type="button"
|
|
aria-label="Close menu"
|
|
>
|
|
<Icon name="close" size="sm" />
|
|
</button>
|
|
)}
|
|
{NAV_ITEMS.map((item) => {
|
|
const activeCount = sessions.filter((s) => s.status === "running").length;
|
|
return (
|
|
<NavLink
|
|
key={item.to}
|
|
to={item.to}
|
|
className={({ isActive }) => (isActive ? "nav-item nav-item-active" : "nav-item")}
|
|
end={item.to === "/"}
|
|
>
|
|
<Icon name={item.icon} size="sm" />
|
|
{item.label}
|
|
{item.badge === "sessions" && activeCount > 0 && (
|
|
<span className="nav-badge">{activeCount}</span>
|
|
)}
|
|
</NavLink>
|
|
);
|
|
})}
|
|
|
|
{sessions.length > 0 && (
|
|
<>
|
|
<div className="nav-divider" />
|
|
<div className="nav-section-title">Live sessions</div>
|
|
{sessions.map((session) => (
|
|
<SessionItem key={session.id} session={session} />
|
|
))}
|
|
</>
|
|
)}
|
|
</aside>
|
|
|
|
{isMobile && mobileMenuOpen && (
|
|
<div
|
|
className="mobile-menu-overlay"
|
|
onClick={() => setMobileMenuOpen(false)}
|
|
/>
|
|
)}
|
|
|
|
<main className="shell-content">
|
|
<Outlet />
|
|
</main>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|