feat(chat): scope AI conversations to the active project

The chat panel now reads projectId from the URL and tags every thread
to it, so:
- Threads listed in the side panel are filtered to the project the user
  is currently viewing (workspace-level chats still work from /projects).
- New conversations started from a project page are persisted with that
  project_id, surviving page reloads.
- The system prompt prepends an ACTIVE PROJECT block so tool calls
  (apps_create, devcontainer_ensure, etc.) use the right projectId
  without the user having to name it.
- A small chip in the chat header shows which project the AI is
  currently talking about.

Schema migrates idempotently on first request (project_id column +
composite index on fs_chat_threads).

Made-with: Cursor
This commit is contained in:
2026-04-29 17:41:45 -07:00
parent 85db68636b
commit b706fa0e89
3 changed files with 174 additions and 33 deletions

View File

@@ -220,6 +220,12 @@ export function ChatPanel() {
const { data: sessionData, status } = useSession();
const params = useParams();
const workspace = (params?.workspace as string) || "";
// When the user is on a /project/<id>/* route, scope the chat to
// that project. The threads list, the new-thread create call, and
// the system prompt all branch on this; the chat header surfaces it
// so the user knows the AI is "talking about" the right thing.
const projectId = (params?.projectId as string) || "";
const [activeProjectName, setActiveProjectName] = useState<string | null>(null);
const [open, setOpen] = useState(() => {
if (typeof window === "undefined") return false;
@@ -272,29 +278,53 @@ export function ChatPanel() {
.catch(() => {});
}, [workspace, status]);
// Load threads
// Load threads (scoped to the current project when one is in the URL).
// Reset the loaded flag when projectId changes so the resume effect
// re-runs against the correct list and doesn't restore a thread from
// the previous project.
const loadThreads = useCallback(async () => {
if (!workspace || status !== "authenticated") return;
try {
const res = await fetch(`/api/chat/threads?workspace=${encodeURIComponent(workspace)}`);
const qs = new URLSearchParams({ workspace });
if (projectId) qs.set("projectId", projectId);
const res = await fetch(`/api/chat/threads?${qs.toString()}`);
const data = await res.json();
setThreads(data.threads || []);
} catch { /* silent */ } finally {
setThreadsLoaded(true);
}
}, [workspace, status]);
}, [workspace, projectId, status]);
useEffect(() => {
setThreadsLoaded(false);
setActiveThread(null);
setMessages([]);
loadThreads();
}, [loadThreads]);
}, [loadThreads, projectId]);
// Create and activate a new thread
// Look up the active project's display name once we have a projectId,
// so the chat header can show "Talking about: <name>".
useEffect(() => {
if (!projectId) { setActiveProjectName(null); return; }
let cancelled = false;
fetch(`/api/projects/${projectId}/anatomy`, { credentials: "include" })
.then((r) => r.ok ? r.json() : null)
.then((d) => {
if (cancelled) return;
const name = d?.project?.name;
if (name) setActiveProjectName(name);
})
.catch(() => {});
return () => { cancelled = true; };
}, [projectId]);
// Create and activate a new thread (tagged to the active project, if any).
const newThread = useCallback(async () => {
try {
const res = await fetch("/api/chat/threads", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ workspace }),
body: JSON.stringify({ workspace, projectId: projectId || undefined }),
});
const data = await res.json();
if (data.thread) {
@@ -304,7 +334,7 @@ export function ChatPanel() {
setShowThreads(false);
}
} catch { /* silent */ }
}, [workspace]);
}, [workspace, projectId]);
// Load thread messages
const loadThread = useCallback(async (id: string) => {
@@ -334,18 +364,21 @@ export function ChatPanel() {
return;
}
const savedKey = `vibn-chat-active-thread:${workspace}`;
const scopeKey = projectId ? `${workspace}:${projectId}` : workspace;
const savedKey = `vibn-chat-active-thread:${scopeKey}`;
const saved = typeof window !== "undefined" ? localStorage.getItem(savedKey) : null;
const target = saved && threads.some((t) => t.id === saved) ? saved : threads[0].id;
loadThread(target);
}, [open, status, workspace, threadsLoaded, threads, activeThread, newThread, loadThread]);
}, [open, status, workspace, projectId, threadsLoaded, threads, activeThread, newThread, loadThread]);
// Persist active thread so reload re-opens the same conversation.
// Persist active thread so reload re-opens the same conversation,
// keyed per-project so each project has its own "last conversation".
useEffect(() => {
if (typeof window === "undefined" || !workspace) return;
const savedKey = `vibn-chat-active-thread:${workspace}`;
const scopeKey = projectId ? `${workspace}:${projectId}` : workspace;
const savedKey = `vibn-chat-active-thread:${scopeKey}`;
if (activeThread) localStorage.setItem(savedKey, activeThread);
}, [activeThread, workspace]);
}, [activeThread, workspace, projectId]);
useEffect(() => { scrollToBottom(); }, [messages, toolEvents, scrollToBottom]);
@@ -583,6 +616,20 @@ export function ChatPanel() {
transition: "transform 0.15s",
transform: showThreads ? "rotate(180deg)" : "none",
}} />
{projectId && (
<span
title={`Chat is scoped to ${activeProjectName ?? "this project"}. Tool calls assume projectId=${projectId}.`}
style={{
marginLeft: 6, padding: "2px 8px",
fontSize: "0.65rem", fontWeight: 600, letterSpacing: "0.02em",
color: "#3d5afe", background: "#3d5afe14",
borderRadius: 999, maxWidth: 160,
whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis",
}}
>
{activeProjectName ?? "this project"}
</span>
)}
</button>
<div style={{ display: "flex", gap: 4 }}>
<button