diff --git a/apps/web/modules/nova-web/components/thread.tsx b/apps/web/modules/nova-web/components/thread.tsx index 87fc3ba..0e030d3 100644 --- a/apps/web/modules/nova-web/components/thread.tsx +++ b/apps/web/modules/nova-web/components/thread.tsx @@ -16,6 +16,7 @@ import { WorkedFor, } from "@/modules/nova-web/components/timeline" import { WorkingProcess } from "@/modules/nova-web/components/working-process" +import { WorkspacePanel } from "@/modules/nova-web/components/workspace-panel" import type { HarnessProvider, NovaAgentMode, @@ -292,59 +293,16 @@ export function ThreadView({ ) : null} {detailsOpen ? ( - +
+ +
) : null} diff --git a/apps/web/modules/nova-web/components/workspace-panel.tsx b/apps/web/modules/nova-web/components/workspace-panel.tsx new file mode 100644 index 0000000..df4c0c7 --- /dev/null +++ b/apps/web/modules/nova-web/components/workspace-panel.tsx @@ -0,0 +1,822 @@ +"use client" + +import { useEffect, useMemo, useState } from "react" +import { + Check, + ChevronDown, + ChevronRight, + Circle, + Diff, + File as FileIcon, + FileCode2, + Folder, + FolderOpen, + Globe, + LayoutDashboard, + ListTodo, + Loader2, + Plus, + Settings2, + X, +} from "lucide-react" + +import { cn } from "@/lib/utils" +import type { LocalProjectDto } from "@/modules/nova-web/api" +import { languageFromPath, type WorkingStep } from "@/modules/nova-web/working-steps" +import { + getLocalWorkspace, + readLocalWorkspaceFile, +} from "@/modules/nova-web/local-workspace" +import type { LocalAttachment } from "@/modules/nova/attachments/contracts" +import type { SessionDetail, TimelineEntry } from "@/modules/nova-web/types" +import { sessionUiLabel } from "@/modules/nova-web/types" + +type PanelTab = + | { id: "overview"; kind: "overview"; title: string } + | { id: "files"; kind: "files"; title: string } + | { id: "diffs"; kind: "diffs"; title: string } + | { id: "setup"; kind: "setup"; title: string } + | { id: "browser"; kind: "browser"; title: string } + | { id: string; kind: "file"; title: string; path: string } + +type OverviewTodo = { + id: string + content: string + status: "pending" | "in_progress" | "completed" +} + +type OverviewTask = { + id: string + title: string + status: "working" | "done" | "failed" | "idle" + detail?: string +} + +function langTone(lang: string) { + const l = lang.toLowerCase() + if (l === "tsx" || l === "jsx") return "bg-[#f472b6]/15 text-[#f9a8d4]" + if (l === "ts" || l === "js") return "bg-[#2dd4bf]/15 text-[#5eead4]" + if (l === "css") return "bg-sky-400/15 text-sky-300" + if (l === "json") return "bg-amber-400/15 text-amber-200" + if (l === "prisma") return "bg-violet-400/15 text-violet-200" + if (l === "md") return "bg-white/[0.06] text-[#b0b0b0]" + return "bg-white/[0.06] text-[#b0b0b0]" +} + +function FileBadge({ path }: { path: string }) { + const lang = languageFromPath(path) + const name = path.split("/").pop() || path + return ( + + + {lang} + + {name} + + ) +} + +function buildTodos(steps: WorkingStep[], streaming: boolean): OverviewTodo[] { + if (steps.length === 0 && streaming) { + return [{ id: "start", content: "Start turn", status: "in_progress" }] + } + return steps.map((step, index) => { + let content = step.label + if (step.kind === "write" && step.meta?.path) content = `Update ${step.meta.path}` + if (step.kind === "read" && step.meta?.path) content = `Read ${step.meta.path}` + if (step.kind === "explore") content = step.label + if (step.kind === "thinking") content = step.status === "active" ? "Think through the approach" : "Finish reasoning" + return { + id: step.id || `todo-${index}`, + content, + status: step.status === "active" ? "in_progress" : "completed", + } + }) +} + +function buildTasks( + steps: WorkingStep[], + timeline: TimelineEntry[], + streaming: boolean, +): OverviewTask[] { + const fromSteps: OverviewTask[] = steps + .filter((step) => step.kind === "explore" || step.kind === "tool" || step.kind === "write" || step.kind === "read") + .map((step) => ({ + id: `step-${step.id}`, + title: step.kind === "write" + ? `Edit ${step.meta?.path?.split("/").pop() || "file"}` + : step.kind === "read" + ? `Read ${step.meta?.path?.split("/").pop() || "file"}` + : step.label, + status: step.status === "active" ? "working" : "done", + detail: step.meta?.path, + })) + + const fromTimeline: OverviewTask[] = timeline + .flatMap((entry) => { + if (entry.kind !== "activity") return [] + if (entry.type !== "action" && entry.type !== "result" && entry.type !== "plan") return [] + return [{ + id: `act-${entry.id}`, + title: entry.title || entry.type, + status: (entry.status === "failed" + ? "failed" + : entry.status === "working" + ? "working" + : "done") as OverviewTask["status"], + detail: entry.body ?? undefined, + }] + }) + .slice(-8) + + const merged = [...fromTimeline, ...fromSteps] + if (streaming && merged.every((task) => task.status !== "working")) { + merged.push({ + id: "live-turn", + title: "Nova turn", + status: "working", + detail: "In progress", + }) + } + return merged.slice(-12) +} + +function collectTouchedPaths( + steps: WorkingStep[], + localFiles: LocalAttachment[], + localProject: LocalProjectDto | null | undefined, +): string[] { + const paths: string[] = [] + const push = (path?: string | null) => { + if (!path) return + if (!paths.includes(path)) paths.push(path) + } + for (const step of steps) { + push(step.meta?.path) + for (const child of step.children ?? []) push(child.path) + } + for (const file of localFiles) push(file.name) + // Prefer project-relative paths that look like source files recently attached + if (localProject?.paths) { + for (const name of localFiles.map((f) => f.name)) { + const match = localProject.paths.find((p) => p === name || p.endsWith(`/${name}`) || p.split("/").pop() === name) + if (match) push(match) + } + } + return paths.slice(0, 24) +} + +type TreeNode = { + name: string + path: string + kind: "dir" | "file" + children?: TreeNode[] +} + +function buildTree(paths: string[]): TreeNode[] { + const root: TreeNode[] = [] + for (const path of paths) { + const parts = path.split("/").filter(Boolean) + let level = root + let acc = "" + for (let i = 0; i < parts.length; i += 1) { + const part = parts[i]! + acc = acc ? `${acc}/${part}` : part + const isFile = i === parts.length - 1 + let node = level.find((item) => item.name === part && item.kind === (isFile ? "file" : "dir")) + if (!node) { + node = { name: part, path: acc, kind: isFile ? "file" : "dir", children: isFile ? undefined : [] } + level.push(node) + } + if (!isFile) level = node.children! + } + } + const sortNodes = (nodes: TreeNode[]) => { + nodes.sort((a, b) => { + if (a.kind !== b.kind) return a.kind === "dir" ? -1 : 1 + return a.name.localeCompare(b.name) + }) + for (const node of nodes) if (node.children) sortNodes(node.children) + } + sortNodes(root) + return root +} + +function TreeView({ + nodes, + depth = 0, + onOpen, + activePath, +}: { + nodes: TreeNode[] + depth?: number + onOpen: (path: string) => void + activePath?: string | null +}) { + return ( + + ) +} + +function TreeRow({ + node, + depth, + onOpen, + activePath, +}: { + node: TreeNode + depth: number + onOpen: (path: string) => void + activePath?: string | null +}) { + const [open, setOpen] = useState(depth < 1) + if (node.kind === "dir") { + return ( +
  • + + {open && node.children?.length ? ( + + ) : null} +
  • + ) + } + return ( +
  • + +
  • + ) +} + +function OverviewPane({ + session, + todos, + tasks, + touchedPaths, + localProject, + streaming, + onOpenPath, +}: { + session: SessionDetail + todos: OverviewTodo[] + tasks: OverviewTask[] + touchedPaths: string[] + localProject?: LocalProjectDto | null + streaming: boolean + onOpenPath: (path: string) => void +}) { + const done = todos.filter((t) => t.status === "completed").length + const total = todos.length + const [todosOpen, setTodosOpen] = useState(true) + const [tasksOpen, setTasksOpen] = useState(true) + + return ( +
    +
    +

    Thread

    +

    {session.objective}

    +

    + {streaming ? "Working" : sessionUiLabel(session.activeRunId ? "working" : session.status)} + {localProject ? ` · ${localProject.displayName}` : ""} +

    +
    + +
    + + {todosOpen ? ( + + ) : null} +
    + +
    + + {tasksOpen ? ( + + ) : null} +
    + +
    +

    + + Files in play +

    + {touchedPaths.length === 0 ? ( +

    No files touched this turn yet.

    + ) : ( + + )} +
    +
    + ) +} + +function FilesPane({ + localProject, + onOpenPath, + activePath, +}: { + localProject?: LocalProjectDto | null + onOpenPath: (path: string) => void + activePath?: string | null +}) { + const [query, setQuery] = useState("") + const paths = localProject?.paths ?? [] + const filtered = useMemo(() => { + const q = query.trim().toLowerCase() + if (!q) return paths + return paths.filter((path) => path.toLowerCase().includes(q)) + }, [paths, query]) + const tree = useMemo(() => buildTree(filtered.slice(0, 800)), [filtered]) + + if (!localProject) { + return ( +
    + +

    No local project linked

    +

    + Open a folder from @ Files to browse and open project files here. +

    +
    + ) + } + + return ( +
    +
    +

    {localProject.displayName}

    +

    + {localProject.fileCount} files{localProject.truncated ? " · partial" : ""} +

    + setQuery(event.target.value)} + placeholder="Search files" + className="mt-2 w-full rounded-md border border-white/[0.08] bg-[#141414] px-2.5 py-1.5 text-[12.5px] text-[#e8e8e8] outline-none placeholder:text-[#4a4a4a] focus:border-[#2dd4bf]/40" + /> +
    +
    + {tree.length === 0 ? ( +

    No files match.

    + ) : ( + + )} +
    +
    + ) +} + +function FileViewerPane({ + path, + content, + loading, + error, +}: { + path: string + content: string | null + loading: boolean + error: string | null +}) { + return ( +
    +
    + + {path} +
    +
    + {loading ? ( +

    + Reading… +

    + ) : error ? ( +

    {error}

    + ) : content == null ? ( +

    No content

    + ) : ( +
    +            {content.split("\n").map((line, index) => (
    +              
    + {index + 1} + {line || " "} +
    + ))} +
    + )} +
    +
    + ) +} + +function PlaceholderPane({ + title, + body, +}: { + title: string + body: string +}) { + return ( +
    +

    {title}

    +

    {body}

    +
    + ) +} + +export function WorkspacePanel({ + session, + timeline, + workingSteps, + streaming, + localProject, + localFiles = [], + className, +}: { + session: SessionDetail + timeline: TimelineEntry[] + workingSteps: WorkingStep[] + streaming: boolean + localProject?: LocalProjectDto | null + localFiles?: LocalAttachment[] + className?: string +}) { + const [tabs, setTabs] = useState([ + { id: "overview", kind: "overview", title: "Overview" }, + ]) + const [activeId, setActiveId] = useState("overview") + const [menuOpen, setMenuOpen] = useState(false) + const [fileBodies, setFileBodies] = useState>({}) + + const todos = useMemo(() => buildTodos(workingSteps, streaming), [workingSteps, streaming]) + const tasks = useMemo(() => buildTasks(workingSteps, timeline, streaming), [workingSteps, timeline, streaming]) + const touchedPaths = useMemo( + () => collectTouchedPaths(workingSteps, localFiles, localProject), + [workingSteps, localFiles, localProject], + ) + + const activeTab = tabs.find((tab) => tab.id === activeId) ?? tabs[0]! + + // Auto-open files Nova is actively writing/reading. + useEffect(() => { + const hot = workingSteps + .filter((step) => step.status === "active" && step.meta?.path && (step.kind === "write" || step.kind === "read")) + .map((step) => step.meta!.path!) + for (const path of hot.slice(0, 3)) { + openFileTab(path, false) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [workingSteps]) + + function ensureTab(tab: PanelTab, activate = true) { + setTabs((current) => (current.some((item) => item.id === tab.id) ? current : [...current, tab])) + if (activate) setActiveId(tab.id) + setMenuOpen(false) + } + + function openFileTab(path: string, activate = true) { + const id = `file:${path}` + ensureTab({ id, kind: "file", title: path.split("/").pop() || path, path }, activate) + void loadFileBody(path) + } + + async function loadFileBody(path: string) { + setFileBodies((current) => ({ + ...current, + [path]: { content: current[path]?.content ?? null, loading: true, error: null }, + })) + + // Prefer attached local file content already in memory. + const attached = localFiles.find((file) => file.name === path || file.name.endsWith(`/${path}`) || path.endsWith(file.name)) + if (attached?.text) { + setFileBodies((current) => ({ + ...current, + [path]: { content: attached.text!, loading: false, error: null }, + })) + return + } + if (attached?.dataBase64 && attached.kind === "image") { + setFileBodies((current) => ({ + ...current, + [path]: { content: `[image ${attached.mediaType} · ${attached.size} bytes]`, loading: false, error: null }, + })) + return + } + + // Local workspace handle + if (getLocalWorkspace()) { + const read = await readLocalWorkspaceFile(path) + if (read.ok) { + setFileBodies((current) => ({ + ...current, + [path]: { + content: read.attachment.text ?? (read.attachment.dataBase64 ? `[binary ${read.attachment.mediaType}]` : ""), + loading: false, + error: null, + }, + })) + return + } + setFileBodies((current) => ({ + ...current, + [path]: { content: null, loading: false, error: read.error }, + })) + return + } + + setFileBodies((current) => ({ + ...current, + [path]: { + content: null, + loading: false, + error: "Re-open the local project folder to view this file, or attach it from @ Files.", + }, + })) + } + + function closeTab(id: string) { + if (id === "overview") return + setTabs((current) => { + const next = current.filter((tab) => tab.id !== id) + if (activeId === id) { + const index = current.findIndex((tab) => tab.id === id) + const fallback = next[Math.max(0, index - 1)] ?? next[0] + if (fallback) setActiveId(fallback.id) + } + return next.length ? next : [{ id: "overview", kind: "overview", title: "Overview" }] + }) + } + + const menuItems: Array<{ id: PanelTab["kind"]; label: string; icon: typeof LayoutDashboard; description: string }> = [ + { id: "overview", label: "Overview", icon: LayoutDashboard, description: "Todos, tasks, files in play" }, + { id: "files", label: "Files", icon: FolderOpen, description: "Browse the local project" }, + { id: "diffs", label: "Diffs", icon: Diff, description: "Changes this turn" }, + { id: "setup", label: "Setup", icon: Settings2, description: "Thread harness settings" }, + { id: "browser", label: "Browser", icon: Globe, description: "Preview surface" }, + ] + + return ( + + ) +} diff --git a/apps/web/modules/nova-web/working-steps.ts b/apps/web/modules/nova-web/working-steps.ts index 377dff6..3f54f33 100644 --- a/apps/web/modules/nova-web/working-steps.ts +++ b/apps/web/modules/nova-web/working-steps.ts @@ -55,6 +55,9 @@ export function languageFromPath(path: string): string { if (ext === "go") return "go" if (ext === "rs") return "rs" if (ext === "swift") return "swift" + if (ext === "prisma") return "prisma" + if (ext === "sql") return "sql" + if (ext === "yml" || ext === "yaml") return "yml" return ext || "file" } diff --git a/apps/web/package.json b/apps/web/package.json index 5bafaad..2330cc6 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -10,7 +10,7 @@ "lint": "eslint", "typecheck": "tsc --noEmit", "db:generate": "prisma generate --schema ../../packages/db/prisma/schema.prisma", - "db:migrate": "prisma migrate deploy --config ../../packages/db/prisma.config.ts" + "db:migrate": "bun run --cwd ../../packages/db db:migrate" }, "dependencies": { "@ai-sdk/anthropic": "^4.0.49", diff --git a/packages/db/migrate-deploy.ts b/packages/db/migrate-deploy.ts new file mode 100644 index 0000000..a968394 --- /dev/null +++ b/packages/db/migrate-deploy.ts @@ -0,0 +1,161 @@ +#!/usr/bin/env bun +/** + * prisma migrate deploy hardened for Neon + Vercel. + * + * - Prefer unpooled / direct DB URL (PgBouncer cannot hold session advisory locks) + * - Strip Neon `-pooler.` hosts when only DATABASE_URL is set + * - On Vercel/CI, disable migrate advisory locking (P1002 under concurrent previews) + * - Retry transient connectivity / lock errors + */ +import { spawn } from "node:child_process" +import path from "node:path" + +const ROOT = path.resolve(import.meta.dir) +const MAX_ATTEMPTS = 6 +const BASE_DELAY_MS = 4_000 + +function stripPooler(url: string): string { + try { + const parsed = new URL(url) + // ep-xxx-pooler.region.aws.neon.tech → ep-xxx.region.aws.neon.tech + if (parsed.hostname.includes("-pooler.")) { + parsed.hostname = parsed.hostname.replace("-pooler.", ".") + } + // Some Neon URLs use pooler as a label elsewhere + parsed.hostname = parsed.hostname.replace(/\.pooler\./g, ".") + parsed.searchParams.delete("pgbouncer") + parsed.searchParams.delete("connection_limit") + parsed.searchParams.delete("pool_timeout") + return parsed.toString() + } catch { + return url.replace("-pooler.", ".").replace(".pooler.", ".") + } +} + +function redactedHost(url: string): string { + try { + return new URL(url).hostname + } catch { + return "(invalid-url)" + } +} + +function resolveMigrateUrl(): string { + const candidates = [ + process.env.DATABASE_URL_UNPOOLED, + process.env.DIRECT_URL, + process.env.DIRECT_DATABASE_URL, + process.env.POSTGRES_URL_NON_POOLING, + process.env.DATABASE_URL, + ] + for (const value of candidates) { + const raw = value?.trim() + if (!raw) continue + return stripPooler(raw) + } + throw new Error( + "No database URL set for migrate. Expected DATABASE_URL_UNPOOLED, DIRECT_URL, or DATABASE_URL.", + ) +} + +function runMigrate(env: NodeJS.ProcessEnv): Promise<{ code: number; output: string }> { + return new Promise((resolve) => { + const child = spawn( + "bunx", + ["prisma", "migrate", "deploy", "--config", "./prisma.config.ts"], + { + cwd: ROOT, + env, + stdio: ["ignore", "pipe", "pipe"], + }, + ) + + let output = "" + const onChunk = (chunk: Buffer) => { + const text = chunk.toString() + output += text + process.stdout.write(text) + } + child.stdout.on("data", onChunk) + child.stderr.on("data", onChunk) + child.on("close", (code) => resolve({ code: code ?? 1, output })) + child.on("error", (error) => { + const message = error instanceof Error ? error.message : String(error) + console.error("[db:migrate]", message) + resolve({ code: 1, output: message }) + }) + }) +} + +function isRetryable(output: string): boolean { + return ( + output.includes("P1002") + || output.includes("advisory lock") + || output.includes("Timed out trying to acquire") + || output.includes("40P01") + || output.includes("ECONNRESET") + || output.includes("ECONNREFUSED") + || output.includes("Can't reach database server") + || output.includes("Connection terminated") + || output.includes("server closed the connection") + || output.includes("timeout expired") + ) +} + +function sleep(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +const onVercel = process.env.VERCEL === "1" || process.env.CI === "true" +const migrateUrl = resolveMigrateUrl() + +console.log(`[db:migrate] host=${redactedHost(migrateUrl)} vercel=${onVercel ? "yes" : "no"}`) + +// Force both the Prisma config candidates and the classic env name to the direct URL. +const env: NodeJS.ProcessEnv = { + ...process.env, + DATABASE_URL: migrateUrl, + DATABASE_URL_UNPOOLED: migrateUrl, + DIRECT_URL: migrateUrl, + DIRECT_DATABASE_URL: migrateUrl, +} + +// Session advisory locks break under Neon pooler and concurrent Vercel previews. +// migrate deploy is still serialized by _prisma_migrations rows; disabling the lock +// avoids P1002 when another preview holds/drops the lock mid-deploy. +if (onVercel || process.env.PRISMA_MIGRATE_DISABLE_LOCK === "1") { + env.PRISMA_SCHEMA_DISABLE_ADVISORY_LOCK = "1" + console.log("[db:migrate] PRISMA_SCHEMA_DISABLE_ADVISORY_LOCK=1") +} + +let attempt = 0 +while (attempt < MAX_ATTEMPTS) { + attempt += 1 + console.log(`[db:migrate] attempt ${attempt}/${MAX_ATTEMPTS}`) + const { code, output } = await runMigrate(env) + if (code === 0) { + console.log("[db:migrate] success") + process.exit(0) + } + + // If lock failed even with disable flag unset path, force-disable and retry immediately. + if ( + (output.includes("P1002") || output.includes("advisory lock")) + && env.PRISMA_SCHEMA_DISABLE_ADVISORY_LOCK !== "1" + ) { + env.PRISMA_SCHEMA_DISABLE_ADVISORY_LOCK = "1" + console.warn("[db:migrate] enabling PRISMA_SCHEMA_DISABLE_ADVISORY_LOCK after lock timeout") + continue + } + + if (!isRetryable(output) || attempt >= MAX_ATTEMPTS) { + console.error("[db:migrate] failed") + process.exit(code) + } + + const delay = BASE_DELAY_MS * attempt + console.warn(`[db:migrate] retryable error; waiting ${delay}ms`) + await sleep(delay) +} + +process.exit(1) diff --git a/packages/db/package.json b/packages/db/package.json index a00ff6b..17d1921 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -13,7 +13,7 @@ "typecheck": "echo 'db typecheck pending'", "db:clean": "rm -rf src/generated", "db:generate": "prisma generate --schema ./prisma/schema.prisma", - "db:migrate": "prisma migrate deploy --schema ./prisma/schema.prisma" + "db:migrate": "bun run ./migrate-deploy.ts" }, "dependencies": { "@prisma/adapter-pg": "^7.8.0", diff --git a/packages/db/prisma.config.ts b/packages/db/prisma.config.ts index cc6ce0d..53627ff 100644 --- a/packages/db/prisma.config.ts +++ b/packages/db/prisma.config.ts @@ -1,4 +1,46 @@ -import { defineConfig } from "prisma/config"; +import { defineConfig } from "prisma/config" + +/** + * Prefer a direct (unpooled) Postgres URL for Prisma Migrate. + * Neon/PgBouncer pooler URLs cannot hold session-scoped advisory locks, which + * surfaces on Vercel as P1002: "Timed out trying to acquire a postgres advisory lock". + * + * App runtime keeps using DATABASE_URL (pooled) via @prisma/adapter-pg. + * migrate-deploy.ts also overwrites DATABASE_URL to the direct URL for the child process. + */ +function stripPooler(url: string): string { + try { + const parsed = new URL(url) + if (parsed.hostname.includes("-pooler.")) { + parsed.hostname = parsed.hostname.replace("-pooler.", ".") + } + parsed.hostname = parsed.hostname.replace(/\.pooler\./g, ".") + parsed.searchParams.delete("pgbouncer") + parsed.searchParams.delete("connection_limit") + parsed.searchParams.delete("pool_timeout") + return parsed.toString() + } catch { + return url.replace("-pooler.", ".").replace(".pooler.", ".") + } +} + +function migrateDatabaseUrl(): string { + const candidates = [ + process.env.DATABASE_URL_UNPOOLED, + process.env.DIRECT_URL, + process.env.DIRECT_DATABASE_URL, + process.env.POSTGRES_URL_NON_POOLING, + process.env.DATABASE_URL, + ] + + for (const value of candidates) { + const url = value?.trim() + if (!url) continue + return stripPooler(url) + } + + return "postgresql://postgres:postgres@localhost:5432/postgres" +} export default defineConfig({ schema: "prisma/schema.prisma", @@ -6,8 +48,6 @@ export default defineConfig({ path: "prisma/migrations", }, datasource: { - url: - process.env["DATABASE_URL"] ?? - "postgresql://postgres:postgres@localhost:5432/postgres", + url: migrateDatabaseUrl(), }, -}); +})