From 7c9ae7bd503c3601e75a03ba6b1ebc5f7c390d94 Mon Sep 17 00:00:00 2001 From: Yash Dewasthale Date: Tue, 13 Oct 2026 10:29:03 +0530 Subject: [PATCH] feat: introduce Nova sidebar and code block components for enhanced file management: - Added a new `CodeBlock` component for displaying and copying code snippets with line count and wrapping options. - Implemented a `FileCard` component to represent individual files with status indicators and expandable content. - Created a `NovaSidebar` component to manage and display a list of files, supporting resizable functionality and responsive design. - Introduced hooks for media queries and sidebar resizing to improve user experience across devices. - Added utility functions for handling file proposals and applying changes locally, enhancing project interaction capabilities. --- apps/web/components/nova/code-block.tsx | 80 ++++++++ apps/web/components/nova/file-card.tsx | 85 ++++++++ apps/web/components/nova/nova-sidebar.tsx | 144 +++++++++++++ apps/web/hooks/use-media-query.ts | 18 ++ apps/web/hooks/use-sidebar-resize.ts | 105 ++++++++++ apps/web/modules/nova-web/api.ts | 7 + apps/web/modules/nova-web/apply-proposals.ts | 148 ++++++++++++++ .../modules/nova-web/components/thread.tsx | 26 +++ .../modules/nova-web/components/timeline.tsx | 4 +- .../nova-web/components/workspace-panel.tsx | 43 +++- apps/web/modules/nova-web/local-workspace.ts | 193 +++++++++++++++++- apps/web/modules/nova-web/nova-app.tsx | 72 +++++++ .../nova/agent-files/parse-proposals.test.ts | 29 +++ .../nova/agent-files/parse-proposals.ts | 126 ++++++++++++ apps/web/modules/nova/sessions/turn.ts | 86 +++++--- 15 files changed, 1128 insertions(+), 38 deletions(-) create mode 100644 apps/web/components/nova/code-block.tsx create mode 100644 apps/web/components/nova/file-card.tsx create mode 100644 apps/web/components/nova/nova-sidebar.tsx create mode 100644 apps/web/hooks/use-media-query.ts create mode 100644 apps/web/hooks/use-sidebar-resize.ts create mode 100644 apps/web/modules/nova-web/apply-proposals.ts create mode 100644 apps/web/modules/nova/agent-files/parse-proposals.test.ts create mode 100644 apps/web/modules/nova/agent-files/parse-proposals.ts diff --git a/apps/web/components/nova/code-block.tsx b/apps/web/components/nova/code-block.tsx new file mode 100644 index 00000000..9bb4cc2a --- /dev/null +++ b/apps/web/components/nova/code-block.tsx @@ -0,0 +1,80 @@ +"use client" + +import { useMemo, useState } from "react" +import { Check, Copy } from "lucide-react" +import { cn } from "@/lib/utils" + +interface CodeBlockProps { + code: string + language?: string + className?: string + maxHeight?: number +} + +export function CodeBlock({ code, language, className, maxHeight = 384 }: CodeBlockProps) { + const [wrapped, setWrapped] = useState(false) + const [copied, setCopied] = useState(false) + + const lineCount = useMemo(() => (code ? code.split("\n").length : 0), [code]) + + const handleCopy = async () => { + try { + await navigator.clipboard.writeText(code) + setCopied(true) + window.setTimeout(() => setCopied(false), 1600) + } catch { + // Clipboard API unavailable (insecure context) — no-op. + } + } + + return ( +
+ {/* Toolbar */} +
+
+ {language ? ( + + {language} + + ) : null} + + {lineCount} {lineCount === 1 ? "line" : "lines"} + +
+
+ + +
+
+ {/* Code */} +
+        {code}
+      
+
+ ) +} diff --git a/apps/web/components/nova/file-card.tsx b/apps/web/components/nova/file-card.tsx new file mode 100644 index 00000000..6482f0e8 --- /dev/null +++ b/apps/web/components/nova/file-card.tsx @@ -0,0 +1,85 @@ +"use client" + +import { useState } from "react" +import { File as FileIcon, FileCode2, FileJson } from "lucide-react" +import { cn } from "@/lib/utils" +import { CodeBlock } from "./code-block" + +export type FileChangeStatus = "added" | "modified" | "deleted" | "renamed" + +export interface FileCardData { + path: string + content?: string + status?: FileChangeStatus + language?: string +} + +const STATUS_STYLES: Record = { + added: { label: "A", className: "bg-emerald-500/15 text-emerald-500" }, + modified: { label: "M", className: "bg-amber-500/15 text-amber-500" }, + deleted: { label: "D", className: "bg-red-500/15 text-red-500" }, + renamed: { label: "R", className: "bg-blue-500/15 text-blue-500" }, +} + +const CODE_EXTS = new Set([ + "ts", "tsx", "js", "jsx", "mjs", "cjs", "py", "go", "rs", "swift", "kt", "kts", + "c", "cc", "cpp", "h", "hpp", "java", "rb", "php", "sh", "bash", "zsh", "sql", + "css", "scss", "html", "md", "mdx", "yml", "yaml", "toml", "graphql", "vue", "svelte", +]) + +function iconForPath(path: string) { + const ext = path.split(".").pop()?.toLowerCase() ?? "" + if (ext === "json") return + if (CODE_EXTS.has(ext)) return + return +} + +export function FileCard({ path, content, status, language }: FileCardData) { + const [open, setOpen] = useState(Boolean(content)) + const parts = path.split("/") + const basename = parts[parts.length - 1] || path + const dir = parts.slice(0, -1).join("/") + const statusStyle = status ? STATUS_STYLES[status] : null + const expandable = content != null + + return ( +
+ + {content && open ? ( +
+ +
+ ) : null} +
+ ) +} diff --git a/apps/web/components/nova/nova-sidebar.tsx b/apps/web/components/nova/nova-sidebar.tsx new file mode 100644 index 00000000..07cde64f --- /dev/null +++ b/apps/web/components/nova/nova-sidebar.tsx @@ -0,0 +1,144 @@ +"use client" + +import { useCallback, type KeyboardEvent as ReactKeyboardEvent } from "react" +import { X } from "lucide-react" +import { cn } from "@/lib/utils" +import { useMediaQuery } from "@/hooks/use-media-query" +import { useResizableSidebar } from "@/hooks/use-sidebar-resize" +import { FileCard, type FileCardData } from "./file-card" + +export interface NovaSidebarProps { + files: FileCardData[] + open?: boolean + onOpenChange?: (open: boolean) => void + title?: string +} + +const MIN_WIDTH = 280 +const MAX_WIDTH = 640 + +export function NovaSidebar({ files, open = true, onOpenChange, title = "Session files" }: NovaSidebarProps) { + const isDesktop = useMediaQuery("(min-width: 1024px)") + const { width, setWidth, isResizing, onPointerDown } = useResizableSidebar({ + initialWidth: 384, + minWidth: MIN_WIDTH, + maxWidth: MAX_WIDTH, + storageKey: "nova:right-sidebar-width", + }) + + // Clamp to the viewport so a stored width never overflows a small window. + const safeWidth = typeof window === "undefined" ? width : Math.min(width, window.innerWidth) + + const handleClose = useCallback(() => onOpenChange?.(false), [onOpenChange]) + + const handleResizeKeyDown = useCallback( + (event: ReactKeyboardEvent) => { + if (event.key === "ArrowLeft") { + event.preventDefault() + setWidth((w) => w - 16) + } else if (event.key === "ArrowRight") { + event.preventDefault() + setWidth((w) => w + 16) + } + }, + [setWidth] + ) + + // Desktop: inline, resizable panel. Hidden entirely when closed. + if (isDesktop) { + if (!open) return null + return ( + + ) + } + + // Mobile / tablet: overlay drawer. + return ( + <> + {open ? ( + ) : null} @@ -337,6 +344,25 @@ export function ThreadView({
+ {fileProposals && fileProposals.length > 0 ? ( +
+

+ {applyStatus + ? applyStatus + : `${fileProposals.length} file${fileProposals.length === 1 ? "" : "s"} ready — one click writes the whole set into your project folder`} +

+ +
+ ) : null}
, }} > - {content} + {cleaned}
) diff --git a/apps/web/modules/nova-web/components/workspace-panel.tsx b/apps/web/modules/nova-web/components/workspace-panel.tsx index df4c0c7e..2e3ac77e 100644 --- a/apps/web/modules/nova-web/components/workspace-panel.tsx +++ b/apps/web/modules/nova-web/components/workspace-panel.tsx @@ -145,6 +145,7 @@ function collectTouchedPaths( steps: WorkingStep[], localFiles: LocalAttachment[], localProject: LocalProjectDto | null | undefined, + fileProposals: Array<{ path: string }> = [], ): string[] { const paths: string[] = [] const push = (path?: string | null) => { @@ -156,6 +157,7 @@ function collectTouchedPaths( for (const child of step.children ?? []) push(child.path) } for (const file of localFiles) push(file.name) + for (const proposal of fileProposals) push(proposal.path) // Prefer project-relative paths that look like source files recently attached if (localProject?.paths) { for (const name of localFiles.map((f) => f.name)) { @@ -540,6 +542,7 @@ export function WorkspacePanel({ streaming, localProject, localFiles = [], + fileProposals = [], className, }: { session: SessionDetail @@ -548,6 +551,7 @@ export function WorkspacePanel({ streaming: boolean localProject?: LocalProjectDto | null localFiles?: LocalAttachment[] + fileProposals?: Array<{ path: string; content: string; language?: string }> className?: string }) { const [tabs, setTabs] = useState([ @@ -560,8 +564,8 @@ export function WorkspacePanel({ 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], + () => collectTouchedPaths(workingSteps, localFiles, localProject, fileProposals), + [workingSteps, localFiles, localProject, fileProposals], ) const activeTab = tabs.find((tab) => tab.id === activeId) ?? tabs[0]! @@ -595,7 +599,19 @@ export function WorkspacePanel({ [path]: { content: current[path]?.content ?? null, loading: true, error: null }, })) - // Prefer attached local file content already in memory. + // 1) Agent proposals win — show what Nova wants to write even without a folder handle. + const proposal = fileProposals.find( + (file) => file.path === path || file.path.endsWith(`/${path}`) || path.endsWith(file.path), + ) + if (proposal?.content) { + setFileBodies((current) => ({ + ...current, + [path]: { content: proposal.content, loading: false, error: null }, + })) + return + } + + // 2) 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) => ({ @@ -612,7 +628,7 @@ export function WorkspacePanel({ return } - // Local workspace handle + // 3) Local workspace handle if (getLocalWorkspace()) { const read = await readLocalWorkspaceFile(path) if (read.ok) { @@ -626,9 +642,14 @@ export function WorkspacePanel({ })) return } + // Fall through with a softer error if we still have no body. setFileBodies((current) => ({ ...current, - [path]: { content: null, loading: false, error: read.error }, + [path]: { + content: null, + loading: false, + error: `${read.error} If Nova proposed this file, open the Changes tab or re-run Agent mode with the folder linked.`, + }, })) return } @@ -643,6 +664,18 @@ export function WorkspacePanel({ })) } + // Keep file tabs in sync when new proposals arrive. + useEffect(() => { + for (const proposal of fileProposals.slice(0, 8)) { + openFileTab(proposal.path, false) + setFileBodies((current) => ({ + ...current, + [proposal.path]: { content: proposal.content, loading: false, error: null }, + })) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [fileProposals]) + function closeTab(id: string) { if (id === "overview") return setTabs((current) => { diff --git a/apps/web/modules/nova-web/local-workspace.ts b/apps/web/modules/nova-web/local-workspace.ts index d89d10ca..571a4da4 100644 --- a/apps/web/modules/nova-web/local-workspace.ts +++ b/apps/web/modules/nova-web/local-workspace.ts @@ -36,7 +36,7 @@ type FileSystemDirectoryHandleLike = { name: string values: () => AsyncIterableIterator getDirectoryHandle?: (name: string, options?: { create?: boolean }) => Promise - getFileHandle?: (name: string) => Promise + getFileHandle?: (name: string, options?: { create?: boolean }) => Promise queryPermission?: (descriptor?: { mode?: "read" | "readwrite" }) => Promise requestPermission?: (descriptor?: { mode?: "read" | "readwrite" }) => Promise } @@ -280,13 +280,24 @@ export async function pickLocalWorkspaceFolder(options?: { } let handle: FileSystemDirectoryHandleLike try { - handle = await anyWindow.showDirectoryPicker({ mode: "read" }) + // Prefer readwrite so Agent mode can apply patches back to disk. + handle = await anyWindow.showDirectoryPicker({ mode: "readwrite" }).catch(async () => { + return anyWindow.showDirectoryPicker({ mode: "read" }) + }) } catch (error) { if (error instanceof DOMException && error.name === "AbortError") return null throw error } const allowed = await ensurePermission(handle) if (!allowed) throw new Error("Folder permission was denied") + // Best-effort upgrade to write for agent applies (user may deny and stay read-only). + try { + if (handle.requestPermission) { + await handle.requestPermission({ mode: "readwrite" }) + } + } catch { + // stay read-only + } // New folder grant creates/updates a DB-backed project for this user. return indexDirectoryHandle(handle, { projectId: null, @@ -457,3 +468,181 @@ export function localWorkspaceStatusMessage(): string | undefined { const base = `Local project · ${state.displayName || state.rootName} · ${count} file${count === 1 ? "" : "s"} · ${saved}` return state.truncated ? `${base} (partial — folder is large)` : base } + +type FileSystemWritable = { + write: (data: string | BufferSource | Blob) => Promise + close: () => Promise +} + +type WritableFileHandle = FileSystemFileHandleLike & { + createWritable?: () => Promise +} + +/** + * Write text into the granted local folder (requires readwrite permission). + * Creates intermediate directories when the File System Access API allows it. + */ +export async function writeLocalWorkspaceFile( + path: string, + content: string, +): Promise<{ ok: true; path: string } | { ok: false; error: string }> { + try { + if (!memoryHandle) { + return { ok: false, error: "No local folder linked. Re-open the project folder with write access." } + } + const allowed = await ensureWritePermission(memoryHandle) + if (!allowed) { + return { ok: false, error: "Write permission denied. Re-open the folder and allow editing." } + } + + const parts = path.replaceAll("\\", "/").split("/").filter(Boolean) + if (parts.length === 0) return { ok: false, error: "Invalid path" } + const fileName = parts[parts.length - 1]! + let dir: FileSystemDirectoryHandleLike = memoryHandle + + for (let i = 0; i < parts.length - 1; i += 1) { + const part = parts[i]! + if (!dir.getDirectoryHandle) { + return { ok: false, error: "This browser cannot create nested folders." } + } + dir = await dir.getDirectoryHandle(part, { create: true }) + } + + if (!dir.getFileHandle) { + return { ok: false, error: "This browser cannot write files in the folder." } + } + const handle = (await dir.getFileHandle(fileName, { create: true })) as WritableFileHandle + if (!handle.createWritable) { + return { ok: false, error: "Writable file streams are unavailable in this browser." } + } + const writable = await handle.createWritable() + await writable.write(content) + await writable.close() + pathHandleCache.set(path.replaceAll("\\", "/"), handle) + + // Keep the in-memory index aware of new paths. + if (memoryState) { + const normalized = path.replaceAll("\\", "/") + if (!memoryState.entries.some((entry) => entry.path === normalized)) { + memoryState = { + ...memoryState, + entries: [ + ...memoryState.entries, + { + path: normalized, + name: fileName, + kind: classifyPath(normalized), + }, + ], + } + } + } + + return { ok: true, path: path.replaceAll("\\", "/") } + } catch (error) { + return { + ok: false, + error: error instanceof Error ? error.message : "Could not write local file", + } + } +} + +async function ensureWritePermission(handle: FileSystemDirectoryHandleLike): Promise { + try { + if (handle.queryPermission) { + const current = await handle.queryPermission({ mode: "readwrite" }) + if (current === "granted") return true + } + if (handle.requestPermission) { + const next = await handle.requestPermission({ mode: "readwrite" }) + return next === "granted" + } + return false + } catch { + return false + } +} + +export function hasLocalWorkspaceHandle(): boolean { + return Boolean(memoryHandle) +} + +export async function hasLocalWorkspaceWriteAccess(): Promise { + if (!memoryHandle) return false + try { + if (memoryHandle.queryPermission) { + return (await memoryHandle.queryPermission({ mode: "readwrite" })) === "granted" + } + return false + } catch { + return false + } +} + +/** + * Ensure we have a folder handle with readwrite permission. + * Call only from a user gesture (button click). May open the directory picker. + */ +export async function ensureLocalWorkspaceWritable(): Promise<{ + ok: boolean + prompted: boolean + error?: string +}> { + // Already writable + if (memoryHandle) { + const allowed = await ensureWritePermission(memoryHandle) + if (allowed) return { ok: true, prompted: false } + } + + // Try restoring a persisted handle for the active project (still needs a gesture for permission). + if (!memoryHandle) { + try { + const restored = await restoreLocalWorkspace(memoryState?.projectId ?? getActiveLocalProjectId()) + if (restored && memoryHandle) { + const allowed = await ensureWritePermission(memoryHandle) + if (allowed) return { ok: true, prompted: true } + } + } catch { + // fall through to picker + } + } + + // Last resort: pick the project folder with write access (user gesture). + if (!supportsDirectoryPicker()) { + return { + ok: false, + prompted: true, + error: "This browser can’t write to folders. Use Chrome/Edge.", + } + } + + try { + const anyWindow = window as unknown as { + showDirectoryPicker: (options?: { mode?: "read" | "readwrite" }) => Promise + } + const handle = await anyWindow.showDirectoryPicker({ mode: "readwrite" }) + const writeOk = await ensureWritePermission(handle) + if (!writeOk) { + return { + ok: false, + prompted: true, + error: "Write access was denied. Choose the project folder and allow editing.", + } + } + await indexDirectoryHandle(handle, { + projectId: memoryState?.projectId ?? getActiveLocalProjectId(), + displayName: memoryState?.displayName, + persistRemote: true, + }) + return { ok: true, prompted: true } + } catch (error) { + if (error instanceof DOMException && error.name === "AbortError") { + return { ok: false, prompted: true, error: "Folder picker cancelled" } + } + return { + ok: false, + prompted: true, + error: error instanceof Error ? error.message : "Could not open project folder", + } + } +} diff --git a/apps/web/modules/nova-web/nova-app.tsx b/apps/web/modules/nova-web/nova-app.tsx index b5902aa3..fff47c83 100644 --- a/apps/web/modules/nova-web/nova-app.tsx +++ b/apps/web/modules/nova-web/nova-app.tsx @@ -14,8 +14,14 @@ import { listSessions, streamTurn, syncSession, + type FileProposalDto, type LocalProjectDto, } from "@/modules/nova-web/api" +import { + applyFileProposalsLocally, + recoverProposalsFromBody, + stripProposalTrailer, +} from "@/modules/nova-web/apply-proposals" import { ApprovalsView } from "@/modules/nova-web/components/approvals" import { ConnectionsView } from "@/modules/nova-web/components/connections" import { EmptyHome } from "@/modules/nova-web/components/home" @@ -65,6 +71,12 @@ import type { NovaReference } from "@/modules/nova/references/contracts" const PENDING_TURN_KEY = "nova:pending-turn" +function mergeProposals(current: FileProposalDto[], incoming: FileProposalDto[]): FileProposalDto[] { + const map = new Map(current.map((item) => [item.path, item])) + for (const item of incoming) map.set(item.path, item) + return [...map.values()] +} + type PendingTurn = { sessionId: string content: string @@ -208,6 +220,9 @@ export function NovaApp({ since: number until: number }>>({}) + /** Pending / applied agent file proposals for the active thread. */ + const [fileProposals, setFileProposals] = useState([]) + const [applyStatus, setApplyStatus] = useState(null) const [decidingApprovalId, setDecidingApprovalId] = useState(null) const [error, setError] = useState(null) const [isPending, startTransition] = useTransition() @@ -637,6 +652,42 @@ export function NovaApp({ }) if (event.activity.type === "response") { setStreamingText("") + // Recover proposals from trailer if stream event was missed. + if (event.activity.body) { + const recovered = recoverProposalsFromBody(event.activity.body) + if (recovered.length) { + setFileProposals((current) => mergeProposals(current, recovered)) + } + } + } + } else if (event.type === "file_proposals") { + if (event.runId) activeRunIdRef.current = event.runId + setFileProposals((current) => mergeProposals(current, event.proposals)) + setWorkingSteps((steps) => { + const next = reduceWorkingSteps(steps, { + type: "status", + message: `Proposed ${event.proposals.length} file change${event.proposals.length === 1 ? "" : "s"}`, + }) + workingStepsRef.current = next + return next + }) + // Browsers block write permission without a user gesture — don't auto-apply. + // Probe quietly; if write is already granted, apply. Otherwise wait for the button. + if ((opts?.mode ?? agentMode) === "agent") { + void applyFileProposalsLocally(event.proposals, { interactive: false }).then((batch) => { + if (batch.applied > 0) { + setApplyStatus(batch.message) + setWorkingSteps((steps) => { + const next = reduceWorkingSteps(steps, { type: "status", message: batch.message }) + workingStepsRef.current = next + return next + }) + } else { + setApplyStatus( + `${event.proposals.length} file${event.proposals.length === 1 ? "" : "s"} ready — click Make these changes to write the whole project folder`, + ) + } + }) } } else if (event.type === "error") { setError(event.message) @@ -799,6 +850,27 @@ export function NovaApp({ streaming={streaming} workingSince={workingSince} completedWorkByRunId={completedWorkByRunId} + fileProposals={fileProposals} + applyStatus={applyStatus} + onApplyProposals={async () => { + if (!fileProposals.length) return + setApplyStatus("Granting folder access and applying…") + // interactive:true runs inside this click — browser allows write + picker. + const batch = await applyFileProposalsLocally(fileProposals, { interactive: true }) + setApplyStatus(batch.message) + setWorkingSteps((steps) => { + const next = reduceWorkingSteps(steps, { type: "status", message: batch.message }) + workingStepsRef.current = next + return next + }) + if (batch.applied > 0 && localProject) { + // Refresh project meta so the panel sees new paths. + setLocalProject({ + ...localProject, + fileCount: Math.max(localProject.fileCount, (localProject.paths?.length ?? 0)), + }) + } + }} loading={isPending} decidingApprovalId={decidingApprovalId} detailsOpen={detailsOpen} diff --git a/apps/web/modules/nova/agent-files/parse-proposals.test.ts b/apps/web/modules/nova/agent-files/parse-proposals.test.ts new file mode 100644 index 00000000..72092738 --- /dev/null +++ b/apps/web/modules/nova/agent-files/parse-proposals.test.ts @@ -0,0 +1,29 @@ +import { expect, test } from "bun:test" + +import { parseFileProposals, resolveProposalPath } from "./parse-proposals" + +test("parses path= fenced full files", () => { + const text = ` +Here is the fix. + +\`\`\`tsx path=apps/web/modules/nova-web/components/code-block.tsx +"use client" +export function CodeBlock() { return null } +\`\`\` +` + const proposals = parseFileProposals(text) + expect(proposals).toHaveLength(1) + expect(proposals[0]?.path).toBe("apps/web/modules/nova-web/components/code-block.tsx") + expect(proposals[0]?.content).toContain("export function CodeBlock") +}) + +test("resolves invented component paths against the index", () => { + const indexed = [ + "apps/web/modules/nova-web/components/workspace-panel.tsx", + "apps/web/modules/nova-web/components/sidebar.tsx", + "package.json", + ] + expect( + resolveProposalPath("apps/web/components/nova/workspace-panel.tsx", indexed), + ).toBe("apps/web/modules/nova-web/components/workspace-panel.tsx") +}) diff --git a/apps/web/modules/nova/agent-files/parse-proposals.ts b/apps/web/modules/nova/agent-files/parse-proposals.ts new file mode 100644 index 00000000..da0b684a --- /dev/null +++ b/apps/web/modules/nova/agent-files/parse-proposals.ts @@ -0,0 +1,126 @@ +/** + * Parse mergeable file proposals out of an agent answer. + * Supports: + * ```tsx path=apps/web/foo.tsx + * ...body... + * ``` + * ```ts file=apps/web/foo.ts + * ``` + * --- a/path + * +++ b/path + * @@ ... unified diff (best-effort full-file only when it's a pure add) + */ + +export type FileProposal = { + path: string + content: string + language?: string +} + +const FENCE_RE = + /```([A-Za-z0-9_+.-]*)[^\n]*?(?:path|file)\s*=\s*([^\s\n`]+)[^\n]*\n([\s\S]*?)```/gi + +const ALT_FENCE_RE = + /```([A-Za-z0-9_+.-]*)\s+([A-Za-z0-9_./@+-]+\.[A-Za-z0-9]{1,12})\s*\n([\s\S]*?)```/gi + +function cleanPath(raw: string): string | null { + let path = raw.trim().replace(/^["'`]|["'`]$/g, "") + path = path.replace(/^\.\//, "").replaceAll("\\", "/") + // Strip accidental a/ b/ prefixes from diffs + if (path.startsWith("a/") || path.startsWith("b/")) path = path.slice(2) + if (!path || path.includes("..")) return null + if (!/^[A-Za-z0-9_./@+-]+$/.test(path)) return null + if (!path.includes(".") && !path.includes("/")) return null + return path +} + +export function parseFileProposals(text: string): FileProposal[] { + if (!text?.trim()) return [] + const byPath = new Map() + + const push = (pathRaw: string, content: string, language?: string) => { + const path = cleanPath(pathRaw) + if (!path) return + const body = content.replace(/^\n/, "").replace(/\n$/, "") + // Ignore empty or tiny stubs + if (body.trim().length < 8) return + // Prefer later (usually final) version of the same path + byPath.set(path, { + path, + content: body.endsWith("\n") ? body : `${body}\n`, + language: language || undefined, + }) + } + + for (const match of text.matchAll(FENCE_RE)) { + push(match[2]!, match[3] ?? "", match[1] || undefined) + } + + // Fallback: ```tsx apps/web/foo.tsx\n... + for (const match of text.matchAll(ALT_FENCE_RE)) { + const maybePath = match[2]! + if (!maybePath.includes("/") && !maybePath.includes(".")) continue + // Skip if already captured via path= + if (byPath.has(cleanPath(maybePath) ?? "")) continue + push(maybePath, match[3] ?? "", match[1] || undefined) + } + + return [...byPath.values()] +} + +/** Resolve a model path against an indexed project tree (fuzzy). */ +export function resolveProposalPath( + proposed: string, + indexedPaths: string[], +): string { + const clean = cleanPath(proposed) || proposed + if (indexedPaths.includes(clean)) return clean + + const base = clean.split("/").pop() || clean + const lower = clean.toLowerCase() + + // Exact basename unique match + const byBase = indexedPaths.filter((p) => (p.split("/").pop() || p) === base) + if (byBase.length === 1) return byBase[0]! + + // Suffix match + const bySuffix = indexedPaths.filter( + (p) => p === clean || p.endsWith(`/${clean}`) || p.endsWith(clean), + ) + if (bySuffix.length === 1) return bySuffix[0]! + + // Contains path segments from the proposal + const segments = lower.split("/").filter((s) => s && s !== "apps" && s !== "src") + if (segments.length >= 2) { + const scored = indexedPaths + .map((p) => { + const pl = p.toLowerCase() + let score = 0 + for (const seg of segments) { + if (pl.includes(seg)) score += 1 + } + if ((p.split("/").pop() || "") === base) score += 5 + return { p, score } + }) + .filter((x) => x.score >= Math.min(2, segments.length)) + .sort((a, b) => b.score - a.score) + if (scored[0] && scored[0].score >= (scored[1]?.score ?? 0) + 1) { + return scored[0].p + } + } + + return clean +} + +export function remapProposals( + proposals: FileProposal[], + indexedPaths: string[], +): FileProposal[] { + if (!indexedPaths.length) return proposals + const out = new Map() + for (const proposal of proposals) { + const path = resolveProposalPath(proposal.path, indexedPaths) + out.set(path, { ...proposal, path }) + } + return [...out.values()] +} diff --git a/apps/web/modules/nova/sessions/turn.ts b/apps/web/modules/nova/sessions/turn.ts index 8e66477c..ec5366ba 100644 --- a/apps/web/modules/nova/sessions/turn.ts +++ b/apps/web/modules/nova/sessions/turn.ts @@ -8,6 +8,11 @@ import { type LocalAttachment, type LocalAttachmentMeta, } from "@/modules/nova/attachments/contracts" +import { + parseFileProposals, + remapProposals, + type FileProposal, +} from "@/modules/nova/agent-files/parse-proposals" import { packGithubProjectForTurn } from "@/modules/nova/local-projects/pack" import { formatLocalProjectContext } from "@/modules/nova/local-projects/service" import type { NovaReference, ReferenceInput } from "@/modules/nova/references/contracts" @@ -55,6 +60,8 @@ export type TurnStreamEvent = createdAt: string } } + /** Full file bodies the agent proposed — client applies these to disk / shows in the panel. */ + | { type: "file_proposals"; proposals: FileProposal[]; runId?: string } | { type: "error"; message: string } | { type: "finish"; reason: string; runId?: string; latestSequence?: number; model?: string } @@ -232,30 +239,6 @@ async function buildTurnContext( return { objective: session.objective, messages: chatMessages, localProject } } -/** Pull path= fences / unified-diff headers from the assistant answer for the working log. */ -function extractWorkedFiles(text: string): Array<{ path: string; kind: "write" | "read" }> { - const found: Array<{ path: string; kind: "write" | "read" }> = [] - const seen = new Set() - const push = (path: string, kind: "write" | "read") => { - const clean = path.replace(/^["'`]|["'`]$/g, "").trim() - if (!clean || seen.has(clean)) return - if (!/^[A-Za-z0-9_./@+-]+$/.test(clean)) return - if (!clean.includes("/") && !clean.includes(".")) return - seen.add(clean) - found.push({ path: clean, kind }) - } - for (const match of text.matchAll(/```[\w.+-]*[^\n]*?(?:path\s*=\s*|file\s*=\s*)([^\s\n`]+)/gi)) { - push(match[1]!, "write") - } - for (const match of text.matchAll(/^\+\+\+\s+[ab]\/(.+)$/gm)) { - push(match[1]!, "write") - } - for (const match of text.matchAll(/(?:^|\n)(?:Writing|Updated|Created|Edited)\s+`?([A-Za-z0-9_./+-]+\.[A-Za-z0-9]{1,8})`?/gi)) { - push(match[1]!, "write") - } - return found.slice(0, 24) -} - function harnessContentToText(content: HarnessChatMessage["content"]): string { if (typeof content === "string") return content if (!Array.isArray(content)) { @@ -584,7 +567,7 @@ export async function* runWebTurn(input: { text += `\n\n${imageNotes.join("\n")}` } if (mode === "agent") { - text += `\n\n[Nova agent mode] Implement now. Prefer full file replacements with path= fences for every file you change. Do not reply with only a plan.` + text += `\n\n[Nova agent mode] Implement now. For EVERY file you change, emit a FULL file body in a fenced block with path=, e.g. \n\`\`\`tsx path=apps/web/modules/nova-web/components/workspace-panel.tsx\n...entire file...\n\`\`\`\nNova will apply those fences to the user's local folder. Do not claim you wrote a file unless you emitted its full path= fence. Do not reply with only a plan.` } userMessage.content = text } @@ -679,8 +662,24 @@ export async function* runWebTurn(input: { throw new Error("Harness returned an empty response") } - // Surface proposed file writes in the working log (Capy-style). - for (const file of extractWorkedFiles(text)) { + // Parse real file bodies (path= fences). Only then mark writes in the working log. + let proposals = parseFileProposals(text) + if (context.localProject?.paths?.length) { + proposals = remapProposals(proposals, context.localProject.paths) + } + + if (mode === "agent" && proposals.length === 0) { + // Soft nudge only in the working log — answer still delivers. + yield { + type: "status", + phase: "proposals", + message: "No path= file fences found — agent did not emit applyable patches", + runId: posted.runId, + model: selection.model, + } + } + + for (const file of proposals) { const activity = await prisma.$transaction(async (tx) => { const sequence = await tx.agentSession.update({ where: { id: input.sessionId }, @@ -695,8 +694,8 @@ export async function* runWebTurn(input: { sequence: sequence.nextSequence - 1, type: "action", status: "completed", - title: file.kind === "write" ? "Writing" : "Reading", - body: `${file.kind === "write" ? "wrote" : "read"} ${file.path}`, + title: "Writing", + body: `wrote ${file.path} +${file.content.split("\n").length}`, }, }) }) @@ -716,17 +715,44 @@ export async function* runWebTurn(input: { } } + if (proposals.length > 0) { + yield { + type: "file_proposals", + proposals, + runId: posted.runId, + } + } + await prisma.agentRun.updateMany({ where: { id: posted.runId, status: { in: ["planning", "executing"] } }, data: { status: "delivering", version: { increment: 1 } }, }) const modelTag = `${selection.provider}:${selection.model}` + // Append a machine-readable trailer so clients/reloads can still recover proposals. + let body = text.trim() + if (proposals.length > 0) { + const trailer = [ + "", + "", "--\\>"), + "-->", + ].join("\n") + body = `${body}${trailer}` + } + const completed = await completeRun({ runId: posted.runId, sessionId: input.sessionId, surfaceId: posted.surfaceId, - body: text.trim(), + body, model: modelTag, }) yield { type: "activity", activity: completed.activity }