Skip to content

Commit 6ad6575

Browse files
committed
Add the Platform's live runtime behind a resolved mode
fixture, live or none, resolved once per server entry. Live reads the surface with forwarded cookies, applies the legacy layout's gates in its order, prefetches the shell under the hooks' keys, and mounts query, nuqs, session, organization and deployment-shape providers, with session-expired and impersonation states. Adds access-denied, unavailable, error, not-found and loading states. Fixture and fixture-off servers load none of the seam.
1 parent 652c451 commit 6ad6575

26 files changed

Lines changed: 729 additions & 7 deletions
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { type ReactNode, Suspense } from 'react'
2+
import { redirect } from 'next/navigation'
3+
import { AccessDenied, SurfaceUnavailable } from '@/platform/app/_components/shell-gate/shell-gate'
4+
import { resolveLiveDestinations } from '@/platform/app/(shell)/_components/destinations'
5+
import { ProductSession } from '@/platform/app/(shell)/_components/product-session'
6+
import { SessionState } from '@/platform/app/(shell)/_components/session-state/session-state'
7+
import { resolveSettingsNavigation } from '@/platform/app/(shell)/_components/settings-navigation/destinations'
8+
import { hasOrganizationSurface, legacyRoutes } from '@/platform/lib/data/organization-model'
9+
import { prefetchShell } from '@/platform/lib/data/prefetch'
10+
import { LiveProviders } from '@/platform/lib/data/providers'
11+
import { legacyUrl, loginUrl, readOrganizationSurface } from '@/platform/lib/data/surface'
12+
13+
/**
14+
* The shell for a live session: the legacy organization layout's gates, in its order, against data
15+
* read over the API. Each gate decides before anything product-shaped renders.
16+
*/
17+
export async function LiveShell({ children }: { children: ReactNode }) {
18+
const surface = await readOrganizationSurface()
19+
if (surface.status === 'unauthenticated') redirect(loginUrl())
20+
if (surface.status === 'denied') return <AccessDenied workspacesHref={legacyUrl('/workspace')} />
21+
if (surface.status === 'unavailable') return <SurfaceUnavailable />
22+
23+
const { organization } = surface
24+
if (!hasOrganizationSurface(organization)) redirect(legacyRoutes(organization).workspaceSettings)
25+
26+
return (
27+
<LiveProviders
28+
organization={organization}
29+
navigation={{
30+
destinations: resolveLiveDestinations(organization),
31+
settingsNavigation: resolveSettingsNavigation(organization),
32+
}}
33+
dehydratedState={await prefetchShell(organization)}
34+
>
35+
<Suspense fallback={null}>
36+
<ProductSession fixtures={null} banner={<SessionState />}>
37+
{children}
38+
</ProductSession>
39+
</Suspense>
40+
</LiveProviders>
41+
)
42+
}

‎apps/sim/platform/app/(shell)/_components/platform-shell/platform-shell.module.css‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,16 @@
66
block-size: 100dvh;
77
isolation: isolate;
88
}
9+
.frame {
10+
display: flex;
11+
flex-direction: column;
12+
block-size: 100dvh;
13+
}
14+
.frame > .shell {
15+
flex: 1;
16+
block-size: auto;
17+
min-block-size: 0;
18+
}
919
.shell[data-sidebar-collapsed] {
1020
grid-template-columns: minmax(0, 1fr);
1121
}

‎apps/sim/platform/app/(shell)/_components/platform-shell/platform-shell.tsx‎

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,29 @@ import { useCompactViewport } from '@/platform/design-system/components/layout/u
1818
import { useScrollEdges } from '@/platform/design-system/components/scroll-area/use-scroll-edges'
1919
import { platformHref } from '@/platform/design-system/navigation'
2020

21+
/** Live navigation names a route on the legacy surface by its absolute URL. */
22+
const LEGACY_DESTINATION = /^https?:\/\//
23+
2124
interface PlatformShellProps {
2225
data: ShellData | null
2326
children: ReactNode
2427
}
2528

