From e8d996c8092ce3f469baa0e5d6a10bd31ce3284c Mon Sep 17 00:00:00 2001 From: Brian Love Date: Tue, 1 Sep 2026 09:33:11 -0700 Subject: [PATCH 1/2] docs: specify unified workspace control plane --- ...026-09-01-custom-runtime-targets-design.md | 227 ++++++++++++++++++ ...26-09-01-unified-workspace-shell-design.md | 196 +++++++++++++++ ...09-01-workspace-control-plane-v2-design.md | 188 +++++++++++++++ 3 files changed, 611 insertions(+) create mode 100644 docs/superpowers/specs/2026-09-01-custom-runtime-targets-design.md create mode 100644 docs/superpowers/specs/2026-09-01-unified-workspace-shell-design.md create mode 100644 docs/superpowers/specs/2026-09-01-workspace-control-plane-v2-design.md diff --git a/docs/superpowers/specs/2026-09-01-custom-runtime-targets-design.md b/docs/superpowers/specs/2026-09-01-custom-runtime-targets-design.md new file mode 100644 index 000000000..9b77798c9 --- /dev/null +++ b/docs/superpowers/specs/2026-09-01-custom-runtime-targets-design.md @@ -0,0 +1,227 @@ +# Custom AG-UI and LangSmith runtime targets + +## Status + +Approved through interactive design review on 2026-09-01. This is release 2 of the unified control-plane program and depends on the unified workspace shell. + +## Summary + +Let users connect the unified workspace to either their own AG-UI endpoint or a LangSmith deployment URL and API key. Endpoint metadata may be stored on the current device. API keys are memory-only: they are never persisted, placed in URLs, included in diagnostics, emitted to analytics, or forwarded through a Threadplane server. + +The parent workspace remains the sole credential-management authority: it accepts, replaces, and clears credentials. It configures the mounted Angular runtime through a strict-origin, nonce-bound iframe handshake. Angular bootstraps with the selected runtime target in memory, acknowledges the exact configuration generation, and reports actionable health states back to the control plane. The trusted child Agent client may retain the key in volatile memory only for that configured generation so it can authorize requests. + +## Goals + +1. Support Shared development, Custom AG-UI, and Custom LangSmith targets. +2. Keep API keys memory-only across the entire browser flow. +3. Configure compatible Angular runtime examples without query parameters or browser storage. +4. Make authorization, CORS, network, configuration, and bridge failures distinguishable. +5. Preserve the existing shared-development default and standalone examples. +6. Apply one generated, drift-tested configuration contract across compatible Cockpit runtimes. + +## Non-goals + +- Remembering API keys after refresh, navigation away, or tab close. +- Account-synced targets or credential vaulting. +- Proxying custom traffic through Threadplane infrastructure. +- Deployment management, server restart, or mutation commands. +- Arbitrary request headers, OAuth flows, or custom authentication schemes. +- Claiming compatibility for static examples with no Agent transport. + +## Target model + +```ts +type SavedRuntimeTarget = + | { id: 'shared'; kind: 'shared'; label: 'Shared development' } + | { id: string; kind: 'ag-ui'; label: string; endpoint: string } + | { id: string; kind: 'langsmith'; label: string; apiUrl: string }; + +type EphemeralCredentials = { + targetId: string; + kind: 'langsmith'; + apiKey: string; +} | null; +``` + +Only `SavedRuntimeTarget` enters device-local preferences. The `EphemeralCredentials` record exists only in React state owned by the mounted workspace provider. After configuration, the child Agent client's separately scoped volatile key copy is permitted only under the generation-lifetime rules below; it is never an `EphemeralCredentials` store or credential-management surface. + +Release 2 stores target metadata in a dedicated `threadplane:runtime-targets:v1` record: + +```ts +interface RuntimeTargetPreferencesV1 { + version: 1; + selectedTargetId: string; + savedTargets: SavedRuntimeTarget[]; +} +``` + +The record contains no credential field or extension bag. Invalid selected IDs fall back to `shared`; malformed custom targets are dropped individually. Release 3 may migrate this record into the unified workspace preference schema, but release 2 keeps this narrow store independently deployable. + +After refresh, a saved LangSmith target is selected but enters `credentials_required` until the key is entered again. Removing or changing a target clears any matching ephemeral credentials immediately. + +## Endpoint validation + +- Require an absolute HTTP or HTTPS URL. +- Allow HTTP only for `localhost`, `127.0.0.1`, and `[::1]` development targets. +- Reject URL user information, fragments, and query strings. +- Normalize trailing slashes without changing the path. +- Reject values containing control characters. +- Never echo a rejected raw value into Activity or diagnostics. +- Display the normalized origin and path only after successful validation. + +The UI derives and displays the exact Angular runtime origin from the selected capability's iframe URL. It explains that the custom server must allow that runtime origin—not the top-level workspace origin—in `Access-Control-Allow-Origin`. Local fake servers assert the received browser `Origin`, and production smoke asserts that the displayed required origin exactly matches the mounted runtime iframe origin. The application does not attempt to bypass CORS. + +Compatible runtime deployments must permit validated targets in `connect-src`. Supporting arbitrary validated HTTPS endpoints inherently requires HTTPS network egress from the child runtime; local development additionally requires the allowlisted loopback HTTP origins. Deployment tests inspect the effective policy and prove a validated fake target is reachable. This allowance never changes the exact destination chosen by the Agent client, relaxes parent-message authorization, or permits Threadplane to proxy the request. + +## Runtime compatibility + +The workspace registry adds `runtimeAdapter: 'langgraph' | 'ag-ui' | 'none'`. + +- LangSmith targets are available to `langgraph` entries. +- AG-UI targets are available to `ag-ui` entries. Runtime-portability entries that consume the AG-UI Agent contract are classified as `ag-ui`; there is no unnamed compatibility exception. +- `none` entries show the target selector as unavailable with a truthful explanation. +- Switching to an incompatible target is prevented before iframe configuration. + +## Secure configuration protocol + +Version 2 extends the existing private runtime bridge with child-ready, host-intent, configure, and acknowledge messages. Exact message names are centralized in `@threadplane/cockpit-runtime-bridge`. + +```ts +interface RuntimeChildReadyMessage { + type: 'tplane:runtime-child-ready'; + version: 2; + nonce: string; +} + +interface RuntimeHostMessage { + type: 'tplane:runtime-host'; + version: 2; + nonce: string; + generation: number; +} + +interface RuntimeConfigureMessage { + type: 'tplane:runtime-configure'; + version: 2; + nonce: string; + generation: number; + target: + | { kind: 'shared' } + | { kind: 'ag-ui'; endpoint: string } + | { kind: 'langsmith'; apiUrl: string; apiKey: string }; +} + +interface RuntimeConfiguredMessage { + type: 'tplane:runtime-configured'; + version: 2; + nonce: string; + generation: number; +} +``` + +Security rules: + +- The parent sends only to the exact iframe origin; never `*`. +- Compatible child builds receive an exact `allowedParentOrigins` list generated from the repository deployment configuration for production, named previews, and local development. Tests may inject explicit origins. Wildcards, suffix matching, and arbitrary referrer-derived authority are forbidden. +- The child accepts configuration only when `window.parent` is the message source, the referrer origin exactly matches one `allowedParentOrigins` entry, and the message origin equals that referrer origin. +- The unified host sets `referrerPolicy="origin"` on runtime iframes, and runtime deployment headers must not suppress that referrer. Deployment smoke verifies that the child receives the exact parent origin required for authorization. +- Both sides validate protocol version, message shape, nonce, generation, source window, and origin. +- Before Angular bootstrap, an allowed-parent child installs its listener, creates a fresh nonce, and sends `runtime-child-ready` to its exact referrer origin. The parent listener is installed before assigning the iframe source. +- The parent validates the ready message and responds to that exact source with `runtime-host` followed by `runtime-configure`, echoing the nonce and current generation. The child repeats ready and the parent repeats host/configure on bounded timers until the matching `runtime-configured` acknowledgement ends the handshake. +- The first valid configure payload accepted for a nonce and generation is authoritative. An identical duplicate re-sends `runtime-configured` without reconstructing the Agent client or repeating bootstrap. A conflicting payload for an accepted nonce and generation is rejected and reported with an allowlisted protocol error code. +- An embed whose referrer origin is allowlisted is recognized immediately and fails closed with `incompatible_bridge` if the handshake does not complete before the bounded deadline. It never bootstraps the Shared development default, even if every parent message is lost. +- A standalone window uses its registry default immediately. An embed whose referrer origin is not allowlisted is unrecognized, ignores every configuration message, and may use the existing registry default. +- The child Agent client may retain the key in a private in-memory closure or client object only for the acknowledged generation. Disposing or superseding that generation destroys the client reference; the child exposes no credential setter, reader, persistence path, diagnostic field, or serialized copy. +- Messages are never logged, serialized to diagnostics, or copied into DOM attributes. +- A newer generation invalidates every older configure or health response. +- The acknowledgement reveals no credential or endpoint value. + +The protocol does not make an untrusted custom endpoint safe. It only protects configuration transport between the unified parent and the known Angular iframe. + +## Angular bootstrap integration + +Add a shared Angular runtime-target provider used by all compatible Cockpit applications. + +- The lightweight bridge responder installs before Angular bootstrap. +- A recognized unified embed waits for valid configuration for a bounded interval and fails closed on timeout. +- A standalone or unrecognized embed uses its existing registry default as defined by the host-detection rules above. +- A shared `provideCockpitAgent(...)` integration resolves the selected target before `bootstrapApplication(...)` and delegates to `@threadplane/langgraph` or `@threadplane/ag-ui` as declared by the registry. +- Component-scoped Agent providers are migrated explicitly; a registry-derived drift test rejects compatible applications that bypass the target provider. +- Static render-only examples remain unchanged and declare `runtimeAdapter: 'none'`. + +The migration must cover production and Cockpit entry points. No application may read a key from `window`, URL parameters, local storage, or session storage. + +## Runtime state + +Extend the control-plane state with: + +```ts +type RuntimePhase = + | ExistingRuntimePhase + | 'credentials_required' + | 'configuring' + | 'unauthorized' + | 'network_blocked' + | 'incompatible_bridge'; +``` + +Required behavior: + +- `credentials_required` blocks mounting or configuring a LangSmith target until a key is entered. +- Clearing credentials increments generation, cancels checks, disposes the configured child client, unmounts the runtime iframe, and remains unmounted in `credentials_required` until a replacement key exists. +- `configuring` covers the nonce-bound target handshake. +- An explicit 401 or 403 response becomes `unauthorized` without exposing response bodies. +- Fetch rejection or an opaque browser failure becomes `network_blocked` and explains CORS or network causes without pretending to distinguish them. +- A configuration timeout or wrong protocol version becomes `incompatible_bridge`. +- Existing Ready, Recheck, Reload, and recovery behavior remains available after successful configuration. +- Any effective runtime configuration change—target kind, selected target ID, normalized endpoint, or LangSmith key—increments generation, cancels checks, disposes the old child client, and remounts the runtime iframe for a fresh pre-bootstrap handshake. +- Effective-configuration equality includes the selected target ID, kind, normalized endpoint, and the current in-memory key value. Selecting a different saved target therefore remounts even when two targets point to the same endpoint. +- Renaming a target, editing an unselected target, or re-selecting the already active unchanged configuration does not remount. Recheck and Reload retain their existing semantics. + +## Settings experience + +Settings contains a Runtime target section: + +- Target type selector. +- Saved target selector. +- Add, rename, and remove custom endpoint metadata. +- Endpoint or API URL field. +- Password input for the current LangSmith key. +- Clear credentials action. +- Connection requirements and inline validation. +- The exact runtime origin the custom server must allow for CORS. + +Saving a LangSmith URL does not imply that credentials were saved. The UI labels the key `For this tab only` and shows `Credentials required after refresh`. + +## Diagnostics and privacy + +Diagnostics may include: + +- Target kind. +- Sanitized origin and path. +- Adapter kind. +- Runtime phase and allowlisted reason code. +- Protocol version and configuration generation. + +Diagnostics must not include keys, authorization headers, raw postMessage payloads, prompts, response bodies, or rejected raw URLs. Existing analytics may record target kind and allowlisted outcome only; this release does not add endpoint values or expand behavioral tracking. + +## Testing + +- Pure validation tests for URL rules and credential separation. +- Preference serialization tests proving keys cannot be represented or persisted. +- Redaction tests across diagnostics, Activity, errors, and analytics property bags. +- Runtime bridge contract tests for exact parent allowlists, ready/host/configure retries, idempotent duplicates, conflicting duplicates, origin, source, nonce, generation, stale replies, lost messages, timeouts, and unknown messages. +- Angular bootstrap tests for custom configuration, standalone fallback, and component-scoped providers. +- Registry-derived coverage for every compatible Angular application. +- Browser E2E with local fake AG-UI and LangSmith servers, including exact request-origin assertions, CSP reachability, Ready, acknowledgement loss, unauthorized, CORS/network failure, refresh, live credential clearing, and target switching. +- Production smoke continues to use Shared development, asserts the displayed CORS origin equals the mounted iframe origin, and verifies the deployed child referrer and connection policies. No real user key is required in CI. + +## Acceptance criteria + +1. A user can run a compatible workspace against a custom AG-UI endpoint. +2. A user can run a compatible workspace against a LangSmith URL and tab-memory API key. +3. Refresh retains endpoint metadata and forgets the key. +4. Repository search and automated tests prove no key persistence or URL transport path exists. +5. Every compatible Angular runtime uses the shared target integration. +6. Failure states are actionable and contain no secret or arbitrary remote response text. +7. Shared development and standalone example behavior remain unchanged. diff --git a/docs/superpowers/specs/2026-09-01-unified-workspace-shell-design.md b/docs/superpowers/specs/2026-09-01-unified-workspace-shell-design.md new file mode 100644 index 000000000..1aa84023f --- /dev/null +++ b/docs/superpowers/specs/2026-09-01-unified-workspace-shell-design.md @@ -0,0 +1,196 @@ +# Unified Docs and Cockpit workspace shell + +## Status + +Approved through interactive design review on 2026-09-01. This is release 1 of the unified control-plane program. + +## Summary + +Replace the current cross-origin Docs-to-Cockpit handoff with one long-lived application shell hosted by `apps/website`. Docs, Run, Code, and API become modes of the same workspace. The rail, context pane, selected capability, runtime controller, and utility state remain mounted while only the main panel changes. + +The website is the host because it already owns canonical docs URLs, MDX rendering, search, SEO, and public navigation. Existing Cockpit panels and runtime behavior move behind shared workspace boundaries rather than being rewritten. Legacy Cockpit remains available during migration and redirects only after the unified surface reaches production parity. + +## Baseline + +- Docs and Cockpit are separate Next.js applications on separate origins. +- Both use the shared `@threadplane/ui-react` rail and context primitives. +- Docs owns the canonical MDX route and a configuration-only Runtime preview. +- Cockpit owns Run, Code, Docs, API, Runtime, Activity, and Settings. +- `@threadplane/cockpit-registry` maps capability and docs identities, but Docs configuration and Cockpit presentation data still have separate owners. +- Existing Docs rail links perform a full navigation to `cockpit.threadplane.ai`. + +## Goals + +1. Keep canonical docs content and SEO intact. +2. Switch Docs, Run, Code, and API without leaving or remounting the workspace shell. +3. Give Docs and operational modes one capability identity, preference store, and utility model. +4. Preserve all shipped Cockpit behavior while changing its host. +5. Provide a truthful route for runnable capabilities that have no canonical docs page. +6. Make legacy Cockpit URLs deterministic redirects after parity is verified. +7. Establish the stable memory boundary required by release 2 custom runtime credentials. + +## Non-goals + +- Custom AG-UI or LangSmith targets; those are release 2. +- Command palette, pins, recents, or Activity filters; those are release 3. +- Account sync, authentication, or server-side workspace state. +- Rewriting Angular examples or the runtime bridge protocol. +- Moving marketing pages into the workspace shell. +- Removing the old Cockpit deployment before production parity. + +## Product decisions + +1. `apps/website` is the unified host. +2. Existing `/docs/...` routes remain canonical and server-rendered. +3. A safe `mode=docs|run|code|api` query parameter represents the selected panel. No target URL, credential, prompt, or runtime payload enters the page URL. +4. Capabilities without a canonical docs page use `/workspace/[product]/[topic]` in the same application and shell. +5. Unsupported docs pages keep Docs available and disable unavailable operational modes with an explanation. They do not guess a capability or fall back to a generic home page. +6. The current Cockpit app is frozen as a migration fallback once the unified host ships. + +## Workspace identity + +Create one registry-derived identity contract: + +```ts +interface WorkspaceIdentity { + id: string; + product: CockpitProduct; + section: string; + topic: string; + page: string; + language: 'python' | 'typescript'; + title: string; + docsPath: string | null; + workspacePath: string; + runtimeAdapter: 'langgraph' | 'ag-ui' | 'none'; +} + +type WorkspaceResolution = + | { kind: 'mapped'; identity: WorkspaceIdentity } + | { + kind: 'docs-only'; + docsPath: string; + title: string; + unavailableReason: 'no-workspace-capability'; + }; +``` + +`id` is a stable registry key, not a URL. `docsPath` is null when no canonical narrative page exists. `workspacePath` always exists for runnable or inspectable Cockpit entries. + +The route resolver returns `WorkspaceResolution`, not a fabricated identity. A `docs-only` resolution has no capability ID, workspace path, runtime adapter, or operational content descriptors. The provider accepts that discriminated state, keeps Docs active, disables Run, Code, and API, and exposes the `unavailableReason` for accessible UI copy. Only a `mapped` resolution may enter capability navigation, runtime configuration, recents, legacy redirects, or workspace-only routes. + +`@threadplane/cockpit-registry` becomes the authority for: + +- Docs-to-capability mapping. +- Capability-to-docs mapping. +- Canonical workspace paths. +- Legacy Cockpit paths. +- Runtime adapter classification. +- Code, backend, narrative, and API content descriptors. + +Generation or drift tests must fail on ambiguous mappings, duplicate IDs, invalid paths, or runnable entries without a runtime adapter. + +## Application architecture + +### Server route boundary + +The website route resolves a `WorkspaceResolution` and server-rendered Docs content. It passes the serializable resolution and content descriptor to a client workspace boundary. MDX remains server-rendered and crawlable even though the shell is interactive. + +### Workspace provider + +The provider owns: + +- Active mode. +- Active workspace resolution and, only when mapped, its identity. +- Runtime controller and Activity state. +- Control-plane disclosure preferences. +- Active utility and focus-restoration state. +- A server-rendered Docs slot. +- Resolved Run, Code, and API panel data. + +The provider must live above mode panels so switching modes cannot unmount the runtime iframe or discard later memory-only credentials. + +### Component boundaries + +- Shared structural primitives stay in `@threadplane/ui-react`. +- Workspace-specific state and shell components move from `apps/cockpit` into a private React workspace library or an equivalent importable library boundary. +- `apps/website` owns route composition and provides the Docs slot. +- `apps/cockpit` temporarily consumes the same workspace library during migration so parity can be verified without maintaining two shell implementations. +- Mode panels remain isolated components with explicit identity and content inputs. + +No library may import from an application directory. + +## Routing and history + +- `/docs/[library]/[section]/[slug]` defaults to Docs. +- `?mode=run`, `?mode=code`, and `?mode=api` deep-link to mapped panels. +- `/workspace/[product]/[topic]` defaults to Run for runnable entries and Docs for narrative-only entries. +- Explicit mode changes update browser history without remounting the provider. +- Back and Forward restore modes and identities. +- Canonical metadata for docs pages omits `mode`. +- Unknown or incompatible modes fall back to the route's truthful default. + +Legacy Cockpit paths map through the registry. Redirect activation is a separate, reversible deployment step after production smoke proves the new destination. + +## Information architecture + +The desktop shell retains the approved three-column geometry: + +1. Mode and utility rail. +2. Context pane. +3. Main panel. + +The context pane contains Scope, mode-specific navigation, and Runtime. Activity and Settings replace the context content temporarily, preserve the selected mode, support Escape, and restore focus to their invokers. + +Docs page actions stay in the existing top-right ellipsis menu. The main article remains visually primary; operational controls do not become an application dashboard header. + +## Responsive behavior + +- Desktop at `64rem` and wider keeps the persistent rail and context pane. +- Tablet from `48rem` through `63.999rem` keeps the rail and collapses the context pane behind its trigger. +- Mobile below `48rem` uses one modal navigation sheet and a compact mode strip. +- Escape or an explicit close from the normal context view dismisses the tablet or mobile surface and restores focus to its navigation invoker. +- Selecting a mode or capability closes the surface and moves focus to the destination panel heading after navigation completes. +- Selecting Activity or Settings keeps the tablet or mobile surface open, replaces its context body, and moves focus to the utility heading. +- Closing a utility returns to the normal context body and restores focus to the utility control that opened it; a subsequent close dismisses the surface and restores the navigation invoker. +- The same semantic labels and ordering are used at every breakpoint. + +## Migration + +1. Add the unified identity contract and drift tests without changing routes. +2. Extract the existing Cockpit shell and mode panels behind an importable workspace boundary. +3. Render the existing Docs mode inside the workspace shell on website docs routes. +4. Enable Run, Code, and API for mapped pages and add `/workspace/...` routes. +5. Reach unit, E2E, accessibility, and production-smoke parity. +6. Activate registry-driven redirects from legacy Cockpit URLs. +7. Observe production before removing the fallback deployment in a later task. + +Each step must be deployable without a flag-day dependency on the next. + +## Error handling + +- A missing mapping leaves the docs page functional and operational modes disabled. +- A mode panel failure is contained by a panel boundary and does not replace Docs or navigation. +- Existing Runtime and Activity error boundaries remain active. +- Invalid legacy routes return the existing not-found behavior rather than a guessed redirect. +- A failed redirect rollout can be reversed without rebuilding the unified workspace. + +## Testing + +- Registry tests for uniqueness, round trips, and canonical path stability. +- Provider tests proving mode changes keep the runtime panel mounted. +- Route tests for docs pages, workspace-only capabilities, query modes, and legacy mappings. +- Component tests for disabled unavailable modes, focus restoration, and utility semantics. +- Browser E2E for Docs → Run → Code → API → Docs on one route. +- Tablet and mobile utility replacement, focus restoration, navigation, and history restoration E2E. +- Existing Cockpit runtime, Activity, Settings, forced-colors, reduced-motion, and production-smoke coverage must pass against the unified host before redirects activate. + +## Acceptance criteria + +1. A mapped docs page can switch among all four modes without a cross-origin navigation. +2. The runtime iframe is not remounted by ordinary mode or utility changes. +3. Canonical docs markup remains server-rendered and indexed at its existing URL. +4. Unmapped docs pages make no false capability claim. +5. Workspace-only capabilities have stable same-origin routes. +6. Legacy redirects are registry-derived and covered before activation. +7. Existing control-plane accessibility and production-smoke behavior is preserved. diff --git a/docs/superpowers/specs/2026-09-01-workspace-control-plane-v2-design.md b/docs/superpowers/specs/2026-09-01-workspace-control-plane-v2-design.md new file mode 100644 index 000000000..1a5a66ce7 --- /dev/null +++ b/docs/superpowers/specs/2026-09-01-workspace-control-plane-v2-design.md @@ -0,0 +1,188 @@ +# Workspace control-plane v2 navigation and polish + +## Status + +Approved through interactive design review on 2026-09-01. This is release 3 of the unified control-plane program and depends on both the unified workspace shell and custom runtime targets. + +## Summary + +Turn the unified rail and context pane into a fast, restrained workspace control plane. Add a unified command palette, device-local pins and recents, Activity filters, and complete the requested visual and interaction polish across Docs and operational modes. + +The design remains rail-first and context-led. It does not become a dashboard, launcher grid, or dense toolbar. Modern icons support recognition, but every action retains an accessible name and keyboard path. + +## Goals + +1. Make any capability, mode, or safe runtime action reachable through one palette. +2. Keep important and recent capabilities visible without overwhelming primary navigation. +3. Make Activity useful during diagnosis without persisting operational history. +4. Finish the approved minimal headings, rounded active states, modern iconography, and three-dot page actions. +5. Provide robust mobile, tablet, Firefox, Safari/WebKit, forced-color, and reduced-motion behavior. +6. Normalize remaining product labels, including `AG-UI`. + +## Non-goals + +- Account-synced pins or recents. +- Persistent Activity history. +- Arbitrary shell commands, destructive runtime actions, or credential-bearing palette commands. +- Cloud search or a new indexing backend. +- Replacing docs content hierarchy with a flat launcher. +- New behavioral analytics beyond existing allowlisted event contracts needed for current features. + +## Preference model + +Create a versioned preference migration from both predecessor records: + +- `threadplane:control-plane:v1`, whose exact `ControlPlanePreferencesV1` shape contains Docs and Cockpit disclosure state plus `cockpit.activeMode`. +- `threadplane:runtime-targets:v1`, whose exact `RuntimeTargetPreferencesV1` shape contains `selectedTargetId` and `savedTargets` but no credentials. + +The new authoritative `threadplane:workspace-preferences:v2` record stores only non-secret state: + +```ts +interface WorkspacePreferencesV2 { + version: 2; + expanded: Record; + lastMode: ControlPlaneMode; + pins: string[]; + recents: string[]; + activityFilter: 'all' | 'runtime' | 'navigation' | 'errors'; + selectedTargetId: string; + savedTargets: SavedRuntimeTarget[]; +} +``` + +Rules: + +- Maximum 12 pins and 8 recents. +- Attempting to add a thirteenth pin leaves the existing pins unchanged and announces `You can pin up to 12 capabilities`; it never silently evicts a pin. +- Adding a ninth unique recent item evicts the oldest item after deduplication. +- IDs must resolve through the current workspace registry; unknown IDs are dropped on read. +- Recents are unique, newest first, and updated only on a successful workspace navigation. +- The current workspace may appear in Pinned but is not duplicated in Recent rendering. +- A failed or malformed migration falls back to defaults without deleting unrelated browser data. +- When no v2 record exists, migration validates each predecessor independently, preserves every valid disclosure and target field, maps `cockpit.activeMode` to `lastMode`, and writes v2 only after constructing a complete valid record. The predecessor keys remain during this release for rollback but are no longer authoritative after a successful v2 write. +- Activity events and API keys are structurally absent from the preference type. +- On initial navigation, an explicit valid `mode` query wins, followed by the canonical route default from release 1. `lastMode` never overrides a deep link or the Docs default; it is used only by mode-preserving in-shell navigation that has no explicit destination mode. + +## Context hierarchy + +Desktop context order: + +1. Command trigger. +2. Scope. +3. Pinned, when non-empty. +4. Recent, when non-empty. +5. Mode-specific Learn or Capability navigation. +6. Runtime. + +Empty Pinned and Recent sections are omitted. The shell does not render instructional empty-state cards in the persistent pane. + +Section headings use quiet title case, the shared sans font, moderate weight, and readable size. They are not uppercase or letter-spaced microcopy. + +## Command palette + +`Cmd+K` on macOS and `Ctrl+K` elsewhere opens the palette from any mode. The visible context trigger opens the same surface. + +Palette groups: + +- Pinned. +- Recent. +- Capabilities. +- Documentation. +- Modes. +- Safe runtime commands. + +Safe runtime commands are limited to Recheck, Reload runtime, Open runtime, Copy diagnostics, and Configure runtime. Disabled commands remain discoverable with a reason. Clearing Activity stays in the Activity menu. Removing targets and clearing credentials stay in Settings. + +Behavior: + +- Local search only. Capability results are registry-derived; Documentation results reuse the existing Docs search index and ranking. +- Match product, title, topic, section, and stable aliases. +- Arrow keys move through results; Home and End jump; Enter selects; Escape closes and restores focus. +- Results use semantic links for navigation and buttons for commands. +- The active item and query are never persisted. +- Credentials, endpoints, Activity summaries, and remote response text are not searchable. +- Mobile uses the same dialog and semantics. +- The existing Docs search trigger opens this palette, and `Cmd+K` or `Ctrl+K` has exactly one workspace-level handler. The prior standalone Docs-search dialog is removed only after its indexing, ranking, keyboard navigation, and accessible labeling are covered in the unified palette. + +## Pins and recents + +- The page header exposes Pin or Unpin as a direct reversible icon action. +- The action has a visible tooltip, pressed state, and live-region confirmation. +- Context entries use modern Pin and History icons only where they add recognition; row text remains visible. +- Selecting an entry navigates within the mounted workspace provider. +- The palette and context sections use the same selectors and ordering helpers. + +## Activity filters + +Activity remains newest-first, memory-only, and capped by the existing reducer. Add four filters: + +- All. +- Runtime. +- Navigation. +- Errors. + +Filtering never changes the underlying event list or attention indicator. The selected filter may persist locally because it contains no operational data. Empty filtered results state `No matching session activity`. Clear session activity remains in the three-dot menu. + +Every `SessionActivityEvent` has an allowlisted `category: 'runtime' | 'navigation'` assigned by the central event factory. Existing runtime/check/reload/open/diagnostics/configuration events and release 2 target events are `runtime`. The only navigation inputs are `mode_changed` and a new `capability_changed` event emitted after a successful registry-resolved in-shell capability navigation; it contains stable from/to capability IDs and no URL. Filter predicates are deterministic: + +- All: every event. +- Runtime: `event.category === 'runtime'`. +- Navigation: `event.category === 'navigation'`. +- Errors: `event.severity === 'error'`, intentionally overlapping the two domain categories. + +New event kinds must declare category and severity in the same exhaustive factory before they compile. + +## Visual design + +- Active and hover rows use rounded backgrounds consistent with docs cards and inputs. +- Remove the disliked active left-border or rounded-left-edge marker from the website, docs navigation, and workspace navigation. +- Use current Lucide icons with consistent 2px stroke weight and optical size. +- Replace remaining thin or hand-drawn carets with the shared chevron treatment. +- Keep icon-only quick actions in toolbars; labels remain available through accessible tooltips. +- Keep `On this page` and `Copy page as Markdown` inside the page ellipsis menu. +- Refine that menu's spacing, focus ring, selection feedback, and narrow-screen placement. +- Normalize product presentation through shared labels: `AG-UI`, `LangGraph`, `Deep Agents`, `A2UI`, and `JSON Render` where applicable. +- Preserve serif article headings; control-plane section headings remain minimal sans-serif. + +## Responsive and browser behavior + +- Desktop: persistent rail and context pane. +- Tablet: collapsible context pane with persistent mode access. +- Mobile: modal context sheet, compact mode strip, 44px minimum targets, inert background, scroll isolation, and deterministic focus restoration. +- Tooltips never become the only mobile label. +- Menus and palette stay within the visual viewport and respect safe areas. +- Motion used for disclosure or panel changes is removed under reduced-motion. +- Forced colors preserve boundaries, current state, focus, and attention without relying on background color. + +Add focused Chromium, Firefox, and WebKit shell E2E. The full capability matrix may remain Chromium-only; cross-browser coverage targets the unified shell, palette, Settings, Activity, menus, and one representative runtime. + +## Error handling + +- Blocked storage leaves pins, recents, and preferences as in-memory progressive enhancement. +- Stale registry IDs are discarded, not rendered as broken links. +- Palette errors close only the palette and do not replace the workspace. +- Activity boundary behavior remains isolated. +- A missing icon or label mapping falls back to a readable title, never a raw lowercase product ID when an approved product label exists. + +## Testing + +- Two-record-to-v2 preference migration, field preservation, route precedence, bounds, deduplication, and malformed storage tests. +- Pure palette indexing and matching tests. +- Docs search parity and single-shortcut-handler tests. +- Component tests for keyboard traversal, focus restoration, pressed pin state, filtered Activity, and menu placement. +- CSS contract tests for rounded active states and absence of active left-border treatments. +- Label tests for AG-UI, A2UI, and JSON Render. +- Responsive E2E at mobile, tablet, and desktop widths. +- Forced-colors and reduced-motion E2E. +- Representative Chromium, Firefox, and WebKit shell E2E. +- Chrome visual audit for Docs, Run, Code, API, palette, Activity, Settings, custom target errors, and mobile sheet. + +## Acceptance criteria + +1. A keyboard-only user can reach any capability, mode, pin, recent item, or safe runtime command through the palette. +2. Pins and recents survive refresh without storing Activity or credentials. +3. Activity filters work without mutating history or attention state. +4. No active navigation treatment uses a left border or partial rounded edge. +5. Page-specific secondary actions remain in a polished three-dot menu. +6. Product labels and icons are consistent across Docs and operational modes. +7. The representative unified shell passes Chromium, Firefox, and WebKit coverage plus accessibility media-state tests. From a96fc43df013136af644146ce47bc3cc6dc11091 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Tue, 1 Sep 2026 17:06:35 -0700 Subject: [PATCH 2/2] feat: unify docs and cockpit workspace --- .../cockpit/cockpit-capability-wiring.spec.ts | 106 +- apps/cockpit/cockpit-e2e-wiring.spec.ts | 1 + apps/cockpit/e2e/control-plane.spec.ts | 47 +- apps/cockpit/e2e/production-smoke.spec.ts | 31 + apps/cockpit/package.json | 3 +- apps/cockpit/project.json | 12 + apps/cockpit/scripts/deploy-smoke.spec.ts | 67 +- apps/cockpit/scripts/deploy-smoke.ts | 67 +- apps/cockpit/src/app/[...slug]/page.spec.tsx | 99 +- apps/cockpit/src/app/[...slug]/page.tsx | 61 +- apps/cockpit/src/app/cockpit.css | 1073 +------------ apps/cockpit/src/app/page.tsx | 26 +- .../src/components/cockpit-shell.spec.tsx | 568 +++++-- apps/cockpit/src/components/cockpit-shell.tsx | 635 +++----- .../control-plane/cockpit-control-plane.tsx | 261 ---- .../src/components/pane-rendering.spec.tsx | 27 +- .../sidebar/language-picker.spec.tsx | 58 - .../components/sidebar/navigation-groups.tsx | 128 -- apps/cockpit/src/lib/analytics/events.ts | 2 +- apps/cockpit/src/lib/cockpit-page.spec.ts | 195 +++ apps/cockpit/src/lib/cockpit-page.ts | 141 +- apps/cockpit/src/lib/content-bundle.spec.ts | 195 --- apps/cockpit/src/lib/extract-docs.ts | 161 -- apps/cockpit/src/lib/route-resolution.ts | 293 ---- .../src/lib/verify-shared-deployment.spec.ts | 1 + apps/cockpit/tsconfig.json | 47 +- apps/website/e2e/docs-shell.spec.ts | 44 +- apps/website/e2e/docs.spec.ts | 7 +- apps/website/e2e/nav-height.spec.ts | 26 +- apps/website/e2e/website.spec.ts | 110 +- apps/website/e2e/workspace-shell.spec.ts | 480 ++++++ apps/website/next.config.ts | 28 + apps/website/playwright.config.ts | 24 +- apps/website/project.json | 4 + apps/website/src/app/api/ingest/route.spec.ts | 29 +- apps/website/src/app/api/ingest/route.ts | 40 +- .../[library]/[section]/[slug]/page.spec.tsx | 101 ++ .../docs/[library]/[section]/[slug]/page.tsx | 176 ++- apps/website/src/app/global.css | 5 + apps/website/src/app/layout.tsx | 21 +- .../workspace/[product]/[topic]/page.spec.tsx | 76 + .../app/workspace/[product]/[topic]/page.tsx | 91 ++ .../src/components/docs/DocsControlPlane.tsx | 44 +- .../src/components/docs/DocsSidebar.tsx | 3 + .../src/components/docs/DocsTOC.spec.tsx | 109 ++ apps/website/src/components/docs/DocsTOC.tsx | 18 +- .../components/shared/AnnouncementToast.tsx | 34 +- apps/website/src/components/shared/Nav.tsx | 349 +++-- .../workspace/WebsiteWorkspace.spec.tsx | 727 +++++++++ .../components/workspace/WebsiteWorkspace.tsx | 474 ++++++ apps/website/src/lib/analytics/events.ts | 5 + apps/website/src/lib/workspace-page.spec.ts | 161 ++ apps/website/src/lib/workspace-page.ts | 57 + apps/website/src/styles/chrome.css | 7 +- apps/website/src/styles/docs.css | 66 +- .../src/styles/style-contracts.spec.ts | 55 + apps/website/tsconfig.json | 10 +- libs/cockpit-registry/project.json | 15 + libs/cockpit-registry/src/index.ts | 2 + .../src/lib/content-descriptors.spec.ts | 289 ++++ .../src/lib/content-descriptors.ts | 1056 +++++++++++++ .../cockpit-registry/src/lib/manifest.spec.ts | 6 +- libs/cockpit-registry/src/lib/manifest.ts | 47 +- .../src/lib/manifest.types.ts | 40 +- .../src/lib/validate-manifest.spec.ts | 130 +- .../src/lib/validate-manifest.ts | 106 +- .../src/lib/workspace-resolution.spec.ts | 130 ++ .../src/lib/workspace-resolution.ts | 113 ++ libs/cockpit-shell/package.json | 7 +- libs/cockpit-shell/project.json | 24 +- libs/cockpit-shell/src/index.ts | 4 + .../src/lib/extract-docs.spec.ts | 71 +- libs/cockpit-shell/src/lib/extract-docs.ts | 336 ++++ .../src/lib/render-markdown.spec.ts | 65 +- .../cockpit-shell}/src/lib/render-markdown.ts | 159 +- .../src/lib/workspace-content.spec.ts | 428 +++++ .../src/lib/workspace-content.ts | 96 +- .../src/lib/workspace-presentation.spec.ts | 194 ++- .../src/lib/workspace-presentation.ts | 263 ++++ libs/cockpit-shell/tsconfig.json | 3 +- libs/cockpit-shell/tsconfig.lib.json | 2 +- libs/cockpit-shell/vite.config.mts | 11 + .../lib/control-plane/control-plane.spec.tsx | 120 +- .../src/lib/control-plane/control-plane.tsx | 113 +- libs/workspace-react/package.json | 28 + libs/workspace-react/project.json | 35 + libs/workspace-react/src/index.ts | 42 + .../workspace-react/src/lib/activity-types.ts | 1 + .../components/api-mode/api-mode.spec.tsx | 31 +- .../src/lib}/components/api-mode/api-mode.tsx | 69 +- .../components/code-mode/code-mode.spec.tsx | 0 .../lib}/components/code-mode/code-mode.tsx | 2 +- .../components/code-mode/file-tree.spec.tsx | 26 +- .../lib}/components/code-mode/file-tree.tsx | 0 .../code-mode/file-tree.utils.spec.ts | 42 +- .../components/code-mode/file-tree.utils.ts | 32 +- .../lib}/components/code-pane/code-pane.tsx | 1 - .../activity-panel-boundary.spec.tsx | 0 .../control-plane/activity-panel-boundary.tsx | 2 +- .../control-plane/activity-panel.spec.tsx | 21 +- .../control-plane/activity-panel.tsx | 3 +- .../cockpit-control-plane.spec.tsx | 66 +- .../control-plane/cockpit-control-plane.tsx | 340 ++++ .../control-plane-overflow-menu.spec.tsx | 0 .../control-plane-overflow-menu.tsx | 2 +- .../control-plane/runtime-section.spec.tsx | 8 +- .../control-plane/runtime-section.tsx | 4 +- .../components/mobile-nav-overlay.spec.tsx | 244 ++- .../lib}/components/mobile-nav-overlay.tsx | 191 ++- .../components/modes/mode-switcher.spec.tsx | 12 +- .../lib}/components/modes/mode-switcher.tsx | 23 +- .../narrative-docs/narrative-docs.spec.tsx | 11 +- .../narrative-docs/narrative-docs.tsx | 15 +- .../components/run-mode/run-mode.spec.tsx | 7 +- .../src/lib}/components/run-mode/run-mode.tsx | 30 +- .../sidebar/cockpit-sidebar.spec.tsx | 7 +- .../components/sidebar/cockpit-sidebar.tsx | 13 +- .../sidebar/language-picker.spec.tsx | 117 ++ .../components/sidebar/language-picker.tsx | 41 +- .../sidebar/navigation-groups.spec.tsx | 95 +- .../components/sidebar/navigation-groups.tsx | 154 ++ .../src/lib}/components/ui/tabs.tsx | 2 +- .../src/lib/docs-links.spec.ts | 22 +- .../workspace-react}/src/lib/docs-links.ts | 0 libs/workspace-react/src/lib/host-services.ts | 65 + libs/workspace-react/src/lib/mode-panels.tsx | 40 + .../src/lib/navigation-labels.ts | 0 .../src/lib/public-api.spec.tsx | 174 +++ .../src/lib/runtime-contracts.ts | 6 + .../lib/runtime/runtime-diagnostics.spec.ts | 0 .../src/lib/runtime/runtime-diagnostics.ts | 0 .../src/lib/runtime/runtime-state.spec.ts | 0 .../src/lib/runtime/runtime-state.ts | 0 .../src/lib/runtime/session-activity.spec.ts | 0 .../src/lib/runtime/session-activity.ts | 0 .../runtime/use-runtime-controller.spec.tsx | 0 .../src/lib/runtime/use-runtime-controller.ts | 0 .../src/lib/workspace-contracts.ts | 73 + .../src/lib/workspace-navigation.ts | 55 + .../src/lib/workspace-provider.spec.tsx | 337 ++++ .../src/lib/workspace-provider.tsx | 468 ++++++ .../src/lib/workspace-shell.spec.tsx | 447 ++++++ .../src/lib/workspace-shell.tsx | 507 ++++++ libs/workspace-react/src/styles/workspace.css | 1390 +++++++++++++++++ libs/workspace-react/tsconfig.json | 9 + libs/workspace-react/tsconfig.lib.json | 29 + libs/workspace-react/vite.config.mts | 11 + package-lock.json | 31 +- tsconfig.base.json | 2 + 149 files changed, 13298 insertions(+), 3601 deletions(-) delete mode 100644 apps/cockpit/src/components/control-plane/cockpit-control-plane.tsx delete mode 100644 apps/cockpit/src/components/sidebar/language-picker.spec.tsx delete mode 100644 apps/cockpit/src/components/sidebar/navigation-groups.tsx create mode 100644 apps/cockpit/src/lib/cockpit-page.spec.ts delete mode 100644 apps/cockpit/src/lib/content-bundle.spec.ts delete mode 100644 apps/cockpit/src/lib/extract-docs.ts delete mode 100644 apps/cockpit/src/lib/route-resolution.ts create mode 100644 apps/website/e2e/workspace-shell.spec.ts create mode 100644 apps/website/src/app/docs/[library]/[section]/[slug]/page.spec.tsx create mode 100644 apps/website/src/app/workspace/[product]/[topic]/page.spec.tsx create mode 100644 apps/website/src/app/workspace/[product]/[topic]/page.tsx create mode 100644 apps/website/src/components/docs/DocsTOC.spec.tsx create mode 100644 apps/website/src/components/workspace/WebsiteWorkspace.spec.tsx create mode 100644 apps/website/src/components/workspace/WebsiteWorkspace.tsx create mode 100644 apps/website/src/lib/workspace-page.spec.ts create mode 100644 apps/website/src/lib/workspace-page.ts create mode 100644 libs/cockpit-registry/src/lib/content-descriptors.spec.ts create mode 100644 libs/cockpit-registry/src/lib/content-descriptors.ts create mode 100644 libs/cockpit-registry/src/lib/workspace-resolution.spec.ts create mode 100644 libs/cockpit-registry/src/lib/workspace-resolution.ts rename {apps/cockpit => libs/cockpit-shell}/src/lib/extract-docs.spec.ts (58%) create mode 100644 libs/cockpit-shell/src/lib/extract-docs.ts rename {apps/cockpit => libs/cockpit-shell}/src/lib/render-markdown.spec.ts (64%) rename {apps/cockpit => libs/cockpit-shell}/src/lib/render-markdown.ts (59%) create mode 100644 libs/cockpit-shell/src/lib/workspace-content.spec.ts rename apps/cockpit/src/lib/content-bundle.ts => libs/cockpit-shell/src/lib/workspace-content.ts (59%) rename apps/cockpit/src/lib/route-resolution.spec.ts => libs/cockpit-shell/src/lib/workspace-presentation.spec.ts (57%) create mode 100644 libs/cockpit-shell/src/lib/workspace-presentation.ts create mode 100644 libs/cockpit-shell/vite.config.mts create mode 100644 libs/workspace-react/package.json create mode 100644 libs/workspace-react/project.json create mode 100644 libs/workspace-react/src/index.ts create mode 100644 libs/workspace-react/src/lib/activity-types.ts rename {apps/cockpit/src => libs/workspace-react/src/lib}/components/api-mode/api-mode.spec.tsx (79%) rename {apps/cockpit/src => libs/workspace-react/src/lib}/components/api-mode/api-mode.tsx (68%) rename {apps/cockpit/src => libs/workspace-react/src/lib}/components/code-mode/code-mode.spec.tsx (100%) rename {apps/cockpit/src => libs/workspace-react/src/lib}/components/code-mode/code-mode.tsx (98%) rename {apps/cockpit/src => libs/workspace-react/src/lib}/components/code-mode/file-tree.spec.tsx (74%) rename {apps/cockpit/src => libs/workspace-react/src/lib}/components/code-mode/file-tree.tsx (100%) rename {apps/cockpit/src => libs/workspace-react/src/lib}/components/code-mode/file-tree.utils.spec.ts (76%) rename {apps/cockpit/src => libs/workspace-react/src/lib}/components/code-mode/file-tree.utils.ts (71%) rename {apps/cockpit/src => libs/workspace-react/src/lib}/components/code-pane/code-pane.tsx (91%) rename {apps/cockpit/src => libs/workspace-react/src/lib}/components/control-plane/activity-panel-boundary.spec.tsx (100%) rename {apps/cockpit/src => libs/workspace-react/src/lib}/components/control-plane/activity-panel-boundary.tsx (95%) rename {apps/cockpit/src => libs/workspace-react/src/lib}/components/control-plane/activity-panel.spec.tsx (94%) rename {apps/cockpit/src => libs/workspace-react/src/lib}/components/control-plane/activity-panel.tsx (98%) rename {apps/cockpit/src => libs/workspace-react/src/lib}/components/control-plane/cockpit-control-plane.spec.tsx (85%) create mode 100644 libs/workspace-react/src/lib/components/control-plane/cockpit-control-plane.tsx rename {apps/cockpit/src => libs/workspace-react/src/lib}/components/control-plane/control-plane-overflow-menu.spec.tsx (100%) rename {apps/cockpit/src => libs/workspace-react/src/lib}/components/control-plane/control-plane-overflow-menu.tsx (99%) rename {apps/cockpit/src => libs/workspace-react/src/lib}/components/control-plane/runtime-section.spec.tsx (98%) rename {apps/cockpit/src => libs/workspace-react/src/lib}/components/control-plane/runtime-section.tsx (99%) rename {apps/cockpit/src => libs/workspace-react/src/lib}/components/mobile-nav-overlay.spec.tsx (71%) rename {apps/cockpit/src => libs/workspace-react/src/lib}/components/mobile-nav-overlay.tsx (60%) rename {apps/cockpit/src => libs/workspace-react/src/lib}/components/modes/mode-switcher.spec.tsx (92%) rename {apps/cockpit/src => libs/workspace-react/src/lib}/components/modes/mode-switcher.tsx (83%) rename {apps/cockpit/src => libs/workspace-react/src/lib}/components/narrative-docs/narrative-docs.spec.tsx (91%) rename {apps/cockpit/src => libs/workspace-react/src/lib}/components/narrative-docs/narrative-docs.tsx (81%) rename {apps/cockpit/src => libs/workspace-react/src/lib}/components/run-mode/run-mode.spec.tsx (96%) rename {apps/cockpit/src => libs/workspace-react/src/lib}/components/run-mode/run-mode.tsx (77%) rename {apps/cockpit/src => libs/workspace-react/src/lib}/components/sidebar/cockpit-sidebar.spec.tsx (82%) rename {apps/cockpit/src => libs/workspace-react/src/lib}/components/sidebar/cockpit-sidebar.tsx (78%) create mode 100644 libs/workspace-react/src/lib/components/sidebar/language-picker.spec.tsx rename {apps/cockpit/src => libs/workspace-react/src/lib}/components/sidebar/language-picker.tsx (74%) rename {apps/cockpit/src => libs/workspace-react/src/lib}/components/sidebar/navigation-groups.spec.tsx (55%) create mode 100644 libs/workspace-react/src/lib/components/sidebar/navigation-groups.tsx rename {apps/cockpit/src => libs/workspace-react/src/lib}/components/ui/tabs.tsx (97%) rename {apps/cockpit => libs/workspace-react}/src/lib/docs-links.spec.ts (89%) rename {apps/cockpit => libs/workspace-react}/src/lib/docs-links.ts (100%) create mode 100644 libs/workspace-react/src/lib/host-services.ts create mode 100644 libs/workspace-react/src/lib/mode-panels.tsx rename {apps/cockpit => libs/workspace-react}/src/lib/navigation-labels.ts (100%) create mode 100644 libs/workspace-react/src/lib/public-api.spec.tsx create mode 100644 libs/workspace-react/src/lib/runtime-contracts.ts rename {apps/cockpit => libs/workspace-react}/src/lib/runtime/runtime-diagnostics.spec.ts (100%) rename {apps/cockpit => libs/workspace-react}/src/lib/runtime/runtime-diagnostics.ts (100%) rename {apps/cockpit => libs/workspace-react}/src/lib/runtime/runtime-state.spec.ts (100%) rename {apps/cockpit => libs/workspace-react}/src/lib/runtime/runtime-state.ts (100%) rename {apps/cockpit => libs/workspace-react}/src/lib/runtime/session-activity.spec.ts (100%) rename {apps/cockpit => libs/workspace-react}/src/lib/runtime/session-activity.ts (100%) rename {apps/cockpit => libs/workspace-react}/src/lib/runtime/use-runtime-controller.spec.tsx (100%) rename {apps/cockpit => libs/workspace-react}/src/lib/runtime/use-runtime-controller.ts (100%) create mode 100644 libs/workspace-react/src/lib/workspace-contracts.ts create mode 100644 libs/workspace-react/src/lib/workspace-navigation.ts create mode 100644 libs/workspace-react/src/lib/workspace-provider.spec.tsx create mode 100644 libs/workspace-react/src/lib/workspace-provider.tsx create mode 100644 libs/workspace-react/src/lib/workspace-shell.spec.tsx create mode 100644 libs/workspace-react/src/lib/workspace-shell.tsx create mode 100644 libs/workspace-react/src/styles/workspace.css create mode 100644 libs/workspace-react/tsconfig.json create mode 100644 libs/workspace-react/tsconfig.lib.json create mode 100644 libs/workspace-react/vite.config.mts diff --git a/apps/cockpit/cockpit-capability-wiring.spec.ts b/apps/cockpit/cockpit-capability-wiring.spec.ts index f15186f11..3bbc3f764 100644 --- a/apps/cockpit/cockpit-capability-wiring.spec.ts +++ b/apps/cockpit/cockpit-capability-wiring.spec.ts @@ -1,16 +1,18 @@ -import { cockpitManifest } from '@threadplane/cockpit-registry'; -import { capabilities } from './scripts/capability-registry'; import { - buildNavigationTree, capabilityModules, -} from './src/lib/route-resolution'; + cockpitManifest, +} from '@threadplane/cockpit-registry'; +import { capabilities } from './scripts/capability-registry'; +import { buildNavigationTree } from '@threadplane/cockpit-shell'; +import { existsSync, readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; /** * The cockpit site is assembled from three lists that nothing forced to agree: * * - `apps/cockpit/scripts/capability-registry.ts` — what serve/build/deploy know about; * - `libs/cockpit-registry` `cockpitManifest` — what the Next route can resolve; - * - `capabilityModules` in `route-resolution.ts` — what supplies a page's assets. + * - registry-owned `capabilityModules` — what supplies a page's assets. * * When the `runtimes` product shipped, only the first list learned about it, so * `/runtimes/core-capabilities//overview/` threw @@ -18,26 +20,46 @@ import { * the whole suite stayed green. These assertions are the missing coupling. */ describe('cockpit capability wiring', () => { - const manifestKey = (e: { product: string; section: string; topic: string }) => - `${e.product}/${e.section}/${e.topic}`; + const resolveCockpitConfig = (fileName: string): string => { + const workspaceConfigPath = resolve( + process.cwd(), + 'apps/cockpit', + fileName + ); + return existsSync(workspaceConfigPath) + ? workspaceConfigPath + : resolve(process.cwd(), fileName); + }; + + const manifestKey = (e: { + product: string; + section: string; + topic: string; + }) => `${e.product}/${e.section}/${e.topic}`; it('gives every registered capability a resolvable manifest entry', () => { const manifestKeys = new Set(cockpitManifest.map(manifestKey)); const unroutable = capabilities - .map((capability) => `${capability.product}/core-capabilities/${capability.topic}`) + .map( + (capability) => + `${capability.product}/core-capabilities/${capability.topic}` + ) .filter((key) => !manifestKeys.has(key)); expect(unroutable).toEqual([]); }); - it('gives every registered capability a cockpit module in route-resolution', () => { + it('gives every registered capability a registry-owned content descriptor', () => { const moduleKeys = new Set( capabilityModules.map((module) => manifestKey(module.manifestIdentity)) ); const unwired = capabilities - .map((capability) => `${capability.product}/core-capabilities/${capability.topic}`) + .map( + (capability) => + `${capability.product}/core-capabilities/${capability.topic}` + ) .filter((key) => !moduleKeys.has(key)); expect(unwired).toEqual([]); @@ -46,7 +68,8 @@ describe('cockpit capability wiring', () => { it('points every cockpit module at a capability that still exists', () => { const capabilityKeys = new Set( capabilities.map( - (capability) => `${capability.product}/core-capabilities/${capability.topic}` + (capability) => + `${capability.product}/core-capabilities/${capability.topic}` ) ); @@ -58,19 +81,25 @@ describe('cockpit capability wiring', () => { }); it('surfaces every manifest product in the navigation tree', () => { - const manifestProducts = [...new Set(cockpitManifest.map((entry) => entry.product))]; + const manifestProducts = [ + ...new Set(cockpitManifest.map((entry) => entry.product)), + ]; const navigationProducts = buildNavigationTree(cockpitManifest).map( (product) => product.product ); - expect([...manifestProducts].sort()).toEqual([...navigationProducts].sort()); + expect([...manifestProducts].sort()).toEqual( + [...navigationProducts].sort() + ); for (const product of buildNavigationTree(cockpitManifest)) { const entries = product.sections.flatMap((section) => section.entries); - expect({ product: product.product, empty: entries.length === 0 }).toEqual({ - product: product.product, - empty: false, - }); + expect({ product: product.product, empty: entries.length === 0 }).toEqual( + { + product: product.product, + empty: false, + } + ); } }); @@ -78,9 +107,48 @@ describe('cockpit capability wiring', () => { // `cockpitManifest` is typed `CockpitManifestEntry[]`, so a product that is // not in the union cannot appear here — the runtime check is that the // registry's products are all representable in the manifest. - const manifestProducts = new Set(cockpitManifest.map((entry) => entry.product)); + const manifestProducts = new Set( + cockpitManifest.map((entry) => entry.product) + ); const registryProducts = [...new Set(capabilities.map((c) => c.product))]; - expect(registryProducts.filter((p) => !manifestProducts.has(p))).toEqual([]); + expect(registryProducts.filter((p) => !manifestProducts.has(p))).toEqual( + [] + ); + }); + + it('has no direct project references to capability example lanes', () => { + const tsconfig = JSON.parse( + readFileSync(resolveCockpitConfig('tsconfig.json'), 'utf8') + ) as { references?: Array<{ path: string }> }; + + expect( + tsconfig.references?.filter((reference) => + reference.path.startsWith('../../cockpit/') + ) + ).toEqual([]); + }); + + it('includes external capability content assets in the Cockpit build inputs', () => { + const project = JSON.parse( + readFileSync(resolveCockpitConfig('project.json'), 'utf8') + ) as { + targets: { build: { inputs: string[] } }; + namedInputs: Record; + }; + + expect(project.targets.build.inputs).toEqual([ + 'default', + 'deploymentConfig', + 'contentAssets', + '^default', + ]); + expect(project.namedInputs['contentAssets']).toEqual([ + '{workspaceRoot}/cockpit/**/prompts/**', + '{workspaceRoot}/cockpit/**/angular/src/**', + '{workspaceRoot}/cockpit/**/python/src/**', + '{workspaceRoot}/cockpit/**/docs/**', + '{workspaceRoot}/deployments/ag-ui-mastra/*.mjs', + ]); }); }); diff --git a/apps/cockpit/cockpit-e2e-wiring.spec.ts b/apps/cockpit/cockpit-e2e-wiring.spec.ts index 32aec4244..7f766d87f 100644 --- a/apps/cockpit/cockpit-e2e-wiring.spec.ts +++ b/apps/cockpit/cockpit-e2e-wiring.spec.ts @@ -4,6 +4,7 @@ import { fileURLToPath } from 'node:url'; import { capabilities } from './scripts/capability-registry'; // @ts-expect-error — .mjs ES module without .d.ts; the e2e tsconfig uses // allowJs:true but this top-level test file doesn't go through that config. +// eslint-disable-next-line @nx/enforce-module-boundaries -- repo-root port registry is intentionally outside an Nx project. import { portsFor } from '../../cockpit/ports.mjs'; interface E2eWiring { diff --git a/apps/cockpit/e2e/control-plane.spec.ts b/apps/cockpit/e2e/control-plane.spec.ts index 490cd4b53..649ded2b7 100644 --- a/apps/cockpit/e2e/control-plane.spec.ts +++ b/apps/cockpit/e2e/control-plane.spec.ts @@ -1,6 +1,7 @@ import { expect, test, type Page } from '@playwright/test'; const route = '/langgraph/core-capabilities/streaming/overview/python'; +const RUN_RAIL_ITEM = /^Run(?:,|$)/; declare global { interface Window { @@ -108,12 +109,43 @@ test.describe('Cockpit operational control plane', () => { await expect(desktopNavigation).toBeVisible(); await expect(mobileTrigger).toBeHidden(); await expect( - page.getByRole('button', { name: 'Runtime', exact: true }) - ).toBeVisible(); - await page.getByRole('button', { name: 'Activity' }).click(); - await expect( - page.getByRole('heading', { name: 'Activity' }) + desktopNavigation.getByRole('button', { name: RUN_RAIL_ITEM }) ).toBeVisible(); + if (viewport.width >= 1024) { + await expect( + page.getByRole('button', { name: 'Runtime', exact: true }) + ).toBeVisible(); + await page.getByRole('button', { name: 'Activity' }).click(); + await expect( + page.getByRole('heading', { name: 'Activity' }) + ).toBeVisible(); + } else { + const contextTrigger = page.getByRole('button', { + name: 'Open context', + }); + await expect(contextTrigger).toBeVisible(); + await contextTrigger.click(); + const contextDialog = page.getByRole('dialog', { + name: 'Cockpit control plane context', + }); + await expect( + contextDialog.getByRole('button', { + name: 'Runtime', + exact: true, + }) + ).toBeVisible(); + await page.keyboard.press('Escape'); + await expect(contextDialog).toBeHidden(); + await expect(contextTrigger).toBeFocused(); + + await desktopNavigation + .getByRole('button', { name: 'Activity' }) + .click(); + await expect(contextDialog).toBeVisible(); + await expect( + contextDialog.getByRole('heading', { name: 'Activity' }) + ).toBeVisible(); + } } else { await expect(desktopNavigation).toBeHidden(); await expect(mobileTrigger).toBeVisible(); @@ -131,13 +163,16 @@ test.describe('Cockpit operational control plane', () => { '' ); await expect( - dialog.getByRole('button', { name: 'Runtime', exact: true }) + dialog.getByRole('button', { name: RUN_RAIL_ITEM }) ).toBeVisible(); await dialog.getByRole('button', { name: 'Activity' }).click(); await expect( dialog.getByRole('heading', { name: 'Activity' }) ).toBeVisible(); await dialog.getByRole('button', { name: 'Close Activity' }).click(); + await expect( + dialog.getByRole('button', { name: RUN_RAIL_ITEM }) + ).toBeVisible(); await expect( dialog.getByRole('button', { name: 'Runtime', exact: true }) ).toBeVisible(); diff --git a/apps/cockpit/e2e/production-smoke.spec.ts b/apps/cockpit/e2e/production-smoke.spec.ts index 8d088239e..84080a1bc 100644 --- a/apps/cockpit/e2e/production-smoke.spec.ts +++ b/apps/cockpit/e2e/production-smoke.spec.ts @@ -1,5 +1,9 @@ import { expect, test } from '@playwright/test'; import { capabilities } from '../scripts/capability-registry'; +import { + getRedirectDisabledProbePath, + getRegistryWebsiteDestinations, +} from '../scripts/deploy-smoke'; /** * Production smoke test: verifies the deployed cockpit shell and deployed @@ -20,6 +24,7 @@ const COCKPIT_URL = process.env['BASE_URL'] ?? 'https://cockpit.threadplane.ai'; const EXAMPLES_URL = process.env['EXAMPLES_URL'] ?? 'https://examples.threadplane.ai'; const DEMO_URL = process.env['DEMO_URL'] ?? 'https://demo.threadplane.ai'; +const WEBSITE_URL = process.env['WEBSITE_URL'] ?? 'https://threadplane.ai'; const CHAT_CAPABILITIES = [ 'langgraph/streaming', @@ -85,6 +90,20 @@ const AG_UI_TOPICS = capabilities .sort(); const SEND_RECEIVE_TIMEOUT_MS = 30_000; +const WEBSITE_DESTINATIONS = getRegistryWebsiteDestinations(); + +test.describe('Production: registry-owned Website destinations load', () => { + for (const destination of WEBSITE_DESTINATIONS) { + test(`${destination} is reachable`, async ({ request }) => { + const response = await request.get( + new URL(destination, WEBSITE_URL).toString() + ); + + expect(response.status()).toBeLessThan(400); + }); + } +}); + test.describe('Production: Angular chat example apps load', () => { for (const cap of CHAT_CAPABILITIES) { test(`${cap} loads at examples URL`, async ({ page }) => { @@ -176,6 +195,18 @@ test.describe('Production: cockpit shell loads', () => { expect(response.status()).toBeLessThan(400); }); + + test('legacy workspace redirects remain disabled before opt-in activation', async ({ + request, + }) => { + const response = await request.get( + new URL(getRedirectDisabledProbePath(), COCKPIT_URL).toString(), + { maxRedirects: 0 } + ); + + expect(response.status()).toBe(200); + expect(response.headers()['location']).toBeUndefined(); + }); }); test.describe('Production: canonical demo sends runtime telemetry', () => { diff --git a/apps/cockpit/package.json b/apps/cockpit/package.json index 7b15cf322..68a1e837e 100644 --- a/apps/cockpit/package.json +++ b/apps/cockpit/package.json @@ -4,10 +4,9 @@ "private": true, "dependencies": { "@radix-ui/react-slot": "^1.1.0", - "@radix-ui/react-tabs": "^1.1.0", + "@threadplane/workspace-react": "*", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", - "marked": "^15.0.0", "next": "~16.1.6", "posthog-js": "^1.372.6", "react": "^19.0.0", diff --git a/apps/cockpit/project.json b/apps/cockpit/project.json index 885ae6fc7..5cad9513d 100644 --- a/apps/cockpit/project.json +++ b/apps/cockpit/project.json @@ -30,6 +30,7 @@ "inputs": [ "default", "deploymentConfig", + "contentAssets", "^default" ] }, @@ -58,6 +59,10 @@ "configFile": "apps/cockpit/vite.config.mts" } }, + "lint": { + "executor": "@nx/eslint:lint", + "outputs": ["{options.outputFile}"] + }, "e2e": { "executor": "@nx/playwright:playwright", "options": { @@ -164,6 +169,13 @@ } }, "namedInputs": { + "contentAssets": [ + "{workspaceRoot}/cockpit/**/prompts/**", + "{workspaceRoot}/cockpit/**/angular/src/**", + "{workspaceRoot}/cockpit/**/python/src/**", + "{workspaceRoot}/cockpit/**/docs/**", + "{workspaceRoot}/deployments/ag-ui-mastra/*.mjs" + ], "deploymentConfig": [ "{workspaceRoot}/vercel.cockpit.json", "{workspaceRoot}/vercel.examples.json", diff --git a/apps/cockpit/scripts/deploy-smoke.spec.ts b/apps/cockpit/scripts/deploy-smoke.spec.ts index de71b6db9..659b5767d 100644 --- a/apps/cockpit/scripts/deploy-smoke.spec.ts +++ b/apps/cockpit/scripts/deploy-smoke.spec.ts @@ -1,5 +1,10 @@ import { describe, expect, it, vi } from 'vitest'; -import { parseDeploySmokeArgs, runDeploySmoke } from './deploy-smoke'; +import { + getRegistryWebsiteDestinations, + getRedirectDisabledProbePath, + parseDeploySmokeArgs, + runDeploySmoke, +} from './deploy-smoke'; describe('deploy smoke helper', () => { it('parses the deploy smoke command line', () => { @@ -12,9 +17,12 @@ describe('deploy smoke helper', () => { '5', '--retry-delay-ms', '1000', + '--website-url', + 'https://threadplane.ai', ]) ).toEqual({ url: 'https://cockpit.threadplane.ai', + websiteUrl: 'https://threadplane.ai', expectedTitle: 'Cockpit', dryRun: true, retries: 5, @@ -22,6 +30,25 @@ describe('deploy smoke helper', () => { }); }); + it('derives unique canonical Website destinations from the registry', () => { + const destinations = getRegistryWebsiteDestinations(); + + expect(destinations).toContain('/docs/langgraph/guides/streaming'); + expect(destinations).toContain('/workspace/langgraph/durable-execution'); + expect(destinations).toContain( + '/docs/deep-agents/capabilities/planning' + ); + expect(destinations).not.toContain('/workspace/deep-agents/overview'); + expect(destinations).toEqual([...destinations].sort()); + expect(new Set(destinations).size).toBe(destinations.length); + }); + + it('uses a registry-owned legacy route to prove redirects remain disabled', () => { + expect(getRedirectDisabledProbePath()).toBe( + '/langgraph/core-capabilities/streaming/overview/python' + ); + }); + it('formats dry-run output without performing a network request', async () => { await expect( runDeploySmoke({ @@ -57,4 +84,42 @@ describe('deploy smoke helper', () => { expect(fetchImpl).toHaveBeenCalledTimes(2); expect(sleep).toHaveBeenCalledTimes(1); }); + + it('verifies every canonical Website destination and the default-off redirect gate', async () => { + const cockpitUrl = 'https://cockpit.threadplane.ai'; + const websiteUrl = 'https://threadplane.ai'; + const destinations = getRegistryWebsiteDestinations(); + const redirectProbe = getRedirectDisabledProbePath(); + const fetchImpl = vi.fn( + async (input: string | URL | Request, init?: RequestInit) => { + const requestedUrl = String(input); + if (requestedUrl === cockpitUrl) { + return new Response('Cockpit', { status: 200 }); + } + if (requestedUrl === `${cockpitUrl}${redirectProbe}`) { + expect(init?.redirect).toBe('manual'); + return new Response('Cockpit', { status: 200 }); + } + return new Response('Threadplane', { status: 200 }); + } + ) as unknown as typeof fetch; + + await expect( + runDeploySmoke({ + url: cockpitUrl, + websiteUrl, + fetchImpl, + }) + ).resolves.toBe( + `pass:${cockpitUrl}:Cockpit:website:${destinations.length}:redirects-off` + ); + + expect(fetchImpl).toHaveBeenCalledTimes(destinations.length + 2); + for (const destination of destinations) { + expect(fetchImpl).toHaveBeenCalledWith(`${websiteUrl}${destination}`); + } + expect(fetchImpl).toHaveBeenCalledWith(`${cockpitUrl}${redirectProbe}`, { + redirect: 'manual', + }); + }); }); diff --git a/apps/cockpit/scripts/deploy-smoke.ts b/apps/cockpit/scripts/deploy-smoke.ts index dbf64db32..1e22a4602 100644 --- a/apps/cockpit/scripts/deploy-smoke.ts +++ b/apps/cockpit/scripts/deploy-smoke.ts @@ -1,7 +1,12 @@ import { resolve } from 'node:path'; +import { + cockpitManifest, + getWorkspaceDestinationPath, +} from '@threadplane/cockpit-registry'; export interface DeploySmokeOptions { url: string; + websiteUrl?: string; expectedTitle?: string; dryRun?: boolean; retries?: number; @@ -10,7 +15,7 @@ export interface DeploySmokeOptions { sleep?: (delayMs: number) => Promise; } -export interface ParsedDeploySmokeArgs extends DeploySmokeOptions {} +export type ParsedDeploySmokeArgs = DeploySmokeOptions; const DEFAULT_EXPECTED_TITLE = 'Cockpit'; const DEFAULT_RETRIES = 0; @@ -20,6 +25,27 @@ const defaultSleep = (delayMs: number): Promise => setTimeout(resolvePromise, delayMs); }); +export const getRegistryWebsiteDestinations = (): string[] => + [ + ...new Set( + cockpitManifest + .filter((entry) => entry.availableModes.length > 0) + .map(getWorkspaceDestinationPath) + ), + ].sort(); + +export const getRedirectDisabledProbePath = (): string => { + const streaming = cockpitManifest.find( + (entry) => entry.product === 'langgraph' && entry.topic === 'streaming' + ); + if (!streaming) { + throw new Error( + 'Deploy smoke requires the registry-owned LangGraph streaming route' + ); + } + return streaming.legacyPath; +}; + export const parseDeploySmokeArgs = (argv: string[]): ParsedDeploySmokeArgs => { const options: ParsedDeploySmokeArgs = { url: 'http://127.0.0.1:3000', @@ -44,6 +70,12 @@ export const parseDeploySmokeArgs = (argv: string[]): ParsedDeploySmokeArgs => { continue; } + if (current === '--website-url' && argv[index + 1]) { + options.websiteUrl = argv[index + 1]; + index += 1; + continue; + } + if (current === '--dry-run') { options.dryRun = true; continue; @@ -66,6 +98,7 @@ export const parseDeploySmokeArgs = (argv: string[]): ParsedDeploySmokeArgs => { export const runDeploySmoke = async ({ url, + websiteUrl, expectedTitle = DEFAULT_EXPECTED_TITLE, dryRun = false, retries = DEFAULT_RETRIES, @@ -94,6 +127,38 @@ export const runDeploySmoke = async ({ throw new Error(`Deploy smoke failed for ${url}: missing title ${expectedTitle}`); } + if (websiteUrl) { + const destinations = getRegistryWebsiteDestinations(); + for (const destination of destinations) { + const destinationUrl = new URL(destination, websiteUrl).toString(); + const destinationResponse = await fetchImpl(destinationUrl); + if (!destinationResponse.ok) { + throw new Error( + `Deploy smoke failed for ${destinationUrl}: ${destinationResponse.status} ${destinationResponse.statusText}` + ); + } + } + + const redirectProbeUrl = new URL( + getRedirectDisabledProbePath(), + url + ).toString(); + const redirectProbeResponse = await fetchImpl(redirectProbeUrl, { + redirect: 'manual', + }); + if ( + !redirectProbeResponse.ok || + (redirectProbeResponse.status >= 300 && + redirectProbeResponse.status < 400) + ) { + throw new Error( + `Deploy smoke failed for ${redirectProbeUrl}: legacy redirects must remain disabled before activation` + ); + } + + return `pass:${url}:${expectedTitle}:website:${destinations.length}:redirects-off`; + } + return `pass:${url}:${expectedTitle}`; } catch (error: unknown) { lastError = error instanceof Error ? error : new Error(String(error)); diff --git a/apps/cockpit/src/app/[...slug]/page.spec.tsx b/apps/cockpit/src/app/[...slug]/page.spec.tsx index da4ab68ed..fa9dd7915 100644 --- a/apps/cockpit/src/app/[...slug]/page.spec.tsx +++ b/apps/cockpit/src/app/[...slug]/page.spec.tsx @@ -7,19 +7,39 @@ vi.mock('next/navigation', () => ({ }), })); -vi.mock('../../lib/content-bundle', () => ({ - getContentBundle: vi.fn().mockResolvedValue({ - codeFiles: {}, - promptFiles: {}, - runtimeUrl: null, - docSections: [], - narrativeDocs: [], - }), -})); +vi.mock('@threadplane/cockpit-shell', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + getContentBundle: vi.fn().mockResolvedValue({ + codeFiles: {}, + promptFiles: {}, + runtimeUrl: null, + docSections: [], + narrativeDocs: [], + }), + }; +}); -import CockpitRoutePage from './page'; +import CockpitRoutePage, { + getCockpitRouteRedirect, + getLegacyRouteRedirect, +} from './page'; import { getCockpitPageModel } from '../../lib/cockpit-page'; +const enabledEnv = { + UNIFIED_WORKSPACE_REDIRECTS_ENABLED: 'true', + NEXT_PUBLIC_WEBSITE_ORIGIN: 'https://threadplane.ai', + NODE_ENV: 'production', +}; + +const renderRoute = (slug: string[]) => + CockpitRoutePage({ + params: Promise.resolve({ slug }), + searchParams: Promise.resolve({}), + }); + describe('CockpitRoutePage', () => { it('keys the rendered CockpitShell on the canonical path', async () => { const slug = [ @@ -31,9 +51,7 @@ describe('CockpitRoutePage', () => { ]; const { canonicalPath } = getCockpitPageModel(slug); - const element = await CockpitRoutePage({ - params: Promise.resolve({ slug }), - }); + const element = await renderRoute(slug); expect(element.key).toBe(canonicalPath); }); @@ -54,12 +72,8 @@ describe('CockpitRoutePage', () => { 'python', ]; - const streamingElement = await CockpitRoutePage({ - params: Promise.resolve({ slug: streamingSlug }), - }); - const persistenceElement = await CockpitRoutePage({ - params: Promise.resolve({ slug: persistenceSlug }), - }); + const streamingElement = await renderRoute(streamingSlug); + const persistenceElement = await renderRoute(persistenceSlug); expect(streamingElement.key).not.toBe(persistenceElement.key); expect(streamingElement.key).toBe( @@ -70,3 +84,50 @@ describe('CockpitRoutePage', () => { ); }); }); + +describe('canonical Cockpit route redirects', () => { + it('preserves a valid mode query that is available on the canonical entry', () => { + expect( + getCockpitRouteRedirect( + [ + 'langgraph', + 'core-capabilities', + 'streaming', + 'overview', + 'python', + 'extra', + ], + 'code' + ) + ).toBe('/langgraph/core-capabilities/streaming/overview/python?mode=code'); + }); + + it('keeps the external adapter disabled by default', () => { + expect( + getLegacyRouteRedirect( + ['langgraph', 'core-capabilities', 'streaming', 'overview', 'python'], + 'run', + {} + ) + ).toBeNull(); + }); + + it('redirects only exact registry legacy routes when enabled', () => { + expect( + getLegacyRouteRedirect( + ['deep-agents', 'core-capabilities', 'planning', 'overview', 'python'], + 'api', + enabledEnv + ) + ).toBe( + 'https://threadplane.ai/docs/deep-agents/capabilities/planning?mode=api' + ); + expect( + getLegacyRouteRedirect( + ['deep-agents', 'core-capabilities', 'planning'], + 'run', + enabledEnv + ) + ).toBeNull(); + }); +}); diff --git a/apps/cockpit/src/app/[...slug]/page.tsx b/apps/cockpit/src/app/[...slug]/page.tsx index 9d67b5324..a253b98d6 100644 --- a/apps/cockpit/src/app/[...slug]/page.tsx +++ b/apps/cockpit/src/app/[...slug]/page.tsx @@ -1,27 +1,66 @@ import React from 'react'; import { redirect } from 'next/navigation'; import { CockpitShell } from '../../components/cockpit-shell'; -import { getContentBundle } from '../../lib/content-bundle'; -import { cockpitManifest, getCockpitPageModel } from '../../lib/cockpit-page'; +import { getContentBundle } from '@threadplane/cockpit-shell'; +import { + cockpitManifest, + getCanonicalCockpitRedirect, + getCockpitPageModel, + getLegacyWebsiteRedirect, + normalizeRequestedMode, + type UnifiedWorkspaceRedirectEnvironment, +} from '../../lib/cockpit-page'; export async function generateStaticParams() { return cockpitManifest.map((entry) => ({ - slug: [entry.product, entry.section, entry.topic, entry.page, entry.language], + slug: [ + entry.product, + entry.section, + entry.topic, + entry.page, + entry.language, + ], })); } +export function getCockpitRouteRedirect( + slug: string[], + mode: string | string[] | undefined +): string | null { + const model = getCockpitPageModel(slug); + const requestedPath = `/${slug.join('/')}`; + return slug.length > 0 && requestedPath !== model.canonicalPath + ? getCanonicalCockpitRedirect(model, mode) + : null; +} + +export function getLegacyRouteRedirect( + slug: string[], + mode: string | string[] | undefined, + environment: UnifiedWorkspaceRedirectEnvironment = process.env +): string | null { + if (slug.length === 0) return null; + return getLegacyWebsiteRedirect(`/${slug.join('/')}`, mode, environment); +} + export default async function CockpitRoutePage({ params, + searchParams, }: { params: Promise<{ slug?: string[] }>; + searchParams: Promise<{ mode?: string | string[] }>; }) { const { slug = [] } = await params; - const { entry, presentation, navigationTree, canonicalPath } = - getCockpitPageModel(slug); - const requestedPath = `/${slug.join('/')}`; - - if (slug.length > 0 && requestedPath !== canonicalPath) { - redirect(canonicalPath); + const { mode } = await searchParams; + const legacyRedirectDestination = getLegacyRouteRedirect(slug, mode); + if (legacyRedirectDestination) { + redirect(legacyRedirectDestination); + } + const model = getCockpitPageModel(slug); + const { resolution, presentation, navigationTree, canonicalPath } = model; + const redirectDestination = getCockpitRouteRedirect(slug, mode); + if (redirectDestination) { + redirect(redirectDestination); } const contentBundle = await getContentBundle(presentation); @@ -30,9 +69,11 @@ export default async function CockpitRoutePage({ ); } diff --git a/apps/cockpit/src/app/cockpit.css b/apps/cockpit/src/app/cockpit.css index 5e3a2db69..fa43b0677 100644 --- a/apps/cockpit/src/app/cockpit.css +++ b/apps/cockpit/src/app/cockpit.css @@ -1,1073 +1,2 @@ @import "tailwindcss"; - -/* Shiki code blocks — preserve dark background from theme */ -pre.shiki { - padding: 1rem; - border-radius: 0.5rem; - overflow-x: auto; - font-size: 0.85rem; - line-height: 1.6; -} - -/* ── Doc components ────────────────────────────────────────── */ - -.doc-summary { - background: var(--ds-accent-surface); - border: 1px solid var(--ds-accent-border); - border-radius: 0.5rem; - padding: 0.75rem 1rem; - margin-bottom: 1.5rem; - font-size: 0.9rem; - color: var(--ds-text-secondary); - line-height: 1.6; -} - -.doc-callout { - border-radius: 0.5rem; - padding: 0.75rem 1rem; - margin: 1.25rem 0; - font-size: 0.85rem; - line-height: 1.6; -} -.doc-callout__label { - font-size: 0.7rem; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.06em; - margin-bottom: 0.25rem; -} -.doc-callout__content { color: var(--ds-text-secondary); } -.doc-callout--tip { - background: var(--ds-accent-surface); - border: 1px solid var(--ds-accent-border); -} -.doc-callout--tip .doc-callout__label { color: var(--ds-accent); } -.doc-callout--note { - background: rgba(250, 204, 21, 0.06); - border: 1px solid rgba(250, 204, 21, 0.2); -} -.doc-callout--note .doc-callout__label { color: #b8960f; } -.doc-callout--warning { - background: rgba(255, 107, 107, 0.06); - border: 1px solid rgba(255, 107, 107, 0.2); -} -.doc-callout--warning .doc-callout__label { color: #e04545; } - -.doc-steps { margin: 1.5rem 0; } -.doc-step { display: flex; gap: 0.75rem; } -.doc-step__indicator { - display: flex; - flex-direction: column; - align-items: center; - flex-shrink: 0; -} -.doc-step__number { - width: 1.5rem; - height: 1.5rem; - border-radius: 50%; - background: var(--ds-accent); - color: #fff; - font-size: 0.7rem; - font-weight: 700; - display: flex; - align-items: center; - justify-content: center; -} -.doc-step__line { - width: 2px; - flex: 1; - background: var(--ds-accent-border); - margin: 0.375rem 0; - min-height: 1rem; -} -.doc-step:last-child .doc-step__line { display: none; } -.doc-step__body { flex: 1; padding-bottom: 1.5rem; } -.doc-step:last-child .doc-step__body { padding-bottom: 0; } -.doc-step__title { - font-size: 0.95rem; - font-weight: 600; - color: var(--ds-text-primary); - margin-bottom: 0.25rem; -} -.doc-step__content { - font-size: 0.85rem; - color: var(--ds-text-secondary); - line-height: 1.7; -} -.doc-step__content p { margin: 0.5rem 0; } -.doc-step__content pre.shiki { margin: 0.5rem 0; border-radius: 0.5rem; } - -.doc-codeblock { - border: 1px solid var(--ds-accent-border); - border-radius: 0.5rem; - overflow: hidden; - margin: 0.75rem 0; - max-width: 100%; -} -.doc-codeblock__header { - display: flex; - align-items: center; - gap: 0.5rem; - padding: 0.4rem 0.75rem; - border-bottom: 1px solid var(--ds-border); - background: var(--ds-surface-tinted); - font-size: 0.7rem; -} -.doc-codeblock__file { color: var(--ds-text-secondary); font-family: var(--font-mono), "JetBrains Mono", monospace; } -.doc-codeblock__lang { - padding: 0.1rem 0.35rem; - border-radius: 0.2rem; - background: var(--ds-accent-surface); - color: var(--ds-accent); - font-size: 0.6rem; - font-family: var(--font-mono), "JetBrains Mono", monospace; -} -.doc-codeblock__copy { - margin-left: auto; - padding: 0.1rem 0.5rem; - border: 1px solid var(--ds-border); - border-radius: 0.25rem; - background: transparent; - color: var(--ds-text-muted); - cursor: pointer; -} -.doc-codeblock__copy:hover { color: var(--ds-text-primary); border-color: var(--ds-border-strong); } -.doc-codeblock pre.shiki { margin: 0; border-radius: 0; border: none; overflow-x: auto; } - -.doc-prompt { - background: rgba(168, 85, 247, 0.04); - border: 1px solid rgba(168, 85, 247, 0.2); - border-radius: 0.5rem; - overflow: hidden; - margin: 1.25rem 0; -} -.doc-prompt__header { - display: flex; - justify-content: space-between; - align-items: center; - padding: 0.5rem 0.75rem; - border-bottom: 1px solid rgba(168, 85, 247, 0.15); - background: rgba(168, 85, 247, 0.06); -} -.doc-prompt__label { - font-size: 0.7rem; - font-weight: 600; - color: #9333ea; - text-transform: uppercase; - letter-spacing: 0.06em; -} -.doc-prompt__copy { - font-size: 0.65rem; - color: #9333ea; - padding: 0.1rem 0.5rem; - border: 1px solid rgba(168, 85, 247, 0.25); - border-radius: 0.25rem; - background: rgba(168, 85, 247, 0.08); - cursor: pointer; -} -.doc-prompt__copy:hover { background: rgba(168, 85, 247, 0.15); } -.doc-prompt__content { - padding: 0.75rem; - font-size: 0.85rem; - color: var(--ds-text-secondary); - line-height: 1.7; -} -.doc-prompt__content code { - background: rgba(168, 85, 247, 0.1); - padding: 0.1rem 0.3rem; - border-radius: 0.2rem; - color: #9333ea; - font-size: 0.8rem; -} - -.doc-api-table { margin: 1.25rem 0; } -.doc-api-table table { width: 100%; border-collapse: collapse; font-size: 0.8rem; } -.doc-api-table th { - text-align: left; - padding: 0.5rem 0.75rem; - color: var(--ds-text-muted); - font-weight: 500; - font-size: 0.65rem; - text-transform: uppercase; - letter-spacing: 0.06em; - border-bottom: 1px solid var(--ds-border); -} -.doc-api-table td { - padding: 0.5rem 0.75rem; - border-bottom: 1px solid var(--ds-accent-border); - color: var(--ds-text-secondary); -} -.doc-api-table code { - background: var(--ds-accent-surface); - padding: 0.1rem 0.3rem; - border-radius: 0.2rem; - color: var(--ds-accent); - font-size: 0.75rem; -} - -/* Shared prose layer — docs + api + code mode content */ -.cockpit-prose { - max-width: 42rem; - font-size: 0.9rem; - line-height: 1.7; - color: var(--ds-text-secondary); -} -.cockpit-prose--wide { max-width: 48rem; } -.cockpit-prose--code { max-width: 56rem; } -.cockpit-prose h1, .cockpit-prose h2, .cockpit-prose h3 { - font-family: var(--font-garamond), var(--ds-font-serif); - color: var(--ds-text-primary); - letter-spacing: -0.01em; -} -.cockpit-prose h1 { font-size: 1.875rem; line-height: 1.1; margin: 0 0 0.5rem; padding-bottom: 0.75rem; border-bottom: 1px solid var(--ds-accent-border); } -.cockpit-prose h2 { font-size: 1.5rem; margin: 2.25rem 0 0.75rem; } -.cockpit-prose h3 { font-size: 1.25rem; margin: 1.5rem 0 0.5rem; } -.cockpit-prose .cockpit-api-heading { - font-family: var(--font-inter), var(--ds-font-sans); - letter-spacing: normal; - text-transform: none; -} -.cockpit-prose p { margin: 0 0 0.75rem; } -.cockpit-prose ul { margin: 0 0 0.75rem; padding-left: 1.25rem; list-style: disc; } -.cockpit-prose li { margin-bottom: 0.25rem; } -.cockpit-prose a { color: var(--ds-accent); text-decoration: none; } -.cockpit-prose a:hover { text-decoration: underline; } -.cockpit-prose code { color: var(--ds-accent); background: var(--ds-accent-surface); padding: 0.1rem 0.3rem; border-radius: 0.25rem; font-size: 0.85em; font-family: var(--font-mono), "JetBrains Mono", monospace; } -.cockpit-prose strong { color: var(--ds-text-primary); font-weight: 600; } - -.cockpit-prose table.params { border-collapse: collapse; margin: 0.5rem 0; } -.cockpit-prose table.params th { font-family: var(--font-mono), monospace; font-size: 0.6rem; letter-spacing: 0.06em; text-transform: uppercase; padding-bottom: 0.5rem; border-bottom: 1px solid var(--ds-border); } -.cockpit-prose table.params td { padding: 0.5rem 0.75rem 0.5rem 0; border-bottom: 1px solid var(--ds-border); } - -/* Sidebar navigation items — bg-only active/hover, no left border */ -.cockpit-nav-item { - display: block; - padding: 5px 14px; - margin: 0 8px; - border-radius: 6px; - font-size: 0.825rem; - color: var(--ds-text-secondary); - text-decoration: none; - transition: background 0.15s ease, color 0.15s ease; -} -.cockpit-nav-item:hover { background: var(--ds-surface-dim); color: var(--ds-text-primary); } -.cockpit-nav-item[aria-current="page"] { background: var(--ds-accent-surface); color: var(--ds-accent); } - -/* Sidebar group caret — matches the file-tree chevron */ -.cockpit-nav-caret { - display: inline-flex; - align-items: center; - justify-content: center; - width: 0.85rem; - height: 0.85rem; - color: var(--ds-text-muted); - flex: none; - transition: transform 150ms ease; -} -.cockpit-nav-caret svg { display: block; } -.cockpit-nav-caret--open { transform: rotate(90deg); } - -/* Code-mode file tree */ -.cockpit-file-tree { list-style: none; padding: 0; margin: 0; font-size: 12px; line-height: 1.7; } -.cockpit-file-tree ul { list-style: none; padding: 0; margin: 0; } -.cockpit-file-tree__file, -.cockpit-file-tree__folder { - display: flex; align-items: center; gap: 0.4rem; flex: 1; min-width: 0; - padding: 3px 0.75rem 3px 0.75rem; background: transparent; border: 0; text-align: left; cursor: pointer; - color: var(--ds-text-secondary); font-family: var(--font-mono), "JetBrains Mono", monospace; font-size: 12px; - border-left: 2px solid transparent; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; -} -.cockpit-file-tree__folder { color: var(--ds-text-muted); display: flex; align-items: center; } -.cockpit-file-tree__caret { - display: inline-flex; - align-items: center; - justify-content: center; - width: 0.85rem; - height: 0.85rem; - color: var(--ds-text-muted); - flex: none; - transition: transform 150ms ease; -} -.cockpit-file-tree__caret svg { display: block; } -.cockpit-file-tree__caret--open { transform: rotate(90deg); } -.cockpit-file-tree__label { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.cockpit-file-tree__chip { - font-family: var(--font-mono), monospace; font-size: 9px; - padding: 1px 5px; border-radius: 3px; margin-right: 0.5rem; flex-shrink: 0; - background: var(--ds-accent-surface); color: var(--ds-accent); - opacity: 0.85; -} -.cockpit-file-tree__file:hover { color: var(--ds-text-primary); } -.cockpit-file-tree__file[aria-current="true"] { - background: var(--ds-accent-surface); - color: var(--ds-text-primary); - border-left-color: var(--ds-accent); -} - -/* Tab close (×) on Code-mode tabs */ -.cockpit-tab-trigger { display: inline-flex; align-items: center; gap: 0.4rem; } -.cockpit-tab-trigger__close { - display: inline-flex; align-items: center; justify-content: center; - width: 0.95rem; height: 0.95rem; border-radius: 0.2rem; - color: var(--ds-text-muted); font-size: 0.85rem; line-height: 1; - opacity: 0; cursor: pointer; -} -.cockpit-tab-trigger:hover .cockpit-tab-trigger__close, -.cockpit-tab-trigger[data-state="active"] .cockpit-tab-trigger__close { opacity: 1; } -.cockpit-tab-trigger__close:hover { background: var(--ds-accent-surface); color: var(--ds-text-primary); } - -/* Code-mode editor pane — no chrome, no separate background, full-bleed under the tab strip. */ -.cockpit-code-pane { - min-width: 0; -} -.cockpit-code-pane pre.shiki { - background: transparent !important; - margin: 0; - border-radius: 0; - padding: 1rem 1.25rem; - font-size: 0.8125rem; - white-space: pre; - overflow-x: auto; - max-width: 100%; -} - -/* Shiki dual-theme: light mode uses inline `color` (github-light), dark mode swaps to the --shiki-dark CSS variable (tokyo-night). */ -[data-theme="dark"] .shiki, -[data-theme="dark"] .shiki span { color: var(--shiki-dark) !important; } -[data-theme="dark"] .shiki { background-color: var(--shiki-dark-bg) !important; } -[data-theme="dark"] .cockpit-code-pane .shiki { background-color: transparent !important; } -.cockpit-code-pane--plain { - margin: 0; - padding: 1rem 1.25rem; - color: var(--ds-text-secondary); - font-family: var(--font-mono), "JetBrains Mono", monospace; - font-size: 0.8125rem; - line-height: 1.6; - white-space: pre-wrap; - max-width: 100%; -} -.cockpit-code-pane__empty { - padding: 1rem 1.25rem; - color: var(--ds-text-muted); - font-size: 0.875rem; -} - -/* Unified sidebar control plane */ -.cockpit-shell { - display: grid; - grid-template-columns: minmax(0, 1fr); -} -@media (min-width: 48rem) { - .cockpit-shell { grid-template-columns: 328px minmax(0, 1fr); } -} -.cockpit-control-plane { - --cockpit-state-error: #b42318; - --cockpit-state-success: #1a7a40; - --cockpit-state-working: #9a6700; - display: grid; - grid-template-columns: 56px minmax(0, 272px); - height: 100%; - min-width: 0; - color: var(--ds-text-secondary); - background: var(--ds-surface); -} -[data-theme="dark"] .cockpit-control-plane { - --cockpit-state-error: #ff6369; - --cockpit-state-success: #4cc38a; - --cockpit-state-working: #e0a02f; -} -.cockpit-control-plane [data-control-plane-rail] { - min-width: 0; - padding: 10px 6px; - border-right: 1px solid var(--ds-border); - background: var(--ds-surface-tinted); - display: flex; - flex-direction: column; -} -.cockpit-control-plane [data-control-plane-rail-group] { - display: flex; - flex-direction: column; - gap: 4px; -} -.cockpit-control-plane [data-control-plane-rail-group="utilities"] { - margin-top: auto; - padding-top: 8px; - /* --ds-border is a 1-value difference from --ds-surface-tinted (the rail - background) in dark mode -- rgb(45,45,45) on rgb(44,44,44), effectively - invisible. --ds-border-strong is the token the pane divider already - uses for the same reason (cockpit.css ~L460). */ - border-top: 1px solid var(--ds-border-strong); -} -.cockpit-control-plane [data-control-plane-rail-group-label] { - display: block; - padding-bottom: 4px; - color: var(--ds-text-secondary); - font-size: 10px; - font-weight: 600; - letter-spacing: 0.09em; - text-transform: uppercase; - text-align: center; -} -.cockpit-control-plane-utility-anchor { display: contents; } -.cockpit-control-plane [data-control-plane-rail-item] { - --cockpit-rail-status-ring: var(--ds-surface-tinted); - position: relative; - min-height: 48px; - padding: 6px 2px; - border: 0; - border-radius: 8px; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - gap: 4px; - color: var(--ds-text-secondary); - background: transparent; - text-decoration: none; - cursor: pointer; - transition: background 120ms ease, color 120ms ease; -} -.cockpit-control-plane [data-control-plane-rail-item]:hover { - --cockpit-rail-status-ring: var(--ds-surface); - color: var(--ds-text-primary); - background: var(--ds-surface); -} -.cockpit-control-plane [data-control-plane-rail-item][data-control-plane-active] { - color: var(--ds-accent); - background: var(--ds-accent-surface); -} -.cockpit-control-plane [data-control-plane-rail-label] { - font-size: 10px; - line-height: 1; - font-weight: 600; -} -.cockpit-control-plane [data-control-plane-rail-status] { - position: absolute; - top: 7px; - right: 11px; - width: 7px; - height: 7px; - border: 2px solid var(--cockpit-rail-status-ring); - border-radius: 999px; -} -.cockpit-control-plane [data-control-plane-rail-status="success"] { - background: var(--cockpit-state-success); -} -.cockpit-control-plane [data-control-plane-rail-status="working"] { - background: var(--cockpit-state-working); -} -.cockpit-control-plane [data-control-plane-rail-status="error"] { - background: var(--cockpit-state-error); -} -.cockpit-control-plane [data-control-plane-rail-icon], -[data-cockpit-activity-icon] { - display: inline-flex; - align-items: center; - justify-content: center; -} -.cockpit-control-plane [data-control-plane-rail-icon] > svg, -[data-cockpit-activity-icon] > svg { - width: 18px; - height: 18px; -} -[data-cockpit-activity-icon] { position: relative; } -[data-cockpit-activity-attention] { - position: absolute; - top: -2px; - right: -3px; - width: 6px; - height: 6px; - border: 1px solid var(--ds-surface-tinted); - border-radius: 999px; - background: var(--cockpit-state-error); -} -.cockpit-control-plane [data-control-plane-pane] { - min-width: 0; - overflow-y: auto; - border-right: 1px solid var(--ds-border-strong); -} -[data-cockpit-context-content] { - display: flex; - flex-direction: column; - gap: 2px; - padding: 12px 10px 20px; -} -[data-cockpit-context-content] [data-control-plane-section] { padding: 3px 0; } -[data-cockpit-context-content] [data-control-plane-section-trigger] { - width: 100%; - min-height: 34px; - padding: 6px 8px; - border: 0; - border-radius: 7px; - display: flex; - align-items: center; - justify-content: space-between; - color: var(--ds-text-muted); - background: transparent; - cursor: pointer; - font-size: 12px; - font-weight: 600; - text-align: left; -} -[data-cockpit-context-content] [data-control-plane-section-trigger]:hover { - color: var(--ds-text-primary); - background: var(--ds-surface-tinted); -} -[data-cockpit-context-content] [data-control-plane-section-chevron] { - flex: none; - transition: transform 150ms ease; -} -[data-cockpit-context-content] [data-control-plane-section-trigger][aria-expanded="true"] [data-control-plane-section-chevron] { - transform: rotate(90deg); -} -[data-cockpit-context-content] [data-control-plane-section-heading] { - margin: 0; - padding: 8px; - color: var(--ds-text-muted); - font-size: 12px; - line-height: 1.2; - font-weight: 600; -} -[data-cockpit-context-content] [data-control-plane-section-content] { padding: 2px 0 8px; } - -/* Runtime remains a compact, unboxed operational summary. */ -[data-runtime-section] [data-control-plane-section-trigger] { - justify-content: flex-start; - gap: 8px; -} -[data-runtime-section] [data-control-plane-section-title] { flex: none; } -[data-runtime-section] [data-control-plane-section-end] { - min-width: 0; - flex: 1; - display: flex; - align-items: center; - gap: 6px; -} -[data-runtime-section] [data-control-plane-section-summary] { min-width: 0; } -[data-runtime-section] [data-control-plane-section-chevron] { - flex: none; - margin-left: auto; -} -[data-runtime-status] { - min-width: 0; - display: inline-flex; - align-items: center; - gap: 4px; - color: var(--ds-text-muted); - font-size: 10px; - font-weight: 500; - white-space: nowrap; -} -[data-runtime-status-icon] { display: inline-flex; } -[data-runtime-status][data-runtime-phase="ready"] { - color: var(--cockpit-state-success); -} -[data-runtime-status]:is( - [data-runtime-phase="invalid_configuration"], - [data-runtime-phase="unresponsive"], - [data-runtime-phase="error"] -) { - color: var(--cockpit-state-error); -} -[data-runtime-status]:is( - [data-runtime-phase="connecting"], - [data-runtime-phase="checking"], - [data-runtime-phase="reloading"] -) { - color: var(--ds-accent); -} -@keyframes cockpit-runtime-status-spin { - to { transform: rotate(1turn); } -} -.cockpit-runtime-status-loader { - animation: cockpit-runtime-status-spin 900ms linear infinite; -} -[data-runtime-metadata] { - min-width: 0; - margin: 0 8px 6px; - padding: 2px 0; - display: grid; - gap: 3px; - color: var(--ds-text-muted); - font-size: 10px; - line-height: 1.35; -} -[data-runtime-metadata] > span { min-width: 0; } -[data-runtime-target] { - display: block; - overflow: hidden; - color: var(--ds-text-secondary); - font-family: var(--ds-font-mono); - text-overflow: ellipsis; - white-space: nowrap; -} -[data-runtime-checked-at] { color: var(--ds-text-muted); } -[data-runtime-announcement] { - position: absolute; - width: 1px; - height: 1px; - padding: 0; - margin: -1px; - overflow: hidden; - clip: rect(0, 0, 0, 0); - white-space: nowrap; - border: 0; -} -.cockpit-control-plane-scope { - margin: 0 8px; - padding: 10px; - border-radius: 8px; - background: var(--ds-surface-tinted); - display: grid; - gap: 3px; - color: var(--ds-text-muted); - font-size: 11px; -} -.cockpit-control-plane-scope strong { - color: var(--ds-text-primary); - font-size: 13px; - font-weight: 600; -} -.cockpit-nav-group-label { - color: var(--ds-text-secondary); - font-size: 12px; - font-weight: 600; -} -[data-cockpit-context-content] [aria-label^="Collapse"], -[data-cockpit-context-content] [aria-label^="Expand"] { - border-radius: 7px; - min-height: 32px; -} -[data-cockpit-context-content] [aria-label^="Collapse"]:hover, -[data-cockpit-context-content] [aria-label^="Expand"]:hover { background: var(--ds-surface-tinted) !important; } -.cockpit-nav-item { - padding: 7px 9px; - margin: 1px 8px; - border-radius: 7px; - font-size: 13px; -} -[data-cockpit-context-content] [data-control-plane-environment-list] { - display: grid; - gap: 2px; - margin: 0 8px; -} -[data-cockpit-context-content] [data-control-plane-environment-row] { - min-height: 32px; - padding: 6px 8px; - border-radius: 7px; - display: grid; - grid-template-columns: 18px minmax(0, 1fr) auto; - align-items: center; - gap: 6px; - font-size: 11px; -} -[data-cockpit-context-content] [data-control-plane-environment-row]:hover { background: var(--ds-surface-tinted); } -[data-cockpit-context-content] [data-control-plane-environment-row] dt { color: var(--ds-text-muted); } -[data-cockpit-context-content] [data-control-plane-environment-row] dd { - margin: 0; - color: var(--ds-text-primary); - font-family: var(--ds-font-mono); - font-size: 10px; -} -[data-cockpit-context-content] [data-control-plane-environment-icon] { - display: inline-flex; - color: var(--ds-text-muted); -} -[data-cockpit-context-content] [data-control-plane-action-bar] { - display: flex; - gap: 4px; - margin: 0 8px; -} -[data-cockpit-context-content] [data-control-plane-action] { - position: relative; - width: 34px; - height: 34px; - border: 0; - border-radius: 8px; - display: inline-flex; - align-items: center; - justify-content: center; - color: var(--ds-text-muted); - background: transparent; -} -[data-cockpit-context-content] [data-control-plane-action] > svg { - width: 16px; - height: 16px; -} -[data-cockpit-context-content] [data-control-plane-action]:hover { - color: var(--ds-text-primary); - background: var(--ds-surface-tinted); -} -.cockpit-control-plane [data-control-plane-tooltip] { - position: absolute; - z-index: 60; - padding: 5px 7px; - border-radius: 6px; - color: var(--ds-surface); - background: var(--ds-text-primary); - box-shadow: var(--ds-shadow-sm); - font-size: 11px; - font-weight: 500; - line-height: 1; - white-space: nowrap; - pointer-events: none; - opacity: 0; - visibility: hidden; - transition: opacity 120ms ease, visibility 120ms ease; -} -.cockpit-control-plane [data-control-plane-rail-item] [data-control-plane-tooltip] { - left: calc(100% + 8px); - top: 50%; - transform: translateY(-50%); -} -.cockpit-control-plane [data-control-plane-action] [data-control-plane-tooltip] { - left: 50%; - bottom: calc(100% + 6px); - transform: translateX(-50%); -} -.cockpit-control-plane [data-control-plane-rail-item]:is(:hover, :focus-visible) [data-control-plane-tooltip], -.cockpit-control-plane [data-control-plane-action]:is(:hover, :focus-visible) [data-control-plane-tooltip] { - opacity: 1; - visibility: visible; -} -.cockpit-control-plane [data-control-plane-overflow-menu-root] { position: relative; } -.cockpit-control-plane [data-control-plane-overflow-menu] { - position: absolute; - z-index: 70; - top: calc(100% + 5px); - right: 0; - min-width: 184px; - padding: 5px; - border: 1px solid var(--ds-border-strong); - border-radius: 8px; - background: var(--ds-surface); - box-shadow: var(--ds-shadow-md); -} -.cockpit-control-plane [data-control-plane-overflow-menu-root][data-overflow-placement="start"] > [data-control-plane-overflow-menu] { - left: 0; - right: auto; -} -.cockpit-control-plane [data-control-plane-overflow-menu-root][data-overflow-placement="center"] > [data-control-plane-overflow-menu] { - left: 50%; - right: auto; - transform: translateX(-50%); -} -.cockpit-control-plane [data-control-plane-overflow-item] { - width: 100%; - min-height: 34px; - padding: 7px 9px; - border: 0; - border-radius: 7px; - display: flex; - align-items: center; - color: var(--ds-text-secondary); - background: transparent; - font-size: 12px; - text-align: left; - cursor: pointer; -} -.cockpit-control-plane [data-control-plane-overflow-item]:is(:hover, :focus-visible) { - color: var(--ds-text-primary); - background: var(--ds-surface-tinted); -} -.cockpit-control-plane [data-control-plane-utility-panel] { padding: 14px 12px; } -.cockpit-control-plane [data-control-plane-utility-header] { - min-height: 36px; - display: flex; - align-items: center; - justify-content: space-between; -} -.cockpit-control-plane [data-control-plane-utility-header] h2 { - margin: 0; - color: var(--ds-text-primary); - font-size: 14px; - font-weight: 600; -} -.cockpit-control-plane [data-control-plane-utility-header] button, -.cockpit-control-plane-theme { - width: 34px; - height: 34px; - border: 0; - border-radius: 8px; - display: inline-flex; - align-items: center; - justify-content: center; - color: var(--ds-text-muted); - background: transparent; - cursor: pointer; -} -.cockpit-control-plane [data-control-plane-utility-header] button:hover, -.cockpit-control-plane-theme:hover { background: var(--ds-surface-tinted); color: var(--ds-text-primary); } -.cockpit-control-plane [data-control-plane-utility-header] button > svg, -.cockpit-control-plane-theme > svg { - width: 18px; - height: 18px; -} - -/* Session Activity is newest-first in markup; styling keeps the chronology quiet. */ -[data-activity-empty] { - margin: 14px 0 0; - color: var(--ds-text-muted); - font-size: 12px; - line-height: 1.5; -} -[data-activity-timeline] { - margin: 12px 0 0; - padding: 0; - display: grid; - gap: 0; - list-style: none; -} -[data-activity-event] { - position: relative; - min-width: 0; - padding: 0 0 16px 25px; - display: grid; - grid-template-columns: minmax(0, 1fr) auto; - gap: 3px 8px; - color: var(--ds-text-secondary); - font-size: 11px; - line-height: 1.35; -} -[data-activity-event]:last-child { padding-bottom: 0; } -[data-activity-severity-icon] { - position: absolute; - top: 0; - left: 0; - z-index: 1; - width: 16px; - height: 16px; - display: inline-flex; - align-items: center; - justify-content: center; - color: var(--ds-text-muted); - background: var(--ds-surface); -} -[data-activity-connector] { - position: absolute; - top: 15px; - bottom: -1px; - left: 7px; - width: 1px; - background: var(--ds-border-strong); -} -[data-activity-timestamp] { - grid-column: 2; - grid-row: 1; - justify-self: end; - color: var(--ds-text-muted); - font-family: var(--ds-font-mono); - font-size: 9px; -} -[data-activity-summary] { - min-width: 0; - grid-column: 1; - grid-row: 1; - overflow-wrap: anywhere; -} -[data-activity-capability] { - grid-column: 1 / -1; - grid-row: 2; - color: var(--ds-text-muted); - font-family: var(--ds-font-mono); - font-size: 9px; -} -[data-activity-severity="error"] [data-activity-severity-icon], -[data-activity-severity="error"] [data-activity-summary] { - color: var(--cockpit-state-error); -} -[data-activity-kind="runtime_recovered"] [data-activity-severity-icon], -[data-activity-kind="runtime_recovered"] [data-activity-summary] { - color: var(--cockpit-state-success); -} -.cockpit-control-plane-setting { - min-height: 48px; - padding: 8px 0; - border-bottom: 1px solid var(--ds-border); - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - color: var(--ds-text-muted); - font-size: 12px; -} -.cockpit-control-plane button:focus-visible, -.cockpit-control-plane a:focus-visible, -.cockpit-mobile-navigation-trigger:focus-visible, -.cockpit-mobile-control-plane button:focus-visible, -.cockpit-mobile-control-plane a:focus-visible { - outline: 2px solid var(--ds-accent); - outline-offset: 2px; -} - -/* Adaptive mobile drawer */ -.cockpit-mobile-navigation-trigger { - width: 44px; - height: 44px; - padding: 0; - border: 0; - border-radius: 8px; - color: var(--ds-text-secondary); - background: transparent; - cursor: pointer; -} -.cockpit-mobile-control-plane { - background: color-mix(in srgb, var(--ds-text-primary) 18%, transparent); - opacity: 1; - transition: opacity 150ms ease; -} -.cockpit-mobile-control-plane[data-state="closing"] { opacity: 0; } -.cockpit-mobile-control-plane-panel { - width: 100%; - max-width: 360px; - height: 100%; - min-width: 0; - display: grid; - grid-template-rows: auto minmax(0, 1fr); - background: var(--ds-surface); - transform: translateY(0); - transition: transform 200ms ease-out; -} -.cockpit-mobile-control-plane[data-state="closing"] .cockpit-mobile-control-plane-panel { - transform: translateY(8px); -} -.cockpit-mobile-control-plane-header { - min-height: 48px; - padding: 8px 12px 8px 16px; - border-bottom: 1px solid var(--ds-border); - display: flex; - align-items: center; - justify-content: space-between; - color: var(--ds-text-secondary); - font-size: 13px; - font-weight: 600; -} -.cockpit-mobile-control-plane-header button { - width: 44px; - height: 44px; - border: 0; - border-radius: 8px; - display: inline-flex; - align-items: center; - justify-content: center; - color: var(--ds-text-muted); - background: transparent; -} -.cockpit-control-plane[data-mobile] { - grid-template-columns: 56px minmax(0, 1fr); - min-height: 0; -} -.cockpit-control-plane[data-mobile] [data-control-plane-pane] { border-right: 0; } -@media (forced-colors: active) { - .cockpit-control-plane, - .cockpit-control-plane [data-control-plane-rail], - .cockpit-control-plane [data-control-plane-pane], - .cockpit-mobile-control-plane-panel { - color: CanvasText; - background: Canvas; - border-color: CanvasText; - } - .cockpit-control-plane [data-control-plane-rail-item], - .cockpit-control-plane [data-control-plane-section-trigger], - .cockpit-control-plane [data-control-plane-action], - .cockpit-control-plane [data-control-plane-utility-header] button, - .cockpit-control-plane-theme, - .cockpit-mobile-navigation-trigger, - .cockpit-mobile-control-plane-close { - border: 1px solid CanvasText; - color: CanvasText; - background: Canvas; - } - .cockpit-control-plane [data-control-plane-rail-item][data-control-plane-active] { - border-color: Highlight; - color: HighlightText; - background: Highlight; - } - [data-runtime-status][data-runtime-phase] { - padding: 1px 4px; - border: 1px solid CanvasText; - border-radius: 7px; - color: CanvasText; - background: Canvas; - } - .cockpit-control-plane [data-control-plane-rail-status] { - border: 1px solid CanvasText; - background: Canvas; - } - .cockpit-control-plane [data-control-plane-overflow-menu] { - border: 1px solid CanvasText; - color: CanvasText; - background: Canvas; - box-shadow: none; - } - .cockpit-control-plane [data-control-plane-overflow-item] { - border: 1px solid CanvasText; - color: CanvasText; - background: Canvas; - } - .cockpit-control-plane [data-control-plane-overflow-item]:is(:hover, :focus-visible) { - border-color: Highlight; - color: HighlightText; - background: Highlight; - } - [data-cockpit-activity-attention] { - border-color: Canvas; - background: Highlight; - } - [data-activity-severity-icon] { - color: CanvasText; - background: Canvas; - } - [data-activity-connector] { background: CanvasText; } - [data-activity-severity="error"] [data-activity-severity-icon], - [data-activity-severity="error"] [data-activity-summary], - [data-activity-kind="runtime_recovered"] [data-activity-severity-icon], - [data-activity-kind="runtime_recovered"] [data-activity-summary] { - color: CanvasText; - } - .cockpit-control-plane button:focus-visible, - .cockpit-control-plane a:focus-visible, - .cockpit-mobile-navigation-trigger:focus-visible, - .cockpit-mobile-control-plane button:focus-visible, - .cockpit-mobile-control-plane a:focus-visible { - outline: 2px solid Highlight; - outline-offset: 2px; - box-shadow: none; - } -} -@media (pointer: coarse) { - .cockpit-mobile-navigation-trigger, - .cockpit-mobile-control-plane-close { - min-width: 44px; - min-height: 44px; - } - .cockpit-control-plane [data-control-plane-rail-item], - .cockpit-control-plane [data-control-plane-section-trigger], - .cockpit-control-plane [data-control-plane-action], - .cockpit-nav-item { min-height: 44px; } - .cockpit-control-plane [data-control-plane-action] { - width: 44px; - height: 44px; - } - .cockpit-control-plane [data-control-plane-overflow-menu-root][data-overflow-placement="center"] > [data-control-plane-overflow-menu] { - left: auto; - right: 0; - transform: none; - } -} -@media (prefers-reduced-motion: reduce) { - .cockpit-control-plane * { - scroll-behavior: auto !important; - transition: none !important; - animation: none !important; - } - .cockpit-mobile-control-plane, - .cockpit-mobile-control-plane-panel { - scroll-behavior: auto !important; - transition: none !important; - animation: none !important; - } - .cockpit-runtime-status-loader { - animation: none !important; - } -} +@import "../../../../libs/workspace-react/src/styles/workspace.css"; diff --git a/apps/cockpit/src/app/page.tsx b/apps/cockpit/src/app/page.tsx index c808f2590..eca0ccf94 100644 --- a/apps/cockpit/src/app/page.tsx +++ b/apps/cockpit/src/app/page.tsx @@ -1,18 +1,34 @@ import React from 'react'; +import { redirect } from 'next/navigation'; import { CockpitShell } from '../components/cockpit-shell'; -import { getContentBundle } from '../lib/content-bundle'; -import { getCockpitPageModel } from '../lib/cockpit-page'; +import { getContentBundle } from '@threadplane/cockpit-shell'; +import { + getCockpitPageModel, + getRootWebsiteRedirect, + normalizeRequestedMode, +} from '../lib/cockpit-page'; -export default async function CockpitHomePage() { - const { entry, presentation, navigationTree } = getCockpitPageModel(); +export default async function CockpitHomePage({ + searchParams, +}: { + searchParams: Promise<{ mode?: string | string[] }>; +}) { + const { mode } = await searchParams; + const websiteRedirect = getRootWebsiteRedirect(mode); + if (websiteRedirect) { + redirect(websiteRedirect); + } + const { resolution, presentation, navigationTree } = getCockpitPageModel(); const contentBundle = await getContentBundle(presentation); return ( ); } diff --git a/apps/cockpit/src/components/cockpit-shell.spec.tsx b/apps/cockpit/src/components/cockpit-shell.spec.tsx index b9afbc183..163f7ac6b 100644 --- a/apps/cockpit/src/components/cockpit-shell.spec.tsx +++ b/apps/cockpit/src/components/cockpit-shell.spec.tsx @@ -13,24 +13,36 @@ import { ThemeProvider, } from '@threadplane/ui-react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { NO_COCKPIT_DOCS_LINK } from '@threadplane/cockpit-registry'; import { getCockpitPageModel } from '../lib/cockpit-page'; -import type { CockpitPageModel } from '../lib/cockpit-page'; -import type { UseRuntimeControllerOptions } from '../lib/runtime/use-runtime-controller'; +import type { + UseRuntimeControllerOptions, + WorkspaceProviderProps, + WorkspaceShellProps, +} from '@threadplane/workspace-react'; + +type CockpitSharedShellProps = WorkspaceShellProps & { + modeNavigationLabel?: string; + contextPaneLabel?: string; + mobileDialogLabel?: string; + mobileTitle?: string; +}; const operationalMocks = vi.hoisted(() => ({ controllerInstances: 0, latestControllerOptions: null as UseRuntimeControllerOptions | null, activityShouldThrow: false, + latestProviderProps: null as WorkspaceProviderProps | null, + latestShellProps: null as CockpitSharedShellProps | null, track: vi.fn(), push: vi.fn(), + replace: vi.fn(), })); vi.mock('next/navigation', () => ({ useRouter: () => ({ push: operationalMocks.push, refresh: vi.fn(), - replace: vi.fn(), + replace: operationalMocks.replace, back: vi.fn(), forward: vi.fn(), prefetch: vi.fn(), @@ -39,43 +51,67 @@ vi.mock('next/navigation', () => ({ vi.mock('../lib/analytics/client', () => ({ track: operationalMocks.track })); -vi.mock('../lib/runtime/use-runtime-controller', async (importOriginal) => { +vi.mock('@threadplane/workspace-react', async (importOriginal) => { const ReactModule = await import('react'); const actual = await importOriginal< - typeof import('../lib/runtime/use-runtime-controller') + typeof import('@threadplane/workspace-react') >(); return { ...actual, - useRuntimeController(options: UseRuntimeControllerOptions) { - const mounted = ReactModule.useRef(false); - if (!mounted.current) { - mounted.current = true; - operationalMocks.controllerInstances += 1; - } - ReactModule.useLayoutEffect(() => { - operationalMocks.latestControllerOptions = options; - }, [options]); - return actual.useRuntimeController(options); + WorkspaceProvider(props: WorkspaceProviderProps) { + operationalMocks.latestProviderProps = props; + return ReactModule.createElement(actual.WorkspaceProvider, props); }, - }; -}); - -vi.mock('./control-plane/activity-panel', async (importOriginal) => { - const ReactModule = await import('react'); - const actual = await importOriginal< - typeof import('./control-plane/activity-panel') - >(); - return { - ...actual, - ActivityPanel(props: React.ComponentProps) { - if (operationalMocks.activityShouldThrow) { - throw new Error('sensitive activity render failure'); - } - return ReactModule.createElement(actual.ActivityPanel, props); + WorkspaceShell(props: WorkspaceShellProps) { + operationalMocks.latestShellProps = props; + return ReactModule.createElement(actual.WorkspaceShell, props); }, }; }); +vi.mock( + '../../../../libs/workspace-react/src/lib/runtime/use-runtime-controller', + async (importOriginal) => { + const ReactModule = await import('react'); + const actual = await importOriginal< + typeof import('../../../../libs/workspace-react/src/lib/runtime/use-runtime-controller') + >(); + return { + ...actual, + useRuntimeController(options: UseRuntimeControllerOptions) { + const mounted = ReactModule.useRef(false); + if (!mounted.current) { + mounted.current = true; + operationalMocks.controllerInstances += 1; + } + ReactModule.useLayoutEffect(() => { + operationalMocks.latestControllerOptions = options; + }, [options]); + return actual.useRuntimeController(options); + }, + }; + } +); + +vi.mock( + '../../../../libs/workspace-react/src/lib/components/control-plane/activity-panel', + async (importOriginal) => { + const ReactModule = await import('react'); + const actual = await importOriginal< + typeof import('../../../../libs/workspace-react/src/lib/components/control-plane/activity-panel') + >(); + return { + ...actual, + ActivityPanel(props: React.ComponentProps) { + if (operationalMocks.activityShouldThrow) { + throw new Error('sensitive activity render failure'); + } + return ReactModule.createElement(actual.ActivityPanel, props); + }, + }; + } +); + import { CockpitShell } from './cockpit-shell'; const model = getCockpitPageModel(); @@ -115,25 +151,26 @@ const renderShell = (runtimeUrl: string | null = null) => ); -const renderShellFor = ( - slug: string[], - presentationOverrides: Partial = {} -) => { +const renderShellFor = (slug: string[]) => { const pageModel = getCockpitPageModel(slug); return render( ); @@ -159,8 +196,11 @@ describe('CockpitShell operational composition', () => { operationalMocks.controllerInstances = 0; operationalMocks.latestControllerOptions = null; operationalMocks.activityShouldThrow = false; + operationalMocks.latestProviderProps = null; + operationalMocks.latestShellProps = null; operationalMocks.track.mockClear(); operationalMocks.push.mockClear(); + operationalMocks.replace.mockClear(); document.documentElement.dataset.theme = 'light'; vi.stubGlobal('fetch', vi.fn().mockResolvedValue({})); }); @@ -172,50 +212,69 @@ describe('CockpitShell operational composition', () => { vi.restoreAllMocks(); }); - it('always opens in Run, ignoring a stored activeMode from an older visit', async () => { - window.localStorage.setItem( - CONTROL_PLANE_STORAGE_KEY, - JSON.stringify({ - version: 1, - docs: { expanded: { Learn: true, Environment: false } }, - cockpit: { - activeMode: 'Code', - expanded: { Capability: true, Runtime: true }, - }, - }) - ); + it('adapts Cockpit route, content, navigation, analytics, session, telemetry, theme, and labels into the shared workspace', () => { renderShell(); - await waitFor(() => { - expect( - screen - .getByRole('button', { name: RUN_RAIL_ITEM }) - .getAttribute('aria-pressed') - ).toBe('true'); + expect(operationalMocks.latestProviderProps).toMatchObject({ + contentBundle: baseContentBundle, + routeKind: 'workspace', + routePath: model.canonicalPath, + requestedMode: null, + runtimeTelemetry: { + posthogToken: process.env.NEXT_PUBLIC_COCKPIT_POSTHOG_TOKEN, + ingestHost: process.env.NEXT_PUBLIC_COCKPIT_INGEST_HOST, + }, }); + expect(operationalMocks.latestProviderProps?.resolution.kind).toBe( + 'mapped' + ); expect( - screen.getByRole('button', { name: 'Code' }).getAttribute('aria-pressed') - ).toBe('false'); - }); - - it('consumes a valid mode query once and lands in that mode', async () => { - seedExpanded(); - window.history.replaceState({}, '', '/?mode=code&keep=1'); - renderShell(); - - await waitFor(() => { - expect( - screen - .getByRole('button', { name: 'Code' }) - .getAttribute('aria-pressed') - ).toBe('true'); + operationalMocks.latestProviderProps?.resolution.kind === 'mapped' + ? operationalMocks.latestProviderProps.resolution.identity.id + : null + ).toBe('langgraph:core-capabilities:streaming:overview:python'); + expect(operationalMocks.latestProviderProps?.presentation.kind).toBe( + 'capability' + ); + expect(operationalMocks.latestProviderProps?.getSessionId).toBeTypeOf( + 'function' + ); + expect(operationalMocks.latestProviderProps?.pushIdentity).toBeTypeOf( + 'function' + ); + expect(operationalMocks.latestProviderProps?.pushMode).toBeTypeOf( + 'function' + ); + expect(operationalMocks.latestProviderProps?.replaceMode).toBeTypeOf( + 'function' + ); + expect(operationalMocks.latestProviderProps?.trackNavigation).toBeTypeOf( + 'function' + ); + expect( + operationalMocks.latestProviderProps?.trackNarrativeAction + ).toBeTypeOf('function'); + expect(operationalMocks.latestProviderProps?.trackModeChange).toBeTypeOf( + 'function' + ); + expect(operationalMocks.latestProviderProps?.trackRuntimeAction).toBeTypeOf( + 'function' + ); + expect( + operationalMocks.latestProviderProps?.trackRuntimeTransition + ).toBeTypeOf('function'); + expect(operationalMocks.latestShellProps).toMatchObject({ + navigationTree: model.navigationTree, + ariaLabel: 'Cockpit shell', + modeNavigationLabel: 'Cockpit modes', + contextPaneLabel: 'Cockpit context', + mobileDialogLabel: 'Cockpit control plane', + mobileTitle: 'Cockpit', }); - expect(window.location.search).toBe('?keep=1'); + expect(operationalMocks.latestShellProps?.themeControl).toBeTruthy(); }); - it('ignores invalid mode queries and falls back to Run', async () => { - seedExpanded(); - window.history.replaceState({}, '', '/?mode=preview'); + it('uses the truthful workspace route default when no mode query is present', async () => { renderShell(); await waitFor(() => { @@ -225,23 +284,13 @@ describe('CockpitShell operational composition', () => { .getAttribute('aria-pressed') ).toBe('true'); }); - expect(window.location.search).toBe(''); + expect(screen.getByRole('region', { name: 'Run mode' })).toBeTruthy(); }); - it('lands a newly navigated-to capability on Run even after switching to Code, when the shell remounts on the route key', async () => { - const { rerender } = render( - - - - ); + it('keeps a valid mode query as route state without persisting the mode', async () => { + window.history.replaceState({}, '', '/?mode=code&keep=1'); + renderShell(); - fireEvent.click(screen.getByRole('button', { name: 'Code' })); await waitFor(() => { expect( screen @@ -249,18 +298,13 @@ describe('CockpitShell operational composition', () => { .getAttribute('aria-pressed') ).toBe('true'); }); + expect(window.location.search).toBe('?mode=code&keep=1'); + expect(window.localStorage.getItem(CONTROL_PLANE_STORAGE_KEY)).toBeNull(); + }); - rerender( - - - - ); + it('normalizes invalid mode queries to the truthful route default', async () => { + window.history.replaceState({}, '', '/?mode=preview'); + renderShell(); await waitFor(() => { expect( @@ -269,9 +313,7 @@ describe('CockpitShell operational composition', () => { .getAttribute('aria-pressed') ).toBe('true'); }); - expect( - screen.getByRole('button', { name: 'Code' }).getAttribute('aria-pressed') - ).toBe('false'); + expect(operationalMocks.replace).toHaveBeenCalledWith('/?mode=run'); }); it('owns one controller and one Activity store shared by desktop and mobile adapters', async () => { @@ -469,7 +511,7 @@ describe('CockpitShell operational composition', () => { vi.useRealTimers(); }); - it('closes and restores focus before routing an internal capability exactly once', async () => { + it('closes before routing and restores destination-panel focus after navigation exactly once', async () => { vi.useFakeTimers(); vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => window.setTimeout(() => callback(performance.now()), 16) @@ -482,6 +524,9 @@ describe('CockpitShell operational composition', () => { await vi.runAllTimersAsync(); }); const trigger = screen.getByRole('button', { name: 'Open navigation' }); + const panel = screen.getByRole('heading', { + name: 'LangGraph Streaming Run', + }); fireEvent.click(trigger); const overlay = screen.getByRole('dialog', { name: 'Cockpit control plane', @@ -495,12 +540,8 @@ describe('CockpitShell operational composition', () => { destination.getAttribute('href') ?? '', window.location.href ).pathname; - const inertAtFocusAttempt: boolean[] = []; - const nativeFocus = trigger.focus.bind(trigger); - vi.spyOn(trigger, 'focus').mockImplementation(() => { - inertAtFocusAttempt.push(Boolean(trigger.closest('[inert]'))); - nativeFocus(); - }); + const triggerFocus = vi.spyOn(trigger, 'focus'); + const panelFocus = vi.spyOn(panel, 'focus'); operationalMocks.track.mockClear(); expect(fireEvent.click(destination)).toBe(false); @@ -518,8 +559,8 @@ describe('CockpitShell operational composition', () => { expect(operationalMocks.push).not.toHaveBeenCalled(); act(() => vi.advanceTimersByTime(16)); - expect(inertAtFocusAttempt).toEqual([false]); - expect(document.activeElement).toBe(trigger); + expect(triggerFocus).not.toHaveBeenCalled(); + expect(panelFocus).not.toHaveBeenCalled(); expect(operationalMocks.push).toHaveBeenCalledTimes(1); expect(operationalMocks.push).toHaveBeenCalledWith(destinationPath); @@ -528,21 +569,80 @@ describe('CockpitShell operational composition', () => { ); act(() => vi.advanceTimersByTime(16)); - expect(inertAtFocusAttempt).toEqual([false, false]); - expect(document.activeElement).toBe(trigger); + expect(triggerFocus).not.toHaveBeenCalled(); + expect(panelFocus).toHaveBeenCalledTimes(1); + expect(document.activeElement).toBe(panel); expect(operationalMocks.push).toHaveBeenCalledTimes(1); rendered.unmount(); vi.useRealTimers(); }); + it('focuses the selected mobile destination panel instead of the navigation trigger', () => { + vi.useFakeTimers(); + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => + window.setTimeout(() => callback(performance.now()), 16) + ); + vi.stubGlobal('cancelAnimationFrame', (handle: number) => + window.clearTimeout(handle) + ); + renderShell(); + const trigger = screen.getByRole('button', { name: 'Open navigation' }); + const triggerFocus = vi.spyOn(trigger, 'focus'); + + fireEvent.click(trigger); + fireEvent.click( + within( + screen.getByRole('dialog', { name: 'Cockpit control plane' }) + ).getByRole('button', { name: 'Code' }) + ); + const codePanel = screen.getByRole('heading', { + name: 'LangGraph Streaming Code', + hidden: true, + }); + act(() => vi.advanceTimersByTime(150)); + act(() => vi.advanceTimersByTime(16)); + + expect(triggerFocus).not.toHaveBeenCalled(); + expect(document.activeElement).toBe(codePanel); + }); + + it('uses the Cockpit host adapter for desktop capability navigation', async () => { + const rendered = renderShell(); + await waitFor(() => + expect(screen.getByRole('link', { name: 'Persistence' })).toBeTruthy() + ); + const destination = screen.getByRole('link', { name: 'Persistence' }); + const destinationPath = new URL( + destination.getAttribute('href') ?? '', + window.location.href + ).pathname; + + expect(fireEvent.click(destination)).toBe(false); + + expect(operationalMocks.push).toHaveBeenCalledWith(destinationPath); + expect( + JSON.parse( + window.sessionStorage.getItem( + 'threadplane:cockpit:workspace-panel-focus' + ) ?? '{}' + ) + ).toEqual({ + destination: destinationPath, + requestedAt: expect.any(Number), + }); + rendered.unmount(); + }); + it('does not focus the mobile trigger on an ordinary shell load', async () => { const rendered = renderShell(); await waitFor(() => @@ -556,7 +656,7 @@ describe('CockpitShell operational composition', () => { rendered.unmount(); }); - it('does not consume a navigation focus intent into the hidden desktop trigger', () => { + it('consumes a cross-route focus intent into the active destination panel', () => { vi.useFakeTimers(); vi.stubGlobal( 'matchMedia', @@ -574,7 +674,7 @@ describe('CockpitShell operational composition', () => { ); window.history.replaceState({}, '', persistenceModel.canonicalPath); window.sessionStorage.setItem( - 'threadplane:cockpit:mobile-navigation-focus', + 'threadplane:cockpit:workspace-panel-focus', JSON.stringify({ destination: persistenceModel.canonicalPath, requestedAt: Date.now(), @@ -584,9 +684,11 @@ describe('CockpitShell operational composition', () => { ); @@ -595,15 +697,140 @@ describe('CockpitShell operational composition', () => { hidden: true, }); const focus = vi.spyOn(trigger, 'focus'); + const panel = screen.getByRole('heading', { + name: 'LangGraph Persistence Run', + }); + const panelFocus = vi.spyOn(panel, 'focus'); act(() => vi.advanceTimersByTime(16)); expect(focus).not.toHaveBeenCalled(); - expect(document.activeElement).not.toBe(trigger); + expect(panelFocus).toHaveBeenCalledTimes(1); + expect(document.activeElement).toBe(panel); rendered.unmount(); vi.useRealTimers(); }); + it('uses a persistent tablet rail while Activity and Settings replace the context surface', () => { + vi.stubGlobal( + 'matchMedia', + vi.fn((query: string) => ({ + matches: + query === '(min-width: 48rem)' || + query === '(min-width: 48rem) and (max-width: 63.999rem)', + media: query, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + })) + ); + renderShell(); + + const settings = screen.getByRole('button', { name: 'Settings' }); + fireEvent.click(settings); + const surface = screen.getByRole('dialog', { + name: 'Cockpit control plane context', + }); + expect( + within(surface).getByRole('heading', { name: 'Settings' }) + ).toBeTruthy(); + expect( + screen.getByRole('navigation', { name: 'Cockpit modes' }) + ).toBeTruthy(); + + const activity = screen.getByRole('button', { name: 'Activity' }); + fireEvent.click(activity); + expect( + within(surface).getByRole('heading', { name: 'Activity' }) + ).toBeTruthy(); + fireEvent.click( + within(surface).getByRole('button', { name: 'Close Activity' }) + ); + + expect(document.activeElement).toBe(activity); + expect( + within(surface).getByRole('button', { name: 'Capability' }) + ).toBeTruthy(); + }); + + it('closes the tablet context surface and focuses the selected mode panel', () => { + vi.useFakeTimers(); + vi.stubGlobal( + 'matchMedia', + vi.fn((query: string) => ({ + matches: + query === '(min-width: 48rem)' || + query === '(min-width: 48rem) and (max-width: 63.999rem)', + media: query, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + })) + ); + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => + window.setTimeout(() => callback(performance.now()), 16) + ); + vi.stubGlobal('cancelAnimationFrame', (handle: number) => + window.clearTimeout(handle) + ); + renderShell(); + + fireEvent.click(screen.getByRole('button', { name: 'Open context' })); + expect( + screen.getByRole('dialog', { name: 'Cockpit control plane context' }) + ).toBeTruthy(); + fireEvent.click(screen.getByRole('button', { name: 'Code' })); + const codePanel = screen.getByRole('heading', { + name: 'LangGraph Streaming Code', + hidden: true, + }); + + act(() => vi.advanceTimersByTime(150)); + act(() => vi.advanceTimersByTime(16)); + expect(screen.queryByRole('dialog')).toBeNull(); + expect(document.activeElement).toBe(codePanel); + }); + + it('restores the tablet context trigger after explicit dismissal', () => { + vi.useFakeTimers(); + vi.stubGlobal( + 'matchMedia', + vi.fn((query: string) => ({ + matches: + query === '(min-width: 48rem)' || + query === '(min-width: 48rem) and (max-width: 63.999rem)', + media: query, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + })) + ); + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => + window.setTimeout(() => callback(performance.now()), 16) + ); + vi.stubGlobal('cancelAnimationFrame', (handle: number) => + window.clearTimeout(handle) + ); + renderShell(); + + const trigger = screen.getByRole('button', { name: 'Open context' }); + fireEvent.click(trigger); + fireEvent.click( + within( + screen.getByRole('dialog', { + name: 'Cockpit control plane context', + }) + ).getByRole('button', { name: 'Close navigation' }) + ); + act(() => vi.advanceTimersByTime(150)); + act(() => vi.advanceTimersByTime(16)); + + expect(document.activeElement).toBe(trigger); + }); + it('records one fixed Activity event and one existing analytics event only for an actual mode change', async () => { renderShell(); await waitFor(() => @@ -624,6 +851,9 @@ describe('CockpitShell operational composition', () => { to_mode: 'code', } ); + expect(operationalMocks.push).toHaveBeenCalledTimes(1); + expect(operationalMocks.push).toHaveBeenCalledWith('/?mode=code'); + expect(operationalMocks.replace).not.toHaveBeenCalled(); }); it('keeps the exact Run iframe mounted while Activity and Settings replace only context', async () => { @@ -636,6 +866,79 @@ describe('CockpitShell operational composition', () => { expect(screen.getByTitle('LangGraph Streaming live example')).toBe(frame); }); + it('renders non-empty Run, Code, narrative Docs, and API fixtures through the shared panels without remounting Run', async () => { + if (model.presentation.kind !== 'capability') { + throw new Error('Expected the streaming capability presentation'); + } + const codePath = model.presentation.codeAssetPaths[0]; + if (!codePath) throw new Error('Expected a streaming code asset'); + window.history.replaceState({}, '', '/?mode=run'); + + render( + + const adapterFixture = true;', + }, + promptFiles: {}, + runtimeUrl: 'https://runtime.test/parity', + narrativeDocs: [ + { + title: 'Adapter narrative', + html: '

