From b84759735b51b579650920423069179669ad4e2b Mon Sep 17 00:00:00 2001 From: Jarel Remick Date: Tue, 29 Sep 2026 08:24:22 +1000 Subject: [PATCH 01/12] Open architecture editing in a full-page Workbench --- apps/web/src/App.tsx | 24 +- .../ArchitectureContextSelectors.tsx | 60 + .../ArchitectureDashboardDetailPanel.tsx | 342 ++--- .../ArchitectureDashboardEditorPanels.tsx | 59 +- .../ArchitectureDashboardPreviewPanel.tsx | 185 +-- .../architecture/ArchitectureTabs.tsx | 73 + .../ArchitectureWorkbenchPanel.tsx | 179 +++ .../architecture/ArchitecturesDashboard.tsx | 748 ++++++---- .../architecture/architecture-route.ts | 89 ++ .../editor/ArchitectureEditor.tsx | 287 +++- .../editor/architecture-editor.css | 293 ++-- .../components/architecture/editor/types.ts | 12 + .../useArchitectureNavigationGuard.ts | 29 +- .../control-plane/control-plane-surface.css | 128 +- apps/web/test/App.test.tsx | 45 +- apps/web/test/e2e/full-stack.spec.ts | 4 +- .../mocked-architecture-control-plane.spec.ts | 59 +- .../e2e/mocked-architecture-workbench.spec.ts | 1253 +++++++++++++++++ docs/ARCHITECTURE_WORKBENCH.md | 44 + 19 files changed, 3149 insertions(+), 764 deletions(-) create mode 100644 apps/web/src/components/architecture/ArchitectureContextSelectors.tsx create mode 100644 apps/web/src/components/architecture/ArchitectureTabs.tsx create mode 100644 apps/web/src/components/architecture/ArchitectureWorkbenchPanel.tsx create mode 100644 apps/web/src/components/architecture/architecture-route.ts create mode 100644 apps/web/test/e2e/mocked-architecture-workbench.spec.ts create mode 100644 docs/ARCHITECTURE_WORKBENCH.md diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 17d0de65..74be2d13 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -63,6 +63,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"; @@ -139,7 +141,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 }; @@ -208,6 +209,8 @@ function RegistryContent({ client: registryClient }: { client: RegistryClient }) const architectureNavigationGuardRef = useRef(null); const restoringPopstateRef = useRef(false); const [view, setView] = useState(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(() => readStoredSession()); // Bundle catalog, when the client and server provide it. A 404 from the // catalog falls back to the flat registry for this session. @@ -312,6 +315,7 @@ 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) => { @@ -319,6 +323,12 @@ function RegistryContent({ client: registryClient }: { client: RegistryClient }) 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) => { @@ -362,10 +372,13 @@ 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 @@ -373,7 +386,7 @@ function RegistryContent({ client: registryClient }: { client: RegistryClient }) : nextHistoryIndex !== null && nextHistoryIndex > historyIndexRef.current ? "go forward" : "navigate away"; - if (!guard(action)) { + if (!guard(action, currentBrowserUrl())) { const restoreDelta = nextHistoryIndex === null ? null : historyIndexRef.current - nextHistoryIndex; @@ -398,6 +411,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 @@ -1228,7 +1242,7 @@ function RegistryContent({ client: registryClient }: { client: RegistryClient }) ) : activeView === "teams" && session ? ( ) : activeView === "architectures" && session ? ( - + ) : activeView === "organizations" && session ? ( ) : activeView === "targets" && session ? ( @@ -5755,7 +5769,7 @@ function initialViewFromPath(pathname: string): AppView { if (pathname === "/submit") { return "submit"; } - if (pathname === "/architectures") { + if (isArchitecturePath(pathname)) { return "architectures"; } if (pathname === "/organizations") { diff --git a/apps/web/src/components/architecture/ArchitectureContextSelectors.tsx b/apps/web/src/components/architecture/ArchitectureContextSelectors.tsx new file mode 100644 index 00000000..a8014162 --- /dev/null +++ b/apps/web/src/components/architecture/ArchitectureContextSelectors.tsx @@ -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 ( +
+ + {profiles.length > 0 && ( + + )} + {environments.length > 0 && ( + + )} + {allowedOrganizationIds.length > 0 && ( + + )} + {organizationOnly && !selectedOrganizationId && ( + + )} +
+ ); +} diff --git a/apps/web/src/components/architecture/ArchitectureDashboardDetailPanel.tsx b/apps/web/src/components/architecture/ArchitectureDashboardDetailPanel.tsx index 1c7025bd..1f021363 100644 --- a/apps/web/src/components/architecture/ArchitectureDashboardDetailPanel.tsx +++ b/apps/web/src/components/architecture/ArchitectureDashboardDetailPanel.tsx @@ -1,101 +1,82 @@ -import type { RefObject } from "react"; +import { useId, type MouseEvent, type ReactNode, type RefObject } from "react"; import { ArrowLeft, + ArrowRight, CircleAlert, - ShieldCheck, + SquarePen, } from "lucide-react"; -import type { ArchitectureSpecV1 } from "@myskills-app/core"; import { Button } from "@/components/ui/button"; import { ArchitectureOrganizationGrantsCard } from "./ArchitectureOrganizationGrantsCard.js"; import { ArchitecturePatternMigrationCard } from "./ArchitecturePatternMigrationCard.js"; import { - type ArchitectureDraftPreview, type ArchitectureDetail, - type ArchitectureEnvironment, type ArchitectureObservedFixture, type ArchitecturePattern, type ArchitecturePatternMigrationCreateResult, type ArchitecturePreview, - type ArchitectureProfile, type ArchitectureRevisionRecord, type ArchitectureSummary, type RegistryClient, } from "../../api.js"; import { architectureRevisionLabel, - bootstrapArchitectureSpec, patternLabel, revisionLabel, } from "./architecture-dashboard-helpers.js"; import { ArchitectureState, ArchitectureDetailLoading } from "./ArchitectureDashboardFeedback.js"; -import { ArchitecturePreviewPanel } from "./ArchitectureDashboardPreviewPanel.js"; import { - AddArchitectureRevisionCard, - ArchitectureEditorCard, - ObservedFixturePreviewCard, -} from "./ArchitectureDashboardEditorPanels.js"; + ArchitecturePlanBanner, + ArchitectureSkillsSection, + ArchitectureTechnicalDetails, + ArchitectureTopologySection, +} from "./ArchitectureDashboardPreviewPanel.js"; +import { ObservedFixturePreviewCard } from "./ArchitectureDashboardEditorPanels.js"; import { RevisionHistoryPanel } from "./ArchitectureDashboardHistoryPanel.js"; -import type { - ArchitectureEditorPreviewRequest, - ArchitectureEditorSaveRequest, - ArchitectureEditorStatus, - ArchitectureRegistryReleaseOption, - ArchitectureRegistrySkillOption, -} from "./editor/index.js"; -import type { - ArchitectureLoadState, - OrganizationChoice, -} from "./architecture-dashboard-types.js"; +import { ArchitectureTabList, ArchitectureTabPanel, type ArchitectureTab } from "./ArchitectureTabs.js"; +import type { ArchitectureLoadState } from "./architecture-dashboard-types.js"; +export type ArchitectureOverviewTab = "overview" | "skills" | "history" | "access"; + +export interface ArchitectureLauncher { + href: string; + label: string; + onClick: (event: MouseEvent) => void; + ref: RefObject; +} + +/** Saved-revision overview. Editing happens on the Workbench surface. */ export function ArchitectureDetailPanel({ - accessExpanded, - historyExpanded, - onAccessExpandedChange, - onHistoryExpandedChange, + tab, + onTabChange, architecture, detail, detailState, message, preview, - draftPreview, historyRevisionId, historyRevision, historyState, historyMessage, - editorSeed, - profiles, - environments, patterns, - selectedProfileId, - selectedEnvironmentId, - allowedOrganizationIds, - organizationChoices, organizationOnly, - selectedOrganizationId, - onProfileChange, - onEnvironmentChange, - onOrganizationChange, - onDraftPreview, - onDraftSave, - onDraftChange, + contextAvailable, + contextSelectors, + notices, + launcher, + hasUnsavedDraft, + fixtureKey, onHistorySelect, onUseRevisionAsDraft, - onSearchRegistrySkills, - onLoadRegistryReleases, onFixturePreview, onPatternMigrationCreated, client, onRetry, titleRef, - editorRef, onBack, }: { - accessExpanded: boolean; - historyExpanded: boolean; - onAccessExpandedChange: (open: boolean) => void; - onHistoryExpandedChange: (open: boolean) => void; + tab: ArchitectureOverviewTab; + onTabChange: (tab: ArchitectureOverviewTab) => void; titleRef?: RefObject; - editorRef?: RefObject; /** Shown in the stacked layout; returns to the list without unmounting the draft. */ onBack?: () => void; architecture: ArchitectureSummary | null; @@ -103,36 +84,27 @@ export function ArchitectureDetailPanel({ detailState: ArchitectureLoadState; message: string | null; preview: ArchitecturePreview | null; - draftPreview: ArchitectureDraftPreview | null; historyRevisionId: string | null; historyRevision: ArchitectureRevisionRecord | null; historyState: "idle" | "loading" | "error"; historyMessage: string | null; - editorSeed: { revisionId: string; spec: ArchitectureSpecV1 } | null; - profiles: ArchitectureProfile[]; - environments: ArchitectureEnvironment[]; patterns: ArchitecturePattern[]; - selectedProfileId: string; - selectedEnvironmentId: string; - allowedOrganizationIds: string[]; - organizationChoices: OrganizationChoice[]; organizationOnly: boolean; - selectedOrganizationId: string; - onProfileChange: (value: string) => void; - onEnvironmentChange: (value: string) => void; - onOrganizationChange: (value: string) => void; - onDraftPreview: (request: ArchitectureEditorPreviewRequest) => Promise; - onDraftSave: (request: ArchitectureEditorSaveRequest) => Promise; - onDraftChange: (status: ArchitectureEditorStatus) => void; + /** False when the saved revision declares no preview context yet. */ + contextAvailable: boolean; + contextSelectors: ReactNode; + notices?: ReactNode; + launcher: ArchitectureLauncher | null; + hasUnsavedDraft: boolean; + fixtureKey: string; onHistorySelect: (revisionId: string) => Promise; onUseRevisionAsDraft: (revision: ArchitectureRevisionRecord) => void; - onSearchRegistrySkills: (query: string) => Promise; - onLoadRegistryReleases: (skill: ArchitectureRegistrySkillOption) => Promise; onFixturePreview: (fixture: ArchitectureObservedFixture) => Promise; onPatternMigrationCreated: (result: ArchitecturePatternMigrationCreateResult) => void; client: RegistryClient; onRetry: () => void; }) { + const idPrefix = useId(); const back = onBack && ( + )} + {contextSelectors} -
- {(profiles.length > 0 && environments.length > 0) || (organizationOnly && allowedOrganizationIds.length > 0) ? ( -
- {profiles.length > 0 && } - {environments.length > 0 && } - {allowedOrganizationIds.length > 0 && ( - - )} - - {organizationOnly && !selectedOrganizationId && ( - - )} -
- ) : ( -
- )} + {hasUnsavedDraft && ( +

+ )} + {notices} -
+ +
+ {detailState === "loading" && } {detailState !== "loading" && message && ( )} - {detailState !== "loading" && !message && activePreview && ( - <> - {draftPreview &&
Unsaved draft preview · noncanonicalThe API compiled this editor draft. The latest saved revision is unchanged until you save it.
} - - + {detailState !== "loading" && !message && detail && !contextAvailable && ( +
)} {detailState !== "loading" && !message && preview && ( - - )} -
- - {/* "Use as new draft" in the history below focuses this wrapper so the - replaced draft is not changed off-screen. */} -
- {detail && (detail.latestRevision || !readOnly) && ( - - )} - {detail && !readOnly && ( - - )} -
- - {/* Native disclosures keep closed content mounted, which preserves - grant drafts and the migration retry idempotency key. */} - {detail && ( -
onHistoryExpandedChange(event.currentTarget.open)}> - Revision history · {currentRevision ? `${revisionLabel(currentRevision)} is current` : "no saved revision yet"} -
- -
-
- )} - {detail && canManage && ( -
onAccessExpandedChange(event.currentTarget.open)}> - Access and migration -
- onRetry()} - /> - + + + } /> -
-
- )} + + )} + + + {preview + ? + :

Effective skills appear when the saved revision compiles for the selected context.

} +
+ + {detail ? ( + + ) :

Revision history is available after the architecture loads.

} +
+ {detail && canManage && ( + // Hidden tab panels stay mounted, which preserves grant drafts and the + // migration retry idempotency key. + + onRetry()} + /> + + + )} ); } diff --git a/apps/web/src/components/architecture/ArchitectureDashboardEditorPanels.tsx b/apps/web/src/components/architecture/ArchitectureDashboardEditorPanels.tsx index bbe9957c..224fc852 100644 --- a/apps/web/src/components/architecture/ArchitectureDashboardEditorPanels.tsx +++ b/apps/web/src/components/architecture/ArchitectureDashboardEditorPanels.tsx @@ -1,6 +1,5 @@ -import { useCallback, useState, type FormEvent } from "react"; +import { useCallback, useEffect, useRef, useState, type FormEvent, type ReactNode } from "react"; import type { ArchitectureSpecV1 } from "@myskills-app/core"; -import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { @@ -19,26 +18,38 @@ import { } from "../../api.js"; import { formatArchitectureSpec } from "./architecture-dashboard-helpers.js"; +/** + * One mounted draft session per architecture revision. The overview hides the + * Workbench instead of unmounting it, so the spec and the revision message + * survive moves between the two surfaces of the same architecture. + */ export function ArchitectureEditorCard({ detail, initialSpec, expectedRevisionId, - seededFromRevision, readOnly, + heading, + previewPanel, + advancedPanel, onPreview, onSave, onDraftChange, + onMessageDirtyChange, onSearchRegistrySkills, onLoadRegistryReleases, }: { detail: ArchitectureDetail; initialSpec?: ArchitectureSpecV1; expectedRevisionId?: string | null; - seededFromRevision?: string | null; readOnly: boolean; + heading?: ReactNode; + previewPanel?: ReactNode; + advancedPanel?: ReactNode; onPreview?: (request: ArchitectureEditorPreviewRequest) => Promise; onSave?: (request: ArchitectureEditorSaveRequest) => Promise; onDraftChange?: (status: ArchitectureEditorStatus) => void; + /** A message-only change is still unsaved work for the navigation guard. */ + onMessageDirtyChange?: (dirty: boolean) => void; onSearchRegistrySkills?: (query: string) => Promise; onLoadRegistryReleases?: (skill: ArchitectureRegistrySkillOption) => Promise; }) { @@ -46,40 +57,33 @@ export function ArchitectureEditorCard({ const handleEditorDraftChange = useCallback((_spec: unknown, status: ArchitectureEditorStatus) => { onDraftChange?.(status); }, [onDraftChange]); + const messageDirty = !readOnly && revisionMessage.trim().length > 0; + + useEffect(() => { + onMessageDirtyChange?.(messageDirty); + }, [messageDirty, onMessageDirtyChange]); + + // An unmounted session has nothing left to protect. + const statusCallbacks = useRef({ onDraftChange, onMessageDirtyChange }); + statusCallbacks.current = { onDraftChange, onMessageDirtyChange }; + useEffect(() => () => { + statusCallbacks.current.onDraftChange?.({ dirty: false, valid: true, validationIssues: [] }); + statusCallbacks.current.onMessageDirtyChange?.(false); + }, []); const effectiveSpec = initialSpec ?? detail.latestRevision?.spec; if (!effectiveSpec) { return null; } - const bootstrap = !detail.latestRevision; return (
-
-
-

{readOnly ? "Read-only workbench" : bootstrap ? "Bootstrap workbench" : "Draft workbench"}

-

{readOnly ? "Inspect this architecture" : bootstrap ? "Build the first revision" : seededFromRevision ? `Draft from revision ${seededFromRevision}` : "Edit the current revision"}

-
- {readOnly ? "Member view" : bootstrap ? "First revision" : "Local draft"} -
-

- {readOnly - ? "This team architecture is available for inspection. Only the owner can append an immutable revision." - : bootstrap - ? "Start from this local bootstrap shell, choose exact registry releases, then save one immutable first revision. Canvas positions are visual only and are not persisted." - : seededFromRevision - ? `This draft starts from immutable revision ${seededFromRevision}; saving appends a new revision against the latest concurrency token. Canvas positions are visual only and are not persisted.` - : "Changes stay in this browser until you preview or save them. Canvas positions are visual only and are not persisted."} -

- {!readOnly && ( - - )}
); diff --git a/apps/web/src/components/architecture/ArchitectureDashboardPreviewPanel.tsx b/apps/web/src/components/architecture/ArchitectureDashboardPreviewPanel.tsx index d281a87b..ba75ac26 100644 --- a/apps/web/src/components/architecture/ArchitectureDashboardPreviewPanel.tsx +++ b/apps/web/src/components/architecture/ArchitectureDashboardPreviewPanel.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useId, useState, type ReactNode } from "react"; import { canonicalArchitectureDiagramArtifactJson } from "@myskills-app/core"; import { AlertTriangle, @@ -14,7 +14,6 @@ import { ArchitectureDiagram } from "./ArchitectureDiagram.js"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { - type ArchitectureDetail, type ArchitecturePreview, type ArchitecturePreviewPlan, type ArchitectureTopologyEdge, @@ -25,79 +24,108 @@ import { runtimeExposureLabel, } from "./architecture-dashboard-helpers.js"; -export function ArchitecturePreviewPanel({ detail, preview }: { detail: ArchitectureDetail | null; preview: ArchitecturePreview }) { - const topology = topologyForPreview(preview); - const [diagramExpanded, setDiagramExpanded] = useState(false); - const diagramJson = canonicalArchitectureDiagramArtifactJson(preview.diagram); +/** The full API result stack, used for the Workbench's unsaved draft preview. */ +export function ArchitecturePreviewPanel({ preview }: { preview: ArchitecturePreview }) { + return ( +
+ + + + + +
+ ); +} + +export function ArchitecturePlanBanner({ preview }: { preview: ArchitecturePreview }) { const conflict = preview.plan?.items.some((item) => item.action === "conflict") ?? false; const unsupported = preview.plan?.items.some((item) => item.action === "unsupported") ?? false; + if (!conflict && !unsupported) return null; return ( -
- {(conflict || unsupported) && ( -
- {conflict ?
- )} +
+ {conflict ?
+ ); +} -
-
-
-

Topology

-

Router and leaf map

-
-
- {topology.nodes.length} nodes · {topology.edges.length} links - -
+export function ArchitectureTopologySection({ preview, title }: { preview: ArchitecturePreview; title: string }) { + const headingId = useId(); + const topology = topologyForPreview(preview); + const [diagramExpanded, setDiagramExpanded] = useState(false); + return ( +
+
+

{title}

+
+ {topology.nodes.length} nodes · {topology.edges.length} links +
- setDiagramExpanded(false)} /> - -
+
+ setDiagramExpanded(false)} /> + +
+ ); +} -
-
-
-

Effective result

-

Skills available in this context

-
- Authorization is resolved server-side. +export function ArchitectureSkillsSection({ preview }: { preview: ArchitecturePreview }) { + const headingId = useId(); + return ( +
+
+

Skills available in this context

+ Authorization is resolved server-side. +
+ {preview.compiled.skills.length === 0 ? ( +
+ ) : ( +
+ + + + {preview.compiled.skills.map((skill) => { + const node = preview.compiled.nodes.find((candidate) => candidate.skillRefId === skill.skillRefId); + return ( + + + + + + + ); + })} + +
SkillVersionExposureReason
{skill.title || skill.slug}{skill.slug}{skill.version}{runtimeExposureLabel(node?.runtimeExposure)}Enabled by profile {preview.compiled.profileId} for {preview.compiled.environmentId}; package access remains {skill.packageVisibility}.
- {preview.compiled.skills.length === 0 ? ( -
- ) : ( -
- - - - {preview.compiled.skills.map((skill) => { - const node = preview.compiled.nodes.find((candidate) => candidate.skillRefId === skill.skillRefId); - return ( - - - - - - - ); - })} - -
SkillVersionExposureReason
{skill.title || skill.slug}{skill.slug}{skill.version}{runtimeExposureLabel(node?.runtimeExposure)}Enabled by profile {preview.compiled.profileId} for {preview.compiled.environmentId}; package access remains {skill.packageVisibility}.
-
- )} -
+ )} +
+ ); +} - +/** Saved-revision technical output: sync plan, observed fixture and exports. */ +export function ArchitectureTechnicalDetails({ preview, fixture }: { preview: ArchitecturePreview; fixture?: ReactNode }) { + return ( +
+ Technical details +
+ + {fixture} + +
+
+ ); +} -
+function ArchitectureCompiledSection({ preview }: { preview: ArchitecturePreview }) { + const headingId = useId(); + const diagramJson = canonicalArchitectureDiagramArtifactJson(preview.diagram); + return ( +
-
-

Portable output

-

Compiled projection

-
+

Compiled projection

@@ -135,18 +163,20 @@ export function ArchitecturePreviewPanel({ detail, preview }: { detail: Architec
-
); } function ArchitectureOutline({ outline }: { outline: ArchitecturePreview["outline"] }) { return (
-

Accessible outline

Same nodes as the diagram
+

Accessible outline

Same nodes as the diagram
{outline.tree.length === 0 ?

No outline is available.

: ( -
    - {outline.tree.map((node) => )} -
+ // Large outlines scroll inside a keyboard-focusable region. +
+
    + {outline.tree.map((node) => )} +
+
)}
); @@ -182,14 +212,12 @@ function topologyForPreview(preview: ArchitecturePreview): { nodes: Architecture } function ArchitectureSyncPlan({ plan }: { plan?: ArchitecturePreviewPlan }) { + const headingId = useId(); if (!plan) { return ( -
+
-
-

Target reconciliation

-

Dry-run sync plan

-
+

Dry-run sync plan

Not generated
@@ -204,12 +232,9 @@ function ArchitectureSyncPlan({ plan }: { plan?: ArchitecturePreviewPlan }) { const changes = plan.items.filter((item) => item.action !== "noop"); const status = conflict ? "Conflict" : unsupported ? "Unsupported" : changes.length === 0 ? "No changes" : `${changes.length} dry-run changes`; return ( -
+
-
-

Target reconciliation

-

Dry-run sync plan

-
+

Dry-run sync plan

{status}

diff --git a/apps/web/src/components/architecture/ArchitectureTabs.tsx b/apps/web/src/components/architecture/ArchitectureTabs.tsx new file mode 100644 index 00000000..cdb14e02 --- /dev/null +++ b/apps/web/src/components/architecture/ArchitectureTabs.tsx @@ -0,0 +1,73 @@ +import { useRef, type KeyboardEvent, type ReactNode } from "react"; + +export interface ArchitectureTab { + id: T; + label: ReactNode; +} + +/** + * WAI-ARIA tabs with automatic activation. Panels are rendered by + * ArchitectureTabPanel and stay mounted while hidden, so drafts, disclosure + * state and in-flight request keys inside a panel survive tab changes. + */ +export function ArchitectureTabList({ label, idPrefix, tabs, selected, onSelect, className }: { + label: string; + idPrefix: string; + tabs: ReadonlyArray>; + selected: T; + onSelect: (id: T) => void; + className?: string; +}) { + const buttons = useRef(new Map()); + function handleKeyDown(event: KeyboardEvent, index: number) { + const last = tabs.length - 1; + const next = event.key === "ArrowRight" ? (index === last ? 0 : index + 1) + : event.key === "ArrowLeft" ? (index === 0 ? last : index - 1) + : event.key === "Home" ? 0 + : event.key === "End" ? last + : null; + if (next === null) return; + event.preventDefault(); + const tab = tabs[next]; + if (!tab) return; + onSelect(tab.id); + buttons.current.get(tab.id)?.focus(); + } + return ( +
+ {tabs.map((tab, index) => ( + + ))} +
+ ); +} + +export function ArchitectureTabPanel({ idPrefix, id, selected, className, children }: { + idPrefix: string; + id: string; + selected: string; + className?: string; + children: ReactNode; +}) { + return ( + + ); +} diff --git a/apps/web/src/components/architecture/ArchitectureWorkbenchPanel.tsx b/apps/web/src/components/architecture/ArchitectureWorkbenchPanel.tsx new file mode 100644 index 00000000..0ad87c1b --- /dev/null +++ b/apps/web/src/components/architecture/ArchitectureWorkbenchPanel.tsx @@ -0,0 +1,179 @@ +import type { MouseEvent, ReactNode, RefObject } from "react"; +import type { ArchitectureSpecV1 } from "@myskills-app/core"; +import { ArrowLeft, CircleAlert } from "lucide-react"; +import type { + ArchitectureDetail, + ArchitectureDraftPreview, + ArchitecturePreview, + ArchitectureSummary, + RegistryClient, +} from "../../api.js"; +import { bootstrapArchitectureSpec, revisionLabel } from "./architecture-dashboard-helpers.js"; +import { ArchitectureDetailLoading, ArchitectureState } from "./ArchitectureDashboardFeedback.js"; +import { AddArchitectureRevisionCard, ArchitectureEditorCard } from "./ArchitectureDashboardEditorPanels.js"; +import { ArchitecturePreviewPanel } from "./ArchitectureDashboardPreviewPanel.js"; +import type { ArchitectureLoadState } from "./architecture-dashboard-types.js"; +import type { + ArchitectureEditorPreviewRequest, + ArchitectureEditorSaveRequest, + ArchitectureEditorStatus, + ArchitectureRegistryReleaseOption, + ArchitectureRegistrySkillOption, +} from "./editor/index.js"; + +export interface ArchitectureEditorSeed { + revisionId: string; + revisionLabel: string; + spec: ArchitectureSpecV1; + /** Unique per accepted "Use as new draft", so reusing one revision still resets the draft. */ + generation: number; +} + +/** + * Full-page Workbench for one architecture. The dashboard keeps this panel + * mounted (hidden) on the overview so one draft session survives both + * surfaces; it never renders the saved-result overview itself. + */ +export function ArchitectureWorkbenchPanel({ + architecture, + detail, + detailState, + message, + editorSeed, + overviewHref, + onOverviewLink, + titleRef, + notices, + contextSelectors, + draftPreview, + client, + onRetry, + onDraftPreview, + onDraftSave, + onDraftChange, + onMessageDirtyChange, + onSearchRegistrySkills, + onLoadRegistryReleases, +}: { + architecture: ArchitectureSummary; + detail: ArchitectureDetail | null; + detailState: ArchitectureLoadState; + message: string | null; + editorSeed: ArchitectureEditorSeed | null; + overviewHref: string; + onOverviewLink: (event: MouseEvent) => void; + titleRef: RefObject; + notices?: ReactNode; + contextSelectors: ReactNode; + draftPreview: ArchitectureDraftPreview | null; + client: RegistryClient; + onRetry: () => void; + onDraftPreview: (request: ArchitectureEditorPreviewRequest) => Promise; + onDraftSave: (request: ArchitectureEditorSaveRequest) => Promise; + onDraftChange: (status: ArchitectureEditorStatus) => void; + onMessageDirtyChange: (dirty: boolean) => void; + onSearchRegistrySkills: (query: string) => Promise; + onLoadRegistryReleases: (skill: ArchitectureRegistrySkillOption) => Promise; +}) { + const readOnly = !(detail?.access?.canAppend ?? architecture.access?.canAppend ?? false); + const latest = detail?.latestRevision ?? null; + const editable = Boolean(detail && (latest || !readOnly)); + const mode = readOnly + ? "Inspect this architecture" + : !latest + ? "Build the first revision" + : editorSeed + ? `Draft from ${editorSeed.revisionLabel}` + : "Edit the current revision"; + const baseLine = !detail + ? null + : !latest + ? "No saved revision yet" + : readOnly + ? `Viewing ${revisionLabel(latest)}` + : editorSeed + ? `Draft from ${editorSeed.revisionLabel} · saves after ${revisionLabel(latest)}` + : `Based on ${revisionLabel(latest)}`; + const heading = ( +
+

{architecture.name}

+ {editable &&

{mode}

} +
+ ); + const activePreview: ArchitecturePreview | null = draftPreview && latest + ? { + revision: { ...latest, spec: draftPreview.draft.spec }, + compiled: draftPreview.compiled, + graph: draftPreview.graph, + outline: draftPreview.outline, + diagram: draftPreview.diagram, + ...(draftPreview.plan ? { plan: draftPreview.plan } : {}), + } + : null; + + return ( +
+
+ + + {baseLine && {baseLine}} +
+ {notices} + {detail && editable ? ( + +
+

Draft preview

+ {contextSelectors} +
+ {activePreview ? ( + <> +
Unsaved draft preview · noncanonicalThe API compiled this editor draft. The latest saved revision is unchanged until you save it.
+ + + ) : ( +

+ )} +
+ )} + advancedPanel={readOnly ? undefined : ( + + )} + /> + ) : ( + <> + {heading} + {detailState === "loading" && } + {detailState !== "loading" && message && !detail && ( + + )} + {detail && !editable && ( +
+ )} + + )} +
+ ); +} diff --git a/apps/web/src/components/architecture/ArchitecturesDashboard.tsx b/apps/web/src/components/architecture/ArchitecturesDashboard.tsx index 7b824a85..d02bde10 100644 --- a/apps/web/src/components/architecture/ArchitecturesDashboard.tsx +++ b/apps/web/src/components/architecture/ArchitecturesDashboard.tsx @@ -1,10 +1,11 @@ import { useCallback, useEffect, useMemo, useRef, useState, type MouseEvent } from "react"; -import type { ArchitectureSpecV1 } from "@myskills-app/core"; -import { Plus, RefreshCw } from "lucide-react"; +import { Check, CircleAlert, Plus, RefreshCw } from "lucide-react"; import { Button } from "@/components/ui/button"; import { ArchitectureList } from "./ArchitectureDashboardListPanel.js"; import { CreateArchitectureCard } from "./ArchitectureDashboardCreatePanel.js"; -import { ArchitectureDetailPanel } from "./ArchitectureDashboardDetailPanel.js"; +import { ArchitectureDetailPanel, type ArchitectureOverviewTab } from "./ArchitectureDashboardDetailPanel.js"; +import { ArchitectureWorkbenchPanel, type ArchitectureEditorSeed } from "./ArchitectureWorkbenchPanel.js"; +import { ArchitectureContextSelectors } from "./ArchitectureContextSelectors.js"; import { useSplitLayout } from "../registry/useSplitLayout.js"; import { ArchitectureState } from "./ArchitectureDashboardFeedback.js"; import { @@ -16,10 +17,19 @@ import { environmentProfileId, isUnsupportedError, organizationChoices, + revisionLabel, } from "./architecture-dashboard-helpers.js"; import type { ArchitectureEditorStatus } from "./editor/index.js"; -import { useArchitectureNavigationGuard } from "./useArchitectureNavigationGuard.js"; +import { useArchitectureNavigationGuard, type ArchitectureNavigationGuard } from "./useArchitectureNavigationGuard.js"; import { useArchitectureRegistry } from "./useArchitectureRegistry.js"; +import { + architectureContextKey, + architectureUrl, + hasRequestedContext, + parseArchitectureRoute, + type ArchitectureContextParams, + type ArchitectureRoute, +} from "./architecture-route.js"; import { BUILTIN_PATTERNS, type ArchitectureLoadState, @@ -39,17 +49,36 @@ import { type RegistryClient, } from "../../api.js"; -export function ArchitecturesDashboard({ client, session, onNavigationGuardChange }: { +const ROOT_ROUTE: ArchitectureRoute = { architectureId: null, surface: "overview", context: {} }; +const CONTEXT_FALLBACK_NOTICE = "The requested preview context isn't available for this architecture. Showing the default context."; + +type PendingFocus = + | { kind: "title" } + | { kind: "workbench-title" } + | { kind: "launcher" } + | { kind: "new" } + | { kind: "row"; id: string }; + +export function ArchitecturesDashboard({ client, session, url, onNavigate, onNavigationGuardChange }: { client: RegistryClient; session: WebSessionLike; - onNavigationGuardChange?: (guard: ((action: string) => boolean) | null) => void; + /** Current app URL (path, query and hash). The URL owns selection, surface and requested context. */ + url: string; + onNavigate: (url: string, mode: "push" | "replace") => void; + onNavigationGuardChange?: (guard: ArchitectureNavigationGuard | null) => void; }) { + const route = useMemo(() => { + const parsed = new URL(url, "http://architecture.invalid"); + return parseArchitectureRoute(parsed.pathname, parsed.search) ?? ROOT_ROUTE; + }, [url]); + const routeId = route.architectureId; + const onNavigateRef = useRef(onNavigate); + onNavigateRef.current = onNavigate; const [loadState, setLoadState] = useState("loading"); const [message, setMessage] = useState(null); const [patterns, setPatterns] = useState(BUILTIN_PATTERNS); const [architectures, setArchitectures] = useState([]); - const [expandedAccessId, setExpandedAccessId] = useState(null); - const [expandedHistoryId, setExpandedHistoryId] = useState(null); + const [overviewTab, setOverviewTab] = useState("overview"); const [profiles, setProfiles] = useState([]); const [environments, setEnvironments] = useState([]); const [selectedArchitectureId, setSelectedArchitectureId] = useState(null); @@ -62,59 +91,198 @@ export function ArchitecturesDashboard({ client, session, onNavigationGuardChang const [historyRevision, setHistoryRevision] = useState(null); const [historyState, setHistoryState] = useState<"idle" | "loading" | "error">("idle"); const [historyMessage, setHistoryMessage] = useState(null); - const [editorSeed, setEditorSeed] = useState<{ revisionId: string; spec: ArchitectureSpecV1 } | null>(null); + const [editorSeed, setEditorSeed] = useState(null); const [preview, setPreview] = useState(null); const [draftPreview, setDraftPreview] = useState(null); const [detailState, setDetailState] = useState("ready"); const [detailMessage, setDetailMessage] = useState(null); + const [contextNotice, setContextNotice] = useState(false); + const [savedNotice, setSavedNotice] = useState(null); const [refreshKey, setRefreshKey] = useState(0); const fixturePreviewEpoch = useRef(0); const draftPreviewEpoch = useRef(0); const historyRevisionEpoch = useRef(0); const previewContextRef = useRef(""); const refreshEpoch = useRef(0); - const [hasUnsavedDraft, setHasUnsavedDraft] = useState(false); + // Spec and revision message are tracked separately: a message-only change + // is unsaved work, but it does not make a draft preview stale. + const [specDirty, setSpecDirty] = useState(false); + const [messageDirty, setMessageDirty] = useState(false); + const hasUnsavedDraft = specDirty || messageDirty; const [mode, setMode] = useState<"detail" | "new">("detail"); const [opened, setOpened] = useState(false); + const [seenRouteId, setSeenRouteId] = useState(routeId); + const routeContextKey = architectureContextKey(route.context); + const [seenRouteContextKey, setSeenRouteContextKey] = useState(routeContextKey); + // Changes whenever the draft being edited is replaced (another architecture + // or a history seed), so late async completions cannot touch a newer draft. + const draftSessionRef = useRef(0); + const seedGenerationRef = useRef(0); + // The latest requested context, read when a detail response arrives, so a + // Back/Forward move during the request wins over the request's closure. + const requestedContextRef = useRef({ profileId: "", environmentId: "", organizationId: "" }); const selectedArchitectureRef = useRef(selectedArchitectureId); selectedArchitectureRef.current = selectedArchitectureId; const { layout, ref: measureSurface } = useSplitLayout(); const surfaceNode = useRef(null); const titleRef = useRef(null); - const editorRef = useRef(null); + const workbenchTitleRef = useRef(null); + const launcherRef = useRef(null); const newNameRef = useRef(null); const openerRef = useRef(null); - const pendingFocus = useRef<{ kind: "title" } | { kind: "editor" } | { kind: "new" } | { kind: "row"; id: string } | null>(null); + const pendingFocus = useRef(null); const stacked = layout === "stack"; + // The URL owns explicit selection. Guards run before any URL change, so + // adopting the route here never prompts. The root keeps the current + // architecture, or the first one when nothing is selected yet. + const resolvedArchitectureId = loadState !== "ready" + ? selectedArchitectureId + : routeId ?? (selectedArchitectureId && architectures.some((item) => item.id === selectedArchitectureId) + ? selectedArchitectureId + : architectures[0]?.id ?? null); + if (resolvedArchitectureId !== selectedArchitectureId) { + adoptArchitecture(resolvedArchitectureId, routeId === resolvedArchitectureId ? route.context : {}); + } + if (routeId !== seenRouteId) { + setSeenRouteId(routeId); + // A move back to the section root shows the list again on phones. + if (routeId === null) setOpened(false); + } + + function adoptArchitecture(id: string | null, context: ArchitectureContextParams) { + setSelectedArchitectureId(id); + setSelectedDetail(null); + setHistoryRevisionId(null); + setHistoryRevision(null); + setHistoryState("idle"); + setHistoryMessage(null); + setEditorSeed(null); + setProfiles([]); + setEnvironments([]); + // Requested context is validated once the architecture detail loads. + setSelectedProfileId(context.profile ?? ""); + setSelectedEnvironmentId(context.environment ?? ""); + setSelectedOrganizationId(context.organization ?? ""); + setVisibleOrganizations([]); + setSpecDirty(false); + setMessageDirty(false); + setPreview(null); + setDraftPreview(null); + setDetailMessage(null); + setContextNotice(false); + setSavedNotice(null); + setOverviewTab("overview"); + } + + const selectedArchitecture = useMemo( + () => architectures.find((item) => item.id === selectedArchitectureId) ?? null, + [architectures, selectedArchitectureId], + ); + const selectedAvailable = selectedArchitecture !== null; + const unavailable = loadState === "ready" && routeId !== null && !architectures.some((item) => item.id === routeId); + const workbench = route.surface === "workbench" && !unavailable; + const organizationOnly = architectureIsOrganizationOnly(selectedDetail ?? selectedArchitecture); + const currentContext = useMemo(() => organizationOnly + ? (selectedOrganizationId ? { organization: selectedOrganizationId } : {}) + : { + ...(selectedProfileId ? { profile: selectedProfileId } : {}), + ...(selectedEnvironmentId ? { environment: selectedEnvironmentId } : {}), + ...(selectedOrganizationId ? { organization: selectedOrganizationId } : {}), + }, [organizationOnly, selectedEnvironmentId, selectedOrganizationId, selectedProfileId]); + + // Back and Forward between entries of the same architecture restore that + // entry's context. Our own replaceState writes already match the state, so + // only a real history move reaches restoreRouteContext. + if (routeContextKey !== seenRouteContextKey) { + setSeenRouteContextKey(routeContextKey); + if (routeId !== null + && routeId === selectedArchitectureId + && resolvedArchitectureId === selectedArchitectureId + && hasRequestedContext(route.context) + && routeContextKey !== architectureContextKey(currentContext)) { + restoreRouteContext(route.context); + } + } + + function restoreRouteContext(context: ArchitectureContextParams) { + if (!selectedDetail || selectedDetail.id !== selectedArchitectureId) { + // The pending detail response validates this request when it arrives. + setSelectedProfileId(context.profile ?? ""); + setSelectedEnvironmentId(context.environment ?? ""); + setSelectedOrganizationId(context.organization ?? ""); + setContextNotice(false); + return; + } + const allowedOrganizationIds = architectureOrganizationIds(selectedDetail); + const requestedOrganization = context.organization ?? ""; + // An organization outside the grant is never applied. + const organizationAllowed = requestedOrganization === "" || allowedOrganizationIds.includes(requestedOrganization); + let replaced = !organizationAllowed; + if (!architectureIsOrganizationOnly(selectedDetail)) { + const profile = profiles.find((item) => item.id === context.profile) ?? profiles[0]; + const environment = (profile && environments.find((item) => item.id === context.environment && environmentBelongsToProfile(item, profile.id))) + || (profile ? boundEnvironmentForProfile(environments, profile.id) : undefined); + if ((context.profile && profile?.id !== context.profile) || (context.environment && environment?.id !== context.environment)) replaced = true; + setSelectedProfileId(profile?.id ?? ""); + setSelectedEnvironmentId(environment?.id ?? ""); + } + setSelectedOrganizationId(organizationAllowed ? requestedOrganization : ""); + setDraftPreview(null); + setContextNotice(replaced); + } + + // Another architecture, a new history seed or a reload each replace the draft session. + useEffect(() => { + draftSessionRef.current += 1; + }, [selectedArchitectureId, editorSeed, refreshKey]); + + requestedContextRef.current = { profileId: selectedProfileId, environmentId: selectedEnvironmentId, organizationId: selectedOrganizationId }; + const surfaceRef = useCallback((node: HTMLDivElement | null) => { surfaceNode.current = node; measureSurface(node); }, [measureSurface]); + // Declared before the focus effect so a surface change focuses in the same commit. + const previousSurface = useRef(workbench); + useEffect(() => { + if (previousSurface.current === workbench) return; + previousSurface.current = workbench; + pendingFocus.current = workbench ? { kind: "workbench-title" } : { kind: "launcher" }; + }, [workbench]); + useEffect(() => { const next = pendingFocus.current; if (!next) return; const element = next.kind === "title" ? titleRef.current - : next.kind === "editor" ? editorRef.current - : next.kind === "new" ? newNameRef.current - : Array.from(surfaceNode.current?.querySelectorAll("[data-architecture-id]") ?? []).find((item) => item.dataset.architectureId === next.id) ?? null; + : next.kind === "workbench-title" ? workbenchTitleRef.current + : next.kind === "launcher" ? launcherRef.current ?? titleRef.current + : next.kind === "new" ? newNameRef.current + : Array.from(surfaceNode.current?.querySelectorAll("[data-architecture-id]") ?? []).find((item) => item.dataset.architectureId === next.id) ?? null; if (!element || element.closest("[hidden]")) return; pendingFocus.current = null; element.focus(); }); - const confirmDiscardDraft = useArchitectureNavigationGuard(hasUnsavedDraft); + // Moves inside the section keep the draft when they stay on the current + // architecture or return to the section root, which keeps the selection. + const keepsDraft = useCallback((destination: URL) => { + const next = parseArchitectureRoute(destination.pathname, destination.search); + return next !== null && (next.architectureId === null || next.architectureId === selectedArchitectureRef.current); + }, []); + const { confirmDiscardDraft, guardNavigation } = useArchitectureNavigationGuard(hasUnsavedDraft, keepsDraft); useEffect(() => { if (!onNavigationGuardChange) return; - onNavigationGuardChange(confirmDiscardDraft); + onNavigationGuardChange(guardNavigation); return () => onNavigationGuardChange(null); - }, [confirmDiscardDraft, onNavigationGuardChange]); + }, [guardNavigation, onNavigationGuardChange]); const requestRefresh = useCallback(() => { if (!confirmDiscardDraft("refresh")) return; - setHasUnsavedDraft(false); + setSpecDirty(false); + setMessageDirty(false); setRefreshKey((value) => value + 1); }, [confirmDiscardDraft]); @@ -136,9 +304,6 @@ export function ArchitecturesDashboard({ client, session, onNavigationGuardChang // A phone view must not reopen onto a different architecture when the // selected one is no longer visible. if (!nextArchitectures.some((item) => item.id === selectedArchitectureRef.current)) setOpened(false); - setSelectedArchitectureId((current) => current && nextArchitectures.some((item) => item.id === current) - ? current - : nextArchitectures[0]?.id ?? null); setLoadState("ready"); } catch (error) { if (requestEpoch !== refreshEpoch.current) return; @@ -157,7 +322,8 @@ export function ArchitecturesDashboard({ client, session, onNavigationGuardChang // A fast list refresh can batch loading and ready, so detail also tracks refresh intent. useEffect(() => { - if (!selectedArchitectureId || loadState !== "ready") { + // An explicit ID outside the authorized list is never requested. + if (!selectedArchitectureId || loadState !== "ready" || !selectedAvailable) { setSelectedDetail(null); setHistoryRevisionId(null); setHistoryRevision(null); @@ -174,30 +340,36 @@ export function ArchitecturesDashboard({ client, session, onNavigationGuardChang setDetailMessage(null); client.getArchitecture(selectedArchitectureId) .then((detail) => { + if (!active) return; + // Validate the latest request, not the values captured when the fetch began. + const requested = requestedContextRef.current; const contexts = architectureContexts(detail); - const profileId = contexts.profiles.some((item) => item.id === selectedProfileId) - ? selectedProfileId + const profileId = contexts.profiles.some((item) => item.id === requested.profileId) + ? requested.profileId : contexts.profiles[0]?.id ?? ""; - const environmentId = contexts.environments.find((environment) => environment.id === selectedEnvironmentId && environmentBelongsToProfile(environment, profileId))?.id + const environmentId = contexts.environments.find((environment) => environment.id === requested.environmentId && environmentBelongsToProfile(environment, profileId))?.id ?? boundEnvironmentForProfile(contexts.environments, profileId)?.id ?? ""; - if (active) { - setSelectedDetail(detail); - setHistoryRevisionId(detail.latestRevision?.id ?? null); - setHistoryRevision(detail.latestRevision ?? null); - setHistoryState("idle"); - setHistoryMessage(null); - setEditorSeed(null); - setProfiles(contexts.profiles); - setEnvironments(contexts.environments); - setSelectedProfileId(profileId); - setSelectedEnvironmentId(environmentId); - const allowedOrganizationIds = architectureOrganizationIds(detail); - setSelectedOrganizationId((current) => allowedOrganizationIds.includes(current) ? current : ""); - setPreview(null); - setDraftPreview(null); - setDetailState("ready"); - } + const allowedOrganizationIds = architectureOrganizationIds(detail); + // Say so when a requested context was replaced; never widen it silently. + const contextReplaced = (!architectureIsOrganizationOnly(detail) && ((requested.profileId !== "" && profileId !== requested.profileId) + || (requested.environmentId !== "" && environmentId !== requested.environmentId))) + || (requested.organizationId !== "" && !allowedOrganizationIds.includes(requested.organizationId)); + setSelectedDetail(detail); + setHistoryRevisionId(detail.latestRevision?.id ?? null); + setHistoryRevision(detail.latestRevision ?? null); + setHistoryState("idle"); + setHistoryMessage(null); + setEditorSeed(null); + setProfiles(contexts.profiles); + setEnvironments(contexts.environments); + setSelectedProfileId(profileId); + setSelectedEnvironmentId(environmentId); + setSelectedOrganizationId((current) => allowedOrganizationIds.includes(current) ? current : ""); + if (contextReplaced) setContextNotice(true); + setPreview(null); + setDraftPreview(null); + setDetailState("ready"); }) .catch((error: unknown) => { if (!active) { @@ -217,7 +389,7 @@ export function ArchitecturesDashboard({ client, session, onNavigationGuardChang return () => { active = false; }; - }, [client, loadState, refreshKey, selectedArchitectureId]); + }, [client, loadState, refreshKey, selectedArchitectureId, selectedAvailable]); useEffect(() => { if (!selectedDetail || !architectureIsOrganizationOnly(selectedDetail)) { @@ -249,11 +421,11 @@ export function ArchitecturesDashboard({ client, session, onNavigationGuardChang }, [client, selectedDetail]); useEffect(() => { - const organizationOnly = architectureIsOrganizationOnly(selectedDetail); - if (!selectedArchitectureId || selectedDetail?.id !== selectedArchitectureId || (!organizationOnly && (!selectedDetail?.latestRevision || !selectedProfileId || !selectedEnvironmentId))) { + const organizationOnlyDetail = architectureIsOrganizationOnly(selectedDetail); + if (!selectedArchitectureId || selectedDetail?.id !== selectedArchitectureId || (!organizationOnlyDetail && (!selectedDetail?.latestRevision || !selectedProfileId || !selectedEnvironmentId))) { return; } - if (organizationOnly && !selectedOrganizationId) { + if (organizationOnlyDetail && !selectedOrganizationId) { setPreview(null); setDetailMessage(null); setDetailState("ready"); @@ -262,7 +434,7 @@ export function ArchitecturesDashboard({ client, session, onNavigationGuardChang let active = true; setDetailState("loading"); setDetailMessage(null); - const request = organizationOnly + const request = organizationOnlyDetail ? { organizationId: selectedOrganizationId } : { profileId: selectedProfileId, @@ -272,7 +444,7 @@ export function ArchitecturesDashboard({ client, session, onNavigationGuardChang }; client.previewArchitecture(selectedArchitectureId, request).then((nextPreview) => { if (!active) return; - if (organizationOnly) { + if (organizationOnlyDetail) { const profileId = nextPreview.compiled.profileId; const environmentId = nextPreview.compiled.environmentId; setProfiles([{ id: profileId, name: "Authorized profile", scope: "personal", environmentIds: [environmentId] }]); @@ -293,10 +465,14 @@ export function ArchitecturesDashboard({ client, session, onNavigationGuardChang }; }, [client, previewSelectionKey, selectedArchitectureId, selectedDetail, selectedOrganizationId]); - const selectedArchitecture = useMemo( - () => architectures.find((item) => item.id === selectedArchitectureId) ?? null, - [architectures, selectedArchitectureId], - ); + // Keep the explicit URL in step with the validated context, replacing the + // entry so reloads, new tabs and Back/Forward restore the same view. + useEffect(() => { + if (routeId === null || routeId !== selectedArchitectureId || selectedDetail?.id !== routeId) return; + if (architectureContextKey(route.context) === architectureContextKey(currentContext)) return; + onNavigateRef.current(architectureUrl(routeId, route.surface, currentContext), "replace"); + }, [currentContext, route, routeId, selectedArchitectureId, selectedDetail]); + const previewContextKey = [ selectedArchitectureId ?? "", selectedDetail?.id ?? "", @@ -343,62 +519,58 @@ export function ArchitecturesDashboard({ client, session, onNavigationGuardChang const handleUseRevisionAsDraft = useCallback((revision: ArchitectureRevisionRecord) => { if (!selectedArchitectureId || !selectedDetail || selectedDetail.id !== selectedArchitectureId || !selectedDetail.latestRevision) return; if (!confirmDiscardDraft("replace the current draft")) return; - setEditorSeed({ revisionId: revision.id, spec: structuredClone(revision.spec) }); + // Saving still sends the latest revision as the concurrency token. + seedGenerationRef.current += 1; + setEditorSeed({ revisionId: revision.id, revisionLabel: revisionLabel(revision), spec: structuredClone(revision.spec), generation: seedGenerationRef.current }); setDraftPreview(null); + setSavedNotice(null); draftPreviewEpoch.current += 1; - // The editor sits above the history, so move the reader to the new draft. - pendingFocus.current = { kind: "editor" }; - }, [confirmDiscardDraft, selectedArchitectureId, selectedDetail]); - const availableProfiles = profiles; - const availableEnvironments = environments; + onNavigateRef.current(architectureUrl(selectedArchitectureId, "workbench", currentContext), "push"); + }, [confirmDiscardDraft, currentContext, selectedArchitectureId, selectedDetail]); + const handleDraftChange = useCallback((status: ArchitectureEditorStatus) => { - setHasUnsavedDraft(status.dirty); - if (status.dirty) { - setDraftPreview(null); - draftPreviewEpoch.current += 1; - } + setSpecDirty(status.dirty); + // Any spec change, including an undo back to the baseline, makes an + // earlier draft preview stale. + setDraftPreview(null); + draftPreviewEpoch.current += 1; + if (status.dirty) setSavedNotice(null); }, []); - function selectArchitecture(id: string): boolean { - if (id === selectedArchitectureId) { - return true; - } - if (!confirmDiscardDraft("switch architectures")) return false; - setSelectedDetail(null); - setHistoryRevisionId(null); - setHistoryRevision(null); - setHistoryState("idle"); - setHistoryMessage(null); - setEditorSeed(null); - setProfiles([]); - setEnvironments([]); - setSelectedProfileId(""); - setSelectedEnvironmentId(""); - setSelectedOrganizationId(""); - setVisibleOrganizations([]); - setHasUnsavedDraft(false); - setSelectedArchitectureId(id); - setPreview(null); - setDraftPreview(null); - setDetailMessage(null); - return true; + const handleMessageDirtyChange = useCallback((dirty: boolean) => { + setMessageDirty(dirty); + }, []); + + function followSectionLink(event: MouseEvent) { + // Modified clicks keep normal browser behaviour, such as a new tab. + if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return; + const href = event.currentTarget.getAttribute("href"); + if (!href) return; + event.preventDefault(); + onNavigate(href, "push"); + } + + // Replace a root URL once the reader picks a context, so it can be shared. + function syncRootContext(context: ArchitectureContextParams) { + if (routeId === null && selectedArchitectureId) onNavigate(architectureUrl(selectedArchitectureId, "overview", context), "replace"); } - // Tapping the already-selected row still opens it in the stacked layout; - // selectArchitecture keeps the discard guard for a different architecture. function openArchitecture(id: string) { - if (!selectArchitecture(id)) return; + if (id !== selectedArchitectureId && !confirmDiscardDraft("switch architectures")) return; setMode("detail"); openerRef.current = null; if (stacked) { setOpened(true); pendingFocus.current = { kind: "title" }; } + const next = architectureUrl(id, "overview", id === selectedArchitectureId ? currentContext : {}); + if (routeId !== id || route.surface !== "overview") onNavigate(next, "push"); } function backToArchitectures() { setOpened(false); if (selectedArchitectureId) pendingFocus.current = { kind: "row", id: selectedArchitectureId }; + if (routeId !== null) onNavigate(architectureUrl(null, "overview"), "push"); } function openNew(event: MouseEvent) { @@ -417,13 +589,66 @@ export function ArchitecturesDashboard({ client, session, onNavigationGuardChang } const noArchitectures = loadState === "ready" && architectures.length === 0; - const newVisible = mode === "new" || noArchitectures; - const showList = !stacked || (!opened && !newVisible); - const showInspector = !stacked || opened || newVisible; + const newVisible = !workbench && (mode === "new" || (noArchitectures && routeId === null)); + const detailOpen = routeId !== null || opened; + const showList = !workbench && (!stacked || (!detailOpen && !newVisible)); + const showInspector = workbench || !stacked || detailOpen || newVisible; + const readOnly = !(selectedDetail?.access?.canAppend ?? selectedArchitecture?.access?.canAppend ?? false); + const launcherLabel = !selectedDetail || selectedDetail.id !== selectedArchitectureId + ? null + : hasUnsavedDraft + ? "Resume draft" + : selectedDetail.latestRevision + ? readOnly ? "Inspect in workbench" : "Open workbench" + : readOnly ? null : "Build first revision"; + const allowedOrganizationIds = architectureOrganizationIds(selectedDetail ?? selectedArchitecture); + const contextAvailable = (profiles.length > 0 && environments.length > 0) || (organizationOnly && allowedOrganizationIds.length > 0); + const contextSelectors = ( + { + const profile = profiles.find((item) => item.id === value); + const environment = profile ? boundEnvironmentForProfile(environments, profile.id) : undefined; + if (!profile || !environment) return; + setDraftPreview(null); + setContextNotice(false); + setSelectedProfileId(profile.id); + setSelectedEnvironmentId(environment.id); + syncRootContext({ ...currentContext, profile: profile.id, environment: environment.id }); + }} + onEnvironmentChange={(value) => { + const environment = environments.find((item) => item.id === value); + const profileId = environment ? environmentProfileId(environment, profiles) : undefined; + if (!environment || !profileId) return; + setDraftPreview(null); + setContextNotice(false); + setSelectedProfileId(profileId); + setSelectedEnvironmentId(environment.id); + syncRootContext({ ...currentContext, profile: profileId, environment: environment.id }); + }} + onOrganizationChange={(value) => { + if (value && !allowedOrganizationIds.includes(value)) return; + setDraftPreview(null); + setContextNotice(false); + setSelectedOrganizationId(value); + syncRootContext({ ...currentContext, organization: value || undefined }); + }} + /> + ); + const contextNoticeNode = contextNotice + ?

+ : null; return ( -
-
+
+
); } @@ -882,6 +1009,7 @@ function ProfileEnvironmentPanel({ onEnvironmentChange, onBindingChange, readOnly, + locked, }: { spec: ArchitectureSpecV1; selectedProfileId: string; @@ -896,7 +1024,10 @@ function ProfileEnvironmentPanel({ onEnvironmentChange: (update: { name?: string; kind?: ArchitectureEnvironmentKind; profileId?: string; parentId?: string | null }) => void; onBindingChange: (nodeId: string, update: Partial>) => void; readOnly: boolean; + /** True while a save is pending; edits are paused, not hidden. */ + locked: boolean; }) { + const editDisabled = readOnly || locked; const profile = spec.profiles.find((candidate) => candidate.id === selectedProfileId); const environment = spec.environments.find((candidate) => candidate.id === selectedEnvironmentId); const allEnvironmentIds = spec.environments.map((candidate) => candidate.id); @@ -918,12 +1049,12 @@ function ProfileEnvironmentPanel({
-
Profiles{spec.profiles.length} declared
+
Profiles{spec.profiles.length} declared
{profile && <> - +
Subject{profile.subject.type}:{profile.subject.id}
- + }
@@ -931,17 +1062,17 @@ function ProfileEnvironmentPanel({
Logical environments{spec.environments.length} connected contexts
{environment && <> - - - - - + + + + + } {!readOnly &&
Add contextDeclare a new environment
- +
}
@@ -954,9 +1085,9 @@ function ProfileEnvironmentPanel({ const scoped = binding.environmentIds !== undefined; const resolution = resolveBindingForEditor(spec, profile.id, environment.id, node.id); return
- - -
Environment scope{spec.environments.map((candidate) =>