29+
interface PlatformShellFrameProps {
30+
banner: ReactNode
31+
children: ReactNode
32+
}
33+
34+
/** Gives a banner its own row above the shell, which otherwise fills the window. */
35+
export function PlatformShellFrame({ banner, children }: PlatformShellFrameProps) {
36+
return (
37+
<div className={styles.frame}>
38+
{banner}
39+
{children}
40+
</div>
41+
)
42+
}
43+
2644
export function PlatformShell({ data, children }: PlatformShellProps) {
2745
const scrollEdges = useScrollEdges<HTMLDivElement>()
2846
const pathname = usePathname()
@@ -36,8 +54,10 @@ export function PlatformShell({ data, children }: PlatformShellProps) {
3654
const drawerRef = useRef<HTMLDialogElement>(null)
3755
const chat = useChatChanges()
3856
const settings = pathname.startsWith('/settings')
57+
const settingsDestinations: readonly { key: string; title: string }[] =
58+
data?.settingsNavigation?.flatMap((group) => group.items) ?? SETTINGS_DESTINATIONS
3959
const section = settings
40-
? SETTINGS_DESTINATIONS.find((item) => item.key === (pathname.split('/')[2] || 'preferences'))
60+
? settingsDestinations.find((item) => item.key === (pathname.split('/')[2] || 'preferences'))
4161
: undefined
4262
const conversation = pathname.startsWith('/chat')
4363
const activeChat = conversation ? pathname.split('/')[2] : undefined
@@ -90,7 +110,8 @@ export function PlatformShell({ data, children }: PlatformShellProps) {
90110
function navigate(path: string) {
91111
setCommand(false)
92112
setDrawer(false)
93-
router.push(platformHref(path, search))
113+
if (LEGACY_DESTINATION.test(path)) window.location.assign(path)
114+
else router.push(platformHref(path, search))
94115
}
95116
function toggle() {
96117
if (compact) setDrawer((previous) => !previous)
@@ -99,6 +120,7 @@ export function PlatformShell({ data, children }: PlatformShellProps) {
99120
const actions = (
100121
<SidebarToolbar
101122
items={data?.inbox ?? []}
123+
showInbox={!data?.unbacked?.includes('inbox')}
102124
navigate={navigate}
103125
onSearch={() => setCommand(true)}
104126
onCollapse={toggle}
@@ -183,6 +205,7 @@ export function PlatformShell({ data, children }: PlatformShellProps) {
183205
navigate={navigate}
184206
chats={chats}
185207
workspaces={data?.workspaces ?? []}
208+
destinations={data?.destinations}
186209
/>
187210
</div>
188211
<NotificationTray history={data?.notifications ?? []} navigate={navigate} />

‎apps/sim/platform/app/(shell)/_components/product-session.tsx‎

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,10 @@ import { useSearchParams } from 'next/navigation'
55
import { DesktopHost } from '@/platform/app/(shell)/_components/desktop/desktop-host'
66
import type { ShellData } from '@/platform/app/(shell)/_components/model'
77
import { NotificationSession } from '@/platform/app/(shell)/_components/notification-tray/notification-tray'
8-
import { PlatformShell } from '@/platform/app/(shell)/_components/platform-shell/platform-shell'
8+
import {
9+
PlatformShell,
10+
PlatformShellFrame,
11+
} from '@/platform/app/(shell)/_components/platform-shell/platform-shell'
912
import { ChatChangesProvider } from '@/platform/app/(shell)/_components/session/chat-changes'
1013
import { ComposerDrafts } from '@/platform/app/(shell)/_components/session/composer-drafts'
1114
import { IntegrationChanges } from '@/platform/app/(shell)/_components/session/integration-changes'
@@ -14,15 +17,22 @@ import { ProductSurface } from '@/platform/app/(shell)/_components/session/produ
1417
import { SidebarLayout } from '@/platform/app/(shell)/_components/sidebar/sidebar-layout'
1518
import { readAppearance, readScenario } from '@/platform/design-system/navigation'
1619
import type { Scenario } from '@/platform/fixtures'
20+
import { useLiveShellData } from '@/platform/lib/data/shell-context'
1721

1822
interface ProductSessionProps {
1923
fixtures: Record<Scenario, ShellData> | null
24+
/** Above the shell, inside the theme: session states only a live session has. */
25+
banner?: ReactNode
2026
children: ReactNode
2127
}
2228

23-
export function ProductSession({ children, fixtures }: ProductSessionProps) {
29+
export function ProductSession({ children, fixtures, banner }: ProductSessionProps) {
2430
const search = useSearchParams()
2531
const scenario = readScenario(search.get('state'))
32+
const live = useLiveShellData()
33+
const shell = (
34+
<PlatformShell data={live ?? fixtures?.[scenario] ?? null}>{children}</PlatformShell>
35+
)
2636
return (
2737
<InterfacePreferences key={scenario}>
2838
<DesktopHost />
@@ -32,7 +42,11 @@ export function ProductSession({ children, fixtures }: ProductSessionProps) {
3242
<ComposerDrafts>
3343
<ProductSurface preference={readAppearance(search.get('theme'))}>
3444
<IntegrationChanges key={search.get('role') === 'member' ? 'member' : 'admin'}>
35-
<PlatformShell data={fixtures?.[scenario] ?? null}>{children}</PlatformShell>
45+
{banner ? (
46+
<PlatformShellFrame banner={banner}>{shell}</PlatformShellFrame>
47+
) : (
48+
shell
49+
)}
3650
</IntegrationChanges>
3751
</ProductSurface>
3852
</ComposerDrafts>
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
.banner {
2+
display: flex;
3+
align-items: center;
4+
justify-content: center;
5+
gap: var(--spacing-12);
6+
flex-wrap: wrap;
7+
padding: var(--spacing-8) var(--spacing-16);
8+
background: var(--control-danger-soft);
9+
}
10+
11+
.takeover {
12+
position: fixed;
13+
z-index: 3;
14+
inset: 0;
15+
display: flex;
16+
flex-direction: column;
17+
align-items: center;
18+
justify-content: center;
19+
gap: var(--spacing-12);
20+
padding: var(--spacing-24);
21+
text-align: center;
22+
background: var(--ui-canvas);
23+
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
'use client'
2+
3+
import styles from '@/platform/app/(shell)/_components/session-state/session-state.module.css'
4+
import { Button } from '@/platform/design-system/components'
5+
import { useImpersonation, useSessionExpiry } from '@/platform/lib/data/session'
6+
7+
function ImpersonationBanner() {
8+
const { active, name, email, stopping, stop } = useImpersonation()
9+
if (!active) return null
10+
return (
11+
<div className={styles.banner} role='status'>
12+
<span data-type='T060'>
13+
Impersonating {name}
14+
{email ? ` (${email})` : ''}. Changes will apply to this account until you switch back.
15+
</span>
16+
<Button size='compact' variant='destructive' disabled={stopping} onClick={stop}>
17+
{stopping ? 'Returning…' : 'Stop impersonating'}
18+
</Button>
19+
</div>
20+
)
21+
}
22+
23+
function SessionExpired() {
24+
const { expired, failed, wasImpersonation, retry } = useSessionExpiry()
25+
if (!expired) return null
26+
const subject = wasImpersonation ? 'The impersonation session expired' : 'Your session expired'
27+
return (
28+
<main className={styles.takeover}>
29+
<p data-type='T067' role='status'>
30+
{failed ? `${subject}, but signing out failed.` : `${subject}. Signing you out…`}
31+
</p>
32+
{failed && (
33+
<Button variant='primary' onClick={retry}>
34+
Try again
35+
</Button>
36+
)}
37+
</main>
38+
)
39+
}
40+
41+
/** What the session itself has to say, above whatever route is showing. */
42+
export function SessionState() {
43+
return (
44+
<>
45+
<ImpersonationBanner />
46+
<SessionExpired />
47+
</>
48+
)
49+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
'use client'
2+
3+
import { PageError } from '@/platform/app/_components/shell-gate/shell-gate'
4+
5+
export default function ErrorPage({ reset }: { reset: () => void }) {
6+
return <PageError reset={reset} />
7+
}

‎apps/sim/platform/app/(shell)/layout.tsx‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,14 @@
11
import { type ReactNode, Suspense } from 'react'
22
import { ProductSession } from '@/platform/app/(shell)/_components/product-session'
33
import { loadShellScenarios } from '@/platform/fixtures'
4+
import { resolvePlatformMode } from '@/platform/lib/data/mode'
45

56
export default async function Layout({ children }: { children: ReactNode }) {
7+
if (resolvePlatformMode() === 'live') {
8+
/** Loaded only here, so a fixture server never evaluates the data seam. */
9+
const { LiveShell } = await import('@/platform/app/(shell)/_components/live-shell/live-shell')
10+
return <LiveShell>{children}</LiveShell>
11+
}
612
const fixtures = await loadShellScenarios()
713
return (
814
<Suspense fallback={null}>
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
/**
2+
* Lets the shell paint while a page reads its data. Blank, so nothing moves when the page arrives;
3+
* a route whose final geometry is known supplies its own skeleton in its own `loading.tsx`.
4+
*/
5+
export default function Loading() {
6+
return null
7+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import { PageNotFound } from '@/platform/app/_components/shell-gate/shell-gate'
2+
3+
export default function NotFound() {
4+
return <PageNotFound />
5+
}

0 commit comments

Comments
 (0)