Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 19 additions & 5 deletions apps/web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,8 @@ import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Frame, FrameDescription, FrameHeader, FramePanel, FrameTitle } from "@/components/reui/frame";
import { ArchitecturesDashboard } from "@/components/architecture/ArchitecturesDashboard";
import { isArchitecturePath } from "@/components/architecture/architecture-route";
import type { ArchitectureNavigationGuard } from "@/components/architecture/useArchitectureNavigationGuard";
import { OrganizationsDashboard } from "@/components/organization/OrganizationsDashboard";
import { ArchitectureTargetsDashboard } from "@/components/target/ArchitectureTargetsDashboard";
import { LibrariesDashboard } from "@/components/library/LibrariesDashboard";
Expand Down Expand Up @@ -144,7 +146,6 @@ interface CatalogLocation {
bundle: string | null;
}

type ArchitectureNavigationGuard = (action: string) => boolean;
type RegistryLayout = "split" | "stack";
type MobileMenu = "more" | "account";
type RegistryFocus = { kind: "title" } | { kind: "row"; slug: string };
Expand Down Expand Up @@ -216,6 +217,8 @@ function RegistryContent({ client: registryClient }: { client: RegistryClient })
const architectureNavigationGuardRef = useRef<ArchitectureNavigationGuard | null>(null);
const restoringPopstateRef = useRef(false);
const [view, setView] = useState<AppView>(initialLocation.view);
// Routes that own URL state beyond the view (the architecture section) read it from here.
const [appUrl, setAppUrl] = useState(currentBrowserUrl);
const [session, setSession] = useState<WebSession | null>(() => readStoredSession());
// Bundle catalog, when the client and server provide it. A 404 from the
// catalog falls back to the flat Skills list for this session.
Expand Down Expand Up @@ -321,13 +324,20 @@ function RegistryContent({ client: registryClient }: { client: RegistryClient })
window.history.replaceState(appHistoryState(historyIndexRef.current), "", nextUrl);
currentLocationRef.current = appLocationFromWindow();
currentUrlRef.current = currentBrowserUrl();
setAppUrl(currentUrlRef.current);
};

const pushAppHistory = (nextUrl: string) => {
historyIndexRef.current += 1;
window.history.pushState(appHistoryState(historyIndexRef.current), "", nextUrl);
currentLocationRef.current = appLocationFromWindow();
currentUrlRef.current = currentBrowserUrl();
setAppUrl(currentUrlRef.current);
};

const navigateArchitectures = (nextUrl: string, mode: "push" | "replace") => {
if (mode === "push") pushAppHistory(nextUrl);
else replaceAppHistory(nextUrl);
};

const registerArchitectureNavigationGuard = useCallback((guard: ArchitectureNavigationGuard | null) => {
Expand Down Expand Up @@ -371,18 +381,21 @@ function RegistryContent({ client: registryClient }: { client: RegistryClient })
}
currentLocationRef.current = next;
currentUrlRef.current = currentBrowserUrl();
setAppUrl(currentUrlRef.current);
return;
}

