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
31 changes: 9 additions & 22 deletions client/features/applets/AppletMount.tsx
Original file line number Diff line number Diff line change
@@ -1,43 +1,30 @@
import { type ReactElement, type ReactNode, cloneElement, isValidElement } from 'react'
import { type ReactNode } from 'react'

import { useWorkspaceId } from '@/client/features/workspace/WorkspaceContext'

import { type AppletSegment, appletStyleKey } from './applet-cache'
import { type AppletSegment, appletScope, appletStyleKey } from './applet-cache'
import { useAppletStyle } from './applet-styles'

type AppletMountProps = {
segment: AppletSegment
name: string
version: number
// Merge the scope attribute into the single child element instead of
// rendering a wrapper — use when the caller already has a filled container
// (e.g. the shells' motion.div), so no extra node enters the DOM. The child
// must forward unknown props to its DOM element.
asChild?: boolean
children: ReactNode
}

// The style scope for one mounted applet: puts the `data-applet` attribute the
// bundle's scoped CSS selectors key off (see server/bundler/applet-css.ts) on a
// container, and keeps the applet's <style> tag mounted exactly as long as the
// applet is — unmounting removes the styles from the page. Without `asChild`
// it renders its own wrapper div filling the parent box (the widget path —
// merging onto the shell's motion.div interferes with AnimatePresence there);
// with `asChild` it merges onto the child element instead (the view path).
export function AppletMount({ segment, name, version, asChild, children }: AppletMountProps) {
// wrapper filling the parent box, and keeps the applet's <style> tag mounted
// exactly as long as the applet is — unmounting removes the styles from the
// page. This is the widget path. Views don't use it: ViewManager parks a view's
// DOM offscreen instead of unmounting it, so it holds the styles itself, above
// the boundary that hides the view.
export function AppletMount({ segment, name, version, children }: AppletMountProps) {
const workspaceId = useWorkspaceId()
useAppletStyle(appletStyleKey(segment, workspaceId, name), version)

const kind = segment === 'widgets' ? 'widget' : 'view'
const scope = `${kind}:${name}`

if (asChild && isValidElement(children)) {
return cloneElement(children as ReactElement<{ 'data-applet'?: string }>, {
'data-applet': scope
})
}
return (
<div data-applet={scope} className="size-full">
<div data-applet={appletScope(segment, name)} className="size-full">
{children}
</div>
)
Expand Down
7 changes: 7 additions & 0 deletions client/features/applets/applet-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,13 @@ export function appletStyleKey(segment: AppletSegment, workspaceId: string, name
return `/api/workspaces/${workspaceId}/${segment}/${name}`
}

// The `data-applet` value the bundle's scoped CSS selectors key off (see
// server/bundler/applet-css.ts). It goes on the container wrapping a mounted
// applet — AppletMount for widgets, the view slot for views.
export function appletScope(segment: AppletSegment, name: string): string {
return `${segment === 'widgets' ? 'widget' : 'view'}:${name}`
}

export function getCachedApplet(key: string): Promise<unknown> | undefined {
return moduleCache.get(key)
}
Expand Down
22 changes: 21 additions & 1 deletion client/features/applets/applet-styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,10 +52,30 @@ export function acquireAppletStyle(key: string): () => void {
}
}

// Move an applet's <style> to the end of <head>. Everything in an applet sheet
// is scoped to its `[data-applet]` container and so can't collide — except the
// names of global at-rules (`@keyframes`, `@property`, `@font-face`), which the
// scoper leaves alone because they aren't selectors (server/bundler/applet-css.ts).
// Two applets defining `@keyframes pulse` differently therefore share one
// document-global name, and the last definition wins for both. Raising the
// applet the user is looking at makes the winner the one on screen.
export function raiseAppletStyle(key: string): void {
const entry = active.get(key)
if (!entry || entry.el === document.head.lastElementChild) return
document.head.appendChild(entry.el)
}

// Keep the applet's <style> mounted for the lifetime of the calling component.
// `version` re-runs the effect after a rebuild so the tag picks up the fresh
// registry text. useInsertionEffect runs before layout effects and paint, so
// the styles are in place before the applet's first frame.
export function useAppletStyle(key: string, version: number): void {
//
// `onTop` marks the applet as the visible one — see `raiseAppletStyle`. Only
// ever set it on one mounted applet at a time; with several claiming it the
// winner is whichever effect React happens to run last.
export function useAppletStyle(key: string, version: number, onTop = false): void {
useInsertionEffect(() => acquireAppletStyle(key), [key, version])
useInsertionEffect(() => {
if (onTop) raiseAppletStyle(key)
}, [key, onTop, version])
}
2 changes: 1 addition & 1 deletion client/features/applets/useApplet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ export type AppletComponentProps = {
params?: Record<string, unknown>
}

type AppletState =
export type AppletState =
| { status: 'loading'; version: number }
| { status: 'ready'; Component: ComponentType<AppletComponentProps>; version: number }
| { status: 'error'; error: string; version: number }
Expand Down
258 changes: 258 additions & 0 deletions client/features/views/ViewManager.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,258 @@
// The workspace's view surface: every view the user has open lives here, and
// switching tabs picks one of them instead of tearing the old one down and
// building the new one up.
//
// Why no transition between views: a view is an agent-authored bundle with its
// own styles and its own state. Animating the swap means keeping the outgoing
// DOM alive past the moment React drops the applet's <style> tag, so the view
// spends its exit unstyled. Keeping views mounted removes the problem instead
// of timing around it — the switch is instant, and a view only ever loads once.
//
// The one animation left is the rebuild dissolve: when the view you are looking
// at is rebuilt, the new build fades in over the old one. That is safe here
// because both builds share the slot's style tag (see ViewSlot), and it is
// worth the 200ms — it is the only signal that the thing under your cursor just
// changed underneath you.
import { Activity, type ComponentType, useCallback, useEffect, useRef, useState } from 'react'

import { Spinner } from '@/client/components/ui/spinner'
import { appletScope, appletStyleKey } from '@/client/features/applets/applet-cache'
import { useAppletStyle } from '@/client/features/applets/applet-styles'
import {
type AppletComponentProps,
type AppletState,
useView
} from '@/client/features/applets/useApplet'
import { WidgetErrorBoundary } from '@/client/features/applets/WidgetErrorBoundary'
import { useWorkspaceId } from '@/client/features/workspace/WorkspaceContext'
import { cn } from '@/client/lib/cn'
import type { ViewInfo } from '@/lib/types'

import {
nextEvictionDelay,
reconcileResidents,
type ResidentView,
sameResidents
} from './view-residency'

type ViewManagerProps = {
views: ViewInfo[]
// The view the active tab names, or null when another tab is on screen. The
// manager stays mounted either way — that is what makes coming back from the
// agent or widgets tab instant too.
activeViewId: string | null
// The active view's addressable state, read from navigation state (focusTab /
// `moi tab focus`). `{}` on a fresh mount, a new browser tab, or a plain
// tab-bar click — a view must render sensibly with that.
params: Record<string, unknown>
}

export function ViewManager({ views, activeViewId, params }: ViewManagerProps) {
const residents = useResidentViews(activeViewId, views)
// Render in workspace order, not residency order: the policy ranks views by
// recency, and reordering the children would make React move live DOM around
// on every switch. The slots are stacked absolutely with one visible, so
// their order on the page carries no meaning.
const resident = new Set(residents.map(entry => entry.id))

return (
// Hidden rather than unmounted when no view tab is on screen: the parked
// views keep their DOM, and `display: none` keeps this layer out of the
// layout and out of the way of pointer events on the tab that IS on screen.
<div className={cn('relative min-h-0 flex-1 overflow-hidden', !activeViewId && 'hidden')}>
{views
.filter(view => resident.has(view.id))
.map(view => (
<ViewSlot key={view.id} view={view} active={view.id === activeViewId} params={params} />
))}
</div>
)
}

// The views mounted right now: the active one, plus the recently-visited ones
// parked offscreen. The policy (and its timing) lives in view-residency.ts.
function useResidentViews(activeId: string | null, views: ViewInfo[]): ResidentView[] {
const [residents, setResidents] = useState<ResidentView[]>([])
// A workspace refetch hands us a new array on every event; residency only
// cares whether a view appeared or disappeared. So the effect keys off the id
// set, and the reconcile reads the current list through the ref.
const viewsRef = useRef(views)
viewsRef.current = views
const availableIds = views.map(view => view.id).join('\n')

const reconcile = useCallback(() => {
setResidents(current => {
const next = reconcileResidents(current, {
activeId,
available: new Set(viewsRef.current.map(view => view.id)),
now: Date.now()
})
return sameResidents(current, next) ? current : next
})
}, [activeId])

// Promote the view the user switched to, and release the one it replaced.
useEffect(() => {
reconcile()
}, [availableIds, reconcile])

// Then evict on the retention deadline — one timer, for the nearest one.
useEffect(() => {
const delay = nextEvictionDelay(residents, Date.now())
if (delay === null) return
const timer = setTimeout(reconcile, delay)
return () => clearTimeout(timer)
}, [reconcile, residents])

return residents
}

type ViewSlotProps = {
view: ViewInfo
active: boolean
params: Record<string, unknown>
}

// One resident view. The bundle it holds is loaded and kept fresh for as long
// as the slot lives — a view rebuilt while parked picks the new build up in
// place, so it is current the moment the user comes back to it.
function ViewSlot({ view, active, params }: ViewSlotProps) {
const workspaceId = useWorkspaceId()
const bundle = useView(view.id)
const { current, outgoing } = useLoadedBundle(bundle, active)
// A parked view keeps rendering with the params it was last shown with: the
// active view's `focusTab` state is not its to render.
const [shownParams, setShownParams] = useState(params)
if (active && shownParams !== params) setShownParams(params)

// The applet's <style> is acquired HERE, outside the Activity below. Hiding
// an Activity unmounts its children's effects, which would strip the styles
// off the page while the DOM they style is still parked — the exact flash
// this component exists to remove. The tag drops when the slot is evicted.
// Holding it here is also what lets the dissolve below overlap two builds:
// the styles belong to the slot, not to either frame. The active view's sheet
// is raised to the end of <head> so the global at-rule names it shares with
// the parked views resolve to the one on screen.
useAppletStyle(
appletStyleKey('views', workspaceId, view.id),
current?.version ?? bundle.version,
active
)

const failed = bundle.status === 'error'

return (
<>
{active && failed && (
<p className="absolute inset-0 p-4 text-xs text-destructive">{bundle.error}</p>
)}
{active && !failed && !current && <ViewSplash />}
{current &&
!failed && (
// React hides these nodes with `display: none` while the Activity is
// hidden, so a parked view neither paints nor swallows clicks.
<Activity mode={active ? 'visible' : 'hidden'}>
{outgoing && (
<ViewFrame key={outgoing.version} view={view} build={outgoing} params={shownParams} />
)}
<ViewFrame
key={current.version}
view={view}
build={current}
params={shownParams}
entering={outgoing !== null}
/>
</Activity>
)}
</>
)
}

type ViewFrameProps = {
view: ViewInfo
build: ViewBuild
params: Record<string, unknown>
// Play the rebuild dissolve. Set on the incoming build only, and only while
// the build it replaced is still rendered underneath it.
entering?: boolean
}

// One build of one view, in its style scope. Frames stack absolutely, so during
// a rebuild the incoming one dissolves in over the outgoing one still on screen.
function ViewFrame({ view, build, params, entering }: ViewFrameProps) {
const workspaceId = useWorkspaceId()

return (
<div
data-applet={appletScope('views', view.id)}
className={cn(
'absolute inset-0 overflow-auto',
entering && 'animate-in duration-200 ease-out blur-in-4 fade-in'
)}
>
<WidgetErrorBoundary
name={view.id}
kind="view"
workspaceId={workspaceId}
resetKey={build.version}
>
<build.Component params={params} />
</WidgetErrorBoundary>
</div>
)
}

type ViewBuild = {
Component: ComponentType<AppletComponentProps>
version: number
}

type LoadedBundle = {
// The build to render, held across reloads. A rebuild flips the bundle back
// to `loading` for as long as the fetch takes, and swapping a live view for a
// spinner every time the agent edits it is worse than showing the previous
// build for that moment. The splash is for a view with nothing to show yet —
// the first time it is opened.
current: ViewBuild | null
// The build `current` just replaced, kept underneath for the length of the
// dissolve so the new one fades in over the view instead of over an empty
// panel. Null except during a rebuild swap on screen.
outgoing: ViewBuild | null
}

// How long the outgoing build stays underneath. Matches the `duration-200` the
// incoming frame animates with.
const DISSOLVE_MS = 200

function useLoadedBundle(bundle: AppletState, dissolve: boolean): LoadedBundle {
const [loaded, setLoaded] = useState<LoadedBundle>({ current: null, outgoing: null })

if (bundle.status === 'ready' && loaded.current?.version !== bundle.version) {
const build = { Component: bundle.Component, version: bundle.version }
// The first build of a view has nothing to dissolve from, and a parked view
// has nobody watching — both swap straight in.
setLoaded(previous => ({ current: build, outgoing: dissolve ? previous.current : null }))
}

useEffect(() => {
if (!loaded.outgoing) return
const timer = setTimeout(
() => setLoaded(previous => ({ ...previous, outgoing: null })),
DISSOLVE_MS
)
return () => clearTimeout(timer)
}, [loaded.outgoing])

return loaded
}

// A view's one loading moment: the first open, while its bundle is fetched. The
// delay keeps it off screen entirely for a fast load — the common case, since
// the module cache outlives eviction and only the network trip is new.
function ViewSplash() {
return (
<div className="absolute inset-0 flex animate-in items-center justify-center delay-150 duration-200 fill-mode-both fade-in">
<Spinner className="text-muted-foreground" />
</div>
)
}
Loading