diff --git a/packages/cli/src/templates/_shared/AGENTS.md b/packages/cli/src/templates/_shared/AGENTS.md index 4f5e90cea..6876b6358 100644 --- a/packages/cli/src/templates/_shared/AGENTS.md +++ b/packages/cli/src/templates/_shared/AGENTS.md @@ -13,13 +13,17 @@ Skills encode patterns like `window.__timelines` registration, `data-*` attribut ## Commands ```bash -npm run dev # preview in browser (studio editor) +npm run dev # start the preview server (long-running — keep it alive in background) npm run check # lint + validate + inspect npm run render # render to MP4 npm run publish # publish and get a shareable link npx hyperframes docs # reference docs in terminal ``` +> **`npm run dev` is a long-running server, not a one-shot command.** It blocks until stopped. +> Always run it as a background process so it stays alive while you edit compositions. +> Running it in the foreground will time out and kill the server, breaking the browser preview. + ## Project Structure - `index.html` — main composition (root timeline) diff --git a/packages/cli/src/templates/_shared/CLAUDE.md b/packages/cli/src/templates/_shared/CLAUDE.md index fea7ce58f..2359316c6 100644 --- a/packages/cli/src/templates/_shared/CLAUDE.md +++ b/packages/cli/src/templates/_shared/CLAUDE.md @@ -25,7 +25,7 @@ ## Commands ```bash -npm run dev # preview in browser (studio editor) +npm run dev # start the preview server (long-running — keep it alive in background) npm run check # lint + validate + inspect npm run render # render to MP4 npm run publish # publish and get a shareable link @@ -34,6 +34,10 @@ npx hyperframes lint --json # machine-readable output for CI npx hyperframes docs # reference docs in terminal ``` +> **`npm run dev` is a long-running server, not a one-shot command.** It blocks until stopped. +> In Claude Code, always run it with `run_in_background: true`. Never run it as a foreground +> command — it will time out and the server will die, breaking the browser preview. + ## Documentation **For quick reference**, use the local CLI docs command (no network required): diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx index 4affd71c1..fcfe3caee 100644 --- a/packages/studio/src/App.tsx +++ b/packages/studio/src/App.tsx @@ -1,5 +1,4 @@ import { useState, useCallback, useRef, useMemo } from "react"; -import { useMountEffect } from "./hooks/useMountEffect"; import type { LeftSidebarHandle } from "./components/sidebar/LeftSidebar"; import { useRenderQueue } from "./components/renders/useRenderQueue"; import { usePlayerStore } from "./player"; @@ -21,7 +20,6 @@ import { useFrameCapture } from "./hooks/useFrameCapture"; import { useLintModal } from "./hooks/useLintModal"; import { useCompositionDimensions } from "./hooks/useCompositionDimensions"; import { useToast } from "./hooks/useToast"; -import { buildProjectHash, parseProjectIdFromHash } from "./utils/projectRouting"; import { STUDIO_INSPECTOR_PANELS_ENABLED, STUDIO_MOTION_PANEL_ENABLED, @@ -38,29 +36,11 @@ import { StudioProvider, type StudioContextValue } from "./contexts/StudioContex import { PanelLayoutProvider } from "./contexts/PanelLayoutContext"; import { FileManagerProvider } from "./contexts/FileManagerContext"; import { DomEditProvider } from "./contexts/DomEditContext"; +import { StudioSplash } from "./components/StudioSplash"; +import { useServerConnection } from "./hooks/useServerConnection"; export function StudioApp() { - const [projectId, setProjectId] = useState(null); - const [resolving, setResolving] = useState(true); - useMountEffect(() => { - const hashProjectId = parseProjectIdFromHash(window.location.hash); - if (hashProjectId) { - setProjectId(hashProjectId); - setResolving(false); - return; - } - fetch("/api/projects") - .then((r) => r.json()) - .then((data) => { - const first = (data.projects ?? [])[0]; - if (first) { - setProjectId(first.id); - window.location.hash = buildProjectHash(first.id); - } - }) - .catch(() => {}) - .finally(() => setResolving(false)); - }); + const { projectId, resolving, waitingForServer } = useServerConnection(); const [activeCompPath, setActiveCompPath] = useState(null); const [compIdToSrc, setCompIdToSrc] = useState>(new Map()); @@ -341,12 +321,8 @@ export function StudioApp() { toggleTimelineVisibility, }; - if (resolving || !projectId) { - return ( -
-
-
- ); + if (resolving || waitingForServer || !projectId) { + return ; } const timelineToolbar = ; diff --git a/packages/studio/src/components/StudioSplash.tsx b/packages/studio/src/components/StudioSplash.tsx new file mode 100644 index 000000000..366542c29 --- /dev/null +++ b/packages/studio/src/components/StudioSplash.tsx @@ -0,0 +1,17 @@ +export function StudioSplash({ waiting }: { waiting?: boolean }) { + return ( +
+ {waiting ? ( +
+
+

+ Waiting for preview server… run{" "} + npm run dev +

+
+ ) : ( +
+ )} +
+ ); +} diff --git a/packages/studio/src/hooks/useServerConnection.ts b/packages/studio/src/hooks/useServerConnection.ts new file mode 100644 index 000000000..396b7a780 --- /dev/null +++ b/packages/studio/src/hooks/useServerConnection.ts @@ -0,0 +1,71 @@ +import { useState } from "react"; +import { buildProjectHash, parseProjectIdFromHash } from "../utils/projectRouting"; +import { useMountEffect } from "./useMountEffect"; + +interface ServerConnectionState { + projectId: string | null; + resolving: boolean; + waitingForServer: boolean; +} + +/** + * Resolves the active project ID by pinging /api/projects. + * + * If the hash contains a project ID the server is still contacted — this + * ensures a dead server (bookmark-reload case) enters the waiting state + * rather than mounting the full Studio against a non-responsive API. + * + * Polls every 2 s until the server responds, then transitions automatically. + * Cleans up pending timers on unmount so it is safe under React StrictMode. + */ +export function useServerConnection(): ServerConnectionState { + const [projectId, setProjectId] = useState(null); + const [resolving, setResolving] = useState(true); + const [waitingForServer, setWaitingForServer] = useState(false); + + useMountEffect(() => { + const hashProjectId = parseProjectIdFromHash(window.location.hash); + let cancelled = false; + let retryTimer: ReturnType | null = null; + + function scheduleRetry() { + setWaitingForServer(true); + retryTimer = window.setTimeout(tryConnect, 2000); + } + + function tryConnect() { + fetch("/api/projects") + .then((r) => r.json()) + .then((data) => { + if (cancelled) return; + if (hashProjectId) { + setProjectId(hashProjectId); + setWaitingForServer(false); + } else { + const first = (data.projects ?? [])[0]; + if (first) { + setProjectId(first.id); + setWaitingForServer(false); + window.location.hash = buildProjectHash(first.id); + } else { + scheduleRetry(); + } + } + }) + .catch(() => { + if (!cancelled) scheduleRetry(); + }) + .finally(() => { + if (!cancelled) setResolving(false); + }); + } + + tryConnect(); + return () => { + cancelled = true; + if (retryTimer !== null) clearTimeout(retryTimer); + }; + }); + + return { projectId, resolving, waitingForServer }; +}