feat(refactor): live zed-style codebase files autocomplete and context attachment

This commit is contained in:
2026-05-21 17:20:31 -07:00
parent 8049a7f1ab
commit b3dd3714c3
5 changed files with 325 additions and 20 deletions

View File

@@ -30,6 +30,7 @@ import {
} from "@/lib/dev-container-git"; } from "@/lib/dev-container-git";
import { buildDesignKitPromptSection } from "@/lib/design-kits/for-ai"; import { buildDesignKitPromptSection } from "@/lib/design-kits/for-ai";
import { buildCodebaseSummary } from "@/lib/ai/codebase-summary"; import { buildCodebaseSummary } from "@/lib/ai/codebase-summary";
import { execInDevContainer } from "@/lib/dev-container";
import type { ChatMessage, ToolCall } from "@/lib/ai/gemini-chat"; import type { ChatMessage, ToolCall } from "@/lib/ai/gemini-chat";
// C-01: Lowered from 15 → 8. Real workflows (scaffold → install → // C-01: Lowered from 15 → 8. Real workflows (scaffold → install →
@@ -404,6 +405,7 @@ export async function POST(request: Request) {
workspace: string; workspace: string;
mcp_token?: string; mcp_token?: string;
chatMode?: "vibe" | "collaborate" | "delegate"; chatMode?: "vibe" | "collaborate" | "delegate";
attachedFiles?: string[];
}; };
try { try {
body = await request.json(); body = await request.json();
@@ -411,7 +413,14 @@ export async function POST(request: Request) {
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 }); 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()) { if (!thread_id || !message?.trim()) {
return NextResponse.json( return NextResponse.json(
{ error: "thread_id and message are required" }, { error: "thread_id and message are required" },
@@ -529,6 +538,32 @@ export async function POST(request: Request) {
chatMode, 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 // Sentry-as-product Stage 4: auto-surface unresolved errors at
// chat-turn start. We pull the last 6 hours' unresolved issues // chat-turn start. We pull the last 6 hours' unresolved issues
// for the active project; if anything has fired ≥2 times, we // for the active project; if anything has fired ≥2 times, we

View File

@@ -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 });
}
}

View File

@@ -882,6 +882,24 @@ export function ChatPanel({
const [chatMode, setChatMode] = useState<"collaborate" | "vibe" | "delegate">( const [chatMode, setChatMode] = useState<"collaborate" | "vibe" | "delegate">(
"vibe", "vibe",
); );
const [projectFiles, setProjectFiles] = useState<string[]>([]);
const [attachedFiles, setAttachedFiles] = useState<string[]>([]);
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 [sending, setSending] = useState(false);
const [showThreads, setShowThreads] = useState(false); const [showThreads, setShowThreads] = useState(false);
const [mcpToken, setMcpToken] = useState<string | null>(null); const [mcpToken, setMcpToken] = useState<string | null>(null);
@@ -1149,6 +1167,7 @@ export function ChatPanel({
throw new Error(err.error || `HTTP ${r.status}`); throw new Error(err.error || `HTTP ${r.status}`);
} }
setAttachedFiles([]);
setMessages((prev) => [ setMessages((prev) => [
...prev, ...prev,
{ {
@@ -1170,10 +1189,13 @@ export function ChatPanel({
workspace, workspace,
mcp_token: mcpToken, mcp_token: mcpToken,
chatMode, chatMode,
attachedFiles,
}), }),
signal: controller.signal, signal: controller.signal,
}); });
setAttachedFiles([]);
if (!res.ok || !res.body) throw new Error("Stream failed"); if (!res.ok || !res.body) throw new Error("Stream failed");
const reader = res.body.getReader(); const reader = res.body.getReader();
@@ -1389,6 +1411,7 @@ export function ChatPanel({
unifiedProjectShell, unifiedProjectShell,
chatMode, chatMode,
projectId, projectId,
attachedFiles,
], ],
); );
@@ -1419,7 +1442,50 @@ export function ChatPanel({
window.removeEventListener("vibn:chat-prompt", onPrompt as EventListener); window.removeEventListener("vibn:chat-prompt", onPrompt as EventListener);
}, [sendMessage, projectId]); }, [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<HTMLTextAreaElement>) => { const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
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) { if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault(); e.preventDefault();
sendMessage(); sendMessage();
@@ -1427,6 +1493,7 @@ export function ChatPanel({
e.preventDefault(); e.preventDefault();
cancelMessage(); cancelMessage();
} }
}
}; };
// Slide-out chat hidden until signed in. Structural project shell always // Slide-out chat hidden until signed in. Structural project shell always
@@ -1676,6 +1743,7 @@ export function ChatPanel({
borderTop: "1px solid #e8e4dc", borderTop: "1px solid #e8e4dc",
background: "#faf8f5", background: "#faf8f5",
flexShrink: 0, flexShrink: 0,
position: "relative",
}} }}
> >
{/* Chat Mode Toggle */} {/* Chat Mode Toggle */}
@@ -1806,6 +1874,149 @@ export function ChatPanel({
Read-only mode add your MCP token in Settings to enable actions. Read-only mode add your MCP token in Settings to enable actions.
</div> </div>
)} )}
{/* File Autocomplete Suggestions */}
{showSuggestions &&
(() => {
const filtered = projectFiles
.filter((f) =>
f.toLowerCase().includes(suggestionsFilter.toLowerCase()),
)
.slice(0, 8);
if (filtered.length === 0) return null;
return (
<div
style={{
position: "absolute",
bottom: "calc(100% - 10px)",
left: 14,
right: 14,
background: "#fff",
border: "1px solid #e8e4dc",
borderRadius: 8,
boxShadow:
"0 -4px 12px rgba(0,0,0,0.08), 0 2px 4px rgba(0,0,0,0.02)",
zIndex: 1000,
maxHeight: 200,
overflowY: "auto",
padding: "4px 0",
}}
>
<div
style={{
fontSize: "0.65rem",
fontWeight: 600,
color: "#a09a90",
padding: "6px 12px 4px",
borderBottom: "1px solid #f0ede8",
marginBottom: 4,
textTransform: "uppercase",
letterSpacing: "0.05em",
}}
>
Codebase Files
</div>
{filtered.map((file, idx) => (
<div
key={file}
onClick={() => {
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",
}}
>
<span
style={{
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
📄 {file}
</span>
{idx === suggestionIndex && (
<span
style={{
fontSize: "0.6rem",
color: "#3d5afe",
opacity: 0.7,
fontFamily: "var(--font-inter), sans-serif",
}}
>
press Enter
</span>
)}
</div>
))}
</div>
);
})()}
{/* Attached Files Chips */}
{attachedFiles.length > 0 && (
<div
style={{
display: "flex",
flexWrap: "wrap",
gap: 6,
marginBottom: 8,
}}
>
{attachedFiles.map((file) => (
<div
key={file}
style={{
display: "flex",
alignItems: "center",
gap: 6,
background: "#eef2ff",
border: "1px solid #c7d2fe",
borderRadius: 6,
padding: "4px 8px",
fontSize: "0.7rem",
color: "#312e81",
fontFamily: "var(--font-mono), monospace",
}}
>
<span>📄 {file.split("/").pop()}</span>
<button
type="button"
onClick={() =>
setAttachedFiles((prev) => prev.filter((f) => f !== file))
}
style={{
background: "none",
border: "none",
padding: 0,
cursor: "pointer",
color: "#4338ca",
display: "flex",
alignItems: "center",
}}
title="Remove file"
>
<X style={{ width: 12, height: 12 }} />
</button>
</div>
))}
</div>
)}
<ProjectPreviewChatInputWrap unifiedShell={unifiedProjectShell}> <ProjectPreviewChatInputWrap unifiedShell={unifiedProjectShell}>
{(selectToggle) => ( {(selectToggle) => (
<div <div
@@ -1823,7 +2034,7 @@ export function ChatPanel({
<textarea <textarea
ref={inputRef} ref={inputRef}
value={input} value={input}
onChange={(e) => setInput(e.target.value)} onChange={(e) => handleInputChange(e.target.value)}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
placeholder={ placeholder={
sending ? "Esc to stop generating…" : "Ask Vibn AI anything…" sending ? "Esc to stop generating…" : "Ask Vibn AI anything…"

View File

@@ -1,10 +1,4 @@
import { NextResponse } from "next/server"; import { ensureDevContainer, execInDevContainer } from "@/lib/dev-container";
import { query } from "@/lib/db-postgres";
import {
ensureDevContainer,
execInDevContainer,
getDevContainerStatus,
} from "@/lib/dev-container";
import { authSession } from "@/lib/auth/session-server"; import { authSession } from "@/lib/auth/session-server";
/** /**
@@ -19,7 +13,9 @@ export async function buildCodebaseSummary(
if (!projectId || !projectSlug) return ""; if (!projectId || !projectSlug) return "";
try { try {
const session = await authSession(); const session = (await authSession()) as unknown as {
workspace?: import("@/lib/workspaces").VibnWorkspace;
};
if (!session?.workspace) return ""; if (!session?.workspace) return "";
// Ensure the container is actually running before we try to exec inside it // Ensure the container is actually running before we try to exec inside it
@@ -61,7 +57,7 @@ export async function buildCodebaseSummary(
command: bashScript, command: bashScript,
}); });
if (result.exitCode !== 0 || !result.stdout.trim()) { if (result.code !== 0 || !result.stdout.trim()) {
return ""; return "";
} }

View File

@@ -747,7 +747,7 @@ export async function probeDevServerReadiness(
command: probeCmd, command: probeCmd,
timeoutMs: 310_000, timeoutMs: 310_000,
}); });
if (r.exitCode === 0) { if (r.code === 0) {
await query( await query(
`UPDATE fs_dev_servers SET state = 'running' WHERE id = $1 AND project_id = $2 AND state != 'stopped'`, `UPDATE fs_dev_servers SET state = 'running' WHERE id = $1 AND project_id = $2 AND state != 'stopped'`,
[serverId, projectId], [serverId, projectId],
@@ -759,7 +759,7 @@ export async function probeDevServerReadiness(
projectId, projectId,
serverId, serverId,
port, port,
exitCode: r.exitCode, exitCode: r.code,
stdout: (r.stdout || "").slice(0, 600), 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'`, `UPDATE fs_dev_servers SET state = 'failed' WHERE id = $1 AND project_id = $2 AND state != 'stopped'`,
[serverId, projectId], [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) { } catch (err) {
console.error( console.error(