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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/ci-test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,7 @@ jobs:
set: |
*.context=.
*.args.VERSION=${{ github.sha }}
frontend.args.VITE_V1_DEV_ROUTES=true
frontend.cache-from=type=gha,scope=frontend
frontend.cache-to=type=gha,mode=max,scope=frontend
backend.cache-from=type=gha,scope=backend
Expand Down
17 changes: 16 additions & 1 deletion docker/dockerfiles/frontend.Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,23 @@ RUN bun install --frozen-lockfile --ignore-scripts
# Copy the rest of the application files
COPY ${BUILD_CONTEXT_PATH}/ .

# Declared after the install layer so a different value (the E2E image sets
# VITE_V1_DEV_ROUTES) does not invalidate the cached install it shares with
# release builds.
# VITE_V1_DEV_ROUTES: "true" only for the E2E image (ci-test.yaml); registers
# the v1 demo pages that prove the version switch and honours the v1 preview
# override. Release builds leave it empty.
# VITE_DISTRIBUTION: oss (default when empty), cloud or onprem; the
# v1 route registry ignores pages the distribution lacks. The same name as
# P0-T07's plugin manifest (UN-4286). The cloud and on-prem builds do not set
# it yet, so every image builds as oss until P0-T07 wires them.
ARG VITE_V1_DEV_ROUTES=""
ARG VITE_DISTRIBUTION=""

# Build with Vite
RUN bun run build
RUN VITE_V1_DEV_ROUTES="${VITE_V1_DEV_ROUTES}" \
VITE_DISTRIBUTION="${VITE_DISTRIBUTION}" \
bun run build

# Production stage
FROM nginx:1.29.1-alpine AS production
Expand Down
236 changes: 139 additions & 97 deletions frontend/src/routes/Router.jsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { setOldVersion, VersionSwitch, v1DevRoutesEnabled } from "@v1";
import { Suspense } from "react";
import { Route, Routes, useLocation } from "react-router-dom";
import { Route, Routes, useLocation, useParams } from "react-router-dom";

import { GenericError } from "../components/error/GenericError/GenericError.jsx";
import {
Expand All @@ -17,6 +18,7 @@ import { lazyNamed } from "../helpers/lazyNamed.js";
import { isModuleMissing } from "../helpers/pluginLoader.js";
import { lazyPlugin } from "../helpers/pluginRegistry.js";
import { LandingPage } from "../pages/LandingPage.jsx";
import { useSessionStore } from "../store/session-store.js";
import { useMainAppRoutes } from "./useMainAppRoutes.jsx";

// Heavy route pages are code-split so the unauthenticated /landing page does
Expand Down Expand Up @@ -145,115 +147,155 @@ try {
}
}

// Stand-ins for the v0 side of the v1 demo pages (UN-4271), which prove the
// version switch; routed only in a build with VITE_V1_DEV_ROUTES=true. The
// demo page is always live there, so V0DemoPage renders only if the switch
// fails to redirect: P0-T09's E2E specs (UN-4345, #2355) check for it, so it
// is not dead code.
function V0DemoPage() {
return <h1 data-testid="v0-demo">v0 demo page</h1>;
}

function V0DemoReviewPage() {
const { orgName } = useParams();
return (
<div data-testid="v0-demo-review">
<h1>v0 demo review</h1>
<p>{orgName}</p>
<button type="button" onClick={() => setOldVersion(false)}>
Switch to new version
</button>
</div>
);
}