if (previous.view === "architectures" && next.view !== "architectures") {
// The architecture guard sees every move, including moves inside the
// section; it prompts only when the destination would discard a draft.
if (previous.view === "architectures") {
const guard = architectureNavigationGuardRef.current;
if (guard) {
const action = nextHistoryIndex !== null && nextHistoryIndex < historyIndexRef.current
? "go back"
: nextHistoryIndex !== null && nextHistoryIndex > historyIndexRef.current
? "go forward"
: "navigate away";
if (!guard(action)) {
if (!guard(action, currentBrowserUrl())) {
const restoreDelta = nextHistoryIndex === null
? null
: historyIndexRef.current - nextHistoryIndex;
Expand All @@ -407,6 +420,7 @@ function RegistryContent({ client: registryClient }: { client: RegistryClient })
}
currentLocationRef.current = next;
currentUrlRef.current = currentBrowserUrl();
setAppUrl(currentUrlRef.current);
searchSelectionQuery.current = null;
setView(next.view);
// Clear eagerly only when the detail effects are certain to reload. An
Expand Down Expand Up @@ -1247,7 +1261,7 @@ function RegistryContent({ client: registryClient }: { client: RegistryClient })
) : activeView === "teams" && session ? (
<TeamsDashboard client={registryClient} session={session} />
) : activeView === "architectures" && session ? (
<ArchitecturesDashboard client={registryClient} onNavigationGuardChange={registerArchitectureNavigationGuard} session={session} />
<ArchitecturesDashboard client={registryClient} onNavigate={navigateArchitectures} onNavigationGuardChange={registerArchitectureNavigationGuard} session={session} url={appUrl} />
) : activeView === "organizations" && session ? (
<OrganizationsDashboard client={registryClient} session={session} />
) : activeView === "targets" && session ? (
Expand Down Expand Up @@ -6071,7 +6085,7 @@ function initialViewFromPath(pathname: string): AppView {
if (pathname === "/submit") {
return "submit";
}
if (pathname === "/architectures") {
if (isArchitecturePath(pathname)) {
return "architectures";
}
if (pathname === "/organizations") {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { CircleAlert } from "lucide-react";
import type { ArchitectureEnvironment, ArchitectureProfile } from "../../api.js";
import type { OrganizationChoice } from "./architecture-dashboard-types.js";

/**
* Preview context for the saved overview and the Workbench draft preview.
* Only the visible surface renders it, so each labelled control is unique.
*/
export function ArchitectureContextSelectors({
profiles,
environments,
selectedProfileId,
selectedEnvironmentId,
allowedOrganizationIds,
organizationChoices,
organizationOnly,
selectedOrganizationId,
onProfileChange,
onEnvironmentChange,
onOrganizationChange,
}: {
profiles: ArchitectureProfile[];
environments: ArchitectureEnvironment[];
selectedProfileId: string;
selectedEnvironmentId: string;
allowedOrganizationIds: string[];
organizationChoices: OrganizationChoice[];
organizationOnly: boolean;
selectedOrganizationId: string;
onProfileChange: (value: string) => void;
onEnvironmentChange: (value: string) => void;
onOrganizationChange: (value: string) => void;
}) {
if (!(profiles.length > 0 && environments.length > 0) && !(organizationOnly && allowedOrganizationIds.length > 0)) return null;
return (
<div className="architecture-context-bar" role="group" aria-label="Preview context">
<span className="architecture-context-label" aria-hidden="true">Preview as</span>
{profiles.length > 0 && (
<select aria-label="Preview profile" onChange={(event) => onProfileChange(event.target.value)} value={selectedProfileId}>
{profiles.map((profile) => <option key={profile.id} value={profile.id}>{profile.name} · {profile.scope}</option>)}
</select>
)}
{environments.length > 0 && (
<select aria-label="Preview environment" onChange={(event) => onEnvironmentChange(event.target.value)} value={selectedEnvironmentId}>
{environments.map((environment) => <option key={environment.id} value={environment.id}>{environment.name}</option>)}
</select>
)}
{allowedOrganizationIds.length > 0 && (
<select aria-label="Preview organization" onChange={(event) => onOrganizationChange(event.target.value)} value={selectedOrganizationId}>
{!organizationOnly && <option value="">Owner/team context</option>}
{organizationOnly && <option value="">Choose an organization…</option>}
{organizationChoices.map((organization) => <option key={organization.id} value={organization.id}>{organization.name} · {organization.slug}</option>)}
</select>
)}
{organizationOnly && !selectedOrganizationId && (
<span className="architecture-context-note" role="status"><CircleAlert size={15} aria-hidden="true" /> Select one authorized organization to preview this shared architecture.</span>
)}
</div>
);
}
Loading
Loading