fix(preview): stop spec generation timing out behind the edge proxy
All checks were successful
Deploy to Production / deploy (push) Successful in 50s

The /v1/servers/preview route ran claude-opus-4-7 synchronously; full spec
generation routinely exceeded Cloudflare's ~100s proxy cap, so the browser
received a headerless 524 and reported it as a CORS failure.

- preview now uses claude-sonnet-4-6 with a 45s per-attempt timeout and one
  retry — comfortably inside the proxy budget
- generateSpec maps an exhausted timeout to SpecTimeoutError; the route
  returns a clean 504 (with CORS headers) instead of a stalled connection
- analyze step: live elapsed-seconds counter as freeze-proof, plus a
  reduced-motion exception so the loading spinner keeps spinning (a status
  indicator, which WCAG exempts from reduced-motion)
- textarea resize grip restyled to dark theme (light hatch on dark square)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marco Sadjadi
2026-05-21 23:52:48 +02:00
parent 5d0d5668d8
commit e198d44e1e
4 changed files with 163 additions and 76 deletions

View File

@@ -1,14 +1,14 @@
'use client';
import { Suspense, useEffect, useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { apiFetch } from '@/lib/api';
import { Button } from '@/components/ui/button';
import { Input, Label, Textarea } from '@/components/input';
import { StreamingLogs } from '@/components/streaming-logs';
import { InstallSnippets } from '@/components/install-snippets';
import { CodeBlock } from '@/components/code-block';
import { Input, Label, Textarea } from '@/components/input';
import { InstallSnippets } from '@/components/install-snippets';
import { StreamingLogs } from '@/components/streaming-logs';
import { Button } from '@/components/ui/button';
import { apiFetch } from '@/lib/api';
import { Loader2, RotateCcw, X } from 'lucide-react';
import { useRouter, useSearchParams } from 'next/navigation';
import { Suspense, useEffect, useState } from 'react';
const EXAMPLE_PROMPTS = [
{
@@ -85,6 +85,7 @@ function specToEditable(spec: PreviewResponse['spec']): EditableSpec {
function NewServerPageInner() {
const router = useRouter();
const [step, setStep] = useState<Step>('prompt');
const [elapsedSec, setElapsedSec] = useState(0);
const [prompt, setPrompt] = useState('');
const [name, setName] = useState('');
@@ -105,7 +106,11 @@ function NewServerPageInner() {
const templateSlug = searchParams.get('template');
const trySlug = (n: string) =>
n.toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 32);
n
.toLowerCase()
.replace(/[^a-z0-9-]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 32);
// Fork-from-template flow: skip Step 1, jump straight to Step 2 with the template's spec
useEffect(() => {
@@ -189,6 +194,19 @@ function NewServerPageInner() {
}
}, [preview, editable]);
// Live elapsed counter for the analyze step — a value that ticks every
// second is unambiguous proof the page is alive, even when CSS animation is
// suppressed (e.g. the OS "reduce motion" setting).
useEffect(() => {
if (step !== 'analyzing') return;
setElapsedSec(0);
const startedAt = Date.now();
const id = setInterval(() => {
setElapsedSec(Math.floor((Date.now() - startedAt) / 1000));
}, 1000);
return () => clearInterval(id);
}, [step]);
async function analyze() {
setError(null);
if (prompt.trim().length < 10) {
@@ -323,22 +341,19 @@ function NewServerPageInner() {
};
try {
const res = await apiFetch<{ server: { id: string }; build: { id: string } }>(
'/v1/servers',
{
method: 'POST',
body: JSON.stringify({
name,
slug,
prompt,
secrets: filledSecrets,
previewId: preview.previewId,
// Don't send specEdit when forking — the template's spec + pre-rendered code
// are already in the Redis cache. Edits would invalidate the impls.
...(forkedTemplateId ? { templateId: forkedTemplateId } : { specEdit }),
}),
},
);
const res = await apiFetch<{ server: { id: string }; build: { id: string } }>('/v1/servers', {
method: 'POST',
body: JSON.stringify({
name,
slug,
prompt,
secrets: filledSecrets,
previewId: preview.previewId,
// Don't send specEdit when forking — the template's spec + pre-rendered code
// are already in the Redis cache. Edits would invalidate the impls.
...(forkedTemplateId ? { templateId: forkedTemplateId } : { specEdit }),
}),
});
setBuildId(res.build.id);
setServerId(res.server.id);
setStep('building');
@@ -412,7 +427,9 @@ function NewServerPageInner() {
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="slug" hint="becomes subdomain / id">Slug</Label>
<Label htmlFor="slug" hint="becomes subdomain / id">
Slug
</Label>
<Input
id="slug"
value={slug}
@@ -437,10 +454,13 @@ function NewServerPageInner() {
{step === 'analyzing' && (
<div className="mt-10 panel p-8 text-center">
<Loader2 className="mx-auto animate-spin text-[--color-fg-muted]" size={20} />
<Loader2 className="mx-auto animate-spin text-[--color-accent]" size={22} />
<p className="mt-4 text-[13px]">Analyzing your prompt</p>
<p className="mt-1 text-[12px] text-[--color-fg-subtle]">
Claude Opus 4.7 is parsing the spec. Usually 2040 seconds.
Claude Sonnet 4.6 is drafting the tool spec. Usually 2040 seconds.
</p>
<p className="mono mt-3 text-[11px] tabular-nums text-[--color-fg-muted]">
{elapsedSec}s elapsed
</p>
</div>
)}
@@ -490,7 +510,9 @@ function NewServerPageInner() {
)}
<div className="panel p-4">
<div className="flex items-baseline justify-between">
<h2 className="text-[14px] font-semibold tracking-tight">Confirm what we&apos;ll build</h2>
<h2 className="text-[14px] font-semibold tracking-tight">
Confirm what we&apos;ll build
</h2>
<div className="flex items-center gap-3">
{editsDirty && (
<button
@@ -508,9 +530,9 @@ function NewServerPageInner() {
</div>
<p className="mt-1 text-[12.5px] text-[--color-fg-muted]">{preview.spec.description}</p>
<p className="mt-3 text-[11.5px] leading-relaxed text-[--color-fg-subtle]">
Edit tool names, descriptions or input schemas inline. Renaming parameters may
require an <span className="mono">Iterate</span> after build to update the
implementation the existing impl references the original names.
Edit tool names, descriptions or input schemas inline. Renaming parameters may require
an <span className="mono">Iterate</span> after build to update the implementation
the existing impl references the original names.
</p>
</div>
@@ -560,9 +582,7 @@ function NewServerPageInner() {
</div>
<div>
<h3 className="text-[13px] font-semibold tracking-tight">
Credentials we need
</h3>
<h3 className="text-[13px] font-semibold tracking-tight">Credentials we need</h3>
<p className="mt-1 text-[12px] leading-relaxed text-[--color-fg-muted]">
AES-256-GCM encrypted at rest, injected as env vars at runtime. Remove if your
implementation doesn&apos;t actually use one.
@@ -625,12 +645,7 @@ function NewServerPageInner() {
<Button variant="ghost" size="md" onClick={() => setStep('prompt')}>
Back
</Button>
<Button
variant="primary"
size="md"
onClick={build}
disabled={Boolean(hasSchemaErrors)}
>
<Button variant="primary" size="md" onClick={build} disabled={Boolean(hasSchemaErrors)}>
Build server
</Button>
</div>
@@ -754,9 +769,7 @@ function SharePanel({
}) {
const [share, setShare] = useState(true);
const [category, setCategory] = useState('other');
const [shortDescription, setShortDescription] = useState(
defaultShortDescription.slice(0, 280),
);
const [shortDescription, setShortDescription] = useState(defaultShortDescription.slice(0, 280));
const [hints, setHints] = useState<Record<string, string>>(() =>
Object.fromEntries(secretKeys.map((k) => [k, ''])),
);
@@ -832,8 +845,8 @@ function SharePanel({
</div>
<p className="mt-1 text-[12px] leading-relaxed text-[--color-fg-muted]">
Your secrets stay private they are never copied into a template. But your{' '}
<span className="text-[--color-fg]">generated code becomes publicly viewable</span>{' '}
so others can audit it before forking. Unshare anytime.
<span className="text-[--color-fg]">generated code becomes publicly viewable</span> so
others can audit it before forking. Unshare anytime.
</p>
</div>
</label>
@@ -868,9 +881,7 @@ function SharePanel({
{secretKeys.length > 0 && (
<div className="space-y-1.5">
<Label hint="optional — helps forkers know what to paste">
Credential hints
</Label>
<Label hint="optional — helps forkers know what to paste">Credential hints</Label>
{secretKeys.map((k) => (
<div key={k} className="grid grid-cols-[180px_1fr] gap-2">
<div className="mono flex h-8 items-center rounded-md border border-[--color-border] bg-[--color-bg-subtle] px-2.5 text-[12px] text-[--color-fg-muted]">
@@ -892,12 +903,7 @@ function SharePanel({
<p className="text-[11px] text-[--color-fg-subtle]">
Published code is re-scanned for banned patterns and hardcoded secrets.
</p>
<Button
variant="primary"
size="md"
onClick={publish}
disabled={state === 'submitting'}
>
<Button variant="primary" size="md" onClick={publish} disabled={state === 'submitting'}>
{state === 'submitting' ? 'Publishing…' : 'Publish to marketplace'}
</Button>
</div>

View File

@@ -87,6 +87,17 @@
::-webkit-scrollbar-thumb:hover {
background: var(--color-fg-subtle);
}
/* Textarea resize grip — light hatching on a dark square so it matches the
theme instead of the OS default (light square with dark hatching). */
textarea::-webkit-resizer {
background-color: var(--color-bg);
background-image: repeating-linear-gradient(
-45deg,
var(--color-fg-muted) 0 1px,
transparent 1px 4px
);
border-bottom-right-radius: var(--radius-md);
}
}
@layer components {
@@ -115,6 +126,16 @@
transition-duration: 0.001ms !important;
}
}
/* A loading spinner reports system status — WCAG treats status indicators as
an exception to reduced-motion, so keep it spinning after the rule above
has damped every decorative animation. Same layer + higher specificity
than the universal selector, so this !important wins. */
@media (prefers-reduced-motion: reduce) {
.animate-spin {
animation-duration: 1s !important;
animation-iteration-count: infinite !important;
}
}
}
@keyframes pulse-dot {