Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
2 changes: 1 addition & 1 deletion app/api/builder/cleanup/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ export async function handleBuilderCleanupRequest(
const adapter = dependencies.runtime ?? new VercelSandboxRuntimeAdapter();
const result = await adapter.cleanupIdle({
idleBefore: new Date(now.getTime() - minutes * 60_000),
limit: 100,
limit: 50,
});
return response({
idleMinutes: minutes,
Expand Down
3 changes: 2 additions & 1 deletion components/drops-studio.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1867,7 +1867,8 @@ export function DropsStudio({ hero }: { hero: ReactNode }) {
createdAt: now,
updatedAt: now,
};
const studioHref = `/studio/${project.id}?panel=director&autobuild=1`;
const buildRequestId = crypto.randomUUID();
const studioHref = `/studio/${project.id}?panel=director&autobuild=1&buildRequest=${encodeURIComponent(buildRequestId)}`;
void router.prefetch(studioHref);
void warmProjectExperience(spec);
const stored = await saveProjectSafely(project, {
Expand Down
62 changes: 57 additions & 5 deletions components/project-studio.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,7 @@ import {
studioAccountDisplayName,
studioAccountInitial,
} from "@/lib/studio-account-profile";
import { consumeStudioBuildIntent } from "@/lib/studio-build-intent";

type InspectorTab =
| "project"
Expand All @@ -174,6 +175,7 @@ type ProjectSyncStatus =
| "synced"
| "conflict"
| "error";
type StudioBuildLifecycle = "idle" | "running" | "ready" | "blocked";

const STUDIO_PANEL_WIDTH_KEY = "drops-studio:studio-panel-width";
const STUDIO_PANEL_MIN_WIDTH = 320;
Expand Down Expand Up @@ -650,6 +652,17 @@ function projectV2BuildEvidence(projectV2?: ProjectV2): {
return { passed, total: 5, verified: passed === 5 };
}

function projectV2BuildLifecycle(projectV2?: ProjectV2): StudioBuildLifecycle {
if (projectV2BuildEvidence(projectV2).verified) return "ready";
if (
projectV2?.preview?.status === "failed"
&& projectV2.preview.projectRevision === projectV2.revision
) {
return "blocked";
}
return "idle";
}

function currentProjectV2PreviewUrl(projectV2?: ProjectV2): string | null {
if (
!projectV2?.preview?.url
Expand Down Expand Up @@ -691,6 +704,11 @@ export function ProjectStudio() {
const cloudRevisionRef = useRef<number | null>(null);
const projectV2CloudRevisionRef = useRef<number | null>(null);
const [project, setProject] = useState<GeneratedProject | null>(null);
const [autoBuildRequestId, setAutoBuildRequestId] = useState<string | null>(
null,
);
const [buildLifecycle, setBuildLifecycle] =
useState<StudioBuildLifecycle>("idle");
const [accountProfile, setAccountProfile] = useState<{
name: string;
email?: string;
Expand Down Expand Up @@ -1153,6 +1171,17 @@ export function ProjectStudio() {
setRuntimeSmoke(null);
setProject(migrated);
setRuntimeProject(migrated);
setBuildLifecycle(projectV2BuildLifecycle(migrated.projectV2));
const buildRequestId = consumeStudioBuildIntent(
{
pathname: window.location.pathname,
search: window.location.search,
hash: window.location.hash,
},
(url) => window.history.replaceState(window.history.state, "", url),
() => window.crypto.randomUUID(),
);
setAutoBuildRequestId(buildRequestId);
const requestedPanel = new URLSearchParams(window.location.search).get(
"panel",
);
Expand Down Expand Up @@ -1632,6 +1661,7 @@ export function ProjectStudio() {
committedProjectRef.current = next;
setProject(next);
setDirty(true);
setBuildLifecycle(projectV2BuildLifecycle(nextProjectV2));
setProjectSyncStatus(storageRevision !== undefined ? "synced" : "local");
const save = () =>
saveProjectSafely(next, {
Expand Down Expand Up @@ -1668,6 +1698,15 @@ export function ProjectStudio() {
status: "active" | "done" | "blocked";
message: string;
}) => {
setBuildLifecycle(
event.status === "blocked"
? "blocked"
: event.phase === "preview" && event.status === "done"
? "ready"
: event.status === "active"
? "running"
: "running",
);
const current = projectRef.current;
if (!current) return;
const eventId = `builder-${current.id}-${event.phase}`;
Expand Down Expand Up @@ -3577,9 +3616,13 @@ export function ProjectStudio() {
: externalSetup
? "Needs connection"
: hasProjectV2
? builderEvidence.verified
? "Ready"
: "Draft"
? buildLifecycle === "running"
? "Building"
: buildLifecycle === "blocked"
? "Needs retry"
: builderEvidence.verified
? "Ready"
: "Ready to build"
: "Draft"}
</b>
</div>
Expand Down Expand Up @@ -3720,6 +3763,7 @@ export function ProjectStudio() {
<ProjectV2StudioSurface
key={project.projectV2.id}
ref={projectV2SurfaceRef}
autoBuildRequestId={autoBuildRequestId}
onAgentEvent={recordBuilderAgentEvent}
onNotify={setToast}
onProjectChange={adoptProjectV2}
Expand Down Expand Up @@ -4908,7 +4952,11 @@ export function ProjectStudio() {
{builderEvidence.verified
? "Project V2 build verified"
: hasProjectV2
? "Project V2 build pending"
? buildLifecycle === "running"
? "Building and checking your app"
Comment on lines +4982 to +4983

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Render in-progress builds with a neutral status

Whenever buildLifecycle is running but the five release checks are not yet complete, releaseEvidenceReady remains false and the surrounding quality card keeps the failed class, whose stylesheet renders the card in red error colors. The newly added “Building and checking your app” message therefore appears inside a visual failure state during every normal build; add a running/pending class and reserve failed for the blocked lifecycle.

Useful? React with 👍 / 👎.

: buildLifecycle === "blocked"
? "Build needs another pass"
: "Ready for a verified build"
: quality.readyToPublish
? releaseLabel
: "Build needs attention"}
Expand All @@ -4917,7 +4965,11 @@ export function ProjectStudio() {
{builderEvidence.verified
? "Typecheck, lint, tests, production build and live Sandbox preview passed for this file revision. The legacy score below applies only to standalone /p publishing."
: hasProjectV2
? `${builderEvidence.passed}/${builderEvidence.total} current-revision checks are ready. Open Code to run the remaining checks and start the live preview.`
? buildLifecycle === "running"
? "The saved files are running through install, checks and live preview startup now. Progress remains visible in Chat."
: buildLifecycle === "blocked"
? "Your files and last working preview are safe. Open Code and choose Retry when you are ready."
: `${builderEvidence.passed}/${builderEvidence.total} current-revision checks have passed. Open Code and choose Build & verify to create or refresh the live preview.`
: externalSetup
? "The web setup app can publish, but the external outcome is not live until it is connected and verified."
: "Deterministic checks run on every edit and before every publish."}
Expand Down
73 changes: 52 additions & 21 deletions components/project-v2-studio-surface.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ type ProjectV2StorageMode = "checking" | "cloud" | "local";
export interface ProjectV2StudioSurfaceProps {
project: ProjectV2;
provider: ProjectProvider;
autoBuildRequestId?: string | null;
onProjectChange: (project: ProjectV2, storageRevision?: number) => void;
onAgentEvent?: (event: {
phase: "snapshot" | "sandbox" | "verification" | "preview";
Expand Down Expand Up @@ -332,6 +333,7 @@ export const ProjectV2StudioSurface = forwardRef<
>(function ProjectV2StudioSurface({
project,
provider,
autoBuildRequestId = null,
onProjectChange,
onAgentEvent,
onNotify,
Expand Down Expand Up @@ -611,6 +613,8 @@ export const ProjectV2StudioSurface = forwardRef<
const controller = new AbortController();
builderAbort.current = controller;
let statusTimer: ReturnType<typeof setInterval> | null = null;
let runtimeReadyObserved = false;
let runSettled = false;
setBusy("task:build");
setAgentState("running");
setAgentSummary(
Expand Down Expand Up @@ -642,9 +646,33 @@ export const ProjectV2StudioSurface = forwardRef<
status: "active",
message: "Starting the isolated Node 24 Sandbox and syncing real project files…",
});
const refreshBuildProgress = async () => {
const state = await refreshSandboxStatus(snapshot.project.id);
if (
runSettled
|| controller.signal.aborted
|| runtimeReadyObserved
|| state.status !== "running"
) {
return;
}
runtimeReadyObserved = true;
activePhase = "verification";
onAgentEvent?.({
phase: "sandbox",
status: "done",
message: "The isolated Node 24 Sandbox is running.",
});
onAgentEvent?.({
phase: "verification",
status: "active",
message: "Installing dependencies and running the declared checks…",
});
};
statusTimer = setInterval(() => {
void refreshSandboxStatus(snapshot.project.id).catch(() => undefined);
void refreshBuildProgress().catch(() => undefined);
}, 4_000);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Throttle progress polling below the runtime-action quota

When a build runs near the 270-second server deadline, this four-second interval issues roughly 68 /api/builder/runtime status requests. That route shares the builder-runtime-action limit of 120 requests per hour, so two long build or repair attempts can exhaust the quota and make subsequent status, log, stop, and manual runtime actions return 429 for the rest of the window. Poll less frequently, reserve a separate bounded status quota, or stop polling after a safe request budget.

AGENTS.md reference: AGENTS.md:L109-L109

Useful? React with 👍 / 👎.

void refreshBuildProgress().catch(() => undefined);
const response = await fetch("/api/builder/agent", {
method: "POST",
credentials: "same-origin",
Expand All @@ -657,6 +685,7 @@ export const ProjectV2StudioSurface = forwardRef<
provider: providerSelection(provider),
}),
});
runSettled = true;
if (statusTimer) {
clearInterval(statusTimer);
statusTimer = null;
Expand All @@ -665,11 +694,14 @@ export const ProjectV2StudioSurface = forwardRef<
if (!payload.result) {
throw new Error(payload.error ?? "Builder agent returned no verifiable result.");
}
onAgentEvent?.({
phase: "sandbox",
status: "done",
message: "Project files are running inside the isolated Node 24 Sandbox.",
});
if (!runtimeReadyObserved) {
runtimeReadyObserved = true;
onAgentEvent?.({
phase: "sandbox",
status: "done",
message: "Project files are running inside the isolated Node 24 Sandbox.",
});
}
activePhase = "verification";
onAgentEvent?.({
phase: "verification",
Expand Down Expand Up @@ -699,9 +731,6 @@ export const ProjectV2StudioSurface = forwardRef<
message: "Live Sandbox preview is ready. You can keep chatting to edit multiple files.",
});
} else {
window.sessionStorage.removeItem(
`${AUTO_BUILD_KEY}:${project.id}:${project.revision}`,
);
onAgentEvent?.({
phase: "verification",
status: "blocked",
Expand All @@ -710,9 +739,7 @@ export const ProjectV2StudioSurface = forwardRef<
}
return payload.result;
} catch (error) {
window.sessionStorage.removeItem(
`${AUTO_BUILD_KEY}:${project.id}:${project.revision}`,
);
runSettled = true;
const cancelled = controller.signal.aborted;
const failure = cancelled
? "Build stopped. Your saved files and last working preview are unchanged."
Expand All @@ -737,38 +764,35 @@ export const ProjectV2StudioSurface = forwardRef<
onNotify?.(failure);
return null;
} finally {
runSettled = true;
if (statusTimer) clearInterval(statusTimer);
if (builderAbort.current === controller) builderAbort.current = null;
activeRunRef.current = false;
window.sessionStorage.removeItem(
`${AUTO_BUILD_KEY}:${project.id}:${project.revision}`,
);
if (mounted.current) setBusy(null);
}
}, [
absorbBuilderResult,
onAgentEvent,
onNotify,
project.id,
project.revision,
provider,
refreshSandboxStatus,
syncSnapshot,
]);

useEffect(() => {
if (
autoStarted.current === `${project.id}:${project.revision}` ||
!autoBuildRequestId ||
autoStarted.current === autoBuildRequestId ||
storageMode !== "cloud" ||
project.manifest.framework.name !== "nextjs" ||
project.preview?.status === "ready"
) {
return;
}
const key = `${AUTO_BUILD_KEY}:${project.id}:${project.revision}`;
const key = `${AUTO_BUILD_KEY}:${project.id}:${autoBuildRequestId}`;
const lease = Number(window.sessionStorage.getItem(key));
autoStarted.current = autoBuildRequestId;
if (Number.isFinite(lease) && Date.now() - lease < AUTO_BUILD_LEASE_MS) return;
autoStarted.current = `${project.id}:${project.revision}`;
window.sessionStorage.setItem(key, String(Date.now()));
const timer = window.setTimeout(() => {
void runBuilder(
Expand All @@ -777,7 +801,14 @@ export const ProjectV2StudioSurface = forwardRef<
);
}, 50);
return () => window.clearTimeout(timer);
}, [project.id, project.manifest.framework.name, project.preview?.status, project.revision, runBuilder, storageMode]);
}, [
autoBuildRequestId,
project.id,
project.manifest.framework.name,
project.preview?.status,
runBuilder,
storageMode,
]);

const saveSnapshot = useCallback(async (next: ProjectV2) => {
if (storageMode === "local") {
Expand Down
Binary file modified docs/design/current-home-actual.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/design/current-studio-actual.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
13 changes: 11 additions & 2 deletions e2e/contracts/v0-studio-flow.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ test("Build opens the unified Director workspace with an honest live-preview han
page,
}, testInfo) => {
const assertCleanRuntime = installRuntimeGuards(page)
let builderAgentCalls = 0

await page.route("**/api/account", async (route) => {
await route.fulfill({
Expand All @@ -28,6 +29,7 @@ test("Build opens the unified Director workspace with an honest live-preview han
})
})
await page.route("**/api/builder/agent", async (route) => {
builderAgentCalls += 1
await route.fulfill({
status: 200,
contentType: "application/json",
Expand Down Expand Up @@ -65,8 +67,9 @@ test("Build opens the unified Director workspace with an honest live-preview han
await page.locator('[data-preset="crypto-radio"]').click()
await page.getByRole("button", { name: "Build now", exact: true }).click()

await page.waitForURL(/\/studio\/[a-f0-9-]+\?panel=director&autobuild=1$/i)
await page.waitForURL(/\/studio\/[a-f0-9-]+\?panel=director&autobuild=1&buildRequest=[a-f0-9-]+$/i)
await expect(page.locator(".project-studio-layout")).toHaveClass(/tab-director/)
await expect(page).toHaveURL(/\/studio\/[a-f0-9-]+\?panel=director$/i)
await expect(page.getByText("Drops Agent", { exact: true })).toBeVisible()
await expect(page.getByLabel("AI model")).toHaveValue("free")
await expect(page.getByText("Studio Maker", { exact: true })).toBeVisible()
Expand Down Expand Up @@ -127,8 +130,14 @@ test("Build opens the unified Director workspace with an honest live-preview han
const dialog = page.getByRole("dialog")
await expect(dialog.getByText("Connections Hub", { exact: true })).toBeVisible()
await dialog.getByRole("button", { name: "Close connections" }).click()
await expect(page).toHaveURL(/\/studio\/[a-f0-9-]+\?panel=director&autobuild=1$/i)
await expect(page).toHaveURL(/\/studio\/[a-f0-9-]+\?panel=director$/i)
await expect(page.getByText("Drops Agent", { exact: true })).toBeVisible()

await expect.poll(() => builderAgentCalls).toBe(1)
await page.reload({ waitUntil: "domcontentloaded" })
await expect(page.getByText("Drops Agent", { exact: true })).toBeVisible()
await page.waitForTimeout(500)
expect(builderAgentCalls).toBe(1)

await assertCleanRuntime()
})
4 changes: 1 addition & 3 deletions e2e/fixtures/project-v2-ui-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,19 +227,17 @@ export async function prepareProjectV2UiPage(
});

await page.addInitScript(
({ key, value, autoBuildKey, seedKey }) => {
({ key, value, seedKey }) => {
if (window.top !== window) return;
if (window.sessionStorage.getItem(seedKey) === "1") return;
window.localStorage.clear();
window.sessionStorage.clear();
window.localStorage.setItem(key, value);
window.sessionStorage.setItem(autoBuildKey, String(Date.now()));
window.sessionStorage.setItem(seedKey, "1");
},
{
key: PROJECTS_STORAGE_KEY,
value: JSON.stringify([project]),
autoBuildKey: `drops-studio:v2-auto-build:${id}:${projectV2.revision}`,
seedKey: `drops-studio:e2e-project-v2-seeded:${id}`,
},
);
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Loading