+ {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 }