Files
vibn-frontend/components/project/use-anatomy.ts
mawkone bb4b4df987 fix(stop+stability): stop button interrupts live generation; classifier, prompt + preview pane improvements
Stop button fix:
- Plumb AbortSignal end-to-end: callVibnChat → Gemini SDK (config.abortSignal) / OpenAI fetch → executeMcpTool (/api/mcp fetch)
- Treat abort as clean user stop (not fatal error); partial reply persisted with '(stopped by user)'

Classifier fix:
- Add timeout/gateway/5xx/connection-error vocabulary to diagnose intent
- Prevents 'I get a gateway timeout' from falling through to feature_build (40 rounds) and looping

Prompt / agent behaviour:
- Render verification is now scope-aware: small edits stop at green healthCheck; no browser_console/curl audit on healthy server
- Sanitize stale '### Phase Checkpoint' walls from loaded history so old threads stop biasing new turns
- Next.js dev command updated to --no-turbopack for container stability (per-route lazy compile caused cold-start 503s)
- New public page prompt: agent checks middleware allowlist in the same turn
- Scope discipline and QA-tool gating carried forward from prior session

Code cleanup:
- Remove duplicate AgentPhase declaration (TS2440)
- Remove dead checkpoint emit branch and orphan 'checkpoint' phase value
- Remove unused MAX_TOOL_ROUNDS constant

Preview pane (build status):
- 4-state machine: initial-load / building (with elapsed timer) / build-failed / not-running
- pollMs 0 → 5 000ms so dev-server recovery and build completion auto-update without refresh
- anatomy route + use-anatomy type: inFlightBuild gains createdAt for elapsed timer
2026-06-10 21:40:48 -07:00

192 lines
4.5 KiB
TypeScript

"use client";
/**
* Single-fetch anatomy hook shared by the Product / Hosting tabs.
* Hardened against silent failure: 10s timeout, error surfacing, and
* graceful unmount.
*
* Uses SWR to deduplicate requests across components (ProjectStagePill,
* ProjectHeaderUrls, Hosting page) so they share a single network request
* instead of hammering the API independently.
*/
import useSWR from "swr";
import { useCallback } from "react";
export interface Anatomy {
project: {
id: string;
name: string;
gitea?: string;
coolifyProjectUuid?: string;
};
codebasesReason?: "no_repo" | "empty_repo";
product: {
codebases: Array<{
id: string;
label: string;
path: string;
hint?: string;
}>;
images: Array<{
uuid: string;
name: string;
image: string;
version: string;
serviceType?: string;
status?: string;
}>;
};
hosting: {
live: Array<{
uuid: string;
name: string;
source: "repo" | "image";
sourceLabel: string;
status: string;
fqdn?: string;
domains: string[];
branch?: string;
buildPack?: string;
lastBuild?: {
status: string;
createdAt?: string;
finishedAt?: string;
commit?: string;
};
inFlightBuild?: {
status: string;
createdAt?: string;
finishedAt?: string;
commit?: string;
};
}>;
previews: Array<{
id: string;
name: string;
command?: string;
port: number;
url: string;
state: string;
startedAt: string;
}>;
};
infrastructure: {
databases: Array<{
uuid: string;
name: string;
type: string;
status: string;
isPublic: boolean;
publicPort?: number;
internalAddress?: string;
consumerEnvKey: string;
}>;
providers: Array<{
id: string;
category: "auth" | "email" | "payments" | "llm" | "storage";
vendor: string;
attachments: Array<{
resourceUuid: string;
resourceName: string;
resourceKind: "app" | "service";
keys: string[];
}>;
}>;
bundledStorage: {
status: "ready" | "pending" | "partial" | "error" | "unprovisioned";
bucketName?: string;
hmacAccessId?: string;
region?: string;
errorMessage?: string;
};
secrets: {
total: number;
byResource: Array<{
resourceUuid: string;
resourceName: string;
resourceKind: "app" | "service";
count: number;
keys: string[];
}>;
};
};
}
export interface UseAnatomyResult {
anatomy: Anatomy | null;
loading: boolean;
error: string | null;
reload: () => void;
}
export interface UseAnatomyOptions {
/** When set, re-fetch anatomy every N ms while the component is
* mounted. Used by the project-header status pill so it surfaces
* Coolify build state transitions live (e.g. queued → in_progress
* → success) without the user having to refresh. Pass undefined or
* 0 to disable polling. */
pollMs?: number;
}
const fetcher = async (url: string) => {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 10_000);
try {
const res = await fetch(url, {
credentials: "include",
signal: controller.signal,
cache: "no-store",
});
let body: unknown = {};
try {
body = await res.json();
} catch {
/* keep {} */
}
if (!res.ok) {
const msg =
(body as { error?: string }).error ||
`HTTP ${res.status} ${res.statusText}`.trim();
throw new Error(msg);
}
return body as Anatomy;
} catch (err: any) {
if (err.name === "AbortError") {
throw new Error("Request timed out after 10s.");
}
throw err;
} finally {
clearTimeout(timeout);
}
};
export function useAnatomy(
projectId: string,
options: UseAnatomyOptions = {},
): UseAnatomyResult {
const pollMs = options.pollMs && options.pollMs > 0 ? options.pollMs : 0;
const { data, error, isLoading, mutate } = useSWR<Anatomy, Error>(
projectId ? `/api/projects/${projectId}/anatomy` : null,
fetcher,
{
refreshInterval: pollMs,
dedupingInterval: 5000,
revalidateOnFocus: false,
revalidateOnReconnect: false,
},
);
const reload = useCallback(() => {
mutate();
}, [mutate]);
return {
anatomy: data ?? null,
loading: isLoading,
error: error?.message ?? null,
reload,
};
}