diff --git a/vibn-frontend/app/api/chat/route.ts b/vibn-frontend/app/api/chat/route.ts index c427a22e..7514ef8e 100644 --- a/vibn-frontend/app/api/chat/route.ts +++ b/vibn-frontend/app/api/chat/route.ts @@ -30,6 +30,7 @@ import { } from "@/lib/dev-container-git"; import { buildDesignKitPromptSection } from "@/lib/design-kits/for-ai"; import { buildCodebaseSummary } from "@/lib/ai/codebase-summary"; +import { execInDevContainer } from "@/lib/dev-container"; import type { ChatMessage, ToolCall } from "@/lib/ai/gemini-chat"; // C-01: Lowered from 15 → 8. Real workflows (scaffold → install → @@ -404,6 +405,7 @@ export async function POST(request: Request) { workspace: string; mcp_token?: string; chatMode?: "vibe" | "collaborate" | "delegate"; + attachedFiles?: string[]; }; try { body = await request.json(); @@ -411,7 +413,14 @@ export async function POST(request: Request) { return NextResponse.json({ error: "Invalid JSON" }, { status: 400 }); } - const { thread_id, message, workspace, mcp_token, chatMode = "vibe" } = body; + const { + thread_id, + message, + workspace, + mcp_token, + chatMode = "vibe", + attachedFiles = [], + } = body; if (!thread_id || !message?.trim()) { return NextResponse.json( { error: "thread_id and message are required" }, @@ -529,6 +538,32 @@ export async function POST(request: Request) { chatMode, ); + let fileContextsBlock = ""; + if ( + Array.isArray(attachedFiles) && + attachedFiles.length > 0 && + activeProject?.slug + ) { + fileContextsBlock = + "\n\n=== USER-ATTACHED CODE CONTEXT ===\nThe user has explicitly attached the following files to this conversation turn as active context. You MUST refer to these file states when writing your response or deciding edits:\n"; + for (const f of attachedFiles) { + const safePath = String(f).replace(/\.\./g, "").replace(/^\//, ""); + try { + const res = (await execInDevContainer({ + projectId: activeProject.id, + command: `cat "/workspace/${activeProject.slug}/${safePath}" 2>/dev/null || echo "[File not found]"`, + })) as unknown as { exitCode: number; stdout: string }; + fileContextsBlock += `\nFile: \`${safePath}\`\n\`\`\`\n${res.stdout}\n\`\`\`\n`; + } catch { + fileContextsBlock += `\nFile: \`${safePath}\`\n[Error reading file]\n`; + } + } + } + + if (fileContextsBlock) { + systemPrompt += fileContextsBlock; + } + // Sentry-as-product Stage 4: auto-surface unresolved errors at // chat-turn start. We pull the last 6 hours' unresolved issues // for the active project; if anything has fired ≥2 times, we diff --git a/vibn-frontend/app/api/projects/[projectId]/files/route.ts b/vibn-frontend/app/api/projects/[projectId]/files/route.ts new file mode 100644 index 00000000..d917ac34 --- /dev/null +++ b/vibn-frontend/app/api/projects/[projectId]/files/route.ts @@ -0,0 +1,63 @@ +import { NextResponse } from "next/server"; +import { + execInDevContainer, + ensureDevContainer, +} from "../../../../../lib/dev-container"; +import { authSession } from "../../../../../lib/auth/session-server"; +import { queryOne } from "../../../../../lib/db-postgres"; +import type { VibnWorkspace } from "../../../../../lib/workspaces"; + +export const dynamic = "force-dynamic"; + +export async function GET( + req: Request, + ctx: { params: Promise<{ projectId: string }> }, +) { + try { + const { projectId } = await ctx.params; + const { searchParams } = new URL(req.url); + const workspace = searchParams.get("workspace") || ""; + + const session = await authSession(); + if (!session?.user?.email || !workspace) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + // Fetch project details to get the slug + const r = await queryOne<{ data: { slug: string } }>( + `SELECT data FROM fs_projects WHERE id = $1`, + [projectId], + ); + if (!r?.data?.slug) { + return NextResponse.json({ error: "Project not found" }, { status: 404 }); + } + const projectSlug = r.data.slug; + + // Ensure the container is active + await ensureDevContainer({ + projectId, + projectSlug, + projectName: projectSlug, + workspace: { slug: workspace } as unknown as VibnWorkspace, + }); + + // Run a fast find inside the dev container, excluding build/node_modules/git artifacts + const result = (await execInDevContainer({ + projectId, + command: `find . -maxdepth 4 -not -path "*/node_modules/*" -not -path "*/.git/*" -not -path "*/.next/*" -not -path "*/dist/*" -not -path "*/build/*" | sort | sed 's|^\\./||'`, + })) as unknown as { exitCode: number; stdout: string }; + + if (result.exitCode !== 0) { + return NextResponse.json({ files: [] }); + } + + const files = result.stdout + .split("\n") + .map((f: string) => f.trim()) + .filter((f: string) => f && f !== "." && f !== ".."); + + return NextResponse.json({ files }); + } catch (err) { + return NextResponse.json({ error: String(err) }, { status: 500 }); + } +} diff --git a/vibn-frontend/components/vibn-chat/chat-panel.tsx b/vibn-frontend/components/vibn-chat/chat-panel.tsx index bf1584f3..00fff57f 100644 --- a/vibn-frontend/components/vibn-chat/chat-panel.tsx +++ b/vibn-frontend/components/vibn-chat/chat-panel.tsx @@ -882,6 +882,24 @@ export function ChatPanel({ const [chatMode, setChatMode] = useState<"collaborate" | "vibe" | "delegate">( "vibe", ); + const [projectFiles, setProjectFiles] = useState([]); + const [attachedFiles, setAttachedFiles] = useState([]); + const [showSuggestions, setShowSuggestions] = useState(false); + const [suggestionsFilter, setSuggestionsFilter] = useState(""); + const [suggestionIndex, setSuggestionIndex] = useState(0); + + // Fetch codebase files list inside the scoped project + useEffect(() => { + if (!projectId || !workspace || status !== "authenticated") return; + fetch(`/api/projects/${projectId}/files?workspace=${workspace}`) + .then((res) => res.json()) + .then((data) => { + if (Array.isArray(data.files)) { + setProjectFiles(data.files); + } + }) + .catch(() => {}); + }, [projectId, workspace, status]); const [sending, setSending] = useState(false); const [showThreads, setShowThreads] = useState(false); const [mcpToken, setMcpToken] = useState(null); @@ -1149,6 +1167,7 @@ export function ChatPanel({ throw new Error(err.error || `HTTP ${r.status}`); } + setAttachedFiles([]); setMessages((prev) => [ ...prev, { @@ -1170,10 +1189,13 @@ export function ChatPanel({ workspace, mcp_token: mcpToken, chatMode, + attachedFiles, }), signal: controller.signal, }); + setAttachedFiles([]); + if (!res.ok || !res.body) throw new Error("Stream failed"); const reader = res.body.getReader(); @@ -1389,6 +1411,7 @@ export function ChatPanel({ unifiedProjectShell, chatMode, projectId, + attachedFiles, ], ); @@ -1419,13 +1442,57 @@ export function ChatPanel({ window.removeEventListener("vibn:chat-prompt", onPrompt as EventListener); }, [sendMessage, projectId]); + const handleInputChange = (val: string) => { + setInput(val); + const match = val.match(/\/(\S*)$/); + if (match) { + setShowSuggestions(true); + setSuggestionsFilter(match[1] || ""); + setSuggestionIndex(0); + } else { + setShowSuggestions(false); + } + }; + const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === "Enter" && !e.shiftKey) { - e.preventDefault(); - sendMessage(); - } else if (e.key === "Escape" && sending) { - e.preventDefault(); - cancelMessage(); + if (showSuggestions) { + const filtered = projectFiles + .filter((f) => + f.toLowerCase().includes(suggestionsFilter.toLowerCase()), + ) + .slice(0, 8); + + if (e.key === "ArrowDown") { + e.preventDefault(); + setSuggestionIndex((prev) => (prev + 1) % Math.max(1, filtered.length)); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + setSuggestionIndex( + (prev) => (prev - 1 + filtered.length) % Math.max(1, filtered.length), + ); + } else if (e.key === "Enter" || e.key === "Tab") { + e.preventDefault(); + if (filtered[suggestionIndex]) { + const selectedFile = filtered[suggestionIndex]; + const before = input.slice(0, input.lastIndexOf("/")); + setInput(before + " "); + if (!attachedFiles.includes(selectedFile)) { + setAttachedFiles((prev) => [...prev, selectedFile]); + } + setShowSuggestions(false); + } + } else if (e.key === "Escape") { + e.preventDefault(); + setShowSuggestions(false); + } + } else { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + sendMessage(); + } else if (e.key === "Escape" && sending) { + e.preventDefault(); + cancelMessage(); + } } }; @@ -1676,6 +1743,7 @@ export function ChatPanel({ borderTop: "1px solid #e8e4dc", background: "#faf8f5", flexShrink: 0, + position: "relative", }} > {/* Chat Mode Toggle */} @@ -1806,6 +1874,149 @@ export function ChatPanel({ Read-only mode — add your MCP token in Settings to enable actions. )} + {/* File Autocomplete Suggestions */} + {showSuggestions && + (() => { + const filtered = projectFiles + .filter((f) => + f.toLowerCase().includes(suggestionsFilter.toLowerCase()), + ) + .slice(0, 8); + if (filtered.length === 0) return null; + return ( +
+
+ Codebase Files +
+ {filtered.map((file, idx) => ( +
{ + const before = input.slice(0, input.lastIndexOf("/")); + setInput(before + " "); + if (!attachedFiles.includes(file)) { + setAttachedFiles((prev) => [...prev, file]); + } + setShowSuggestions(false); + }} + onMouseEnter={() => setSuggestionIndex(idx)} + style={{ + padding: "6px 12px", + fontSize: "0.76rem", + fontFamily: "var(--font-mono), monospace", + color: idx === suggestionIndex ? "#3d5afe" : "#1a1a1a", + background: + idx === suggestionIndex ? "#3d5afe0c" : "transparent", + cursor: "pointer", + display: "flex", + justifyContent: "space-between", + alignItems: "center", + }} + > + + 📄 {file} + + {idx === suggestionIndex && ( + + press Enter + + )} +
+ ))} +
+ ); + })()} + + {/* Attached Files Chips */} + {attachedFiles.length > 0 && ( +
+ {attachedFiles.map((file) => ( +
+ 📄 {file.split("/").pop()} + +
+ ))} +
+ )} + {(selectToggle) => (
setInput(e.target.value)} + onChange={(e) => handleInputChange(e.target.value)} onKeyDown={handleKeyDown} placeholder={ sending ? "Esc to stop generating…" : "Ask Vibn AI anything…" diff --git a/vibn-frontend/lib/ai/codebase-summary.ts b/vibn-frontend/lib/ai/codebase-summary.ts index 5d3966d0..ccdf77d9 100644 --- a/vibn-frontend/lib/ai/codebase-summary.ts +++ b/vibn-frontend/lib/ai/codebase-summary.ts @@ -1,10 +1,4 @@ -import { NextResponse } from "next/server"; -import { query } from "@/lib/db-postgres"; -import { - ensureDevContainer, - execInDevContainer, - getDevContainerStatus, -} from "@/lib/dev-container"; +import { ensureDevContainer, execInDevContainer } from "@/lib/dev-container"; import { authSession } from "@/lib/auth/session-server"; /** @@ -19,7 +13,9 @@ export async function buildCodebaseSummary( if (!projectId || !projectSlug) return ""; try { - const session = await authSession(); + const session = (await authSession()) as unknown as { + workspace?: import("@/lib/workspaces").VibnWorkspace; + }; if (!session?.workspace) return ""; // Ensure the container is actually running before we try to exec inside it @@ -61,7 +57,7 @@ export async function buildCodebaseSummary( command: bashScript, }); - if (result.exitCode !== 0 || !result.stdout.trim()) { + if (result.code !== 0 || !result.stdout.trim()) { return ""; } diff --git a/vibn-frontend/lib/dev-container.ts b/vibn-frontend/lib/dev-container.ts index 41753172..76b0586c 100644 --- a/vibn-frontend/lib/dev-container.ts +++ b/vibn-frontend/lib/dev-container.ts @@ -747,7 +747,7 @@ export async function probeDevServerReadiness( command: probeCmd, timeoutMs: 310_000, }); - if (r.exitCode === 0) { + if (r.code === 0) { await query( `UPDATE fs_dev_servers SET state = 'running' WHERE id = $1 AND project_id = $2 AND state != 'stopped'`, [serverId, projectId], @@ -759,7 +759,7 @@ export async function probeDevServerReadiness( projectId, serverId, port, - exitCode: r.exitCode, + exitCode: r.code, stdout: (r.stdout || "").slice(0, 600), }), ); @@ -767,7 +767,7 @@ export async function probeDevServerReadiness( `UPDATE fs_dev_servers SET state = 'failed' WHERE id = $1 AND project_id = $2 AND state != 'stopped'`, [serverId, projectId], ); - throw new Error(`Probe failed with exit code ${r.exitCode}: ${r.stdout}`); + throw new Error(`Probe failed with exit code ${r.code}: ${r.stdout}`); } } catch (err) { console.error(