fix(preview): stop spec generation timing out behind the edge proxy
All checks were successful
Deploy to Production / deploy (push) Successful in 50s
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:
@@ -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 20–40 seconds.
|
||||
Claude Sonnet 4.6 is drafting the tool spec. Usually 20–40 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'll build</h2>
|
||||
<h2 className="text-[14px] font-semibold tracking-tight">
|
||||
Confirm what we'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'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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user