function Router() {
const location = useLocation();
const MainAppRoute = useMainAppRoutes();
const userId = useSessionStore((state) => state.sessionDetails?.id);
return (
// App-wide backstop: catches shell-level chunk failures and errors on
// routes that don't sit inside a LazyOutlet layout. Per-page load errors
// are handled by the scoped boundaries inside the layouts. resetKeys lets
// navigation clear the error without a full reload.
<ErrorBoundary
resetKeys={[location.pathname]}
onError={handleRouteError}
fallbackComponent={<RouteLoadError />}
>
<Suspense fallback={<GenericLoader />}>
<Routes>
<Route path="error" element={<GenericError />} />
<Route path="" element={<PersistentLogin />}>
{/* public routes */}
<Route path="">
{/* public routes accessible only to unauthenticated users */}
<Route path="" element={<RequireGuest />}>
<Route path="landing" element={<LandingPage />} />
</Route>
// Renders v1 for a URL whose page is live in the v1 route registry,
// and these v0 routes for everything else (UN-4271).
<VersionSwitch userId={userId}>
Comment thread
jaseemjaskp marked this conversation as resolved.
Resolved
{/* v0-wide backstop (the v1 tree has its own, in VersionSwitch):
catches shell-level chunk failures and errors on routes that don't
sit inside a LazyOutlet layout. Per-page load
errors are handled by the scoped boundaries inside the layouts.
resetKeys lets navigation clear the error without a full reload. */}
<ErrorBoundary
resetKeys={[location.pathname]}
onError={handleRouteError}
fallbackComponent={<RouteLoadError />}
>
<Suspense fallback={<GenericLoader />}>
<Routes>
<Route path="error" element={<GenericError />} />
<Route path="" element={<PersistentLogin />}>
{/* public routes */}
<Route path="">
{/* public routes accessible only to unauthenticated users */}
<Route path="" element={<RequireGuest />}>
<Route path="landing" element={<LandingPage />} />
</Route>

{/* public routes accessible to both authenticated and unauthenticated users */}
{SimplePromptStudioHelper &&
SimplePromptStudio &&
SpsLanding &&
SpsUpload && (
{/* public routes accessible to both authenticated and unauthenticated users */}
{SimplePromptStudioHelper &&
SimplePromptStudio &&
SpsLanding &&
SpsUpload && (
<Route
path="simple-prompt-studio"
element={<SimplePromptStudioHelper />}
>
<Route path="" element={<SimplePromptStudio />} />
<Route path="landing" element={<SpsLanding />} />
<Route path="upload" element={<SpsUpload />} />
</Route>
)}
{PublicPromptStudioHelper && (
<Route
path="simple-prompt-studio"
element={<SimplePromptStudioHelper />}
path="/promptStudio/share/:id"
element={<PublicPromptStudioHelper />}
>
<Route path="" element={<SimplePromptStudio />} />
<Route path="landing" element={<SpsLanding />} />
<Route path="upload" element={<SpsUpload />} />
<Route path="" element={<ToolIdePage />} />
<Route
path="/promptStudio/share/:id/outputAnalyzer"
element={<OutputAnalyzerPage />}
/>
</Route>
)}
{PublicPromptStudioHelper && (
</Route>

{/* protected routes */}
<Route path="setOrg" element={<SetOrgPage />} />
{SelectProduct && (
<Route path="selectProduct" element={<SelectProduct />} />
)}
{UnstractSubscriptionEndPage && (
<Route
path="/promptStudio/share/:id"
element={<PublicPromptStudioHelper />}
>
<Route path="" element={<ToolIdePage />} />
<Route
path="/promptStudio/share/:id/outputAnalyzer"
element={<OutputAnalyzerPage />}
/>
</Route>
path="/subscription-expired"
element={<UnstractSubscriptionEndPage />}
/>
)}
</Route>

{/* protected routes */}
<Route path="setOrg" element={<SetOrgPage />} />
{SelectProduct && (
<Route path="selectProduct" element={<SelectProduct />} />
)}
{UnstractSubscriptionEndPage && (
<Route
path="/subscription-expired"
element={<UnstractSubscriptionEndPage />}
/>
)}
{PaymentSuccessful && (
<Route path="/payment/success" element={<PaymentSuccessful />} />
)}
{MarketplaceLandingEntry && (
<Route
path="/marketplace-landing"
element={<MarketplaceLandingEntry />}
/>
)}
{MarketplaceStripeConflictEntry && (
<Route
path="/marketplace-stripe-conflict"
element={<MarketplaceStripeConflictEntry />}
/>
)}
{LlmWhispererMarketplaceLandingEntry && (
<Route
path="/llm-whisperer/marketplace-landing"
element={<LlmWhispererMarketplaceLandingEntry />}
/>
)}
{CustomPlanCheckoutPage && (
<Route
path="/subscription/custom"
element={<CustomPlanCheckoutPage />}
/>
)}
{LlmWhispererCustomCheckoutPage && (
<Route
path="/llm-whisperer/custom-checkout"
element={<LlmWhispererCustomCheckoutPage />}
/>
)}
<Route path="" element={<RequireAuth />}>
{MainAppRoute}
{llmWhispererRouter && (
<Route path="llm-whisperer">{llmWhispererRouter()}</Route>
{PaymentSuccessful && (
<Route
path="/payment/success"
element={<PaymentSuccessful />}
/>
)}
{MarketplaceLandingEntry && (
<Route
path="/marketplace-landing"
element={<MarketplaceLandingEntry />}
/>
)}
{MarketplaceStripeConflictEntry && (
<Route
path="/marketplace-stripe-conflict"
element={<MarketplaceStripeConflictEntry />}
/>
)}
{LlmWhispererMarketplaceLandingEntry && (
<Route
path="/llm-whisperer/marketplace-landing"
element={<LlmWhispererMarketplaceLandingEntry />}
/>
)}
{CustomPlanCheckoutPage && (
<Route
path="/subscription/custom"
element={<CustomPlanCheckoutPage />}
/>
)}
{LlmWhispererCustomCheckoutPage && (
<Route
path="/llm-whisperer/custom-checkout"
element={<LlmWhispererCustomCheckoutPage />}
/>
)}
<Route path="" element={<RequireAuth />}>
{MainAppRoute}
{llmWhispererRouter && (
<Route path="llm-whisperer">{llmWhispererRouter()}</Route>
)}
{v1DevRoutesEnabled && (
<>
<Route path=":orgName/v0-demo" element={<V0DemoPage />} />
<Route
path=":orgName/v0-demo-review"
element={<V0DemoReviewPage />}
/>
</>
)}
</Route>
{verticalsRouter && verticalsRouter()}
</Route>
{verticalsRouter && verticalsRouter()}
</Route>
<Route path="*" element={<NotFound />} />
<Route path="oauth-status" element={<OAuthStatus />} />
</Routes>
</Suspense>
</ErrorBoundary>
<Route path="*" element={<NotFound />} />
<Route path="oauth-status" element={<OAuthStatus />} />
</Routes>
</Suspense>
</ErrorBoundary>
</VersionSwitch>
);
}

Expand Down
48 changes: 47 additions & 1 deletion frontend/src/v1/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,14 +108,60 @@ is convention.
- `CodeEditor` uses Monaco from its default CDN, as v0 does. Never call
`loader.config()` in v1: the loader is shared with v0.

## Version switch and route registry

v0's router (`routes/Router.jsx`) wraps its routes in `VersionSwitch`, which
renders v1 for a URL whose page is live in the registry (`app/registry/`) and
v0 for everything else. v1 renders as its own tree (`app/V1App.tsx`), loaded
only when a v1 page first renders.

- **Declare** a page in `app/registry/entries.ts` (`PAGES`) with its v1 routes,
the v0 routes it replaces and the mapping both ways, and add its component
to `app/pages.ts`. Declared pages stay v0 for users.
- **Register** it, in its go-live task only (D9), by adding its key to
`REGISTERED_PAGES`. Its v0 URLs then redirect to v1; reverting is that line.
Comment thread
jaseemjaskp marked this conversation as resolved.
Resolved
v1 renders outside v0's `PersistentLogin` and `RequireAuth`, and its own
session and guards come with P0-T06: a page registered before that ships
with no session load, login redirect or org check.
- `app/registry/validate.test.ts` fails CI on an invalid entry: overlapping
patterns across pages, a v1 route and a different v0 route of one page that
match the same URL (a page may keep its v0 URL), a route without a mapping
to the other version, a page without a component, or `toggle` on any page
but Human Review (UN11, D14) and its demo stand-in.
- Human Review's old-version preference is per user, per browser, in
localStorage (`unstract.v1.oldVersion.<userId>`). `setOldVersion(on)` sets
it, and the switch then moves the user to the mapped screen without a
reload. `unstract.v1.lastUser` lets a reload apply it before the session
loads; on a shared browser that is the previous user's choice until the
session says otherwise.
- Until the v1 session lands (P0-T06), the switch learns the user only from
v0's session, which v0 loads in `PersistentLogin`, a v0 route. A page load
that starts on a v1 URL never loads it, so the switch has only
`unstract.v1.lastUser`. With none (a fresh browser, or an E2E context
signed in by API), `setOldVersion` returns false and changes nothing; with
another person's, a choice made there is saved as theirs. Both last until a
v0 page loads in that tab. E2E round trips seed `unstract.v1.lastUser` or
open a v0 page first.
- `VITE_V1_DEV_ROUTES=true` (the E2E image, or `.env` locally) registers the
demo pages (`/:orgName/v1-demo`, `/:orgName/v1-demo-review`, and their v0
stand-ins `/:orgName/v0-demo…`). It also renders declared pages as v1 before
they go live: set `localStorage["unstract.v1.preview"] = '["UN05"]'`, then
reload (it is read when the app loads).
- `VITE_DISTRIBUTION` (`oss` by default, `cloud` or `onprem`) selects
the build's distribution; entries whose `distributions` omit it are ignored.
An unknown value fails `vite build`. P0-T07's plugin manifest (UN-4286)
reads the same variable. The cloud and on-prem image builds do not set it
yet, so every image builds as `oss`: until P0-T07 wires them, an entry that
lists fewer than all three distributions is not filtered correctly there.

## Folders and what each may import

Every folder may import from itself. Test files (`*.test.ts(x)`,
`*.spec.ts(x)`) may also import `test/`.

| Folder | Holds | May import from |
| --- | --- | --- |
| `index.ts` | Public entry; the version switch (UN-4271) will mount v1 through it | everything |
| `index.ts` | Public entry: the version switch and its helpers | everything |
| `app/` | Entry, providers, router, route registry | everything |
| `components/ui/` | Core components | `lib/`, `styles/`, `assets/` |
| `components/patterns/` | Composites built from core components | `components/ui/`, `lib/`, `services/`, `stores/`, `hooks/`, `assets/` |
Expand Down
Empty file removed frontend/src/v1/app/.gitkeep
Empty file.
36 changes: 36 additions & 0 deletions frontend/src/v1/app/V1App.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
/*
* The v1 tree: its own providers and routes, never inside a v0 layout or
* guard. Loaded lazily by the version switch, so v1's code reaches the
* browser only when a v1 page renders; its styles are in the global
* stylesheet (vite.config.js sets cssCodeSplit: false). The Toaster mounts
* here, once, so `notify()` reaches the user on every v1 page. Session,
* guards (P0-T06) and the shell (P2-T01) mount here.
*/

import type { RegistryEntry } from "@v1/app/registry/types";
import { ThemeProvider } from "@v1/app/theme/ThemeProvider";
import { Toaster } from "@v1/components/ui/feedback";
import { type JSX, Suspense } from "react";
import { Route, Routes } from "react-router-dom";
import { PAGE_COMPONENTS } from "./pages";

export type V1AppProps = Readonly<{ entry: RegistryEntry }>;

export function V1App({ entry }: V1AppProps): JSX.Element {
const Page = PAGE_COMPONENTS[entry.page];
if (!Page) {
throw new Error(`v1 page ${entry.page} is registered but has no component`);
}
return (
<ThemeProvider>
<Suspense fallback={null}>
<Routes>
{entry.v1Routes.map((path) => (
<Route key={path} path={path} element={<Page />} />
))}
</Routes>
</Suspense>
<Toaster />
</ThemeProvider>
);
}
Loading
Loading