Adapter narrative

Shared Docs fixture.

', + sourceFile: 'adapter.md', + }, + ], + docSections: [ + { + title: 'adapterApi', + signature: 'adapterApi(value: string): boolean', + description: 'Shared API fixture.', + params: [{ name: 'value', description: 'Fixture input.' }], + returns: 'Whether the fixture is active.', + sourceFile: 'adapter.ts', + language: 'typescript', + }, + ], + }} + routePath={model.canonicalPath} + requestedMode="run" + /> +
+ ); + + const frame = await screen.findByTitle('LangGraph Streaming live example'); + expect(screen.getByRole('region', { name: 'Run mode' })).toBeTruthy(); + + fireEvent.click(screen.getByRole('button', { name: 'Code' })); + expect(screen.getByRole('region', { name: 'Code mode' })).toBeTruthy(); + expect(screen.getByText('const adapterFixture = true;')).toBeTruthy(); + expect(screen.getByTitle('LangGraph Streaming live example')).toBe(frame); + + fireEvent.click(screen.getByRole('button', { name: 'Docs' })); + expect(screen.getByRole('region', { name: 'Docs mode' })).toBeTruthy(); + expect( + screen.getByRole('heading', { name: 'Adapter narrative' }) + ).toBeTruthy(); + expect(screen.getByText('Shared Docs fixture.')).toBeTruthy(); + expect(screen.getByTitle('LangGraph Streaming live example')).toBe(frame); + + fireEvent.click(screen.getByRole('button', { name: 'API' })); + expect(screen.getByRole('region', { name: 'API mode' })).toBeTruthy(); + expect(screen.getByRole('heading', { name: 'adapterApi' })).toBeTruthy(); + expect(screen.getByText('Shared API fixture.')).toBeTruthy(); + expect(screen.getByTitle('LangGraph Streaming live example')).toBe(frame); + + fireEvent.click(screen.getByRole('button', { name: RUN_RAIL_ITEM })); + expect(screen.getByRole('region', { name: 'Run mode' })).toBeTruthy(); + expect(screen.getByTitle('LangGraph Streaming live example')).toBe(frame); + }); + it('reloads only the iframe while preserving shell state and session Activity', async () => { seedExpanded({ Capability: true, Runtime: true }); renderShell('https://runtime.test/path?secret=hidden'); @@ -882,6 +1185,22 @@ describe('CockpitShell documentation link', () => { expect(link.getAttribute('rel')).toBe('noopener noreferrer'); }); + it('links a docs-only legacy entry to its published canonical page', () => { + renderShellFor([ + 'langgraph', + 'getting-started', + 'overview', + 'overview', + 'python', + ]); + + expect( + screen.getByRole('link', { name: /read docs/i }).getAttribute('href') + ).toBe( + 'https://threadplane.ai/docs/langgraph/getting-started/introduction' + ); + }); + it('links a deep-agents capability at the deep-agents docs library', () => { renderShellFor([ 'deep-agents', @@ -897,15 +1216,4 @@ describe('CockpitShell documentation link', () => { ); }); - it('renders no link for a capability with no published docs page', () => { - // Every mapped capability now points at a published page, so the sentinel - // branch is exercised through a presentation carrying it rather than - // through a table entry that happens to be blank today. - renderShellFor( - ['deep-agents', 'core-capabilities', 'planning', 'overview', 'python'], - { docsPath: NO_COCKPIT_DOCS_LINK } - ); - - expect(screen.queryByRole('link', { name: /read docs/i })).toBeNull(); - }); }); diff --git a/apps/cockpit/src/components/cockpit-shell.tsx b/apps/cockpit/src/components/cockpit-shell.tsx index 3f3a286ca..103874e89 100644 --- a/apps/cockpit/src/components/cockpit-shell.tsx +++ b/apps/cockpit/src/components/cockpit-shell.tsx @@ -1,94 +1,59 @@ 'use client'; -import React, { - useCallback, - useEffect, - useMemo, - useReducer, - useRef, - useState, -} from 'react'; -import { cockpitManifest } from '@threadplane/cockpit-registry'; -import { BookOpen, Menu } from 'lucide-react'; -import { useRouter } from 'next/navigation'; +import React, { useCallback, useEffect, useMemo, useRef } from 'react'; import { - parseControlPlaneMode, - useControlPlanePreferences, - type ControlPlaneMode, -} from '@threadplane/ui-react'; -import type { ContentBundle } from '../lib/content-bundle'; -import type { - CapabilityPresentation, - NavigationProduct, -} from '../lib/route-resolution'; -import { PRODUCT_LABELS } from '../lib/navigation-labels'; -import { track } from '../lib/analytics/client'; -import type { - CockpitRuntimeActionProps, - CockpitRuntimeStatusChangedProps, -} from '../lib/analytics/events'; + cockpitManifest, + type CockpitManifestEntry, + type WorkspaceMode, + type WorkspaceResolution, +} from '@threadplane/cockpit-registry'; import { - activityReducer, - countUnseenProblems, - createSessionActivityEvent, - type ActivityMode, - type RuntimeActivityInput, -} from '../lib/runtime/session-activity'; -import { copyRuntimeDiagnostics } from '../lib/runtime/runtime-diagnostics'; -import type { RuntimeTerminalTransition } from '../lib/runtime/runtime-state'; -import { useRuntimeController } from '../lib/runtime/use-runtime-controller'; -import { resolveDocsUrl } from '../lib/docs-links'; -import { CodeMode } from './code-mode/code-mode'; -import { ApiMode } from './api-mode/api-mode'; -import { NarrativeDocs } from './narrative-docs/narrative-docs'; -import { RunMode } from './run-mode/run-mode'; -import { MobileNavOverlay } from './mobile-nav-overlay'; + toCockpitPath, + type ContentBundle, + type NavigationProduct, + type WorkspacePresentation, +} from '@threadplane/cockpit-shell'; +import { ThemeToggle } from '@threadplane/ui-react'; import { - CockpitControlPlane, - type CockpitControlPlaneProps, - type CockpitUtility, -} from './control-plane/cockpit-control-plane'; + WorkspaceProvider, + WorkspaceShell, + resolveDocsUrl, + type RuntimeTerminalTransition, + type TrackModeChange, + type TrackNarrativeAction, + type TrackNavigation, + type TrackRuntimeAction, + type TrackRuntimeTransition, +} from '@threadplane/workspace-react'; +import { BookOpen } from 'lucide-react'; +import { useRouter } from 'next/navigation'; +import { track } from '../lib/analytics/client'; +import { getCockpitSessionId } from '../lib/analytics/distinct-id'; +import type { CockpitRuntimeStatusChangedProps } from '../lib/analytics/events'; -interface CockpitShellProps { - navigationTree: NavigationProduct[]; - presentation: CapabilityPresentation; - entryTitle: string; - contentBundle: ContentBundle; +export interface CockpitShellProps { + readonly navigationTree: NavigationProduct[]; + readonly resolution: WorkspaceResolution; + readonly presentation: WorkspacePresentation; + readonly contentBundle: ContentBundle; + readonly routePath: string; + readonly requestedMode: string | null; } -const MODE_ANALYTICS: Record< - ControlPlaneMode, - 'run' | 'code' | 'docs' | 'api' -> = { +const MODE_ANALYTICS: Record = { Run: 'run', Code: 'code', Docs: 'docs', API: 'api', }; -const MOBILE_NAVIGATION_FOCUS_INTENT = - 'threadplane:cockpit:mobile-navigation-focus'; -const MOBILE_NAVIGATION_FOCUS_MAX_AGE_MS = 10_000; - -const toLabel = (value: string) => - value - .split('-') - .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) - .join(' '); - -function createLocalActivityInput( - capability: string, - input: - | { kind: 'mode_changed'; mode: ActivityMode } - | { kind: 'diagnostics_copied' | 'diagnostics_copy_failed' } -): RuntimeActivityInput { - return { - id: globalThis.crypto.randomUUID(), - at: new Date().toISOString(), - capability, - ...input, - }; -} +const WORKSPACE_PANEL_FOCUS_INTENT = + 'threadplane:cockpit:workspace-panel-focus'; +const WORKSPACE_PANEL_FOCUS_MAX_AGE_MS = 10_000; +const RUNTIME_FRAME_TELEMETRY = { + posthogToken: process.env.NEXT_PUBLIC_COCKPIT_POSTHOG_TOKEN, + ingestHost: process.env.NEXT_PUBLIC_COCKPIT_INGEST_HOST, +}; function toRuntimeStatusChangedProps( transition: RuntimeTerminalTransition @@ -102,24 +67,16 @@ function toRuntimeStatusChangedProps( }; switch (transition.toState) { - case 'ready': { - if ( - transition.fromState === 'unresponsive' || + case 'ready': + return transition.fromState === 'unresponsive' || transition.fromState === 'error' - ) { - return { - ...common, - from_state: transition.fromState, - to_state: 'ready', - transition: 'recovered', - }; - } - return { - ...common, - from_state: transition.fromState, - to_state: 'ready', - }; - } + ? { + ...common, + from_state: transition.fromState, + to_state: 'ready', + transition: 'recovered', + } + : { ...common, from_state: transition.fromState, to_state: 'ready' }; case 'unresponsive': return { ...common, @@ -147,137 +104,127 @@ function toRuntimeStatusChangedProps( } } +const trackNavigation: TrackNavigation = ({ + capability, + category, + fromCapability, +}) => { + track('cockpit:recipe_opened', { + capability, + category, + from_capability: fromCapability, + }); +}; + +const trackNarrativeAction: TrackNarrativeAction = ({ + capability, + surface, +}) => { + track('cockpit:code_copied', { capability, surface }); +}; + +const trackModeChange: TrackModeChange = ({ capability, fromMode, toMode }) => { + track('cockpit:mode_switched', { + capability, + from_mode: MODE_ANALYTICS[fromMode], + to_mode: MODE_ANALYTICS[toMode], + }); +}; + +const trackRuntimeAction: TrackRuntimeAction = (event) => { + switch (event.action) { + case 'recheck': + case 'reload': + track('cockpit:runtime_action', { + capability: event.capability, + action: event.action, + state_before: event.stateBefore, + outcome: event.outcome, + }); + break; + case 'open': + track('cockpit:runtime_action', { + capability: event.capability, + action: event.action, + state_before: event.stateBefore, + outcome: event.outcome, + }); + break; + case 'copy_diagnostics': + track('cockpit:runtime_action', { + capability: event.capability, + action: event.action, + state_before: event.stateBefore, + outcome: event.outcome, + }); + break; + } +}; + +const trackRuntimeTransition: TrackRuntimeTransition = (transition) => { + track( + 'cockpit:runtime_status_changed', + toRuntimeStatusChangedProps(transition) + ); +}; + +const modeHref = (mode: WorkspaceMode): string => { + const url = new URL(window.location.href); + url.searchParams.set('mode', mode.toLowerCase()); + return `${url.pathname}${url.search}${url.hash}`; +}; + export function CockpitShell({ navigationTree, + resolution, presentation, - entryTitle, contentBundle, + routePath, + requestedMode, }: CockpitShellProps) { const router = useRouter(); const routerRef = useRef(router); routerRef.current = router; - const preferences = useControlPlanePreferences('cockpit'); - const queryHandled = useRef(false); - const mobileTriggerRef = useRef(null); - const [isSidebarOpen, setIsSidebarOpen] = useState(false); - const [activeMode, setActiveMode] = useState('Run'); - const [isMobileOverlayPresent, setIsMobileOverlayPresent] = useState(false); - const [activeUtility, setActiveUtility] = useState(null); - const [activityOpenCycle, setActivityOpenCycle] = useState(0); - const [seenActivityCount, setSeenActivityCount] = useState(0); - const [events, dispatchActivity] = useReducer(activityReducer, []); - const isCapability = presentation.kind === 'capability'; - const codeAssetPaths = isCapability ? presentation.codeAssetPaths : []; - const backendAssetPaths = isCapability - ? presentation.backendAssetPaths ?? [] - : []; - const entry = presentation.entry; - const contextLabel = [ - PRODUCT_LABELS[entry.product] ?? toLabel(entry.product), - toLabel(entry.section), - toLabel(entry.topic), - ].join(' / '); - - const appendActivity = useCallback((input: RuntimeActivityInput) => { - dispatchActivity({ - type: 'add', - event: createSessionActivityEvent(input), - }); - }, []); - const handleTerminalTransition = useCallback( - (transition: RuntimeTerminalTransition) => { - track( - 'cockpit:runtime_status_changed', - toRuntimeStatusChangedProps(transition) - ); - }, - [] - ); - - const controller = useRuntimeController({ - runtimeUrl: contentBundle.runtimeUrl, - capability: entry.topic, - onActivity: appendActivity, - onTerminalTransition: handleTerminalTransition, - }); - // Null for the capabilities that have no published docs page yet — those - // render no link at all rather than one that 404s. - const docsUrl = resolveDocsUrl(presentation.docsPath); - - useEffect(() => { - if (queryHandled.current) return; - queryHandled.current = true; - const url = new URL(window.location.href); - const rawMode = url.searchParams.get('mode'); - const requestedMode = parseControlPlaneMode(rawMode); - if (requestedMode) setActiveMode(requestedMode); - if (rawMode !== null) { - url.searchParams.delete('mode'); - window.history.replaceState( - window.history.state, - '', - url.pathname + url.search + url.hash - ); - } - }, []); - - const isMobileModalActive = isSidebarOpen || isMobileOverlayPresent; - - const handleModeChange = useCallback( - (mode: ControlPlaneMode) => { - if (mode === activeMode) return; - setActiveMode(mode); - appendActivity( - createLocalActivityInput(entry.topic, { - kind: 'mode_changed', - mode, - }) - ); - track('cockpit:mode_switched', { - capability: entry.topic, - from_mode: MODE_ANALYTICS[activeMode], - to_mode: MODE_ANALYTICS[mode], - }); - }, - [activeMode, appendActivity, entry.topic] - ); - - const handleActiveUtilityChange = useCallback( - (utility: CockpitUtility) => { - if (utility === 'activity' && activeUtility !== 'activity') { - setActivityOpenCycle((cycle) => cycle + 1); - setSeenActivityCount(events.length); + const pushIdentity = useCallback( + ( + href: string, + options?: { + restoreFocus?: 'mobile-navigation-trigger' | 'workspace-panel'; + } + ) => { + if (options?.restoreFocus === 'workspace-panel') { + const currentDestination = `${window.location.pathname}${window.location.search}${window.location.hash}`; + if (href !== currentDestination) { + try { + window.sessionStorage.setItem( + WORKSPACE_PANEL_FOCUS_INTENT, + JSON.stringify({ destination: href, requestedAt: Date.now() }) + ); + } catch { + // Client navigation still works if session storage is unavailable. + } + } } - setActiveUtility(utility); + routerRef.current.push(href); }, - [activeUtility, events.length] + [] ); - - const closeMobileNavigation = useCallback(() => { - setIsSidebarOpen(false); + const pushMode = useCallback((mode: WorkspaceMode) => { + routerRef.current.push(modeHref(mode)); }, []); - - const handleCapabilityNavigate = useCallback((destination: string) => { - const currentDestination = - window.location.pathname + window.location.search + window.location.hash; - if (destination !== currentDestination) { - try { - window.sessionStorage.setItem( - MOBILE_NAVIGATION_FOCUS_INTENT, - JSON.stringify({ destination, requestedAt: Date.now() }) - ); - } catch { - // Client navigation still works if session storage is unavailable. - } - } - routerRef.current.push(destination); + const replaceMode = useCallback((mode: WorkspaceMode) => { + routerRef.current.replace(modeHref(mode)); }, []); + const resolveIdentityHref = useCallback( + (entry: CockpitManifestEntry) => toCockpitPath(entry), + [] + ); useEffect(() => { let rawIntent: string | null = null; try { - rawIntent = window.sessionStorage.getItem(MOBILE_NAVIGATION_FOCUS_INTENT); + rawIntent = window.sessionStorage.getItem(WORKSPACE_PANEL_FOCUS_INTENT); } catch { return undefined; } @@ -287,242 +234,82 @@ export function CockpitShell({ try { intent = JSON.parse(rawIntent) as typeof intent; } catch { - window.sessionStorage.removeItem(MOBILE_NAVIGATION_FOCUS_INTENT); + window.sessionStorage.removeItem(WORKSPACE_PANEL_FOCUS_INTENT); return undefined; } - const currentDestination = - window.location.pathname + window.location.search + window.location.hash; + const currentDestination = `${window.location.pathname}${window.location.search}${window.location.hash}`; const isFresh = typeof intent.requestedAt === 'number' && - Date.now() - intent.requestedAt <= MOBILE_NAVIGATION_FOCUS_MAX_AGE_MS; + Date.now() - intent.requestedAt <= WORKSPACE_PANEL_FOCUS_MAX_AGE_MS; if (!isFresh) { - window.sessionStorage.removeItem(MOBILE_NAVIGATION_FOCUS_INTENT); + window.sessionStorage.removeItem(WORKSPACE_PANEL_FOCUS_INTENT); return undefined; } if (intent.destination !== currentDestination) return undefined; - window.sessionStorage.removeItem(MOBILE_NAVIGATION_FOCUS_INTENT); - const focusTrigger = () => { - if ( - typeof window.matchMedia === 'function' && - window.matchMedia('(min-width: 48rem)').matches - ) { - return; - } - const trigger = mobileTriggerRef.current; - if (!trigger?.closest('[inert]')) trigger?.focus(); + window.sessionStorage.removeItem(WORKSPACE_PANEL_FOCUS_INTENT); + const focusPanel = () => { + const panel = document.querySelector( + '[data-workspace-panel-target]:not([aria-hidden="true"])' + ); + if (!panel?.closest('[inert]')) panel?.focus(); }; if (typeof window.requestAnimationFrame === 'function') { - const frame = window.requestAnimationFrame(focusTrigger); + const frame = window.requestAnimationFrame(focusPanel); return () => window.cancelAnimationFrame(frame); } - const timer = window.setTimeout(focusTrigger, 0); + const timer = window.setTimeout(focusPanel, 0); return () => window.clearTimeout(timer); - }, [entry.page, entry.product, entry.section, entry.topic]); + }, [routePath]); - const handleClearActivity = useCallback(() => { - dispatchActivity({ type: 'clear' }); - setSeenActivityCount(0); - }, []); - - const handleRecheck = useCallback(() => { - const stateBefore = controller.snapshot.phase; - controller.recheck(); - track('cockpit:runtime_action', { - capability: entry.topic, - action: 'recheck', - state_before: stateBefore, - outcome: 'requested', - } satisfies CockpitRuntimeActionProps); - return 'requested' as const; - }, [controller, entry.topic]); - - const handleReload = useCallback(() => { - const stateBefore = controller.snapshot.phase; - controller.reload(); - track('cockpit:runtime_action', { - capability: entry.topic, - action: 'reload', - state_before: stateBefore, - outcome: 'requested', - } satisfies CockpitRuntimeActionProps); - return 'requested' as const; - }, [controller, entry.topic]); - - const handleOpenRuntime = useCallback(() => { - const stateBefore = controller.snapshot.phase; - const outcome = controller.open(); - track('cockpit:runtime_action', { - capability: entry.topic, - action: 'open', - state_before: stateBefore, - outcome, - } satisfies CockpitRuntimeActionProps); - return outcome; - }, [controller, entry.topic]); - - const handleCopyDiagnostics = useCallback(async () => { - const snapshot = controller.snapshot; - const stateBefore = snapshot.phase; - const outcome = await copyRuntimeDiagnostics(snapshot, events); - appendActivity( - createLocalActivityInput(entry.topic, { - kind: - outcome === 'succeeded' - ? 'diagnostics_copied' - : 'diagnostics_copy_failed', - }) - ); - track('cockpit:runtime_action', { - capability: entry.topic, - action: 'copy_diagnostics', - state_before: stateBefore, - outcome, - } satisfies CockpitRuntimeActionProps); - return outcome; - }, [appendActivity, controller.snapshot, entry.topic, events]); - - const controlPlaneProps = useMemo< - Omit - >( - () => ({ - navigationTree, - manifest: cockpitManifest, - entry, - activeMode, - onModeChange: handleModeChange, - activeUtility, - onActiveUtilityChange: handleActiveUtilityChange, - activityOpenCycle, - runtimeSnapshot: controller.snapshot, - events, - unseenProblems: countUnseenProblems(events, seenActivityCount), - expanded: preferences.expanded, - onExpandedChange: preferences.setExpanded, - onClearActivity: handleClearActivity, - onRecheck: handleRecheck, - onReload: handleReload, - onOpenRuntime: handleOpenRuntime, - onCopyDiagnostics: handleCopyDiagnostics, - }), - [ - activeMode, - activeUtility, - activityOpenCycle, - controller.snapshot, - entry, - events, - handleActiveUtilityChange, - handleClearActivity, - handleCopyDiagnostics, - handleModeChange, - handleOpenRuntime, - handleRecheck, - handleReload, - navigationTree, - preferences.expanded, - preferences.setExpanded, - seenActivityCount, - ] + const docsUrl = resolveDocsUrl(presentation.docsPath); + const headerActions = useMemo( + () => + docsUrl ? ( + + + ) : null, + [docsUrl] ); return ( -
-
- -
- - } + headerActions={headerActions} + ariaLabel="Cockpit shell" + modeNavigationLabel="Cockpit modes" + contextPaneLabel="Cockpit context" + mobileDialogLabel="Cockpit control plane" + mobileTitle="Cockpit" /> - -
-
-
- -

- {contextLabel} -

-
- {docsUrl ? ( - - - ) : null} -
- -
-
- -
- {activeMode === 'Code' ? ( - - ) : null} - {activeMode === 'Docs' ? ( - - ) : null} - {activeMode === 'API' ? ( - - ) : null} -
-
-
+ ); } diff --git a/apps/cockpit/src/components/control-plane/cockpit-control-plane.tsx b/apps/cockpit/src/components/control-plane/cockpit-control-plane.tsx deleted file mode 100644 index 35a284e3f..000000000 --- a/apps/cockpit/src/components/control-plane/cockpit-control-plane.tsx +++ /dev/null @@ -1,261 +0,0 @@ -'use client'; - -import React, { useRef } from 'react'; -import type { CockpitManifestEntry } from '@threadplane/cockpit-registry'; -import { - Activity as ActivityIcon, - BookOpen, - Braces, - Code2, - Play, - Settings, -} from 'lucide-react'; -import { - ControlPlanePane, - ControlPlaneRail, - ControlPlaneRailItem, - ControlPlaneUtilityPanel, - ThemeToggle, - type ControlPlaneMode, -} from '@threadplane/ui-react'; -import type { NavigationProduct } from '../../lib/route-resolution'; -import { PRODUCT_LABELS } from '../../lib/navigation-labels'; -import type { SessionActivityEvent } from '../../lib/runtime/session-activity'; -import { - runtimeRailStatus, - type RuntimeSnapshot, -} from '../../lib/runtime/runtime-state'; -import { CockpitSidebar } from '../sidebar/cockpit-sidebar'; -import { LanguagePicker } from '../sidebar/language-picker'; -import { ActivityPanel } from './activity-panel'; -import { ActivityPanelBoundary } from './activity-panel-boundary'; -import { RuntimeSection } from './runtime-section'; - -const MODES: Array<{ - label: ControlPlaneMode; - icon: typeof Play; -}> = [ - { label: 'Docs', icon: BookOpen }, - { label: 'Run', icon: Play }, - { label: 'Code', icon: Code2 }, - { label: 'API', icon: Braces }, -]; - -type RuntimeCommandOutcome = void | 'requested' | 'succeeded' | 'failed'; -type RuntimeCommand = () => - | RuntimeCommandOutcome - | PromiseLike; - -export type CockpitUtility = 'activity' | 'settings' | null; - -export interface CockpitControlPlaneProps { - navigationTree: NavigationProduct[]; - manifest: CockpitManifestEntry[]; - entry: CockpitManifestEntry; - activeMode: ControlPlaneMode; - onModeChange(mode: ControlPlaneMode): void; - activeUtility: CockpitUtility; - onActiveUtilityChange(utility: CockpitUtility): void; - activityOpenCycle: number; - runtimeSnapshot: RuntimeSnapshot; - events: readonly SessionActivityEvent[]; - unseenProblems: number; - expanded: Record; - onExpandedChange(key: string, open: boolean): void; - onClearActivity(): void; - onRecheck: RuntimeCommand; - onReload: RuntimeCommand; - onOpenRuntime: RuntimeCommand; - onCopyDiagnostics: RuntimeCommand; - mobile?: boolean; - onModeSelected?: () => void; - onNavigate?: () => void; -} - -function focusUtilityInvoker(ref: React.RefObject) { - ref.current?.querySelector('button')?.focus(); -} - -export function CockpitControlPlane({ - navigationTree, - manifest, - entry, - activeMode, - onModeChange, - activeUtility, - onActiveUtilityChange, - activityOpenCycle, - runtimeSnapshot, - events, - unseenProblems, - expanded, - onExpandedChange, - onClearActivity, - onRecheck, - onReload, - onOpenRuntime, - onCopyDiagnostics, - mobile = false, - onModeSelected, - onNavigate, -}: CockpitControlPlaneProps) { - const activityRef = useRef(null); - const settingsRef = useRef(null); - const railStatus = runtimeRailStatus(runtimeSnapshot.phase); - const attention = unseenProblems > 0; - const activityLabel = attention - ? `Activity, ${unseenProblems} unread problem${ - unseenProblems === 1 ? '' : 's' - }` - : 'Activity'; - const product = PRODUCT_LABELS[entry.product] ?? entry.product; - const language = entry.language === 'typescript' ? 'TypeScript' : 'Python'; - - const closeUtility = ( - utility: Exclude, - invokerRef: React.RefObject - ) => { - if (activeUtility !== utility) return; - onActiveUtilityChange(null); - focusUtilityInvoker(invokerRef); - }; - - const selectUtility = ( - utility: Exclude, - invokerRef: React.RefObject - ) => { - if (activeUtility === utility) { - closeUtility(utility, invokerRef); - return; - } - onActiveUtilityChange(utility); - }; - - const selectMode = (mode: ControlPlaneMode) => { - if (activeUtility !== null) { - onActiveUtilityChange(null); - } - onModeChange(mode); - onModeSelected?.(); - }; - - let paneContent: React.ReactNode; - if (activeUtility === 'activity') { - paneContent = ( - closeUtility('activity', activityRef)} - > - closeUtility('activity', activityRef)} - onClear={onClearActivity} - /> - - ); - } else if (activeUtility === 'settings') { - paneContent = ( - closeUtility('settings', settingsRef)} - > -
- Language - -
-
- Theme - -
-
- ); - } else { - paneContent = ( - <> - - onExpandedChange('Runtime', open)} - onRecheck={onRecheck} - onReload={onReload} - onOpenRuntime={onOpenRuntime} - onCopyDiagnostics={onCopyDiagnostics} - /> - - ); - } - - return ( -
- ( -
- ); -} diff --git a/apps/cockpit/src/components/pane-rendering.spec.tsx b/apps/cockpit/src/components/pane-rendering.spec.tsx index abdbff690..450a831f7 100644 --- a/apps/cockpit/src/components/pane-rendering.spec.tsx +++ b/apps/cockpit/src/components/pane-rendering.spec.tsx @@ -1,8 +1,7 @@ import React from 'react'; import { renderToStaticMarkup } from 'react-dom/server'; import { describe, expect, it } from 'vitest'; -import { CodeMode } from './code-mode/code-mode'; -import { CodePane } from './code-pane/code-pane'; +import { CodeMode, CodePane } from '@threadplane/workspace-react'; import { CockpitShell } from './cockpit-shell'; import { getCockpitPageModel } from '../lib/cockpit-page'; @@ -25,9 +24,17 @@ describe('cockpit shell contract', () => { const html = renderToStaticMarkup( ); @@ -51,9 +58,17 @@ describe('refreshed shell structure', () => {
{ - afterEach(() => { - globalThis.document?.body.replaceChildren(); - }); - - it('shows the current language in the trigger and opens a custom menu', () => { - const dom = new JSDOM(''); - const { window } = dom; - - globalThis.window = window as unknown as Window & typeof globalThis; - globalThis.document = window.document; - globalThis.HTMLElement = window.HTMLElement; - globalThis.Node = window.Node; - globalThis.MouseEvent = window.MouseEvent; - - const entry = cockpitManifest.find( - (candidate) => - candidate.product === 'langgraph' && - candidate.section === 'core-capabilities' && - candidate.topic === 'streaming' && - candidate.language === 'python' - )!; - - const container = document.createElement('div'); - document.body.appendChild(container); - - const root = createRoot(container); - - act(() => { - root.render(); - }); - - expect(container.querySelector('select')).toBeNull(); - expect(container.textContent).toContain('Python'); - - const trigger = container.querySelector('button'); - expect(trigger).not.toBeNull(); - - act(() => { - trigger?.dispatchEvent(new MouseEvent('click', { bubbles: true })); - }); - - expect(container.querySelector('[role="menu"]')).not.toBeNull(); - expect(container.textContent).toContain('TypeScript'); - - act(() => { - root.unmount(); - }); - }); -}); diff --git a/apps/cockpit/src/components/sidebar/navigation-groups.tsx b/apps/cockpit/src/components/sidebar/navigation-groups.tsx deleted file mode 100644 index b9830123d..000000000 --- a/apps/cockpit/src/components/sidebar/navigation-groups.tsx +++ /dev/null @@ -1,128 +0,0 @@ -'use client'; - -import React, { useId } from 'react'; -import { ChevronRight } from 'lucide-react'; -import type { CockpitManifestEntry } from '@threadplane/cockpit-registry'; -import type { NavigationProduct } from '../../lib/route-resolution'; -import { toCockpitPath } from '../../lib/route-resolution'; -import { PRODUCT_LABELS, stripProductPrefix } from '../../lib/navigation-labels'; -import { track } from '../../lib/analytics/client'; - -interface NavigationGroupsProps { - tree: NavigationProduct[]; - currentEntry: CockpitManifestEntry; - expanded?: Record; - onExpandedChange?: (key: string, open: boolean) => void; - onNavigate?: () => void; -} - -function ProductGroup({ - product, - currentEntry, - open, - onOpenChange, - onNavigate, -}: { - product: NavigationProduct; - currentEntry: CockpitManifestEntry; - open: boolean; - onOpenChange: (open: boolean) => void; - onNavigate?: () => void; -}) { - const label = PRODUCT_LABELS[product.product] ?? product.product; - const contentId = useId(); - - return ( -
- - - {open && ( -
- {product.sections.flatMap((section) => - section.entries - .filter((entry) => entry.topic !== 'overview') - .map((entry) => { - const isActive = - entry.product === currentEntry.product && - entry.section === currentEntry.section && - entry.topic === currentEntry.topic && - entry.page === currentEntry.page; - - return ( - { - onNavigate?.(); - track('cockpit:recipe_opened', { - capability: entry.topic, - category: entry.product, - from_capability: currentEntry.topic, - }); - }} - aria-current={isActive ? 'page' : undefined} - className="cockpit-nav-item" - > - {stripProductPrefix(entry.title)} - - ); - }) - )} -
- )} -
- ); -} - -export function NavigationGroups({ - tree, - currentEntry, - expanded = {}, - onExpandedChange, - onNavigate, -}: NavigationGroupsProps) { - return ( - - ); -} diff --git a/apps/cockpit/src/lib/analytics/events.ts b/apps/cockpit/src/lib/analytics/events.ts index c4e3e13ae..2fa99e8e6 100644 --- a/apps/cockpit/src/lib/analytics/events.ts +++ b/apps/cockpit/src/lib/analytics/events.ts @@ -2,7 +2,7 @@ import type { RuntimePhase, RuntimeTerminalPhase, -} from '../runtime/runtime-state'; +} from '@threadplane/workspace-react'; export type CockpitShellEvent = | 'cockpit:recipe_opened' diff --git a/apps/cockpit/src/lib/cockpit-page.spec.ts b/apps/cockpit/src/lib/cockpit-page.spec.ts new file mode 100644 index 000000000..9f9e41741 --- /dev/null +++ b/apps/cockpit/src/lib/cockpit-page.spec.ts @@ -0,0 +1,195 @@ +import { describe, expect, it } from 'vitest'; +import { + getCanonicalCockpitRedirect, + getCockpitPageModel, + getLegacyWebsiteRedirect, + getRootWebsiteRedirect, + getUnifiedWorkspaceRedirectOrigin, + normalizeRequestedMode, +} from './cockpit-page'; +import { cockpitManifest } from '@threadplane/cockpit-registry'; +import { getWorkspaceDestinationPath } from '@threadplane/cockpit-registry'; + +const enabledProductionEnv = { + UNIFIED_WORKSPACE_REDIRECTS_ENABLED: 'true', + NEXT_PUBLIC_WEBSITE_ORIGIN: 'https://threadplane.ai', + NODE_ENV: 'production', +}; + +describe('Cockpit page query normalization', () => { + it('keeps repeated mode params explicitly invalid for provider normalization', () => { + expect(normalizeRequestedMode(['code', 'docs'])).toBe('code,docs'); + expect(normalizeRequestedMode('code')).toBe('code'); + expect(normalizeRequestedMode(undefined)).toBeNull(); + }); + + it('preserves only a syntactically valid mode available on the canonical entry', () => { + const model = getCockpitPageModel([ + 'langgraph', + 'core-capabilities', + 'streaming', + 'overview', + 'python', + ]); + expect(getCanonicalCockpitRedirect(model, 'code')).toBe( + `${model.canonicalPath}?mode=code` + ); + expect(getCanonicalCockpitRedirect(model, 'preview')).toBe( + model.canonicalPath + ); + expect(getCanonicalCockpitRedirect(model, ['code', 'docs'])).toBe( + model.canonicalPath + ); + + const docsOnly = getCockpitPageModel([ + 'langgraph', + 'getting-started', + 'overview', + 'overview', + 'python', + ]); + expect(getCanonicalCockpitRedirect(docsOnly, 'run')).toBe( + docsOnly.canonicalPath + ); + }); +}); + +describe('unified Website redirect gate', () => { + it('is disabled unless the explicit flag and a valid origin are both present', () => { + expect( + getUnifiedWorkspaceRedirectOrigin({ + NEXT_PUBLIC_WEBSITE_ORIGIN: 'https://threadplane.ai', + NODE_ENV: 'production', + }) + ).toBeNull(); + expect( + getUnifiedWorkspaceRedirectOrigin({ + ...enabledProductionEnv, + NEXT_PUBLIC_WEBSITE_ORIGIN: 'http://threadplane.ai', + }) + ).toBeNull(); + expect( + getUnifiedWorkspaceRedirectOrigin({ + ...enabledProductionEnv, + NEXT_PUBLIC_WEBSITE_ORIGIN: 'https://threadplane.ai/docs', + }) + ).toBeNull(); + expect(getUnifiedWorkspaceRedirectOrigin(enabledProductionEnv)).toBe( + 'https://threadplane.ai' + ); + }); + + it('allows HTTP localhost only in development', () => { + const localhost = { + UNIFIED_WORKSPACE_REDIRECTS_ENABLED: 'true', + NEXT_PUBLIC_WEBSITE_ORIGIN: 'http://localhost:3000', + }; + expect( + getUnifiedWorkspaceRedirectOrigin({ + ...localhost, + NODE_ENV: 'development', + }) + ).toBe('http://localhost:3000'); + expect( + getUnifiedWorkspaceRedirectOrigin({ + ...localhost, + NODE_ENV: 'production', + }) + ).toBeNull(); + }); +}); + +describe('registry-derived legacy Website redirects', () => { + it('maps every legacy path to its registry-owned Website destination', () => { + for (const entry of cockpitManifest) { + expect( + getLegacyWebsiteRedirect( + entry.legacyPath, + undefined, + enabledProductionEnv + ) + ).toBe( + `https://threadplane.ai${getWorkspaceDestinationPath(entry)}` + ); + } + }); + + it('preserves only a single valid mode available at the destination', () => { + const streaming = cockpitManifest.find( + (entry) => + entry.id === 'langgraph:core-capabilities:streaming:overview:python' + ); + const overview = cockpitManifest.find( + (entry) => + entry.id === 'langgraph:getting-started:overview:overview:python' + ); + if (!streaming || !overview) throw new Error('Expected fixture entries'); + + expect( + getLegacyWebsiteRedirect( + streaming.legacyPath, + 'code', + enabledProductionEnv + ) + ).toBe('https://threadplane.ai/docs/langgraph/guides/streaming?mode=code'); + expect( + getLegacyWebsiteRedirect( + streaming.legacyPath, + ['code', 'run'], + enabledProductionEnv + ) + ).toBe('https://threadplane.ai/docs/langgraph/guides/streaming'); + expect( + getLegacyWebsiteRedirect(overview.legacyPath, 'run', enabledProductionEnv) + ).toBe( + 'https://threadplane.ai/docs/langgraph/getting-started/introduction' + ); + expect( + getLegacyWebsiteRedirect( + streaming.legacyPath, + 'preview', + enabledProductionEnv + ) + ).toBe('https://threadplane.ai/docs/langgraph/guides/streaming'); + }); + + it('preserves secondary capability identity and its available modes', () => { + const jsonRender = cockpitManifest.find( + (entry) => + entry.id === 'ag-ui:core-capabilities:json-render:overview:python' + ); + if (!jsonRender) throw new Error('Expected AG-UI JSON Render fixture'); + + expect(jsonRender.availableModes).toContain('Run'); + expect( + getLegacyWebsiteRedirect( + jsonRender.legacyPath, + 'run', + enabledProductionEnv + ) + ).toBe('https://threadplane.ai/workspace/ag-ui/json-render?mode=run'); + expect( + getLegacyWebsiteRedirect( + jsonRender.legacyPath, + 'docs', + enabledProductionEnv + ) + ).toBe('https://threadplane.ai/workspace/ag-ui/json-render?mode=docs'); + }); + + it('does not redirect invalid or unmapped legacy paths', () => { + expect( + getLegacyWebsiteRedirect( + '/langgraph/core-capabilities/not-real/overview/python', + 'run', + enabledProductionEnv + ) + ).toBeNull(); + }); + + it('redirects the Cockpit root through its default registry identity', () => { + expect(getRootWebsiteRedirect('run', enabledProductionEnv)).toBe( + 'https://threadplane.ai/docs/langgraph/guides/streaming?mode=run' + ); + }); +}); diff --git a/apps/cockpit/src/lib/cockpit-page.ts b/apps/cockpit/src/lib/cockpit-page.ts index 44c106386..d9b2b7dc6 100644 --- a/apps/cockpit/src/lib/cockpit-page.ts +++ b/apps/cockpit/src/lib/cockpit-page.ts @@ -1,17 +1,30 @@ -import { cockpitManifest, type CockpitProduct, type CockpitSection, type CockpitPageId, type CockpitLanguage } from '@threadplane/cockpit-registry'; +import { + cockpitManifest, + getWorkspaceDestinationPath, + resolveLegacyPath, + toWorkspaceIdentity, + type CockpitProduct, + type CockpitSection, + type CockpitPageId, + type CockpitLanguage, + type WorkspaceMode, + type WorkspaceResolution, +} from '@threadplane/cockpit-registry'; import { buildNavigationTree, - getCapabilityPresentation, + getWorkspacePresentation, resolveCockpitEntry, toCockpitPath, type NavigationProduct, -} from './route-resolution'; + type WorkspacePresentation, +} from '@threadplane/cockpit-shell'; export { cockpitManifest }; export interface CockpitPageModel { entry: ReturnType; - presentation: ReturnType; + resolution: WorkspaceResolution; + presentation: WorkspacePresentation; navigationTree: NavigationProduct[]; canonicalPath: string; } @@ -24,6 +37,119 @@ const DEFAULT_COCKPIT_SLUG = [ 'python', ] as const; +const QUERY_MODES: Record = { + docs: 'Docs', + run: 'Run', + code: 'Code', + api: 'API', +}; + +export interface UnifiedWorkspaceRedirectEnvironment { + readonly UNIFIED_WORKSPACE_REDIRECTS_ENABLED?: string; + readonly NEXT_PUBLIC_WEBSITE_ORIGIN?: string; + readonly NODE_ENV?: string; +} + +export function getUnifiedWorkspaceRedirectOrigin( + environment: UnifiedWorkspaceRedirectEnvironment = process.env +): string | null { + if (environment.UNIFIED_WORKSPACE_REDIRECTS_ENABLED !== 'true') return null; + const rawOrigin = environment.NEXT_PUBLIC_WEBSITE_ORIGIN; + if (!rawOrigin) return null; + + try { + const url = new URL(rawOrigin); + if ( + url.username || + url.password || + url.pathname !== '/' || + url.search || + url.hash + ) { + return null; + } + + const secure = url.protocol === 'https:'; + const developmentLocalhost = + environment.NODE_ENV === 'development' && + url.protocol === 'http:' && + url.hostname === 'localhost'; + return secure || developmentLocalhost ? url.origin : null; + } catch { + return null; + } +} + +const appendAvailableMode = ( + destinationPath: string, + mode: string | string[] | undefined, + availableModes: readonly WorkspaceMode[] +): string => { + if (typeof mode !== 'string') return destinationPath; + const parsed = QUERY_MODES[mode.toLowerCase()]; + if (!parsed || !availableModes.includes(parsed)) return destinationPath; + return `${destinationPath}?mode=${parsed.toLowerCase()}`; +}; + +const toWebsiteRedirect = ( + origin: string, + resolution: WorkspaceResolution, + mode: string | string[] | undefined +): string | null => { + if (resolution.kind !== 'mapped') return null; + const destinationPath = getWorkspaceDestinationPath(resolution.identity); + return new URL( + appendAvailableMode( + destinationPath, + mode, + resolution.identity.availableModes + ), + `${origin}/` + ).toString(); +}; + +export function getLegacyWebsiteRedirect( + legacyPath: string, + mode: string | string[] | undefined, + environment: UnifiedWorkspaceRedirectEnvironment = process.env +): string | null { + const origin = getUnifiedWorkspaceRedirectOrigin(environment); + if (!origin) return null; + const resolution = resolveLegacyPath(legacyPath); + return resolution ? toWebsiteRedirect(origin, resolution, mode) : null; +} + +export function getRootWebsiteRedirect( + mode: string | string[] | undefined, + environment: UnifiedWorkspaceRedirectEnvironment = process.env +): string | null { + const origin = getUnifiedWorkspaceRedirectOrigin(environment); + if (!origin) return null; + return toWebsiteRedirect(origin, getCockpitPageModel().resolution, mode); +} + +export function normalizeRequestedMode( + mode: string | string[] | undefined +): string | null { + return Array.isArray(mode) ? mode.join(',') : mode ?? null; +} + +export function getCanonicalCockpitRedirect( + model: CockpitPageModel, + mode: string | string[] | undefined +): string { + if (typeof mode !== 'string') return model.canonicalPath; + const parsed = QUERY_MODES[mode.toLowerCase()]; + if ( + !parsed || + model.resolution.kind !== 'mapped' || + !model.resolution.identity.availableModes.includes(parsed) + ) { + return model.canonicalPath; + } + return `${model.canonicalPath}?mode=${parsed.toLowerCase()}`; +} + export function getCockpitPageModel(slug: string[] = []): CockpitPageModel { const resolvedEntry = resolveCockpitEntry({ manifest: cockpitManifest, @@ -33,10 +159,15 @@ export function getCockpitPageModel(slug: string[] = []): CockpitPageModel { page: (slug[3] ?? DEFAULT_COCKPIT_SLUG[3]) as CockpitPageId, language: (slug[4] ?? DEFAULT_COCKPIT_SLUG[4]) as CockpitLanguage, }); + const resolution: WorkspaceResolution = { + kind: 'mapped', + identity: toWorkspaceIdentity(resolvedEntry), + }; return { entry: resolvedEntry, - presentation: getCapabilityPresentation(resolvedEntry), + resolution, + presentation: getWorkspacePresentation(resolution), navigationTree: buildNavigationTree(cockpitManifest), canonicalPath: toCockpitPath(resolvedEntry), }; diff --git a/apps/cockpit/src/lib/content-bundle.spec.ts b/apps/cockpit/src/lib/content-bundle.spec.ts deleted file mode 100644 index 070553220..000000000 --- a/apps/cockpit/src/lib/content-bundle.spec.ts +++ /dev/null @@ -1,195 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest'; -import { resolveRuntimeUrl, getContentBundle } from './content-bundle'; -import type { CapabilityPresentation } from './route-resolution'; - -// Stable mock function references, hoisted so vi.mock factories can access them -const { mockReadFileSync, mockCodeToHtml } = vi.hoisted(() => ({ - mockReadFileSync: vi.fn(), - mockCodeToHtml: vi.fn(), -})); - -vi.mock('node:fs', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - default: { ...actual, readFileSync: mockReadFileSync }, - readFileSync: mockReadFileSync, - }; -}); - -vi.mock('shiki', () => ({ - default: { codeToHtml: mockCodeToHtml }, - codeToHtml: mockCodeToHtml, -})); - -describe('resolveRuntimeUrl', () => { - afterEach(() => { - vi.unstubAllEnvs(); - }); - - it('uses NEXT_PUBLIC_COCKPIT_RUNTIME_BASE_URL when set', () => { - vi.stubEnv('NEXT_PUBLIC_COCKPIT_RUNTIME_BASE_URL', 'https://examples.threadplane.ai'); - expect( - resolveRuntimeUrl({ runtimeUrl: 'langgraph/streaming', devPort: 4300 }) - ).toBe('https://examples.threadplane.ai/langgraph/streaming'); - }); - - it('falls back to localhost with devPort when no env var is set', () => { - vi.stubEnv('NEXT_PUBLIC_COCKPIT_RUNTIME_BASE_URL', ''); - expect( - resolveRuntimeUrl({ runtimeUrl: 'langgraph/streaming', devPort: 4300 }) - ).toBe('http://localhost:4300'); - }); - - it('returns null when neither env var nor devPort is available', () => { - vi.stubEnv('NEXT_PUBLIC_COCKPIT_RUNTIME_BASE_URL', ''); - expect( - resolveRuntimeUrl({ runtimeUrl: undefined, devPort: undefined }) - ).toBeNull(); - }); - - it('returns null when runtimeUrl is undefined even with env var set', () => { - vi.stubEnv('NEXT_PUBLIC_COCKPIT_RUNTIME_BASE_URL', 'https://examples.threadplane.ai'); - expect( - resolveRuntimeUrl({ runtimeUrl: undefined, devPort: undefined }) - ).toBeNull(); - }); -}); - -describe('getContentBundle', () => { - afterEach(() => { - mockReadFileSync.mockReset(); - mockCodeToHtml.mockReset(); - vi.unstubAllEnvs(); - }); - - it('returns highlighted code and raw prompt content for a capability presentation', async () => { - mockReadFileSync.mockImplementation((filePath: unknown) => { - const p = String(filePath); - if (p.includes('index.ts')) return 'const x = 1;'; - if (p.includes('streaming.md')) return '# Streaming prompt'; - throw new Error(`ENOENT: ${filePath}`); - }); - mockCodeToHtml.mockResolvedValue('
highlighted
'); - - const presentation: CapabilityPresentation = { - kind: 'capability', - entry: {} as any, - docsPath: '/docs/test', - promptAssetPaths: ['cockpit/langgraph/streaming/python/prompts/streaming.md'], - codeAssetPaths: ['cockpit/langgraph/streaming/python/src/index.ts'], - runtimeUrl: 'langgraph/streaming', - devPort: 4300, - } as any; - - vi.stubEnv('NEXT_PUBLIC_COCKPIT_RUNTIME_BASE_URL', ''); - const bundle = await getContentBundle(presentation); - - expect(Object.keys(bundle.codeFiles)).toContain('cockpit/langgraph/streaming/python/src/index.ts'); - expect(bundle.codeFiles['cockpit/langgraph/streaming/python/src/index.ts']).toBe( - '
highlighted
' - ); - expect(bundle.promptFiles).toEqual({ - 'cockpit/langgraph/streaming/python/prompts/streaming.md': '# Streaming prompt', - }); - expect(bundle.runtimeUrl).toBe('http://localhost:4300'); - expect(bundle.docSections).toEqual([]); - expect(bundle.narrativeDocs).toEqual([]); - }); - - it('returns a placeholder string when a code file is missing', async () => { - mockReadFileSync.mockImplementation(() => { - const err = new Error('ENOENT') as NodeJS.ErrnoException; - err.code = 'ENOENT'; - throw err; - }); - - const presentation: CapabilityPresentation = { - kind: 'capability', - entry: {} as any, - docsPath: '/docs/test', - promptAssetPaths: [], - codeAssetPaths: ['missing/file.ts'], - runtimeUrl: undefined, - devPort: undefined, - } as any; - - const bundle = await getContentBundle(presentation); - - expect(bundle.codeFiles['missing/file.ts']).toBe('File not found: missing/file.ts'); - expect(bundle.runtimeUrl).toBeNull(); - expect(bundle.docSections).toEqual([]); - expect(bundle.narrativeDocs).toEqual([]); - }); - - it('falls back to unhighlighted code when Shiki fails', async () => { - mockReadFileSync.mockReturnValue('const y = 2;'); - mockCodeToHtml.mockRejectedValue(new Error('Shiki error')); - - const presentation: CapabilityPresentation = { - kind: 'capability', - entry: {} as any, - docsPath: '/docs/test', - promptAssetPaths: [], - codeAssetPaths: ['some/file.ts'], - runtimeUrl: undefined, - devPort: undefined, - } as any; - - const bundle = await getContentBundle(presentation); - - expect(bundle.codeFiles['some/file.ts']).toBe( - '
const y = 2;
' - ); - expect(bundle.docSections).toEqual([]); - expect(bundle.narrativeDocs).toEqual([]); - }); - - it('returns empty maps for a docs-only presentation', async () => { - const presentation: CapabilityPresentation = { - kind: 'docs-only', - entry: {} as any, - docsPath: '/docs/test', - }; - - const bundle = await getContentBundle(presentation); - - expect(bundle.codeFiles).toEqual({}); - expect(bundle.promptFiles).toEqual({}); - expect(bundle.runtimeUrl).toBeNull(); - expect(bundle.docSections).toEqual([]); - expect(bundle.narrativeDocs).toEqual([]); - }); - - it('extracts docSections from code and backend files', async () => { - mockReadFileSync.mockImplementation((filePath: unknown) => { - const p = String(filePath); - if (p.includes('streaming.component.ts')) return '/**\n * StreamingComponent renders a chat UI.\n */\nexport class StreamingComponent {}'; - if (p.includes('graph.py')) return 'class StreamingGraph:\n """Streams LLM responses."""\n pass'; - if (p.includes('streaming.md')) return '# Prompt'; - throw new Error('ENOENT'); - }); - mockCodeToHtml.mockResolvedValue('
highlighted
'); - - const presentation = { - kind: 'capability' as const, - entry: {} as any, - docsPath: '/docs/test', - promptAssetPaths: ['prompts/streaming.md'], - codeAssetPaths: ['src/streaming.component.ts'], - backendAssetPaths: ['src/graph.py'], - runtimeUrl: undefined, - devPort: undefined, - }; - - vi.stubEnv('NEXT_PUBLIC_COCKPIT_RUNTIME_BASE_URL', ''); - const bundle = await getContentBundle(presentation); - - expect(bundle.docSections).toHaveLength(2); - expect(bundle.docSections[0].title).toBe('StreamingComponent'); - expect(bundle.docSections[0].language).toBe('typescript'); - expect(bundle.docSections[1].title).toBe('StreamingGraph'); - expect(bundle.docSections[1].language).toBe('python'); - expect(bundle.narrativeDocs).toEqual([]); - }); -}); diff --git a/apps/cockpit/src/lib/extract-docs.ts b/apps/cockpit/src/lib/extract-docs.ts deleted file mode 100644 index 25002d5fd..000000000 --- a/apps/cockpit/src/lib/extract-docs.ts +++ /dev/null @@ -1,161 +0,0 @@ -export interface DocParam { - name: string; - description: string; -} - -export interface DocSection { - title: string; - signature: string; - description: string; - params: DocParam[]; - returns: string | null; - sourceFile: string; - language: 'typescript' | 'python'; -} - -function parseJsDocContent(raw: string): { description: string; params: DocParam[]; returns: string | null } { - const lines = raw.split('\n').map((line) => line.replace(/^\s*\*\s?/, '')); - const params: DocParam[] = []; - let returns: string | null = null; - const descriptionLines: string[] = []; - - for (const line of lines) { - const paramMatch = line.match(/^@param\s+(?:\{[^}]*\}\s+)?(?:-\s+)?(\w+)\s*[-–—]?\s*(.*)/); - const returnsMatch = line.match(/^@returns?\s+(.*)/); - - if (paramMatch) { - params.push({ name: paramMatch[1], description: paramMatch[2].trim() }); - } else if (returnsMatch) { - returns = returnsMatch[1].trim(); - } else if (!line.startsWith('@')) { - descriptionLines.push(line); - } - } - - return { description: descriptionLines.join('\n').trim(), params, returns }; -} - -/** - * Extracts JSDoc blocks that precede export declarations or named members. - * Captures the full signature line following the JSDoc block. - */ -export function extractTsDocSections(source: string, filePath: string): DocSection[] { - const sections: DocSection[] = []; - const lines = source.split('\n'); - - let i = 0; - while (i < lines.length) { - // Find JSDoc start - if (!lines[i].trimStart().startsWith('/**')) { i++; continue; } - - // Collect JSDoc block - const jsDocLines: string[] = []; - let j = i; - while (j < lines.length) { - jsDocLines.push(lines[j]); - if (lines[j].includes('*/')) break; - j++; - } - j++; // move past */ - - // Skip blank lines after JSDoc - while (j < lines.length && lines[j].trim() === '') j++; - - // Check if next non-blank line is a declaration we care about - if (j < lines.length) { - const nextLine = lines[j].trim(); - const declMatch = nextLine.match( - /^(?:export\s+)?(?:class|function|interface|const|type|abstract\s+class)\s+(\w+)|^(?:(?:protected|private|public|readonly)\s+)*(\w+)\s*[=(]/ - ); - - if (declMatch) { - const name = declMatch[1] ?? declMatch[2] ?? 'unknown'; - // Signature is just this one line, cleaned up - const signature = nextLine.replace(/\s*[{=]\s*$/, '').replace(/\s*\{$/, ''); - - const rawComment = jsDocLines - .join('\n') - .replace(/^\s*\/\*\*\s*/, '') - .replace(/\s*\*\/\s*$/, ''); - - const { description, params, returns } = parseJsDocContent(rawComment); - - if (description) { - sections.push({ - title: name, - signature, - description, - params, - returns, - sourceFile: filePath, - language: 'typescript', - }); - } - } - } - - i = j > i ? j : i + 1; - } - - return sections; -} - -/** - * Extracts Python docstrings from class and def declarations. - * Captures the full def/class signature line. - */ -export function extractPyDocSections(source: string, filePath: string): DocSection[] { - const sections: DocSection[] = []; - const pattern = /((?:class|def)\s+(\w+)[^\n]*):\s*\n\s*"""([\s\S]*?)"""/g; - - let match: RegExpExecArray | null; - while ((match = pattern.exec(source)) !== null) { - const signatureLine = match[1].trim(); - const name = match[2]; - const rawDocstring = match[3] - .split('\n') - .map((line) => line.replace(/^\s{4}/, '')) - .join('\n') - .trim(); - - // Parse simple rst-style params (Args: / Returns:) or just use as description - const lines = rawDocstring.split('\n'); - const descriptionLines: string[] = []; - const params: DocParam[] = []; - let returns: string | null = null; - let inArgs = false; - let inReturns = false; - - for (const line of lines) { - if (/^(Args|Arguments|Parameters)\s*:/.test(line)) { inArgs = true; inReturns = false; continue; } - if (/^(Returns?)\s*:/.test(line)) { inReturns = true; inArgs = false; continue; } - if (/^(Attributes)\s*:/.test(line)) { inArgs = true; inReturns = false; continue; } - if (/^\S/.test(line) && !inArgs && !inReturns) { - descriptionLines.push(line); - } else if (inArgs) { - const paramMatch = line.match(/^\s+(\w+)\s*(?:\([^)]*\))?\s*[-:]\s*(.*)/); - if (paramMatch) params.push({ name: paramMatch[1], description: paramMatch[2].trim() }); - } else if (inReturns) { - if (line.trim()) returns = (returns ? returns + ' ' : '') + line.trim(); - } else { - descriptionLines.push(line); - } - } - - const description = descriptionLines.join('\n').trim(); - - if (description) { - sections.push({ - title: name, - signature: signatureLine, - description, - params, - returns, - sourceFile: filePath, - language: 'python', - }); - } - } - - return sections; -} diff --git a/apps/cockpit/src/lib/route-resolution.ts b/apps/cockpit/src/lib/route-resolution.ts deleted file mode 100644 index 380a8bb1d..000000000 --- a/apps/cockpit/src/lib/route-resolution.ts +++ /dev/null @@ -1,293 +0,0 @@ -import { - resolveManifestLanguage, - type CockpitLanguage, - type CockpitManifestEntry, -} from '@threadplane/cockpit-registry'; -import { langgraphStreamingPythonModule } from '../../../../cockpit/langgraph/streaming/python/src/index'; -import { langgraphPersistencePythonModule } from '../../../../cockpit/langgraph/persistence/python/src/index'; -import { langgraphInterruptsPythonModule } from '../../../../cockpit/langgraph/interrupts/python/src/index'; -import { langgraphMemoryPythonModule } from '../../../../cockpit/langgraph/memory/python/src/index'; -import { langgraphDurableExecutionPythonModule } from '../../../../cockpit/langgraph/durable-execution/python/src/index'; -import { langgraphSubgraphsPythonModule } from '../../../../cockpit/langgraph/subgraphs/python/src/index'; -import { langgraphTimeTravelPythonModule } from '../../../../cockpit/langgraph/time-travel/python/src/index'; -import { langgraphDeploymentRuntimePythonModule } from '../../../../cockpit/langgraph/deployment-runtime/python/src/index'; -import { langgraphClientToolsPythonModule } from '../../../../cockpit/langgraph/client-tools/python/src/index'; -import { agUiInterruptsPythonModule } from '../../../../cockpit/ag-ui/interrupts/python/src/index'; -import { agUiStreamingPythonModule } from '../../../../cockpit/ag-ui/streaming/python/src/index'; -import { agUiToolViewsPythonModule } from '../../../../cockpit/ag-ui/tool-views/python/src/index'; -import { agUiJsonRenderPythonModule } from '../../../../cockpit/ag-ui/json-render/python/src/index'; -import { agUiClientToolsPythonModule } from '../../../../cockpit/ag-ui/client-tools/python/src/index'; -import { agUiA2uiPythonModule } from '../../../../cockpit/ag-ui/a2ui/python/src/index'; -import { agUiSubagentsPythonModule } from '../../../../cockpit/ag-ui/subagents/python/src/index'; -import { deepAgentsMemoryPythonModule } from '../../../../cockpit/deep-agents/memory/python/src/index'; -import { deepAgentsPlanningPythonModule } from '../../../../cockpit/deep-agents/planning/python/src/index'; -import { deepAgentsFilesystemPythonModule } from '../../../../cockpit/deep-agents/filesystem/python/src/index'; -import { deepAgentsSubagentsPythonModule } from '../../../../cockpit/deep-agents/subagents/python/src/index'; -import { deepAgentsSkillsPythonModule } from '../../../../cockpit/deep-agents/skills/python/src/index'; -import { renderSpecRenderingPythonModule } from '../../../../cockpit/render/spec-rendering/python/src/index'; -import { renderElementRenderingPythonModule } from '../../../../cockpit/render/element-rendering/python/src/index'; -import { renderStateManagementPythonModule } from '../../../../cockpit/render/state-management/python/src/index'; -import { renderRegistryPythonModule } from '../../../../cockpit/render/registry/python/src/index'; -import { renderRepeatLoopsPythonModule } from '../../../../cockpit/render/repeat-loops/python/src/index'; -import { renderComputedFunctionsPythonModule } from '../../../../cockpit/render/computed-functions/python/src/index'; -import { chatMessagesPythonModule } from '../../../../cockpit/chat/messages/python/src/index'; -import { chatInputPythonModule } from '../../../../cockpit/chat/input/python/src/index'; -import { chatInterruptsPythonModule } from '../../../../cockpit/chat/interrupts/python/src/index'; -import { chatToolCallsPythonModule } from '../../../../cockpit/chat/tool-calls/python/src/index'; -import { chatSubagentsPythonModule } from '../../../../cockpit/chat/subagents/python/src/index'; -import { chatThreadsPythonModule } from '../../../../cockpit/chat/threads/python/src/index'; -import { chatTimelinePythonModule } from '../../../../cockpit/chat/timeline/python/src/index'; -import { chatGenerativeUiPythonModule } from '../../../../cockpit/chat/generative-ui/python/src/index'; -import { chatDebugPythonModule } from '../../../../cockpit/chat/debug/python/src/index'; -import { chatThemingPythonModule } from '../../../../cockpit/chat/theming/python/src/index'; -import { chatA2uiPythonModule } from '../../../../cockpit/chat/a2ui/python/src/index'; -import { runtimesMicrosoftAgentFrameworkPythonModule } from '../../../../cockpit/runtimes/microsoft-agent-framework/python/src/index'; -import { runtimesAwsStrandsPythonModule } from '../../../../cockpit/runtimes/aws-strands/python/src/index'; -// Mastra has no Python lane — its backend is the Node AG-UI service -// deployments/ag-ui-mastra — so its descriptor lives beside the Angular app. -import { runtimesMastraAngularModule } from '../../../../cockpit/runtimes/mastra/angular/src/index'; - -export interface ResolveCockpitEntryOptions { - manifest: CockpitManifestEntry[]; - product: CockpitManifestEntry['product']; - section: CockpitManifestEntry['section']; - topic: string; - page: CockpitManifestEntry['page']; - language: CockpitLanguage; -} - -export interface NavigationSection { - section: CockpitManifestEntry['section']; - entries: CockpitManifestEntry[]; -} - -export interface NavigationProduct { - product: CockpitManifestEntry['product']; - sections: NavigationSection[]; -} - -export type CapabilityPresentation = - | { - kind: 'docs-only'; - entry: CockpitManifestEntry; - docsPath: string; - } - | { - kind: 'capability'; - entry: CockpitManifestEntry; - docsPath: string; - promptAssetPaths: string[]; - codeAssetPaths: string[]; - backendAssetPaths: string[]; - docsAssetPaths: string[]; - runtimeUrl?: string; - devPort?: number; - }; - -/** - * Shape a `cockpit/**\/src/index.ts` descriptor must satisfy to be wired into - * the cockpit. Each example declares its own structural copy of this interface - * (standalone-examples rule), so the fields diverge: the Angular lane carries - * no backend/docs assets. Declaring the element type here keeps the registry - * heterogeneous without widening every reader to a union. - */ -export interface RegisteredCapabilityModule { - id: string; - manifestIdentity: { - product: string; - section: string; - topic: string; - page: string; - language: string; - }; - title: string; - docsPath: string; - promptAssetPaths: string[]; - codeAssetPaths: string[]; - backendAssetPaths?: string[]; - docsAssetPaths?: string[]; - runtimeUrl?: string; - devPort?: number; -} - -export const capabilityModules: RegisteredCapabilityModule[] = [ - langgraphStreamingPythonModule, - langgraphPersistencePythonModule, - langgraphInterruptsPythonModule, - langgraphMemoryPythonModule, - langgraphDurableExecutionPythonModule, - langgraphSubgraphsPythonModule, - langgraphTimeTravelPythonModule, - langgraphDeploymentRuntimePythonModule, - langgraphClientToolsPythonModule, - agUiInterruptsPythonModule, - agUiStreamingPythonModule, - agUiToolViewsPythonModule, - agUiJsonRenderPythonModule, - agUiClientToolsPythonModule, - agUiA2uiPythonModule, - agUiSubagentsPythonModule, - deepAgentsMemoryPythonModule, - deepAgentsPlanningPythonModule, - deepAgentsFilesystemPythonModule, - deepAgentsSubagentsPythonModule, - deepAgentsSkillsPythonModule, - renderSpecRenderingPythonModule, - renderElementRenderingPythonModule, - renderStateManagementPythonModule, - renderRegistryPythonModule, - renderRepeatLoopsPythonModule, - renderComputedFunctionsPythonModule, - chatMessagesPythonModule, - chatInputPythonModule, - chatInterruptsPythonModule, - chatToolCallsPythonModule, - chatSubagentsPythonModule, - chatThreadsPythonModule, - chatTimelinePythonModule, - chatGenerativeUiPythonModule, - chatDebugPythonModule, - chatThemingPythonModule, - chatA2uiPythonModule, - runtimesMicrosoftAgentFrameworkPythonModule, - runtimesAwsStrandsPythonModule, - runtimesMastraAngularModule, -]; - -export const toCockpitPath = (entry: CockpitManifestEntry): string => - `/${entry.product}/${entry.section}/${entry.topic}/${entry.page}/${entry.language}`; - -export const resolveCockpitEntry = ({ - manifest, - product, - section, - topic, - page, - language, -}: ResolveCockpitEntryOptions): CockpitManifestEntry => { - const exactEntry = manifest.find( - (entry) => - entry.product === product && - entry.section === section && - entry.topic === topic && - entry.page === page && - entry.language === language - ); - - if (exactEntry) { - return exactEntry; - } - - const canonicalEntry = manifest.find( - (entry) => - entry.product === product && - entry.section === section && - entry.topic === topic && - entry.page === page - ); - - if (canonicalEntry) { - return resolveManifestLanguage({ - manifest, - entry: canonicalEntry, - language, - }); - } - - const fallbackOverview = manifest.find( - (entry) => - entry.product === product && - entry.section === 'getting-started' && - entry.topic === 'overview' && - entry.page === 'overview' && - entry.language === 'python' - ); - - if (!fallbackOverview) { - throw new Error(`No manifest entry found for ${product}/${section}/${topic}/${page}`); - } - - return resolveManifestLanguage({ - manifest, - entry: fallbackOverview, - language, - }); -}; - -export const buildNavigationTree = ( - manifest: CockpitManifestEntry[] -): NavigationProduct[] => { - const products: CockpitManifestEntry['product'][] = [ - 'deep-agents', - 'langgraph', - 'ag-ui', - 'render', - 'chat', - 'runtimes', - ]; - const sections: CockpitManifestEntry['section'][] = [ - 'getting-started', - 'core-capabilities', - ]; - const uniqueEntries = manifest.filter( - (entry, index, entries) => - entries.findIndex( - (candidate) => - candidate.product === entry.product && - candidate.section === entry.section && - candidate.topic === entry.topic && - candidate.page === entry.page - ) === index - ); - - return products.map((product) => ({ - product, - sections: sections.map((section) => ({ - section, - entries: uniqueEntries.filter( - (entry) => entry.product === product && entry.section === section - ), - })), - })); -}; - -export const getCapabilityPresentation = ( - entry: CockpitManifestEntry -): CapabilityPresentation => { - if (entry.entryKind === 'docs-only') { - return { - kind: 'docs-only', - entry, - docsPath: entry.docsPath, - }; - } - - const matchesIdentity = (candidate: RegisteredCapabilityModule): boolean => - candidate.manifestIdentity.product === entry.product && - candidate.manifestIdentity.section === entry.section && - candidate.manifestIdentity.topic === entry.topic && - candidate.manifestIdentity.page === entry.page; - - // Prefer the module whose lane matches the requested language. Fall back to - // the topic's only module when no lane matches: a topic with no Python lane - // (runtimes/mastra) still resolves to its real assets instead of silently - // falling through to the manifest's generic, non-existent Python paths. - const module = - capabilityModules.find( - (candidate) => - matchesIdentity(candidate) && - candidate.manifestIdentity.language === entry.language - ) ?? capabilityModules.find(matchesIdentity); - - return { - kind: 'capability', - entry, - docsPath: module?.docsPath ?? entry.docsPath, - promptAssetPaths: module?.promptAssetPaths ?? entry.promptAssetPaths, - codeAssetPaths: module?.codeAssetPaths ?? entry.codeAssetPaths, - backendAssetPaths: module?.backendAssetPaths ?? [], - docsAssetPaths: module?.docsAssetPaths ?? [], - runtimeUrl: module?.runtimeUrl, - devPort: module?.devPort, - }; -}; diff --git a/apps/cockpit/src/lib/verify-shared-deployment.spec.ts b/apps/cockpit/src/lib/verify-shared-deployment.spec.ts index 5e52f8cd4..18a96b354 100644 --- a/apps/cockpit/src/lib/verify-shared-deployment.spec.ts +++ b/apps/cockpit/src/lib/verify-shared-deployment.spec.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest'; +// eslint-disable-next-line @nx/enforce-module-boundaries -- repo-root deployment verifier is intentionally outside an Nx project. import { DEFAULT_SMOKE_ASSISTANT_STREAM_TIMEOUT_MS, getSmokeAssistantStreamTimeoutMs, diff --git a/apps/cockpit/tsconfig.json b/apps/cockpit/tsconfig.json index 67aed5d6f..e36918031 100644 --- a/apps/cockpit/tsconfig.json +++ b/apps/cockpit/tsconfig.json @@ -14,7 +14,13 @@ "@/*": ["./src/*"], "@threadplane/design-tokens": ["../../libs/design-tokens/src/index.ts"], "@threadplane/ui-react": ["../../libs/ui-react/src/index.ts"], - "@threadplane/cockpit-registry": ["../../libs/cockpit-registry/src/index.ts"], + "@threadplane/cockpit-registry": [ + "../../libs/cockpit-registry/src/index.ts" + ], + "@threadplane/cockpit-shell": ["../../libs/cockpit-shell/src/index.ts"], + "@threadplane/workspace-react": [ + "../../libs/workspace-react/src/index.ts" + ], "@threadplane/cockpit-runtime-bridge": [ "../../libs/cockpit-runtime-bridge/src/index.ts" ], @@ -29,45 +35,6 @@ "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], "exclude": ["node_modules"], "references": [ - { - "path": "../../cockpit/deep-agents/skills/python" - }, - { - "path": "../../cockpit/deep-agents/subagents/python" - }, - { - "path": "../../cockpit/deep-agents/filesystem/python" - }, - { - "path": "../../cockpit/deep-agents/planning/python" - }, - { - "path": "../../cockpit/deep-agents/memory/python" - }, - { - "path": "../../cockpit/langgraph/deployment-runtime/python" - }, - { - "path": "../../cockpit/langgraph/time-travel/python" - }, - { - "path": "../../cockpit/langgraph/subgraphs/python" - }, - { - "path": "../../cockpit/langgraph/durable-execution/python" - }, - { - "path": "../../cockpit/langgraph/memory/python" - }, - { - "path": "../../cockpit/langgraph/interrupts/python" - }, - { - "path": "../../cockpit/langgraph/persistence/python" - }, - { - "path": "../../cockpit/langgraph/streaming/python" - }, { "path": "../../libs/design-tokens" }, diff --git a/apps/website/e2e/docs-shell.spec.ts b/apps/website/e2e/docs-shell.spec.ts index 0939a345c..b0fcd811d 100644 --- a/apps/website/e2e/docs-shell.spec.ts +++ b/apps/website/e2e/docs-shell.spec.ts @@ -2,6 +2,13 @@ import { test, expect } from '@playwright/test'; const ARTICLE = '/docs/langgraph/getting-started/introduction'; +async function expectWorkspaceReady(page: import('@playwright/test').Page) { + await expect(page.locator('[data-workspace-shell]')).toHaveAttribute( + 'data-hydrated', + 'true', + ); +} + /** * The docs shell is one reading pane: a sticky control plane on the left, one * prose column, and a sticky TOC rail on the right. These guard the parts of @@ -13,12 +20,16 @@ test.describe('DocsTOC rail', () => { test('tracks the reading position on a hard load', async ({ page }) => { await page.setViewportSize({ width: 1440, height: 900 }); await page.goto(ARTICLE); + await expectWorkspaceReady(page); await expect(page.locator('.docs-toc-link').first()).toBeVisible(); // Nothing is active at the top: the first heading is below the reading line. await expect(page.locator('.docs-toc-link[data-active]')).toHaveCount(0); - await page.evaluate(() => window.scrollTo({ top: 4000, behavior: 'instant' })); + const articleScroller = page.locator('.docs-workspace-article'); + await articleScroller.evaluate((element) => + element.scrollTo({ top: 4000, behavior: 'instant' }), + ); await expect .poll(() => page @@ -28,7 +39,9 @@ test.describe('DocsTOC rail', () => { .toEqual(['#connect-with-angular']); // ...and it follows the scroll rather than latching on the first match. - await page.evaluate(() => window.scrollTo({ top: 0, behavior: 'instant' })); + await articleScroller.evaluate((element) => + element.scrollTo({ top: 0, behavior: 'instant' }), + ); await expect.poll(() => page.locator('.docs-toc-link[data-active]').count()).toBe(0); }); @@ -53,23 +66,32 @@ test.describe('DocsTOC rail', () => { }); test.describe('docs shell layout', () => { - test('the sticky rails hold through a full-page scroll', async ({ page }) => { + test('the workspace navigation and TOC hold through a full article scroll', async ({ page }) => { await page.setViewportSize({ width: 1440, height: 900 }); await page.goto(ARTICLE); + await expectWorkspaceReady(page); const navH = await page.evaluate(() => parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--nav-h')), ); const tops = async () => ({ - plane: await page.locator('.docs-control-plane').evaluate((el) => Math.round(el.getBoundingClientRect().top)), + plane: await page.locator('[data-cockpit-desktop-navigation]').evaluate((el) => Math.round(el.getBoundingClientRect().top)), toc: await page.locator('.docs-toc').evaluate((el) => Math.round(el.getBoundingClientRect().top)), }); - expect(await tops()).toEqual({ plane: navH, toc: navH }); - await page.evaluate(() => window.scrollTo({ top: 4000, behavior: 'instant' })); - expect(await tops()).toEqual({ plane: navH, toc: navH }); - await page.evaluate(() => window.scrollTo({ top: document.body.scrollHeight, behavior: 'instant' })); - expect(await tops()).toEqual({ plane: navH, toc: navH }); + const initial = await tops(); + const articleScroller = page.locator('.docs-workspace-article'); + + expect(initial.plane).toBe(navH); + expect(initial.toc).toBeGreaterThan(navH); + await articleScroller.evaluate((element) => + element.scrollTo({ top: 4000, behavior: 'instant' }), + ); + expect(await tops()).toEqual(initial); + await articleScroller.evaluate((element) => + element.scrollTo({ top: element.scrollHeight, behavior: 'instant' }), + ); + expect(await tops()).toEqual(initial); }); test('breadcrumb, prose and prev/next share one right edge', async ({ page }) => { @@ -78,9 +100,11 @@ test.describe('docs shell layout', () => { // ~500px right of the column it belongs to. await page.setViewportSize({ width: 1920, height: 1000 }); await page.goto(ARTICLE); + await expectWorkspaceReady(page); + const docsPanel = page.getByRole('region', { name: 'Docs workspace panel' }); const right = (selector: string) => - page.locator(selector).first().evaluate((el) => Math.round(el.getBoundingClientRect().right)); + docsPanel.locator(selector).evaluate((el) => Math.round(el.getBoundingClientRect().right)); const header = await right('.docs-page-header'); const article = await right('article'); diff --git a/apps/website/e2e/docs.spec.ts b/apps/website/e2e/docs.spec.ts index 3294220f1..594edc15c 100644 --- a/apps/website/e2e/docs.spec.ts +++ b/apps/website/e2e/docs.spec.ts @@ -54,8 +54,13 @@ test.describe('Docs slug page', () => { await page.setViewportSize({ width: 1024, height: 900 }); await page.goto(route); + await expect(page.locator('[data-workspace-shell]')).toHaveAttribute( + 'data-hydrated', + 'true', + ); + const pane = page.locator( - '[data-docs-control-plane] [data-control-plane-pane]', + '[data-cockpit-desktop-navigation] [data-control-plane-pane]', ); const search = pane.getByRole('button', { name: 'Search docs' }); await expect(pane).toBeVisible(); diff --git a/apps/website/e2e/nav-height.spec.ts b/apps/website/e2e/nav-height.spec.ts index efb2eee54..d60b5ec70 100644 --- a/apps/website/e2e/nav-height.spec.ts +++ b/apps/website/e2e/nav-height.spec.ts @@ -47,26 +47,34 @@ test('the docs column starts directly under the nav at a tablet width', async ({ // The 15px overshoot showed up here as dead space above the breadcrumb. await page.setViewportSize({ width: 900, height: 800 }); await page.goto('/docs/langgraph/getting-started/introduction'); + await expect(page.locator('[data-workspace-shell]')).toHaveAttribute( + 'data-hydrated', + 'true', + ); const navBottom = await page .locator('nav') .first() .evaluate((el) => el.getBoundingClientRect().bottom); const shellTop = await page - .locator('.docs-shell-page') - .evaluate((el) => el.getBoundingClientRect().top + parseFloat(getComputedStyle(el).paddingTop)); + .locator('.website-workspace-host .cockpit-shell') + .evaluate((el) => el.getBoundingClientRect().top); expect(Math.abs(shellTop - navBottom)).toBeLessThanOrEqual(1); }); -test('the mobile drawer hangs flush off the nav on a tablet width', async ({ page }) => { - // The drawer is positioned at `top: calc(var(--nav-h) - 1px)`, so a wrong - // --nav-h shows up here as a visible gap between the nav and the panel. +test('the workspace context drawer hangs flush off the nav at tablet width', async ({ page }) => { await page.setViewportSize({ width: 900, height: 800 }); await page.goto('/docs/langgraph/getting-started/introduction'); + await expect(page.locator('[data-workspace-shell]')).toHaveAttribute( + 'data-hydrated', + 'true', + ); - await page.locator('.nav-hamburger').click(); - const overlay = page.locator('.nav-mobile-overlay'); + await page.getByRole('button', { name: 'Open context' }).click(); + const overlay = page.getByRole('dialog', { + name: 'Documentation control plane context', + }); await expect(overlay).toBeVisible(); const navBottom = await page @@ -75,7 +83,5 @@ test('the mobile drawer hangs flush off the nav on a tablet width', async ({ pag .evaluate((el) => el.getBoundingClientRect().bottom); const overlayTop = await overlay.evaluate((el) => el.getBoundingClientRect().top); - // Flush or overlapping the nav's bottom border — never a gap below it. - expect(overlayTop - navBottom).toBeLessThanOrEqual(0); - expect(overlayTop - navBottom).toBeGreaterThanOrEqual(-2); + expect(Math.abs(overlayTop - navBottom)).toBeLessThanOrEqual(1); }); diff --git a/apps/website/e2e/website.spec.ts b/apps/website/e2e/website.spec.ts index 12f0be82a..53b437899 100644 --- a/apps/website/e2e/website.spec.ts +++ b/apps/website/e2e/website.spec.ts @@ -225,54 +225,72 @@ for (const viewport of [ }) => { await page.setViewportSize(viewport); await page.goto(docsRoute); + await expect(page.locator('[data-workspace-shell]')).toHaveAttribute( + 'data-hydrated', + 'true', + ); await expectNoHorizontalOverflow(page, `Docs at ${viewport.width}px`); - const desktopControlPlane = page.locator('[data-docs-control-plane]'); - const mobileTrigger = page.getByRole('button', { name: 'Open menu' }); + const desktopControlPlane = page.locator('[data-cockpit-desktop-navigation]'); + await expect(page.getByRole('button', { name: 'Open menu' })).toBeHidden(); if (viewport.width >= 1024) { await expect(desktopControlPlane).toBeVisible(); - await expect(mobileTrigger).toBeHidden(); - const runtime = desktopControlPlane.getByRole('button', { - name: 'Runtime', - exact: true, - }); - await runtime.click(); await expect( - desktopControlPlane.getByRole('link', { - name: 'Open controls in Cockpit', - }), + desktopControlPlane.locator('[data-control-plane-pane]'), + ).toBeVisible(); + await expect( + desktopControlPlane.getByRole('button', { name: 'Docs', exact: true }), + ).toBeVisible(); + await expect( + desktopControlPlane.getByRole('button', { name: 'Search docs' }), ).toBeVisible(); + } else if (viewport.width >= 768) { + await expect(desktopControlPlane).toBeVisible(); + await expect( + desktopControlPlane.locator('[data-control-plane-pane]'), + ).toBeHidden(); + const contextTrigger = page.getByRole('button', { name: 'Open context' }); + await expect(contextTrigger).toBeVisible(); + await contextTrigger.click(); + const dialog = page.getByRole('dialog', { + name: 'Documentation control plane context', + }); + await expect(dialog).toBeVisible(); + await expect(dialog.getByRole('button', { name: 'Search docs' })).toBeVisible(); + await page.keyboard.press('Escape'); + await expect(dialog).toHaveCount(0); + await expect(contextTrigger).toBeFocused(); } else { await expect(desktopControlPlane).toBeHidden(); - await expect(mobileTrigger).toBeVisible(); - const triggerBox = await mobileTrigger.boundingBox(); + const navigationTrigger = page.getByRole('button', { + name: 'Open navigation', + }); + await expect(navigationTrigger).toBeVisible(); + const triggerBox = await navigationTrigger.boundingBox(); expect(triggerBox?.width).toBeGreaterThanOrEqual(44); expect(triggerBox?.height).toBeGreaterThanOrEqual(44); - await mobileTrigger.click(); - const dialog = page.getByRole('dialog', { name: 'Mobile navigation' }); + await navigationTrigger.click(); + const dialog = page.getByRole('dialog', { + name: 'Documentation control plane', + }); await expect(dialog).toBeVisible(); - await expect(page.locator('#site-content')).toHaveAttribute('inert', ''); + await expect(page.locator('[data-cockpit-workspace]')).toHaveAttribute('inert', ''); await expect(page.locator('nav.nav-bar')).toHaveAttribute('inert', ''); - const close = dialog.getByRole('button', { name: 'Close menu' }); + const close = dialog.getByRole('button', { name: 'Close navigation' }); const closeBox = await close.boundingBox(); expect(closeBox?.width).toBeGreaterThanOrEqual(44); expect(closeBox?.height).toBeGreaterThanOrEqual(44); - const runtime = dialog.getByRole('button', { - name: 'Runtime', - exact: true, - }); - await runtime.click(); await expect( - dialog.getByRole('link', { name: 'Open controls in Cockpit' }), + dialog.getByRole('button', { name: 'Docs', exact: true }), ).toBeVisible(); await expect(dialog.getByRole('button', { name: 'Search docs' })).toBeVisible(); await page.keyboard.press('Escape'); - await expect(dialog).toBeHidden(); - await expect(mobileTrigger).toBeFocused(); + await expect(dialog).toHaveCount(0); + await expect(navigationTrigger).toBeFocused(); } }); } @@ -283,12 +301,16 @@ test('docs forced colors preserve control boundaries and keyboard focus', async await page.emulateMedia({ forcedColors: 'active' }); await page.setViewportSize({ width: 1440, height: 900 }); await page.goto(docsRoute); + await expect(page.locator('[data-workspace-shell]')).toHaveAttribute( + 'data-hydrated', + 'true', + ); - const runtime = page - .locator('[data-docs-control-plane]') - .getByRole('button', { name: 'Runtime', exact: true }); - await runtime.focus(); - const styles = await runtime.evaluate((element) => { + const run = page + .locator('[data-cockpit-desktop-navigation]') + .getByRole('button', { name: 'Run', exact: true }); + await run.focus(); + const styles = await run.evaluate((element) => { const style = getComputedStyle(element); return { borderWidth: style.borderTopWidth, @@ -307,19 +329,33 @@ test('docs reduced motion disables mobile drawer transitions and animations', as await page.emulateMedia({ reducedMotion: 'reduce' }); await page.setViewportSize({ width: 390, height: 844 }); await page.goto(docsRoute); - await page.getByRole('button', { name: 'Open menu' }).click(); + await expect(page.locator('[data-workspace-shell]')).toHaveAttribute( + 'data-hydrated', + 'true', + ); + await page.getByRole('button', { name: 'Open navigation' }).click(); - const overlay = page.locator('.nav-mobile-overlay'); + const overlay = page.getByRole('dialog', { + name: 'Documentation control plane', + }); await expect(overlay).toBeVisible(); const motion = await overlay.evaluate((element) => { - const style = getComputedStyle(element); + const panel = element.querySelector('.cockpit-mobile-control-plane-panel'); + const overlayStyle = getComputedStyle(element); + const panelStyle = panel ? getComputedStyle(panel) : null; return { - animationName: style.animationName, - transitionDuration: style.transitionDuration, + overlayAnimation: overlayStyle.animationName, + overlayTransition: overlayStyle.transitionDuration, + panelAnimation: panelStyle?.animationName, + panelTransition: panelStyle?.transitionDuration, }; }); - expect(motion.animationName).toBe('none'); - expect(motion.transitionDuration).toBe('0s'); + expect(motion).toEqual({ + overlayAnimation: 'none', + overlayTransition: '0s', + panelAnimation: 'none', + panelTransition: '0s', + }); }); test('/llms.txt returns plain text', async ({ page }) => { diff --git a/apps/website/e2e/workspace-shell.spec.ts b/apps/website/e2e/workspace-shell.spec.ts new file mode 100644 index 000000000..0f5a3cd4f --- /dev/null +++ b/apps/website/e2e/workspace-shell.spec.ts @@ -0,0 +1,480 @@ +import { expect, test, type Locator, type Page } from '@playwright/test'; + +const streamingDocsPath = '/docs/langgraph/guides/streaming'; +const persistenceDocsPath = '/docs/langgraph/guides/persistence'; +const mappedDocsOnlyPath = '/docs/langgraph/getting-started/introduction'; +const unmappedDocsOnlyPath = '/docs/langgraph/getting-started/installation'; +const workspaceOnlyPath = '/workspace/langgraph/durable-execution'; +const deepAgentsDocsPath = '/docs/deep-agents/capabilities/planning'; +const RUN_RAIL_ITEM = /^Run(?:,|$)/; + +const modeButton = (page: Page, mode: 'Docs' | 'Run' | 'Code' | 'API') => + page.locator('[data-cockpit-desktop-navigation]').getByRole('button', { + name: mode === 'Run' ? RUN_RAIL_ITEM : mode, + exact: mode !== 'Run', + }); + +const visiblePanel = (page: Page, mode: 'Docs' | 'Run' | 'Code' | 'API') => + page.locator(`[data-workspace-panel-target="${mode}"]`).filter({ + visible: true, + }); + +async function expectMode(page: Page, mode: 'Docs' | 'Run' | 'Code' | 'API') { + const shell = page.locator('[data-workspace-shell]'); + await expect(shell).toHaveAttribute('data-hydrated', 'true'); + await expect(shell).toHaveAttribute('data-workspace-mode', mode); + await expect(visiblePanel(page, mode)).toBeVisible(); +} + +async function expectNoHorizontalOverflow(page: Page, label: string) { + const overflow = await page.evaluate( + () => + document.documentElement.scrollWidth - + document.documentElement.clientWidth + ); + expect(overflow, label).toBeLessThanOrEqual(1); +} + +async function markRuntimeFrame(frame: Locator) { + await frame.evaluate((element) => { + element.setAttribute('data-e2e-runtime-frame', crypto.randomUUID()); + }); + return frame.getAttribute('data-e2e-runtime-frame'); +} + +test.describe('workspace shell', () => { + test.describe.configure({ mode: 'serial' }); + + test.beforeEach(async ({ page }) => { + await page.addInitScript(() => { + const hideDevelopmentIndicator = () => { + document + .querySelectorAll('nextjs-portal') + .forEach((portal) => { + portal.style.setProperty('display', 'none', 'important'); + }); + }; + document.addEventListener( + 'DOMContentLoaded', + () => { + hideDevelopmentIndicator(); + new MutationObserver(hideDevelopmentIndicator).observe( + document.documentElement, + { childList: true, subtree: true } + ); + }, + { once: true } + ); + }); + }); + test('moves Docs to Run to Code to API to Docs without replacing the runtime frame', async ({ + page, + }) => { + await page.setViewportSize({ width: 1440, height: 900 }); + await page.goto(streamingDocsPath); + await expectMode(page, 'Docs'); + + await modeButton(page, 'Run').click(); + await expect(page).toHaveURL(`${streamingDocsPath}?mode=run`); + await expectMode(page, 'Run'); + await expect(page.getByText('Ready', { exact: true })).toBeVisible(); + + const frame = page.locator( + 'iframe[title="LangGraph Streaming live example"]' + ); + await expect(frame).toBeVisible(); + const frameIdentity = await markRuntimeFrame(frame); + expect(frameIdentity).toBeTruthy(); + + for (const mode of ['Code', 'API', 'Docs'] as const) { + await modeButton(page, mode).click(); + await expect(page).toHaveURL( + mode === 'Docs' + ? streamingDocsPath + : `${streamingDocsPath}?mode=${mode.toLowerCase()}` + ); + await expectMode(page, mode); + await expect( + page.locator(`iframe[data-e2e-runtime-frame="${frameIdentity}"]`) + ).toBeAttached(); + } + }); + + test('restores mode and capability navigation through Back and Forward', async ({ + page, + }) => { + await page.setViewportSize({ width: 1440, height: 900 }); + await page.goto(streamingDocsPath); + const shell = page.locator('[data-workspace-shell]'); + await shell.evaluate((element) => { + element.setAttribute('data-e2e-shell-lifetime', 'original'); + }); + + await modeButton(page, 'Run').click(); + await expect(page).toHaveURL(`${streamingDocsPath}?mode=run`); + await expectMode(page, 'Run'); + await modeButton(page, 'Code').click(); + await expect(page).toHaveURL(`${streamingDocsPath}?mode=code`); + await expectMode(page, 'Code'); + + await page.goBack(); + await expect(page).toHaveURL(`${streamingDocsPath}?mode=run`); + await expectMode(page, 'Run'); + await page.goForward(); + await expect(page).toHaveURL(`${streamingDocsPath}?mode=code`); + await expectMode(page, 'Code'); + + await page.getByRole('link', { name: 'Persistence', exact: true }).click(); + await expect(page).toHaveURL(persistenceDocsPath); + await expectMode(page, 'Docs'); + await expect(shell).toHaveAttribute('data-e2e-shell-lifetime', 'original'); + await page + .locator('[data-cockpit-desktop-navigation]') + .getByRole('button', { name: 'Activity', exact: true }) + .click(); + await expect(page.getByText('Mode changed to Code')).toBeVisible(); + await expect( + page.locator('[data-activity-capability]').first() + ).toContainText('streaming'); + await page + .locator('[data-cockpit-desktop-navigation]') + .getByRole('button', { name: 'Activity', exact: true }) + .click(); + await page.goBack(); + await expect(page).toHaveURL(`${streamingDocsPath}?mode=code`); + await expectMode(page, 'Code'); + await expect(shell).toHaveAttribute('data-e2e-shell-lifetime', 'original'); + await page.goForward(); + await expect(page).toHaveURL(persistenceDocsPath); + await expectMode(page, 'Docs'); + await expect(shell).toHaveAttribute('data-e2e-shell-lifetime', 'original'); + }); + + for (const query of ['mode=run&mode=code', 'mode=invalid']) { + test(`normalizes ${query} to the canonical Docs URL`, async ({ page }) => { + await page.goto(`${streamingDocsPath}?${query}`); + await expect(page).toHaveURL(streamingDocsPath); + await expectMode(page, 'Docs'); + }); + } + + test('normalizes a valid but unavailable mode and explains docs-only controls', async ({ + page, + }) => { + await page.goto(`${mappedDocsOnlyPath}?mode=run`); + await expect(page).toHaveURL(mappedDocsOnlyPath); + await expectMode(page, 'Docs'); + await expect(modeButton(page, 'Run')).toHaveAttribute( + 'aria-disabled', + 'true' + ); + + await page.goto(`${unmappedDocsOnlyPath}?mode=api`); + await expect(page).toHaveURL(unmappedDocsOnlyPath); + await expect(page.locator('[data-workspace-shell]')).toHaveAttribute( + 'data-workspace-kind', + 'docs-only' + ); + await expectMode(page, 'Docs'); + for (const mode of ['Run', 'Code', 'API'] as const) { + await expect(modeButton(page, mode)).toHaveAttribute( + 'aria-disabled', + 'true' + ); + await expect(modeButton(page, mode)).toHaveAccessibleDescription( + new RegExp( + `${mode} is unavailable because this page has no workspace capability`, + 'i' + ) + ); + } + }); + + test('uses workspace fallbacks only when a shared Docs path would lose identity', async ({ + page, + }) => { + const response = await page.goto(workspaceOnlyPath); + expect(response?.status()).toBe(200); + await expect(page).toHaveURL(workspaceOnlyPath); + await expect(page.locator('[data-workspace-shell]')).toHaveAttribute( + 'aria-label', + 'Website workspace' + ); + await expectMode(page, 'Run'); + + await page.goto(deepAgentsDocsPath); + await expect(page).toHaveURL(deepAgentsDocsPath); + await expect(page.locator('[data-workspace-shell]')).toHaveAttribute( + 'aria-label', + 'Documentation workspace' + ); + await expectMode(page, 'Docs'); + await expect( + page.locator('iframe[title="Deep Agents Planning live example"]') + ).toBeAttached(); + }); + + test('renders the full desktop rail and context at the 64rem breakpoint', async ({ + page, + }) => { + await page.setViewportSize({ width: 1024, height: 900 }); + await page.goto(streamingDocsPath); + await expectNoHorizontalOverflow(page, 'desktop workspace'); + + const desktop = page.locator('[data-cockpit-desktop-navigation]'); + await expect(desktop).toBeVisible(); + await expect(desktop.locator('[data-control-plane-rail]')).toBeVisible(); + await expect(desktop.locator('[data-control-plane-pane]')).toBeVisible(); + await expect( + page.getByRole('button', { name: 'Open context' }) + ).toBeHidden(); + await expect( + page.getByRole('button', { name: 'Open navigation' }) + ).toBeHidden(); + }); + + test('uses tablet disclosure, focuses destinations, and restores utility focus', async ({ + page, + }) => { + await page.setViewportSize({ width: 800, height: 900 }); + await page.goto(streamingDocsPath); + await expectNoHorizontalOverflow(page, 'tablet workspace'); + + const desktop = page.locator('[data-cockpit-desktop-navigation]'); + await expect(desktop.locator('[data-control-plane-rail]')).toBeVisible(); + await expect(desktop.locator('[data-control-plane-pane]')).toBeHidden(); + const contextTrigger = page.getByRole('button', { name: 'Open context' }); + await expect(contextTrigger).toBeVisible(); + + const activity = desktop.getByRole('button', { name: 'Activity' }); + await activity.click(); + const dialog = page.getByRole('dialog', { + name: 'Documentation control plane context', + }); + await expect(dialog).toBeVisible(); + await expect( + dialog.getByRole('heading', { name: 'Activity' }) + ).toBeFocused(); + + const settings = desktop.getByRole('button', { name: 'Settings' }); + await settings.click(); + await expect( + dialog.getByRole('heading', { name: 'Settings' }) + ).toBeFocused(); + await page.keyboard.press('Escape'); + await expect( + dialog.getByRole('heading', { name: 'Settings' }) + ).toBeHidden(); + await expect(settings).toBeFocused(); + + await modeButton(page, 'Code').click(); + await expect(dialog).toBeHidden(); + await expectMode(page, 'Code'); + await expect(visiblePanel(page, 'Code')).toBeFocused(); + + await contextTrigger.click(); + await dialog + .getByRole('link', { name: 'Persistence', exact: true }) + .click(); + await expect(page).toHaveURL(persistenceDocsPath); + await expect(dialog).toBeHidden(); + await expectMode(page, 'Docs'); + await expect(visiblePanel(page, 'Docs')).toBeFocused(); + }); + + test('uses a modal control plane below 48rem and restores Escape focus', async ({ + page, + }) => { + await page.setViewportSize({ width: 767, height: 844 }); + await page.goto(streamingDocsPath); + await expectNoHorizontalOverflow(page, 'mobile workspace'); + + await expect( + page.locator('[data-cockpit-desktop-navigation]') + ).toBeHidden(); + await expect(page.getByRole('button', { name: 'Open menu' })).toBeHidden(); + const trigger = page.getByRole('button', { name: 'Open navigation' }); + await expect(trigger).toBeVisible(); + await page.evaluate(() => { + const element = document.createElement('div'); + element.className = 'toast-root'; + element.setAttribute('data-announcement-toast', ''); + element.setAttribute('data-mounted', ''); + element.textContent = 'Visible announcement fixture'; + document + .querySelector('[data-announcement-region]') + ?.appendChild(element); + }); + const announcement = page.locator('[data-announcement-toast]'); + const announcementRegion = page.locator('[data-announcement-region]'); + await expect(announcement).toBeVisible(); + await trigger.click(); + + const dialog = page.getByRole('dialog', { + name: 'Documentation control plane', + }); + const globalNavigation = page.locator('[data-site-navigation]'); + await expect(dialog).toBeVisible(); + await expect(dialog).toHaveAttribute('aria-modal', 'true'); + await expect(globalNavigation).toHaveAttribute('inert', ''); + await expect(announcementRegion).toHaveAttribute('inert', ''); + await expect(announcementRegion).toHaveAttribute( + 'data-workspace-modal-hidden', + '' + ); + await expect(announcement).toBeHidden(); + await page.evaluate(() => { + const lateToast = document.createElement('button'); + lateToast.setAttribute('data-late-announcement', ''); + lateToast.textContent = 'Late announcement fixture'; + document + .querySelector('[data-announcement-region]') + ?.appendChild(lateToast); + }); + const lateAnnouncement = page.locator('[data-late-announcement]'); + await expect(lateAnnouncement).toBeHidden(); + await expect(page.locator('[data-cockpit-workspace]')).toHaveAttribute( + 'inert', + '' + ); + + await dialog.getByRole('button', { name: RUN_RAIL_ITEM }).click(); + await expect(dialog).toBeHidden(); + await expectMode(page, 'Run'); + await expect(visiblePanel(page, 'Run')).toBeFocused(); + + await trigger.click(); + await expect(dialog).toBeVisible(); + await page.keyboard.press('Escape'); + await expect(dialog).toHaveAttribute('data-state', 'closing'); + await expect(globalNavigation).toHaveAttribute('inert', ''); + await expect(announcementRegion).toHaveAttribute('inert', ''); + await expect(lateAnnouncement).toBeHidden(); + await expect(dialog).toHaveCount(0); + await expect(globalNavigation).not.toHaveAttribute('inert', ''); + await expect(announcementRegion).not.toHaveAttribute('inert', ''); + await expect(announcementRegion).not.toHaveAttribute( + 'data-workspace-modal-hidden', + '' + ); + await expect(announcement).toBeVisible(); + await expect(lateAnnouncement).toBeVisible(); + await expect(trigger).toBeFocused(); + await announcement.evaluate((element) => element.remove()); + await lateAnnouncement.evaluate((element) => element.remove()); + }); + + test('defers mobile Learn navigation and focuses each destination heading', async ({ + page, + }) => { + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto(streamingDocsPath); + + await page.getByRole('button', { name: 'Open navigation' }).click(); + let dialog = page.getByRole('dialog', { + name: 'Documentation control plane', + }); + await dialog.getByRole('link', { name: 'Streaming', exact: true }).click(); + await expect(page).toHaveURL(streamingDocsPath); + await expect(dialog).toHaveCount(0); + await expect(visiblePanel(page, 'Docs')).toBeFocused(); + + await page.getByRole('button', { name: 'Open navigation' }).click(); + dialog = page.getByRole('dialog', { + name: 'Documentation control plane', + }); + await dialog + .getByRole('link', { name: 'Persistence', exact: true }) + .click(); + await expect(page).toHaveURL(persistenceDocsPath); + await expect(dialog).toHaveCount(0); + await expect(visiblePanel(page, 'Docs')).toBeFocused(); + + await page.getByRole('button', { name: 'Open navigation' }).click(); + dialog = page.getByRole('dialog', { + name: 'Documentation control plane', + }); + await dialog + .getByRole('link', { name: 'Choosing an adapter', exact: true }) + .click(); + await expect(page).toHaveURL('/docs/choosing-an-adapter'); + await expect(dialog).toHaveCount(0); + await expect(page.locator('main h1').first()).toBeFocused(); + }); + + test('keeps Learn and visible Search in mapped and unmapped mobile Docs context', async ({ + page, + }) => { + await page.setViewportSize({ width: 390, height: 844 }); + + for (const path of [streamingDocsPath, unmappedDocsOnlyPath]) { + await page.goto(path); + await page.getByRole('button', { name: 'Open navigation' }).click(); + const dialog = page.getByRole('dialog', { + name: 'Documentation control plane', + }); + await expect(dialog.getByText('Learn', { exact: true })).toBeVisible(); + await dialog.getByRole('button', { name: 'Search docs' }).click(); + await expect(dialog).toHaveCount(0); + await expect( + page.getByRole('dialog', { name: 'Search documentation' }) + ).toBeVisible(); + await page.keyboard.press('Escape'); + } + }); + + test('preserves visible boundaries and focus in forced colors', async ({ + page, + }) => { + await page.emulateMedia({ forcedColors: 'active' }); + await page.setViewportSize({ width: 1024, height: 900 }); + await page.goto(streamingDocsPath); + + const run = modeButton(page, 'Run'); + await run.focus(); + const styles = await run.evaluate((element) => { + const style = getComputedStyle(element); + return { + borderWidth: style.borderTopWidth, + outlineStyle: style.outlineStyle, + outlineWidth: style.outlineWidth, + }; + }); + expect(Number.parseFloat(styles.borderWidth)).toBeGreaterThan(0); + expect(styles.outlineStyle).not.toBe('none'); + expect(Number.parseFloat(styles.outlineWidth)).toBeGreaterThan(0); + }); + + test('removes mobile control-plane motion when reduced motion is requested', async ({ + page, + }) => { + await page.emulateMedia({ reducedMotion: 'reduce' }); + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto(streamingDocsPath); + await page.getByRole('button', { name: 'Open navigation' }).click(); + + const dialog = page.getByRole('dialog', { + name: 'Documentation control plane', + }); + await expect(dialog).toBeVisible(); + const motion = await dialog.evaluate((element) => { + const panel = element.querySelector( + '.cockpit-mobile-control-plane-panel' + ); + const overlayStyle = getComputedStyle(element); + const panelStyle = panel ? getComputedStyle(panel) : null; + return { + overlayAnimation: overlayStyle.animationName, + overlayTransition: overlayStyle.transitionDuration, + panelAnimation: panelStyle?.animationName, + panelTransition: panelStyle?.transitionDuration, + }; + }); + expect(motion).toEqual({ + overlayAnimation: 'none', + overlayTransition: '0s', + panelAnimation: 'none', + panelTransition: '0s', + }); + }); +}); diff --git a/apps/website/next.config.ts b/apps/website/next.config.ts index b67ebc604..d448ea2d9 100644 --- a/apps/website/next.config.ts +++ b/apps/website/next.config.ts @@ -1,10 +1,24 @@ import { composePlugins, withNx } from '@nx/next'; import type { WithNxOptions } from '@nx/next/plugins/with-nx'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const websiteAppDir = dirname(fileURLToPath(import.meta.url)); export const nextConfig: WithNxOptions = { // Use this to set Nx-specific options // See: https://nx.dev/recipes/next/next-config-setup nx: {}, + outputFileTracingRoot: join(websiteAppDir, '../..'), + outputFileTracingIncludes: { + '/*': [ + '../../cockpit/**/*.md', + '../../cockpit/**/*.py', + '../../cockpit/**/*.ts', + '../../deployments/ag-ui-mastra/*.mjs', + '../../nx.json', + ], + }, skipTrailingSlashRedirect: true, rewrites: async () => [ { @@ -16,6 +30,20 @@ export const nextConfig: WithNxOptions = { destination: 'https://us.i.posthog.com/:path*', }, ], + headers: async () => [ + { + source: '/ingest/:path*', + headers: [ + { key: 'Access-Control-Allow-Origin', value: '*' }, + { key: 'Access-Control-Allow-Methods', value: 'POST, OPTIONS' }, + { + key: 'Access-Control-Allow-Headers', + value: 'Content-Type, Authorization', + }, + { key: 'Access-Control-Max-Age', value: '86400' }, + ], + }, + ], }; const plugins = [ diff --git a/apps/website/playwright.config.ts b/apps/website/playwright.config.ts index d87b83c4e..7e7d834b3 100644 --- a/apps/website/playwright.config.ts +++ b/apps/website/playwright.config.ts @@ -3,9 +3,11 @@ import { defineConfig, devices } from '@playwright/test'; const localHost = '127.0.0.1'; const localPort = process.env['WEBSITE_E2E_PORT'] ?? '4308'; const localURL = `http://${localHost}:${localPort}`; +const runtimeURL = 'http://localhost:4300'; const baseURL = process.env['BASE_URL'] ?? localURL; const shouldStartLocalServer = !process.env['BASE_URL']; -const reuseExistingServer = process.env['PLAYWRIGHT_REUSE_EXISTING_SERVER'] === 'true'; +const reuseExistingServer = + process.env['PLAYWRIGHT_REUSE_EXISTING_SERVER'] === 'true'; export default defineConfig({ testDir: './e2e', @@ -26,10 +28,20 @@ export default defineConfig({ }, ], webServer: shouldStartLocalServer - ? { - command: `npx next dev . --hostname ${localHost} --port ${localPort}`, - url: localURL, - reuseExistingServer, - } + ? [ + { + command: `NEXT_PUBLIC_COCKPIT_RUNTIME_BASE_URL='' npx next dev apps/website --hostname ${localHost} --port ${localPort}`, + cwd: '../..', + url: localURL, + reuseExistingServer, + }, + { + command: + 'npx nx run cockpit-langgraph-streaming-angular:serve:cockpit --port 4300', + cwd: '../..', + url: runtimeURL, + reuseExistingServer, + }, + ] : undefined, }); diff --git a/apps/website/project.json b/apps/website/project.json index f14abd9a8..3352773ec 100644 --- a/apps/website/project.json +++ b/apps/website/project.json @@ -3,6 +3,10 @@ "$schema": "../../node_modules/nx/schemas/project-schema.json", "sourceRoot": "apps/website/src", "projectType": "application", + "implicitDependencies": [ + "workspace-react", + "cockpit-langgraph-streaming-angular" + ], "tags": [ "scope:website", "scope:website-e2e", diff --git a/apps/website/src/app/api/ingest/route.spec.ts b/apps/website/src/app/api/ingest/route.spec.ts index f526f1606..4212b79ff 100644 --- a/apps/website/src/app/api/ingest/route.spec.ts +++ b/apps/website/src/app/api/ingest/route.spec.ts @@ -9,7 +9,7 @@ vi.mock('posthog-node', () => ({ }), })); -import { POST } from './route'; +import { OPTIONS, POST } from './route'; describe('/api/ingest', () => { beforeEach(() => { @@ -30,6 +30,7 @@ describe('/api/ingest', () => { }) as never); expect(response.status).toBe(202); + expect(response.headers.get('access-control-allow-origin')).toBe('*'); expect(capture).toHaveBeenCalledWith({ distinctId: 'browser:test', event: 'tplane:browser_chat_init', @@ -40,4 +41,30 @@ describe('/api/ingest', () => { }, }); }); + + it('answers runtime telemetry preflight with the complete CORS contract', async () => { + const response = await OPTIONS(); + + expect(response.status).toBe(204); + expect(response.headers.get('access-control-allow-origin')).toBe('*'); + expect(response.headers.get('access-control-allow-methods')).toBe( + 'POST, OPTIONS' + ); + expect(response.headers.get('access-control-allow-headers')).toBe( + 'Content-Type, Authorization' + ); + expect(response.headers.get('access-control-max-age')).toBe('86400'); + }); + + it('returns CORS headers on rejected telemetry requests too', async () => { + const response = await POST( + new Request('https://threadplane.ai/api/ingest', { + method: 'POST', + body: '{bad json', + }) as never + ); + + expect(response.status).toBe(400); + expect(response.headers.get('access-control-allow-origin')).toBe('*'); + }); }); diff --git a/apps/website/src/app/api/ingest/route.ts b/apps/website/src/app/api/ingest/route.ts index c14dd09ec..6bb36ffdf 100644 --- a/apps/website/src/app/api/ingest/route.ts +++ b/apps/website/src/app/api/ingest/route.ts @@ -3,6 +3,12 @@ import { NextRequest, NextResponse } from 'next/server'; import { normalizePostHogHost, toSafeAnalyticsString } from '@threadplane/telemetry/shared'; const PUBLIC_INGEST_KEY = 'phc_public_cacheplane_telemetry'; +const CORS_HEADERS = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'POST, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type, Authorization', + 'Access-Control-Max-Age': '86400', +} as const; interface TelemetryIngestPayload { key?: unknown; @@ -45,19 +51,40 @@ function readPayload(value: unknown): { }; } +function jsonWithCors(body: unknown, init: { status: number }): NextResponse { + return NextResponse.json(body, { + ...init, + headers: CORS_HEADERS, + }); +} + +export function OPTIONS(): NextResponse { + return new NextResponse(null, { status: 204, headers: CORS_HEADERS }); +} + export async function POST(req: NextRequest) { let body: unknown; try { body = await req.json(); } catch { - return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }); + return jsonWithCors({ error: 'Invalid JSON' }, { status: 400 }); } const payload = readPayload(body); - if (!payload) return NextResponse.json({ error: 'Invalid telemetry payload' }, { status: 400 }); + if (!payload) { + return jsonWithCors( + { error: 'Invalid telemetry payload' }, + { status: 400 } + ); + } const posthog = getPostHogClient(); - if (!posthog) return NextResponse.json({ error: 'Telemetry ingest is not configured' }, { status: 503 }); + if (!posthog) { + return jsonWithCors( + { error: 'Telemetry ingest is not configured' }, + { status: 503 } + ); + } try { posthog.capture({ @@ -70,10 +97,13 @@ export async function POST(req: NextRequest) { }, }); await posthog.shutdown(); - return NextResponse.json({ ok: true }, { status: 202 }); + return jsonWithCors({ ok: true }, { status: 202 }); } catch (err) { console.error('[telemetry-ingest] capture failed:', err); await posthog.shutdown().catch(() => undefined); - return NextResponse.json({ error: 'Telemetry ingest failed' }, { status: 502 }); + return jsonWithCors( + { error: 'Telemetry ingest failed' }, + { status: 502 } + ); } } diff --git a/apps/website/src/app/docs/[library]/[section]/[slug]/page.spec.tsx b/apps/website/src/app/docs/[library]/[section]/[slug]/page.spec.tsx new file mode 100644 index 000000000..25f9cd321 --- /dev/null +++ b/apps/website/src/app/docs/[library]/[section]/[slug]/page.spec.tsx @@ -0,0 +1,101 @@ +import { isValidElement, type ComponentType, type ReactNode } from 'react'; +import { describe, expect, it } from 'vitest'; +import { DocsBreadcrumb } from '../../../../../components/docs/DocsBreadcrumb'; +import { DocsPageHeader } from '../../../../../components/docs/DocsPageHeader'; +import { DocsTOC } from '../../../../../components/docs/DocsTOC'; +import { MdxRenderer } from '../../../../../components/docs/MdxRenderer'; +import { WebsiteWorkspace } from '../../../../../components/workspace/WebsiteWorkspace'; +import DocsPage, { generateMetadata } from './page'; + +interface ElementProps { + children?: ReactNode; + docsSlot?: ReactNode; + requestedMode?: string | null; + resolution?: { kind?: string; identity?: { availableModes?: string[] } }; + contentBundle?: { runtimeUrl?: string | null }; +} + +function findElement( + node: ReactNode, + type: ComponentType +): React.ReactElement | null { + if (Array.isArray(node)) { + for (const child of node) { + const found = findElement(child, type); + if (found) return found; + } + return null; + } + if (!isValidElement(node)) return null; + if (node.type === type) return node; + return findElement(node.props.children, type); +} + +const route = (library: string, section: string, slug: string, mode?: string) => + DocsPage({ + params: Promise.resolve({ library, section, slug }), + searchParams: Promise.resolve(mode ? { mode } : {}), + } as never); + +describe('unified docs workspace route', () => { + it('passes mapped descriptor-backed content and the requested mode to the client boundary', async () => { + const tree = await route('langgraph', 'guides', 'streaming', 'code'); + const workspace = findElement( + tree, + WebsiteWorkspace as ComponentType + ); + + expect(workspace).toBeTruthy(); + // Search state belongs to the client workspace adapter so this canonical + // Docs route remains statically generated. + expect(workspace?.props.requestedMode).toBeUndefined(); + expect(workspace?.props.resolution).toMatchObject({ + kind: 'mapped', + identity: { availableModes: ['Docs', 'Run', 'Code', 'API'] }, + }); + expect(workspace?.props.contentBundle?.runtimeUrl).toMatch( + /(?:langgraph\/streaming|localhost:4300)$/ + ); + expect(workspace?.props.docsContext).toEqual({ + activeLibrary: 'langgraph', + activeSection: 'guides', + activeSlug: 'streaming', + pageTitle: 'Streaming', + }); + }); + + it('keeps an unmapped page as a complete server Docs slot', async () => { + const tree = await route('langgraph', 'guides', 'testing', 'run'); + const workspace = findElement( + tree, + WebsiteWorkspace as ComponentType + ); + const slot = workspace?.props.docsSlot; + + expect(workspace?.props.resolution).toMatchObject({ kind: 'docs-only' }); + expect( + findElement(slot, DocsBreadcrumb as ComponentType) + ).toBeTruthy(); + expect( + findElement(slot, DocsPageHeader as ComponentType) + ).toBeTruthy(); + expect(findElement(slot, MdxRenderer as ComponentType)).toBeTruthy(); + expect(findElement(slot, DocsTOC as ComponentType)).toBeTruthy(); + }); + + it('keeps canonical metadata independent of the workspace mode query', async () => { + const metadata = await generateMetadata({ + params: Promise.resolve({ + library: 'langgraph', + section: 'guides', + slug: 'streaming', + }), + searchParams: Promise.resolve({ mode: 'run' }), + } as never); + + expect(metadata.alternates?.canonical).toBe( + '/docs/langgraph/guides/streaming' + ); + expect(String(metadata.alternates?.canonical)).not.toContain('mode'); + }); +}); diff --git a/apps/website/src/app/docs/[library]/[section]/[slug]/page.tsx b/apps/website/src/app/docs/[library]/[section]/[slug]/page.tsx index eef5319c5..5f3c9ae1f 100644 --- a/apps/website/src/app/docs/[library]/[section]/[slug]/page.tsx +++ b/apps/website/src/app/docs/[library]/[section]/[slug]/page.tsx @@ -1,6 +1,5 @@ import type { Metadata } from 'next'; import { notFound } from 'next/navigation'; -import { DocsControlPlane } from '../../../../../components/docs/DocsControlPlane'; import { MdxRenderer } from '../../../../../components/docs/MdxRenderer'; import { DocsSearch } from '../../../../../components/docs/DocsSearch'; import { DocsBreadcrumb } from '../../../../../components/docs/DocsBreadcrumb'; @@ -15,19 +14,48 @@ import { resolveDocDescription, } from '../../../../../lib/docs'; import { JsonLd } from '../../../../../components/shared/JsonLd'; -import { breadcrumbJsonLd, techArticleJsonLd } from '../../../../../lib/structured-data'; +import { + breadcrumbJsonLd, + techArticleJsonLd, +} from '../../../../../lib/structured-data'; import { getDocLastModified } from '../../../../../lib/sitemap-dates'; -import { ApiDocRenderer, type ApiDocEntry } from '../../../../../components/docs/ApiDocRenderer'; +import { + ApiDocRenderer, + type ApiDocEntry, +} from '../../../../../components/docs/ApiDocRenderer'; import { DocsTOC } from '../../../../../components/docs/DocsTOC'; import { extractHeadings } from '../../../../../lib/extract-headings'; -import { findDocsPage, getLibraryConfig, libraryIntroPath, type LibraryId } from '../../../../../lib/docs-config'; +import { + findDocsPage, + getLibraryConfig, + libraryIntroPath, + type LibraryId, +} from '../../../../../lib/docs-config'; +import { WebsiteWorkspace } from '../../../../../components/workspace/WebsiteWorkspace'; +import { getWebsiteWorkspacePage } from '../../../../../lib/workspace-page'; import fs from 'fs'; import path from 'path'; function loadApiDocs(library: string): ApiDocEntry[] { const candidates = [ - path.join(process.cwd(), 'apps', 'website', 'content', 'docs', library, 'api', 'api-docs.json'), - path.join(process.cwd(), 'content', 'docs', library, 'api', 'api-docs.json'), + path.join( + process.cwd(), + 'apps', + 'website', + 'content', + 'docs', + library, + 'api', + 'api-docs.json' + ), + path.join( + process.cwd(), + 'content', + 'docs', + library, + 'api', + 'api-docs.json' + ), ]; for (const p of candidates) { if (fs.existsSync(p)) return JSON.parse(fs.readFileSync(p, 'utf8')); @@ -40,15 +68,23 @@ interface DocsRouteProps { } export function generateStaticParams() { - return getAllDocSlugs().map(({ library, section, slug }) => ({ library, section, slug })); + return getAllDocSlugs().map(({ library, section, slug }) => ({ + library, + section, + slug, + })); } -export async function generateMetadata({ params }: DocsRouteProps): Promise { +export async function generateMetadata({ + params, +}: DocsRouteProps): Promise { const { library, section, slug } = await params; - return getDocMetadata(library, section, slug) ?? { - title: 'Docs — Threadplane', - description: DEFAULT_DOCS_DESCRIPTION, - }; + return ( + getDocMetadata(library, section, slug) ?? { + title: 'Docs — Threadplane', + description: DEFAULT_DOCS_DESCRIPTION, + } + ); } export default async function DocsPage({ params }: DocsRouteProps) { @@ -62,6 +98,10 @@ export default async function DocsPage({ params }: DocsRouteProps) { const pathname = `/docs/${library}/${section}/${slug}`; const headings = extractHeadings(doc.body); + const workspacePage = await getWebsiteWorkspacePage({ + docsPath: pathname, + title: doc.title, + }); const articleData = techArticleJsonLd({ title: doc.title, @@ -85,62 +125,100 @@ export default async function DocsPage({ params }: DocsRouteProps) { { name: doc.title, pathname }, ]); - return ( -
- - + const docsSlot = ( +
- -
+
{/* Same measure as the article and the prev/next rail below it, so the - * whole column shares one right edge. Without md:max-w-3xl this - * block stretched to the full content width and PageActions floated - * ~500px right of the prose it belongs to (1272px vs 768px at - * 1920). */} + * whole column shares one right edge. Without md:max-w-3xl this + * block stretched to the full content width and PageActions floated + * ~500px right of the prose it belongs to (1272px vs 768px at + * 1920). */}
- + } + actions={ + + } />
- {section === 'api' && (() => { - const entries = loadApiDocs(library); - const target = doc.title.replace(/\(\)$/, ''); - const byName = (name: string) => - entries.find((e: ApiDocEntry) => e.name === name); + {section === 'api' && + (() => { + const entries = loadApiDocs(library); + const target = doc.title.replace(/\(\)$/, ''); + const byName = (name: string) => + entries.find((e: ApiDocEntry) => e.name === name); - // A page normally documents the one export named by its H1. Pages - // covering a group of exports declare them via `apiEntries`. - const configured = findDocsPage(library, section, slug)?.apiEntries; - const rendered = configured - ? configured.map(byName).filter((e): e is ApiDocEntry => Boolean(e)) - : [byName(target) ?? byName(doc.title)].filter((e): e is ApiDocEntry => Boolean(e)); + // A page normally documents the one export named by its H1. Pages + // covering a group of exports declare them via `apiEntries`. + const configured = findDocsPage( + library, + section, + slug + )?.apiEntries; + const rendered = configured + ? configured + .map(byName) + .filter((e): e is ApiDocEntry => Boolean(e)) + : [byName(target) ?? byName(doc.title)].filter( + (e): e is ApiDocEntry => Boolean(e) + ); - return rendered.length > 0 ? ( -
- {rendered.map((entry) => ( - - ))} -
- ) : null; - })()} + return rendered.length > 0 ? ( +
+ {rendered.map((entry) => ( + + ))} +
+ ) : null; + })()}
- +
); + + return ( + <> + + + + + ); } diff --git a/apps/website/src/app/global.css b/apps/website/src/app/global.css index f93e1e61e..be756c59d 100644 --- a/apps/website/src/app/global.css +++ b/apps/website/src/app/global.css @@ -1,5 +1,6 @@ @import "tailwindcss"; @import "@threadplane/design-tokens/theme.css"; +@import "../../../../libs/workspace-react/src/styles/workspace.css"; /* * Scope files for the inline-style migration. These MUST sit with the other @@ -15,6 +16,10 @@ @import "../styles/marketing.css"; @import "../styles/pages.css"; +/* Shared workspace components live outside this app's automatic content + * boundary. Keep their Tailwind utilities in the Website production build. */ +@source "../../../../libs/workspace-react/src"; + * { box-sizing: border-box; } diff --git a/apps/website/src/app/layout.tsx b/apps/website/src/app/layout.tsx index b535dcb84..dd412c7a1 100644 --- a/apps/website/src/app/layout.tsx +++ b/apps/website/src/app/layout.tsx @@ -1,9 +1,11 @@ import type { Metadata } from 'next'; import { EB_Garamond, Inter, JetBrains_Mono } from 'next/font/google'; +import '@threadplane/design-tokens/tokens.css'; import './global.css'; import { Nav } from '../components/shared/Nav'; import { SiteFooter } from '../components/shared/SiteFooter'; import { AnnouncementToast } from '../components/shared/AnnouncementToast'; +import { WebsiteWorkspaceLayout } from '../components/workspace/WebsiteWorkspace'; import { JsonLd } from '../components/shared/JsonLd'; import { rootJsonLd } from '../lib/structured-data'; import { @@ -55,9 +57,16 @@ export const metadata: Metadata = { }, }; -export default function RootLayout({ children }: { children: React.ReactNode }) { +export default function RootLayout({ + children, +}: { + children: React.ReactNode; +}) { return ( - + {/* Site-wide structured data, mounted once here so it is present on every @@ -69,9 +78,13 @@ export default function RootLayout({ children }: { children: React.ReactNode })