diff --git a/.env.example b/.env.example index 287ea44..73aada2 100644 --- a/.env.example +++ b/.env.example @@ -70,9 +70,11 @@ CRON_SECRET= DROPS_PLATFORM_HEALTH_OPERATOR_SECRET= # Optional integer from 5 through 240; defaults to 20 minutes. DROPS_STUDIO_SANDBOX_IDLE_MINUTES= -# Set to "1" only to opt into the live Sandbox contract test. +# Set by `npm run test:live:sandbox`. Never enable it in the standard unit suite; +# the test creates a billable external Sandbox and requires runtime credentials. DROPS_STUDIO_LIVE_SANDBOX= -# Set to "1" only to opt into the full live install/build/preview/browser/checkpoint flow. +# Set by `npm run test:live:builder`. The test creates billable external runtime +# resources and also requires a configured browser snapshot. DROPS_STUDIO_LIVE_BUILDER= # Agent Intelligence v2. The JSON flag object contains booleans only; no credentials. diff --git a/app/api/access/route.ts b/app/api/access/route.ts index fece9e6..b9fb955 100644 --- a/app/api/access/route.ts +++ b/app/api/access/route.ts @@ -5,11 +5,18 @@ import { GUEST_USAGE_COOKIE, memberProjectSyncReadiness, platformAiReadiness, + projectV2SyncReadiness, resolveFundedBuildQuota, resolveGuestAccess, resolveStudioAccount, + resolveStudioProjectActor, STUDIO_ACCOUNT_COOKIE, } from "../../../lib/access-tier.ts"; +import { + PROJECT_STORE_SCOPE_COOKIE, + projectStoreScopeCookieValue, + type ProjectStoreScope, +} from "../../../lib/project-store.ts"; import { readRequestLimitState } from "../../../lib/request-rate-limit.ts"; export const runtime = "nodejs"; @@ -21,6 +28,23 @@ function requestOidcToken(request: NextRequest): string | undefined { : undefined; } +function setProjectStoreScope( + response: NextResponse, + scope: ProjectStoreScope, +): void { + response.cookies.set( + PROJECT_STORE_SCOPE_COOKIE, + projectStoreScopeCookieValue(scope), + { + httpOnly: false, + sameSite: "lax", + secure: process.env.NODE_ENV === "production", + maxAge: 60 * 60 * 24 * 90, + path: "/", + }, + ); +} + export async function GET(request: NextRequest) { const oidcToken = requestOidcToken(request); const readinessEnvironment = oidcToken @@ -42,19 +66,27 @@ export async function GET(request: NextRequest) { }) : { status: "unavailable" as const, count: null, remaining: null }; const platformAvailable = readiness.available && quota.status !== "unavailable" && quota.count !== null; - return NextResponse.json( + const projectStoreScope = { + kind: "member" as const, + identity: account.identity, + }; + const response = NextResponse.json( { access: accessMetadata({ tier: platformAvailable ? memberTier : "fallback", used: quota.count ?? 0, account, - projectSyncAvailable: memberProjectSyncReadiness(readinessEnvironment), + projectSyncAvailable: projectV2SyncReadiness(readinessEnvironment), + accountProjectSyncAvailable: memberProjectSyncReadiness(readinessEnvironment), platformLimit: memberLimit, }), + projectStoreScope, quotaSigningConfigured: readiness.signingConfigured, }, { headers: { "cache-control": "no-store" } }, ); + setProjectStoreScope(response, projectStoreScope); + return response; } const context = resolveGuestAccess({ identityCookie: request.cookies.get(GUEST_IDENTITY_COOKIE)?.value, @@ -66,11 +98,23 @@ export async function GET(request: NextRequest) { tier: context.configured && readiness.available ? "guest" : "fallback", used: context.used, projectSyncAvailable: context.configured - && memberProjectSyncReadiness(readinessEnvironment), + && projectV2SyncReadiness(readinessEnvironment), }); + const signedGuestCookie = context.identityCookie + ?? request.cookies.get(GUEST_IDENTITY_COOKIE)?.value; + const actor = signedGuestCookie + ? resolveStudioProjectActor( + { guestCookie: signedGuestCookie }, + readinessEnvironment, + ) + : null; + const projectStoreScope = actor?.kind === "guest" + ? { kind: "guest" as const, identity: actor.identity } + : null; const response = NextResponse.json( { access, + ...(projectStoreScope ? { projectStoreScope } : {}), quotaSigningConfigured: readiness.signingConfigured, }, { headers: { "cache-control": "no-store" } }, @@ -84,5 +128,6 @@ export async function GET(request: NextRequest) { path: "/", }); } + if (projectStoreScope) setProjectStoreScope(response, projectStoreScope); return response; } diff --git a/app/api/agent/chat/route.ts b/app/api/agent/chat/route.ts index 0da66ef..9959414 100644 --- a/app/api/agent/chat/route.ts +++ b/app/api/agent/chat/route.ts @@ -1,4 +1,4 @@ -import { generateText, type LanguageModel } from "ai"; +import { generateText, streamText, type LanguageModel } from "ai"; import { NextRequest } from "next/server.js"; import { z } from "zod"; @@ -12,6 +12,7 @@ import type { import { ProjectRuntimeProviderError } from "../../../../lib/project-runtime-adapter.ts"; import { builderActor, + BUILDER_NO_STORE_HEADERS, builderJson, builderRouteError, consumeBuilderLimit, @@ -53,6 +54,12 @@ interface GenerateChatInput { abortSignal: AbortSignal; } +interface StreamChatResult { + toTextStreamResponse(init?: ResponseInit): Response; +} + +const MAX_STREAM_LINE_CHARACTERS = 16_384; + export interface AgentChatRouteDependencies { modelResolver?: BuilderModelResolver; rememberConnection?: ( @@ -63,6 +70,14 @@ export interface AgentChatRouteDependencies { selection: BuilderProviderSelection; }>; generate?: (input: GenerateChatInput) => Promise<{ text: string }>; + stream?: (input: GenerateChatInput) => StreamChatResult; +} + +function acceptsTextStream(request: NextRequest): boolean { + if (request.headers.get("x-drops-stream")?.trim() === "1") return true; + return (request.headers.get("accept") ?? "") + .split(",") + .some((value) => value.trim().split(";", 1)[0]?.toLowerCase() === "text/plain"); } function safeProviderFailure(error: unknown): ProjectRuntimeProviderError { @@ -72,6 +87,80 @@ function safeProviderFailure(error: unknown): ProjectRuntimeProviderError { ); } +function secretSafeTextStreamResponse(response: Response): Response { + if (!response.body) return response; + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + const encoder = new TextEncoder(); + let pending = ""; + let scanContext = ""; + const stream = new ReadableStream({ + async pull(controller) { + try { + while (true) { + const { done, value } = await reader.read(); + pending += decoder.decode(value, { stream: !done }); + let enqueued = false; + let newline = pending.indexOf("\n"); + while (newline >= 0) { + const line = pending.slice(0, newline + 1); + pending = pending.slice(newline + 1); + if ( + line.length > MAX_STREAM_LINE_CHARACTERS + || findArtifactSecrets(`${scanContext}${line}`, "agent chat stream").length + ) { + throw new ProjectRuntimeProviderError( + "The selected AI model returned an unsafe streaming response. The project was not changed.", + ); + } + scanContext = `${scanContext}${line}`.slice(-512); + controller.enqueue(encoder.encode(line)); + enqueued = true; + newline = pending.indexOf("\n"); + } + if (pending.length > MAX_STREAM_LINE_CHARACTERS) { + throw new ProjectRuntimeProviderError( + "The selected AI model returned an unsafe or oversized streaming response. The project was not changed.", + ); + } + if (done) { + if ( + pending + && findArtifactSecrets(`${scanContext}${pending}`, "agent chat stream").length + ) { + throw new ProjectRuntimeProviderError( + "The selected AI model returned an unsafe streaming response. The project was not changed.", + ); + } + if (pending) controller.enqueue(encoder.encode(pending)); + pending = ""; + controller.close(); + reader.releaseLock(); + return; + } + if (enqueued) return; + } + } catch (error) { + await reader.cancel().catch(() => undefined); + try { + reader.releaseLock(); + } catch { + // The reader can already be detached after cancellation. + } + controller.error(safeProviderFailure(error)); + } + }, + async cancel(reason) { + await reader.cancel(reason).catch(() => undefined); + }, + }); + return new Response(stream, { + status: response.status, + statusText: response.statusText, + headers: response.headers, + }); +} + export async function handleAgentChatRequest( request: NextRequest, dependencies: AgentChatRouteDependencies = {}, @@ -117,8 +206,47 @@ export async function handleAgentChatRequest( remembered.selection, remembered.credentials, ); - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), 30_000); + const abortSignal = AbortSignal.any([ + request.signal, + AbortSignal.timeout(30_000), + ]); + const generationInput: GenerateChatInput = { + model: resolved.model, + system: + "You are Drops Agent inside one existing crypto product project. Answer in the language of the latest user message. Use only the supplied project context. Be concise and practical. Never claim a file edit, build, deployment, Telegram delivery, connection, or external action unless the context explicitly proves it. Never request or repeat API keys, tokens, private keys, or credentials. If the user asks for a change, explain that a change request will run through the verified file-edit flow; this endpoint is conversation-only.", + prompt: JSON.stringify({ + project: parsed.data.context, + latestUserMessage: parsed.data.message, + }), + abortSignal, + }; + + if (acceptsTextStream(request)) { + try { + const result = (dependencies.stream ?? ((input) => streamText({ + model: input.model, + system: input.system, + prompt: input.prompt, + abortSignal: input.abortSignal, + maxOutputTokens: 1_200, + maxRetries: 1, + onError: ({ error }) => { + console.error( + "[agent-chat] provider stream failed", + error instanceof Error ? error.name : "unknown", + ); + }, + })))(generationInput); + return secretSafeTextStreamResponse( + result.toTextStreamResponse({ + headers: BUILDER_NO_STORE_HEADERS, + }), + ); + } catch (error) { + throw safeProviderFailure(error); + } + } + let result: { text: string }; try { result = await (dependencies.generate ?? (async (input) => generateText({ @@ -128,22 +256,9 @@ export async function handleAgentChatRequest( abortSignal: input.abortSignal, maxOutputTokens: 1_200, maxRetries: 1, - })))( - { - model: resolved.model, - system: - "You are Drops Agent inside one existing crypto product project. Answer in the language of the latest user message. Use only the supplied project context. Be concise and practical. Never claim a file edit, build, deployment, Telegram delivery, connection, or external action unless the context explicitly proves it. Never request or repeat API keys, tokens, private keys, or credentials. If the user asks for a change, explain that a change request will run through the verified file-edit flow; this endpoint is conversation-only.", - prompt: JSON.stringify({ - project: parsed.data.context, - latestUserMessage: parsed.data.message, - }), - abortSignal: controller.signal, - }, - ); + })))(generationInput); } catch (error) { throw safeProviderFailure(error); - } finally { - clearTimeout(timer); } const reply = result.text.trim(); if (!reply || findArtifactSecrets(reply, "agent chat response").length) { diff --git a/app/api/auth/google/callback/route.ts b/app/api/auth/google/callback/route.ts index d297e4c..3773a54 100644 --- a/app/api/auth/google/callback/route.ts +++ b/app/api/auth/google/callback/route.ts @@ -11,6 +11,10 @@ import { GOOGLE_OIDC_TRANSACTION_COOKIE, readGoogleOidcTransaction, } from "@/lib/google-oidc"; +import { + PROJECT_STORE_SCOPE_COOKIE, + projectStoreScopeCookieValue, +} from "@/lib/project-store"; export const dynamic = "force-dynamic"; export const runtime = "nodejs"; @@ -78,6 +82,20 @@ export async function GET(request: NextRequest) { maxAge: 60 * 60 * 24 * 90, path: "/", }); + response.cookies.set( + PROJECT_STORE_SCOPE_COOKIE, + projectStoreScopeCookieValue({ + kind: "member", + identity: account.identity, + }), + { + httpOnly: false, + sameSite: "lax", + secure: process.env.NODE_ENV === "production", + maxAge: 60 * 60 * 24 * 90, + path: "/", + }, + ); clearTransaction(response); response.headers.set("cache-control", "no-store"); return response; diff --git a/app/api/auth/openrouter/exchange/route.ts b/app/api/auth/openrouter/exchange/route.ts index 4be3836..79c0988 100644 --- a/app/api/auth/openrouter/exchange/route.ts +++ b/app/api/auth/openrouter/exchange/route.ts @@ -8,6 +8,10 @@ import { } from "../../../../../lib/access-tier.ts"; import { saveStudioConnection } from "../../../../../db/studio-account-state.ts"; import { consumeRequestLimit, requestIdentity } from "../../../../../lib/request-rate-limit.ts"; +import { + PROJECT_STORE_SCOPE_COOKIE, + projectStoreScopeCookieValue, +} from "../../../../../lib/project-store.ts"; export const runtime = "nodejs"; @@ -139,6 +143,22 @@ export async function POST(request: NextRequest) { path: "/", }); } + if (account) { + result.cookies.set( + PROJECT_STORE_SCOPE_COOKIE, + projectStoreScopeCookieValue({ + kind: "member", + identity: account.identity, + }), + { + httpOnly: false, + sameSite: "lax", + secure: process.env.NODE_ENV === "production", + maxAge: 60 * 60 * 24 * 90, + path: "/", + }, + ); + } return result; } catch (error) { console.error("[openrouter-auth] key exchange failed", error); diff --git a/app/api/auth/session/route.ts b/app/api/auth/session/route.ts index 33b41cf..2c75f35 100644 --- a/app/api/auth/session/route.ts +++ b/app/api/auth/session/route.ts @@ -1,5 +1,6 @@ import { NextResponse } from "next/server.js"; import { MEMBER_USAGE_COOKIE, STUDIO_ACCOUNT_COOKIE } from "../../../../lib/access-tier.ts"; +import { PROJECT_STORE_SCOPE_COOKIE } from "../../../../lib/project-store.ts"; export const runtime = "nodejs"; @@ -17,5 +18,12 @@ export async function DELETE() { path: "/", }); } + response.cookies.set(PROJECT_STORE_SCOPE_COOKIE, "", { + httpOnly: false, + sameSite: "lax", + secure: process.env.NODE_ENV === "production", + expires: new Date(0), + path: "/", + }); return response; } diff --git a/app/api/builder/agent/route.ts b/app/api/builder/agent/route.ts index 587803b..ed1ba6b 100644 --- a/app/api/builder/agent/route.ts +++ b/app/api/builder/agent/route.ts @@ -122,9 +122,22 @@ async function withBuilderExecutionDeadline(input: { }): Promise { let timer: ReturnType | undefined; let timedOut = false; + let removeAbortListener: () => void = () => {}; try { + const aborted = new Promise((_, reject) => { + const onAbort = () => reject(new Error("builder-execution-aborted")); + if (input.controller.signal.aborted) { + onAbort(); + return; + } + input.controller.signal.addEventListener("abort", onAbort, { once: true }); + removeAbortListener = () => { + input.controller.signal.removeEventListener("abort", onAbort); + }; + }); return await Promise.race([ input.operation(), + aborted, new Promise((_, reject) => { timer = setTimeout(() => { timedOut = true; @@ -134,23 +147,35 @@ async function withBuilderExecutionDeadline(input: { }), ]); } catch (error) { - if (!timedOut) throw error; - await boundedCleanup(input.cleanup); - throw new BuilderRouteError( - 504, - "BUILDER_EXECUTION_TIMEOUT", - "Builder verification exceeded its bounded server window. The Sandbox was stopped; run Build & verify to restart it.", - ); + if (timedOut) { + await boundedCleanup(input.cleanup); + throw new BuilderRouteError( + 504, + "BUILDER_EXECUTION_TIMEOUT", + "Builder verification exceeded its bounded server window. The Sandbox was stopped; choose Retry to restart it.", + ); + } + if (input.controller.signal.aborted) { + await boundedCleanup(input.cleanup); + throw new BuilderRouteError( + 499, + "BUILDER_EXECUTION_CANCELLED", + "Builder stopped. Saved project files and the last working preview were preserved.", + ); + } + throw error; } finally { if (timer) clearTimeout(timer); + removeAbortListener(); } } -async function stopTimedOutSession(input: { +async function stopInterruptedSession(input: { actorId: string; repository: BuilderProjectRepository; runtime: ProjectRuntimeAdapter; session: BuilderAgentSession; + cancelled: boolean; }): Promise { const handle = await input.runtime.resume(input.session.runtimeContext).catch(() => null); if (handle) await input.runtime.stop(handle).catch(() => undefined); @@ -161,12 +186,14 @@ async function stopTimedOutSession(input: { ...(project.preview ? { preview: { - status: "failed" as const, + status: input.cancelled ? "stopped" as const : "failed" as const, projectRevision: project.revision, ...(project.preview.sandboxId ? { sandboxId: project.preview.sandboxId } : {}), ...(project.preview.startedAt ? { startedAt: project.preview.startedAt } : {}), stoppedAt: now, - error: "Builder execution exceeded its bounded server window.", + error: input.cancelled + ? "Builder stopped by the user." + : "Builder execution exceeded its bounded server window.", }, } : {}), @@ -225,6 +252,16 @@ export async function handleBuilderAgentRequest( const sandboxRuntime = dependencies.runtime ?? new VercelSandboxRuntimeAdapter({ audit }); const executionController = new AbortController(); + const abortForDisconnectedClient = () => executionController.abort(); + request.signal.addEventListener("abort", abortForDisconnectedClient, { + once: true, + }); + // A client can disconnect while the request body, rate limit, or project + // snapshot is being resolved. EventTarget does not replay an abort event to + // listeners registered afterward, so carry that already-aborted state into + // the bounded execution controller explicitly. + if (request.signal.aborted) executionController.abort(); + try { const session = new BuilderAgentSession({ actorId, requestId: randomUUID(), @@ -247,22 +284,19 @@ export async function handleBuilderAgentRequest( ); const agentRequest = { ...parsed.data, - provider: - parsed.data.mode === "build" - ? ({ provider: "free" } as const) - : remembered.selection, + provider: remembered.selection, approvedTools: [...approvedTools], }; const agentDependencies = { services: session, audit, - credentials: - parsed.data.mode === "build" ? undefined : remembered.credentials, + credentials: remembered.credentials, deterministicFallback: dependencies.deterministicFallback ?? materializedProjectDeterministicFallback, modelResolver: dependencies.modelResolver, runnerFactory: dependencies.runnerFactory, + signal: executionController.signal, }; const execution = await withBuilderExecutionDeadline({ timeoutMs: Math.min( @@ -298,11 +332,12 @@ export async function handleBuilderAgentRequest( ); return { intelligence, result }; }, - cleanup: () => stopTimedOutSession({ + cleanup: () => stopInterruptedSession({ actorId, repository, runtime: sandboxRuntime, session, + cancelled: request.signal.aborted, }), }); const { intelligence, result } = execution; @@ -353,6 +388,9 @@ export async function handleBuilderAgentRequest( }, result.status === "blocked" ? 422 : 200, ); + } finally { + request.signal.removeEventListener("abort", abortForDisconnectedClient); + } } catch (error) { return builderRouteError(error); } diff --git a/app/api/telegram/account/create-channel/route.ts b/app/api/telegram/account/create-channel/route.ts index 4470d31..3238afc 100644 --- a/app/api/telegram/account/create-channel/route.ts +++ b/app/api/telegram/account/create-channel/route.ts @@ -1,13 +1,19 @@ import { NextRequest } from "next/server.js"; -import { createTelegramChannel } from "@/lib/telegram-account"; +import { + createTelegramChannel, + inspectTelegramAccountToken, +} from "@/lib/telegram-account"; import { readTelegramAccountJson, telegramAccountJson, telegramAccountRequestErrorResponse, } from "@/lib/telegram-account-request"; import { consumeRequestLimit, requestIdentity } from "@/lib/request-rate-limit"; -import { readStudioConnectionSecret } from "@/db/studio-account-state"; +import { + readStudioConnectionSecret, + saveStudioConnection, +} from "@/db/studio-account-state"; import { resolveStudioAccount, STUDIO_ACCOUNT_COOKIE, @@ -48,7 +54,44 @@ export async function POST(request: NextRequest) { firstPost: typeof body?.firstPost === "string" ? body.firstPost : "", botToken: typeof body?.botToken === "string" ? body.botToken : undefined, }); - return telegramAccountJson(result); + let remembered = false; + if (account) { + try { + await saveStudioConnection(account.identity, { + provider: "telegram", + credential: result.accountToken, + label: "Telegram account session", + telegramReceipt: { + accountId: inspectTelegramAccountToken(result.accountToken).id, + id: result.id, + title: result.title, + ...(result.username ? { username: result.username } : {}), + url: result.url, + botUsername: result.botUsername, + botAdded: true, + firstPostSent: true, + firstPostMessageId: result.firstPostMessageId, + dmSent: result.dmSent, + dmStartUrl: result.dmStartUrl, + warnings: result.warnings, + createdAt: new Date().toISOString(), + }, + }); + remembered = true; + } catch (error) { + console.warn( + "[telegram-account] rotated session persistence unavailable", + error instanceof Error ? error.name : "unknown", + ); + } + } + return telegramAccountJson({ + ...result, + accountPersistence: { + available: Boolean(account), + remembered, + }, + }); } catch (error) { console.error("Telegram channel creation failed.", error); return telegramAccountJson({ diff --git a/app/api/telegram/account/status/route.ts b/app/api/telegram/account/status/route.ts index d40fd5d..00ab8f0 100644 --- a/app/api/telegram/account/status/route.ts +++ b/app/api/telegram/account/status/route.ts @@ -28,15 +28,27 @@ export async function POST(request: NextRequest) { const remembered = account ? await readStudioConnectionSecret(account.identity, "telegram").catch(() => null) : null; - const token = (typeof body?.accountToken === "string" ? body.accountToken : "") + const suppliedToken = typeof body?.accountToken === "string" + ? body.accountToken + : ""; + const usesRememberedCredential = !suppliedToken && Boolean(remembered?.credential); + const token = suppliedToken || remembered?.credential || ""; if (!token) return telegramAccountJson({ connected: false, remembered: false }); try { + const inspected = inspectTelegramAccountToken(token); + const receipt = remembered?.telegramReceipt; + const receiptMatchesAccount = receipt?.accountId + ? receipt.accountId === inspected.id + : usesRememberedCredential; return telegramAccountJson({ connected: true, remembered: Boolean(remembered), - account: inspectTelegramAccountToken(token), + account: inspected, + ...(receipt && receiptMatchesAccount + ? { channel: receipt } + : {}), }); } catch { return telegramAccountJson({ connected: false, remembered: false }); diff --git a/app/styles/project-studio.accessibility.css b/app/styles/project-studio.accessibility.css index f0ec2aa..cd6b3c8 100644 --- a/app/styles/project-studio.accessibility.css +++ b/app/styles/project-studio.accessibility.css @@ -159,12 +159,21 @@ .studio-splitter svg { height: 24px; - opacity: 0; + opacity: 0.72; position: relative; transition: opacity 160ms ease; width: 16px; } +.studio-splitter svg { + background: #ffffff; + border: 1px solid #d8e2ef; + border-radius: 999px; + box-shadow: 0 4px 14px rgba(30, 61, 110, 0.12); + box-sizing: content-box; + padding: 9px 2px; +} + .studio-splitter:is(:hover, :focus-visible)::before { background: var(--ps-blue); width: 2px; @@ -455,6 +464,40 @@ padding: 7px 9px; } +.chat-model-select { + align-items: center; + color: #52617a; + display: inline-flex; + gap: 7px; + min-width: 0; +} + +.chat-model-select > svg { + color: #6d62e8; + flex: 0 0 auto; + height: 16px; + width: 16px; +} + +.chat-model-select select { + appearance: none; + background: transparent; + border: 0; + color: #31435f; + cursor: pointer; + font-size: 14px; + font-weight: 700; + min-height: 44px; + max-width: 190px; + outline: none; + padding: 0 22px 0 0; +} + +.chat-model-select:focus-within { + border-radius: 9px; + box-shadow: 0 0 0 3px rgba(49, 108, 255, 0.14); +} + .chat-composer footer button { border-radius: 10px; height: 44px; @@ -466,6 +509,12 @@ width: 16px; } +.chat-composer-actions { + align-items: center; + display: flex; + gap: 7px; +} + .inspector-heading { margin-bottom: 18px; } @@ -563,13 +612,76 @@ min-height: 0; } -.project-studio-layout.v2-builder-active { - grid-template-areas: "rail builder"; - grid-template-columns: 68px minmax(0, 1fr); +.project-v2-studio-host { + grid-area: canvas; + min-height: 0; + min-width: 0; + overflow: hidden; +} + +.project-studio-layout.tab-code .studio-inspector, +.project-studio-layout.tab-code .runtime-stage { + display: none !important; +} + +.project-studio-layout.tab-code .assistant-panel { + display: flex !important; } -.project-studio-layout.v2-builder-active .studio-splitter { - display: none; +.project-studio-layout.tab-code .project-v2-studio-host { + display: block; +} + +.runtime-preview-empty { + align-items: center; + background: + radial-gradient(circle at 50% 15%, rgba(49, 108, 255, 0.09), transparent 34%), + #ffffff; + color: #52617a; + display: flex; + flex-direction: column; + gap: 10px; + height: calc(100% - 44px); + justify-content: center; + padding: 32px; + text-align: center; +} + +.runtime-preview-empty > span { + align-items: center; + background: #edf3ff; + border-radius: 14px; + color: var(--ps-blue); + display: flex; + height: 48px; + justify-content: center; + width: 48px; +} + +.runtime-preview-empty strong { + color: #172b49; + font-size: 18px; +} + +.runtime-preview-empty p { + font-size: 14px !important; + margin: 0; + max-width: 440px; +} + +.runtime-preview-empty button { + background: #172b49; + border: 0; + border-radius: 10px; + color: #ffffff; + font-weight: 750; + margin-top: 4px; + padding: 0 16px; +} + +.chat-stop-button { + background: #fff1f1 !important; + color: #a4333c !important; } @media (max-width: 1280px) and (min-width: 921px) { @@ -580,10 +692,6 @@ height: calc(100dvh - 64px); } - .project-studio-layout.v2-builder-active { - grid-template-columns: 62px minmax(0, 1fr); - } - .studio-rail > button { min-height: 56px; } @@ -650,6 +758,17 @@ height: calc(100dvh - 185px); min-height: 620px; } + + .project-studio-layout.tab-code .assistant-panel { + display: none !important; + } + + .project-studio-layout.tab-code .project-v2-studio-host { + display: block; + min-height: calc(100dvh - 185px); + order: 1; + overflow: visible; + } } @media (max-width: 520px) { diff --git a/app/styles/project-studio.responsive.css b/app/styles/project-studio.responsive.css index 81acbf2..b3b8a41 100644 --- a/app/styles/project-studio.responsive.css +++ b/app/styles/project-studio.responsive.css @@ -342,45 +342,9 @@ } } -/* Native Project V2 owns the canvas only while Builder is selected. The - * approved rail stays available, and no permanent fourth column is added. */ -.project-studio-layout.v2-builder-active { - grid-template-areas: "rail builder"; - grid-template-columns: 68px minmax(0, 1fr); -} - .project-v2-studio-host { - grid-area: builder; + grid-area: canvas; min-height: 0; min-width: 0; overflow: hidden; } - -.project-studio-layout.v2-builder-active > :is( - .studio-inspector, - .runtime-stage, - .assistant-panel -) { - display: none !important; -} - -@media (max-width: 1280px) and (min-width: 921px) { - .project-studio-layout.v2-builder-active { - grid-template-columns: 62px minmax(0, 1fr); - } -} - -@media (max-width: 920px) { - .project-studio-layout.v2-builder-active { - display: flex; - flex-direction: column; - min-height: calc(100dvh - 239px); - } - - .project-studio-layout.v2-builder-active .project-v2-studio-host { - display: block; - min-height: calc(100dvh - 239px); - order: 1; - overflow: visible; - } -} diff --git a/components/drops-studio.tsx b/components/drops-studio.tsx index 010b147..0ff19c0 100644 --- a/components/drops-studio.tsx +++ b/components/drops-studio.tsx @@ -54,7 +54,7 @@ import type { } from "@/lib/project-types"; import { deleteProjectSafely, - readProjectsFromStore, + readProjectsAfterScopeBootstrap, saveProjectSafely, } from "@/lib/project-store"; import { @@ -84,6 +84,7 @@ import { } from "@/lib/studio-account-profile"; import { migrateSessionConnectionsToAccount, + preferredRememberedModelProvider, readStudioAccountSnapshot, rememberStudioConnection, type RememberStudioConnectionResult, @@ -512,12 +513,12 @@ export function DropsStudio({ hero }: { hero: ReactNode }) { return; } if (!accountConnectionMigrationRef.current) { - accountConnectionMigrationRef.current = true; const migration = await migrateSessionConnectionsToAccount({ snapshot, storage: window.sessionStorage, }); snapshot = migration.snapshot; + accountConnectionMigrationRef.current = migration.complete; if (migration.error) { setToast(`Signed in, but connection sync needs attention: ${migration.error}`); } else if (migration.migrated.length) { @@ -567,18 +568,17 @@ export function DropsStudio({ hero }: { hero: ReactNode }) { ); } } - if (!window.sessionStorage.getItem("drops-studio:active-brain")) { - const preferred = remembered.find( - (connection) => - connection.connected && isModelProviderId(connection.provider), - ); - if (preferred && isModelProviderId(preferred.provider)) { - window.sessionStorage.setItem( - "drops-studio:active-brain", - preferred.provider, - ); - setActiveBrain(preferred.provider); - } + const currentBrain = window.sessionStorage.getItem("drops-studio:active-brain"); + const currentSessionProvider = currentBrain + && isModelProviderId(currentBrain) + && window.sessionStorage.getItem(`drops-studio:${currentBrain}`)?.trim() + ? currentBrain + : null; + const preferred = currentSessionProvider + ?? preferredRememberedModelProvider(remembered, currentBrain); + if (preferred && isModelProviderId(preferred)) { + window.sessionStorage.setItem("drops-studio:active-brain", preferred); + setActiveBrain(preferred); } }, []); @@ -631,7 +631,32 @@ export function DropsStudio({ hero }: { hero: ReactNode }) { ); if (requestedCatalogPreset) setSelectedId(requestedCatalogPreset.id); - let savedProjects = readProjectsFromStore(); + const accessBootstrap: { access: StudioAccessStatus | null } = { + access: null, + }; + let savedProjects: GeneratedProject[] = []; + try { + savedProjects = await readProjectsAfterScopeBootstrap(async () => { + try { + const accessResponse = await fetch("/api/access", { + credentials: "same-origin", + cache: "no-store", + headers: { accept: "application/json" }, + }); + const accessPayload = (await accessResponse.json()) as { + access?: StudioAccessStatus; + }; + if (accessResponse.ok && accessPayload.access) { + accessBootstrap.access = accessPayload.access; + } + } catch { + /* Existing signed scopes can still restore their browser projects. */ + } + }); + } catch { + /* The builder remains usable while actor-scope bootstrap is offline. */ + } + const hydratedAccess = accessBootstrap.access; try { const legacy = JSON.parse( @@ -730,22 +755,16 @@ export function DropsStudio({ hero }: { hero: ReactNode }) { "drops-studio:guest-id", guestIdRef.current, ); - let hydratedAccess: StudioAccessStatus | null = null; - try { - const accessResponse = await fetch("/api/access", { cache: "no-store" }); - const accessPayload = (await accessResponse.json()) as { - access?: StudioAccessStatus; - }; - if (accessResponse.ok && accessPayload.access) { - hydratedAccess = accessPayload.access; - const accessState = applyAccessStatus(hydratedAccess); - if (accessState.authenticated) { - await hydrateAccountState().catch(() => undefined); - } - if (accessState.authenticated && accessState.projectSync) { - try { - const cloud = await listMemberProjectsFromCloud(); - for (const record of cloud.projects) { + if (hydratedAccess) { + const accessState = applyAccessStatus(hydratedAccess); + if (accessState.authenticated) { + await hydrateAccountState().catch(() => undefined); + } + if (accessState.authenticated && accessState.projectSync) { + try { + const cloud = await listMemberProjectsFromCloud(); + for (const record of cloud.projects) { + try { const local = savedProjects.find( (project) => project.id === record.id, ); @@ -760,15 +779,15 @@ export function DropsStudio({ hero }: { hero: ReactNode }) { expectedUpdatedAt: local?.updatedAt ?? null, }); if (stored.status === "saved") savedProjects = stored.projects; + } catch { + /* Keep syncing other actor-owned projects after one corrupt record. */ } - setProjects(savedProjects); - } catch { - /* Browser projects remain authoritative while cloud sync is offline. */ } + setProjects(savedProjects); + } catch { + /* Browser projects remain authoritative while cloud sync is offline. */ } } - } catch { - /* The local compiler remains available when access status is offline. */ } const handoff = parseStudioConnectionHandoff(window.location.search); if (handoff.connections) { diff --git a/components/project-studio.tsx b/components/project-studio.tsx index 3d1d7b3..d48c1b7 100644 --- a/components/project-studio.tsx +++ b/components/project-studio.tsx @@ -14,6 +14,7 @@ import { Check, ChevronDown, ChevronRight, + CircleStop, Cloud, Code2, Database, @@ -63,7 +64,10 @@ import { TelegramChannelWizard } from "@/components/telegram-channel-wizard"; import { DropsBotWebhookConnection } from "@/components/dropsbot-webhook-connection"; import { StudioAccountTeamPanel } from "@/components/studio-account-team-panel"; import { DropsBrand } from "@/components/drops-brand"; -import { ProjectV2StudioSurface } from "@/components/project-v2-studio-surface"; +import { + ProjectV2StudioSurface, + type ProjectV2StudioSurfaceHandle, +} from "@/components/project-v2-studio-surface"; import { ProjectWorkspaceDialog, type WorkspaceAiEvidenceView, @@ -82,7 +86,6 @@ import { projectV2ArchiveFilename, } from "@/lib/project-v2-export"; import type { ProjectV2 } from "@/lib/project-v2-types"; -import type { BuilderAgentResult } from "@/lib/builder-agent/types"; import { applyAgentPlan, type AgentProductPlan } from "@/lib/product-blueprint"; import { evaluateProjectQuality } from "@/lib/project-quality"; import { @@ -97,6 +100,7 @@ import { secureEditableRuntimeSrcDoc, } from "@/lib/runtime-srcdoc-security"; import { + readProjectsAfterScopeBootstrap, readProjectsFromStore, saveProjectSafely, } from "@/lib/project-store"; @@ -120,6 +124,7 @@ import { } from "@/lib/member-project-sync-client"; import { migrateSessionConnectionsToAccount, + preferredRememberedModelProvider, readStudioAccountSnapshot, } from "@/lib/studio-account-connections-client"; import { @@ -174,6 +179,22 @@ const STUDIO_PANEL_WIDTH_KEY = "drops-studio:studio-panel-width"; const STUDIO_PANEL_MIN_WIDTH = 320; const STUDIO_PANEL_MAX_WIDTH = 720; +function builderConversationMessage(event: { + phase: "snapshot" | "sandbox" | "verification" | "preview"; + status: "active" | "done" | "blocked"; +}) { + if (event.status === "blocked") { + return "I hit an issue while starting the app. Your saved files and last working preview are unchanged. Choose Retry when you are ready."; + } + const messages = { + snapshot: event.status === "active" ? "Saving your project…" : "Project saved.", + sandbox: event.status === "active" ? "Starting your app…" : "App started.", + verification: event.status === "active" ? "Checking and fixing your app…" : "Checks passed.", + preview: event.status === "active" ? "Preparing the live preview…" : "Live preview is ready.", + } as const; + return messages[event.phase]; +} + function friendlyConversationMessage(content: string) { if ( /Independent Verifier|RETRYABLE_FAILURE|deterministic evidence|browser telemetry|host-side check|release gate/i.test( @@ -227,6 +248,15 @@ function usesRussian(text: string) { return /[\u0400-\u04ff]/.test(text); } +function russianFileWord(count: number): string { + const mod100 = Math.abs(count) % 100; + if (mod100 >= 11 && mod100 <= 14) return "файлов"; + const mod10 = Math.abs(count) % 10; + if (mod10 === 1) return "файл"; + if (mod10 >= 2 && mod10 <= 4) return "файла"; + return "файлов"; +} + function requestsExternalAction(text: string) { if ( /\b(?:publish|deploy|send|deliver|register\s+(?:a\s+)?webhook)\b|(?:опубликуй|задеплой|отправь|пришли|зарегистрируй\s+вебхук)/i.test( @@ -630,7 +660,13 @@ function currentProjectV2PreviewUrl(projectV2?: ProjectV2): string | null { } try { const url = new URL(projectV2.preview.url); - return url.protocol === "https:" && !url.username && !url.password + const isSandboxPreviewHost = url.hostname !== "vercel.run" + && url.hostname.endsWith(".vercel.run"); + return url.protocol === "https:" + && !url.username + && !url.password + && !url.port + && isSandboxPreviewHost ? url.toString() : null; } catch { @@ -645,6 +681,7 @@ export function ProjectStudio() { const sourceReturnFocusRef = useRef(null); const publishReturnFocusRef = useRef(null); const projectRef = useRef(null); + const projectV2SurfaceRef = useRef(null); const committedProjectRef = useRef(null); const pendingSpecRef = useRef(null); const quietCommitTimerRef = useRef(null); @@ -661,6 +698,9 @@ export function ProjectStudio() { const [accountBrain, setAccountBrain] = useState( null, ); + const [connectedBrains, setConnectedBrains] = useState([ + "free", + ]); const [runtimeProject, setRuntimeProject] = useState(null); const [runtimeRevision, setRuntimeRevision] = useState(0); @@ -796,8 +836,19 @@ export function ProjectStudio() { return false; } + // The mounted Project V2 surface is the single owner of Next.js file + // snapshot synchronization. Keeping the GeneratedProject metadata and + // Project V2 filesystem on separate writers prevents two optimistic + // PUTs from racing with the same storage revision after a chat edit. + const projectV2SurfaceOwnsCloudSync = + candidate.projectV2?.manifest.framework.name === "nextjs"; + if (!cloudSyncAvailableRef.current) { - if (candidate.projectV2 && projectV2SyncAvailableRef.current) { + if ( + candidate.projectV2 + && projectV2SyncAvailableRef.current + && !projectV2SurfaceOwnsCloudSync + ) { try { const v2Record = await saveProjectV2ToCloud( candidate.projectV2, @@ -822,6 +873,7 @@ export function ProjectStudio() { } } } + if (projectV2SurfaceOwnsCloudSync) return true; setProjectSyncStatus("local"); return true; } @@ -833,7 +885,7 @@ export function ProjectStudio() { cloudRevisionRef.current ?? 0, ); cloudRevisionRef.current = record.revision; - if (candidate.projectV2) { + if (candidate.projectV2 && !projectV2SurfaceOwnsCloudSync) { const v2Record = await saveProjectV2ToCloud( candidate.projectV2, projectV2CloudRevisionRef.current ?? 0, @@ -885,34 +937,42 @@ export function ProjectStudio() { useEffect(() => { let cancelled = false; const loadWorkspace = async () => { - let found = - readProjectsFromStore().find((item) => item.id === params.id) ?? null; + let found: GeneratedProject | null = null; try { - const accessResponse = await fetch("/api/access", { - credentials: "same-origin", - cache: "no-store", - headers: { accept: "application/json" }, - }); - const accessPayload = (await accessResponse.json()) as { - access?: { - authenticated?: boolean; - projectSync?: boolean; - account?: { connected?: boolean; projectSync?: boolean }; + const accessBootstrap: { + responseOk: boolean; + payload: { + access?: { + authenticated?: boolean; + projectSync?: boolean; + account?: { connected?: boolean; projectSync?: boolean }; + }; }; - }; + } = { responseOk: false, payload: {} }; + const browserProjects = await readProjectsAfterScopeBootstrap(async () => { + const accessResponse = await fetch("/api/access", { + credentials: "same-origin", + cache: "no-store", + headers: { accept: "application/json" }, + }); + accessBootstrap.responseOk = accessResponse.ok; + accessBootstrap.payload = (await accessResponse.json()) as typeof accessBootstrap.payload; + }); + const accessPayload = accessBootstrap.payload; const cloudAvailable = Boolean( - accessResponse.ok && + accessBootstrap.responseOk && accessPayload.access?.authenticated && accessPayload.access.account?.connected && accessPayload.access.account.projectSync, ); const projectV2CloudAvailable = Boolean( - accessResponse.ok + accessBootstrap.responseOk && ( accessPayload.access?.projectSync ?? accessPayload.access?.account?.projectSync ), ); + found = browserProjects.find((item) => item.id === params.id) ?? null; cloudSyncAvailableRef.current = cloudAvailable; projectV2SyncAvailableRef.current = projectV2CloudAvailable; if (cloudAvailable) { @@ -973,6 +1033,12 @@ export function ProjectStudio() { setProjectSyncStatus("local"); } } catch { + try { + found = + readProjectsFromStore().find((item) => item.id === params.id) ?? null; + } catch { + found = null; + } cloudSyncAvailableRef.current = false; projectV2SyncAvailableRef.current = false; setProjectSyncStatus("local"); @@ -1412,6 +1478,8 @@ export function ProjectStudio() { () => currentProjectV2PreviewUrl(project?.projectV2), [project?.projectV2], ); + const usesNativeProjectV2 = + project?.projectV2?.manifest.framework.name === "nextjs"; const trustedRuntimeSmoke = (runtimeProject?.quality?.runtimeSmoke?.mode === "server-artifact" || runtimeProject?.quality?.runtimeSmoke?.mode === "server-inspection") @@ -1448,11 +1516,18 @@ export function ProjectStudio() { "custom", ]; const current = window.sessionStorage.getItem("drops-studio:active-brain"); - if (current && providers.includes(current as ProjectProvider)) { - sessionBrainTimer = window.setTimeout(() => { - if (!cancelled) setAccountBrain(current as ProjectProvider); - }, 0); - } + const sessionProviders = providers.filter((provider) => + Boolean(window.sessionStorage.getItem(`drops-studio:${provider}`)?.trim()), + ); + sessionBrainTimer = window.setTimeout(() => { + if (cancelled) return; + setConnectedBrains( + Array.from(new Set(["free", ...sessionProviders])), + ); + if (current && providers.includes(current as ProjectProvider)) { + setAccountBrain(current as ProjectProvider); + } + }, 0); void (async () => { const initial = await readStudioAccountSnapshot(); if (cancelled || !initial.authenticated) return; @@ -1461,22 +1536,41 @@ export function ProjectStudio() { storage: window.sessionStorage, }); const snapshot = migrated.snapshot; - if (snapshot.profile?.name) { - setAccountProfile({ - name: snapshot.profile.name, - ...(snapshot.profile.email ? { email: snapshot.profile.email } : {}), - }); - } - if (current) return; - const preferred = snapshot.connections.find( + const rememberedProviders = snapshot.connections + .filter( (connection) => connection.connected && providers.includes(connection.provider as ProjectProvider), - ); - if (!preferred?.provider) return; - const provider = preferred.provider as ProjectProvider; + ) + .map((connection) => connection.provider as ProjectProvider); + setConnectedBrains( + Array.from( + new Set([ + "free", + ...sessionProviders, + ...rememberedProviders, + ]), + ), + ); + if (snapshot.profile?.name) { + setAccountProfile({ + name: snapshot.profile.name, + ...(snapshot.profile.email ? { email: snapshot.profile.email } : {}), + }); + } + const currentSessionProvider = current + && providers.includes(current as ProjectProvider) + && window.sessionStorage.getItem(`drops-studio:${current}`)?.trim() + ? current as ProjectProvider + : null; + const provider = currentSessionProvider + ?? preferredRememberedModelProvider(snapshot.connections, current); + if (!provider) return; window.sessionStorage.setItem("drops-studio:active-brain", provider); - if (preferred.model) { + const preferred = snapshot.connections.find( + (connection) => connection.connected && connection.provider === provider, + ); + if (preferred?.model) { window.sessionStorage.setItem( `drops-studio:${provider}:model`, preferred.model, @@ -1496,6 +1590,16 @@ export function ProjectStudio() { if (accountBrain) return accountBrain; return project?.spec.brain.provider || "free"; }, [accountBrain, project]); + const selectableBrains = useMemo( + () => Array.from(new Set([...connectedBrains, activeProvider])), + [activeProvider, connectedBrains], + ); + + const selectActiveBrain = useCallback((provider: ProjectProvider) => { + setAccountBrain(provider); + window.sessionStorage.setItem("drops-studio:active-brain", provider); + setToast(`${modelLabels[provider]} selected for this Studio session`); + }, []); const adoptProject = useCallback((next: GeneratedProject) => { if (quietCommitTimerRef.current !== null) { @@ -1567,12 +1671,7 @@ export function ProjectStudio() { const current = projectRef.current; if (!current) return; const eventId = `builder-${current.id}-${event.phase}`; - const content = - event.status === "active" - ? `Working · ${event.message}` - : event.status === "done" - ? `Verified · ${event.message}` - : `Paused · ${event.message}`; + const content = builderConversationMessage(event); const existing = current.conversation ?? []; if (existing.some((item) => item.id === eventId && item.content === content)) { return; @@ -1974,7 +2073,7 @@ export function ProjectStudio() { const currentProject = projectRef.current ?? activeProject; const next: GeneratedProject = { ...currentProject, - conversation: [...baseConversation, assistant], + conversation: [...(currentProject.conversation ?? baseConversation), assistant], updatedAt: new Date().toISOString(), }; projectRef.current = next; @@ -1982,6 +2081,71 @@ export function ProjectStudio() { void persistProject(next, currentProject.updatedAt); }; + const streamAssistantReply = async (response: Response) => { + if (!response.body) { + const reply = (await response.text()).trim(); + if (!reply) throw new Error("The selected model returned an empty response."); + appendAssistantReply(reply); + return; + } + const assistantId = nowId("assistant"); + const createdAt = new Date().toISOString(); + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let reply = ""; + const updateStream = (content: string) => { + const currentProject = projectRef.current ?? conversationDraft; + const conversation = currentProject.conversation ?? baseConversation; + const assistant: ProjectChatMessage = { + id: assistantId, + role: "assistant", + content, + createdAt, + }; + const existing = conversation.findIndex((item) => item.id === assistantId); + const nextConversation = existing >= 0 + ? conversation.map((item, index) => index === existing ? assistant : item) + : [...conversation, assistant]; + const next = { + ...currentProject, + conversation: nextConversation, + updatedAt: new Date().toISOString(), + }; + projectRef.current = next; + setProject(next); + }; + const dropStreamedMessage = () => { + const currentProject = projectRef.current ?? conversationDraft; + const conversation = currentProject.conversation ?? baseConversation; + const next = { + ...currentProject, + conversation: conversation.filter((item) => item.id !== assistantId), + updatedAt: new Date().toISOString(), + }; + projectRef.current = next; + setProject(next); + }; + try { + while (true) { + const chunk = await reader.read(); + if (chunk.done) break; + reply += decoder.decode(chunk.value, { stream: true }); + updateStream(reply); + } + reply += decoder.decode(); + if (!reply.trim()) throw new Error("The selected model returned an empty response."); + updateStream(reply.trim()); + const next = projectRef.current; + if (next) void persistProject(next, activeProject.updatedAt); + } catch (error) { + await reader.cancel().catch(() => undefined); + dropStreamedMessage(); + throw error; + } finally { + reader.releaseLock(); + } + }; + if (requestsExternalAction(instruction)) { appendAssistantReply( usesRussian(instruction) @@ -2024,7 +2188,11 @@ export function ProjectStudio() { const response = await fetch("/api/agent/chat", { method: "POST", credentials: "same-origin", - headers, + headers: { + ...headers, + accept: "text/plain", + "x-drops-stream": "1", + }, signal: AbortSignal.timeout(35_000), body: JSON.stringify({ projectId: activeProject.id, @@ -2052,17 +2220,19 @@ export function ProjectStudio() { }, }), }); - const payload = (await response.json().catch(() => ({}))) as { - reply?: string; - provider?: string; - model?: string; - error?: string; - }; - if (!response.ok || !payload.reply) { - throw new Error(payload.error || "The selected model did not answer."); + if (!response.ok) { + const raw = await response.text(); + let error = "The selected model did not answer."; + try { + const payload = JSON.parse(raw) as { error?: string }; + if (payload.error) error = payload.error; + } catch { + if (raw.trim()) error = raw.trim().slice(0, 500); + } + throw new Error(error); } - appendAssistantReply(payload.reply); - setToast(`${payload.model || modelLabels[provider]} answered with project context`); + await streamAssistantReply(response); + setToast(`${modelLabels[provider]} answered with project context`); } catch (error) { void error; appendAssistantReply( @@ -2111,144 +2281,51 @@ export function ProjectStudio() { return; } - if ( - activeProject.projectV2 - && projectV2SyncAvailableRef.current - ) { - let adopted = false; + if (usesNativeProjectV2 && activeProject.projectV2) { try { - await fetch("/api/access", { - credentials: "same-origin", - cache: "no-store", - headers: { accept: "application/json" }, - }); - let snapshot = await loadProjectV2FromCloud(activeProject.id); - if (!snapshot) { - snapshot = await saveProjectV2ToCloud(activeProject.projectV2, 0); + const runner = projectV2SurfaceRef.current; + if (!runner) { + throw new Error("The project builder is still loading. Retry in a moment."); } - projectV2CloudRevisionRef.current = snapshot.storageRevision; - const provider = activeProvider; - const headers: Record = { - accept: "application/json", - "content-type": "application/json", - }; - const key = provider === "gateway" - ? null - : window.sessionStorage.getItem(`drops-studio:${provider}`); - if (provider === "openrouter" && key) { - headers["x-openrouter-key"] = key; - } else if (key) { - headers["x-provider-key"] = key; - } - const model = - window.sessionStorage.getItem( - provider === "custom" - ? "drops-studio:custom-model" - : `drops-studio:${provider}:model`, - ) || undefined; - const response = await fetch("/api/builder/agent", { - method: "POST", - credentials: "same-origin", - headers, - signal: AbortSignal.timeout(120_000), - body: JSON.stringify({ - projectId: activeProject.id, - prompt: instruction, - mode: "edit", - provider: { - provider, - ...(model ? { model } : {}), - ...(provider === "custom" - ? { - baseUrl: - window.sessionStorage.getItem( - "drops-studio:custom-endpoint", - ) || undefined, - } - : {}), - }, - }), - }); - const payload = (await response.json().catch(() => ({}))) as { - result?: BuilderAgentResult; - error?: string; - }; - if (!payload.result) { - throw new Error(payload.error || "The Project V2 agent returned no verified result."); + const beforeFiles = activeProject.projectV2.files; + const result = await runner.run("edit", instruction); + if (!result) { + appendAssistantReply( + usesRussian(instruction) + ? "Запрос остановлен или уже выполняется другая сборка. Сохранённые файлы не потеряны." + : "The request was stopped or another build is already running. Your saved files are unchanged.", + ); + return; } - const remote = await loadProjectV2FromCloud(activeProject.id); - const projectV2 = remote?.project ?? payload.result.project; - if (remote) projectV2CloudRevisionRef.current = remote.storageRevision; const changedFiles = Array.from( new Set([ - ...Object.keys(snapshot.project.files), - ...Object.keys(projectV2.files), + ...Object.keys(beforeFiles), + ...Object.keys(result.project.files), ]), ).filter( (path) => - snapshot.project.files[path]?.hash !== projectV2.files[path]?.hash, + beforeFiles[path]?.hash !== result.project.files[path]?.hash, ).length; - const assistant: ProjectChatMessage = { - id: nowId("assistant"), - role: "assistant", - createdAt: new Date().toISOString(), - content: payload.result.releaseGate.ok - ? `${payload.result.providerMode === "deterministic-fallback" ? "Free Auto fallback" : "AI agent"} changed ${changedFiles} real file${changedFiles === 1 ? "" : "s"}, passed the release gate, refreshed preview and created a checkpoint. Open Builder to inspect the diff and evidence.` - : `${payload.result.summary} The changed Project V2 files and exact blocking checks are available in Builder; no deployment was claimed.`, - }; - const next: GeneratedProject = { - ...conversationDraft, - projectV2, - conversation: [...baseConversation, assistant], - updatedAt: projectV2.updatedAt, - }; - projectRef.current = next; - committedProjectRef.current = next; - setProject(next); - adopted = true; - setProjectSyncStatus(remote ? "synced" : "local"); - const save = () => - saveProjectSafely(next, { - expectedUpdatedAt: activeProject.updatedAt, - }); - const queued = saveQueueRef.current.then(save, save); - saveQueueRef.current = queued.then( - () => true, - () => false, - ); - const saved = await queued; - if (saved.status === "conflict") { - setProjectSyncStatus("conflict"); - setToast( - "Another tab saved a newer browser version. Reload before continuing Project V2 edits.", - ); - return; - } - setToast( - remote - ? payload.result.releaseGate.ok - ? "Project V2 files changed and verified in Sandbox" - : "Project V2 edit saved with blocking check evidence" - : payload.result.releaseGate.ok - ? "Sandbox verification passed; the Project V2 update is saved in this browser because private cloud sync could not be confirmed." - : "Blocking check evidence is saved in this browser because private cloud sync could not be confirmed.", + appendAssistantReply( + result.releaseGate.ok + ? usesRussian(instruction) + ? `Готово — изменено ${changedFiles} ${russianFileWord(changedFiles)}, проверки прошли, а live preview обновлён.` + : `Done — changed ${changedFiles} real file${changedFiles === 1 ? "" : "s"}, passed the checks, and refreshed the live preview.` + : usesRussian(instruction) + ? "Изменения сохранены, но приложение ещё не готово. Я оставил последнюю рабочую версию preview; нажмите Retry в Code, чтобы продолжить исправление." + : "The changes are saved, but the app is not ready yet. The last working preview is unchanged; choose Retry in Code to continue the repair.", ); + setToast(result.releaseGate.ok + ? "Files changed and live preview refreshed" + : "Changes saved — one more repair pass is needed"); } catch (error) { void error; appendAssistantReply( - adopted - ? usesRussian(instruction) - ? "Файлы обновлены в этой сессии, но сохранение не завершилось. Перезагрузите проект, чтобы проверить состояние." - : "The files changed in this session, but saving did not complete. Reload the project to verify its state." - : usesRussian(instruction) + usesRussian(instruction) ? `Подключённая модель ${modelLabels[activeProvider]} не завершила редактирование файлов. Проект не изменён и Free Auto не подменял модель. Проверьте подключение и повторите запрос или явно выберите Free Auto.` : `${modelLabels[activeProvider]} did not complete the file edit. The project was not changed and Free Auto did not replace the selected model. Check the connection and retry, or explicitly select Free Auto.`, ); - setToast( - adopted - ? "Project V2 files changed, but the save did not complete" - : "AI edit failed safely — no project files changed", - ); + setToast("AI edit failed safely — no project files changed"); } finally { setDirecting(false); } @@ -3530,7 +3607,7 @@ export function ProjectStudio() { type="button" aria-label={ project.projectV2?.manifest.framework.name === "nextjs" - ? "Open Builder to run this app" + ? "Open Code to run this app" : "Run app" } onClick={() => @@ -3542,7 +3619,7 @@ export function ProjectStudio() { {" "} {project.projectV2?.manifest.framework.name === "nextjs" - ? "Open Builder" + ? "Code" : "Run app"} @@ -3606,10 +3683,8 @@ export function ProjectStudio() {
-