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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user