@@ -3243,6 +3324,55 @@ export function UnifiedChat() {
+
+ {/* Project selector — only for new chats */}
+ {!chatId && (
+
+
+
+
+
+ setSelectedProjectId(null)}>
+
+ No project
+
+ {projects.length > 0 && }
+ {projects.map((project) => (
+ setSelectedProjectId(project.id)}
+ >
+
+
+ {project.name}
+
+ ))}
+
+
+ )}
diff --git a/frontend/src/components/projects/CreateProjectDialog.tsx b/frontend/src/components/projects/CreateProjectDialog.tsx
new file mode 100644
index 00000000..a600ed8d
--- /dev/null
+++ b/frontend/src/components/projects/CreateProjectDialog.tsx
@@ -0,0 +1,89 @@
+import { useState, useEffect } from "react";
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogDescription,
+ DialogFooter
+} from "@/components/ui/dialog";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+
+interface CreateProjectDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ onSubmit: (name: string) => void;
+ mode?: "create" | "rename";
+ initialName?: string;
+}
+
+export function CreateProjectDialog({
+ open,
+ onOpenChange,
+ onSubmit,
+ mode = "create",
+ initialName = ""
+}: CreateProjectDialogProps) {
+ const [name, setName] = useState("");
+
+ useEffect(() => {
+ if (open) {
+ setName(mode === "rename" ? initialName : "");
+ }
+ }, [open, mode, initialName]);
+
+ const handleSubmit = (e: React.FormEvent) => {
+ e.preventDefault();
+ const trimmed = name.trim();
+ if (!trimmed) return;
+ onSubmit(trimmed);
+ onOpenChange(false);
+ };
+
+ const isCreate = mode === "create";
+
+ return (
+
+ );
+}
diff --git a/frontend/src/components/projects/CustomInstructionsDialog.tsx b/frontend/src/components/projects/CustomInstructionsDialog.tsx
new file mode 100644
index 00000000..68174e1b
--- /dev/null
+++ b/frontend/src/components/projects/CustomInstructionsDialog.tsx
@@ -0,0 +1,66 @@
+import { useState, useEffect } from "react";
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogDescription,
+ DialogFooter
+} from "@/components/ui/dialog";
+import { Button } from "@/components/ui/button";
+import { Textarea } from "@/components/ui/textarea";
+
+interface CustomInstructionsDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ currentInstructions: string;
+ onSave: (instructions: string) => void;
+}
+
+export function CustomInstructionsDialog({
+ open,
+ onOpenChange,
+ currentInstructions,
+ onSave
+}: CustomInstructionsDialogProps) {
+ const [instructions, setInstructions] = useState("");
+
+ useEffect(() => {
+ if (open) {
+ setInstructions(currentInstructions);
+ }
+ }, [open, currentInstructions]);
+
+ const handleSave = () => {
+ onSave(instructions);
+ onOpenChange(false);
+ };
+
+ return (
+
+ );
+}
diff --git a/frontend/src/components/projects/DeleteProjectDialog.tsx b/frontend/src/components/projects/DeleteProjectDialog.tsx
new file mode 100644
index 00000000..09371c21
--- /dev/null
+++ b/frontend/src/components/projects/DeleteProjectDialog.tsx
@@ -0,0 +1,57 @@
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle
+} from "@/components/ui/alert-dialog";
+
+interface DeleteProjectDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ onConfirm: () => void;
+ projectName: string;
+}
+
+export function DeleteProjectDialog({
+ open,
+ onOpenChange,
+ onConfirm,
+ projectName
+}: DeleteProjectDialogProps) {
+ const handleConfirm = (e: React.MouseEvent) => {
+ e.preventDefault();
+ onConfirm();
+ onOpenChange(false);
+ };
+
+ return (
+
+
+
+ Delete “{projectName}”?
+
+
+
+ This will permanently delete all project files and chats. To save
+ chats, move them to your chat list or another project before deleting.
+
+
+
+
+
+ Cancel
+
+ Delete
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/projects/ProjectDetailPage.tsx b/frontend/src/components/projects/ProjectDetailPage.tsx
new file mode 100644
index 00000000..03065548
--- /dev/null
+++ b/frontend/src/components/projects/ProjectDetailPage.tsx
@@ -0,0 +1,447 @@
+import { useState, useCallback, useEffect } from "react";
+import { useNavigate } from "@tanstack/react-router";
+import {
+ Folder,
+ MoreHorizontal,
+ SquarePen
+} from "lucide-react";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger
+} from "@/components/ui/dropdown-menu";
+import { ChatContextMenu } from "@/components/ChatContextMenu";
+import { RenameChatDialog } from "@/components/RenameChatDialog";
+import { DeleteChatDialog } from "@/components/DeleteChatDialog";
+import { useOpenAI } from "@/ai/useOpenAi";
+import { Button } from "@/components/ui/button";
+import { Sidebar, SidebarToggle } from "@/components/Sidebar";
+import { useProjects } from "@/state/useProjects";
+import { useIsMobile } from "@/utils/utils";
+import { useOpenSecret } from "@opensecret/react";
+import { CreateProjectDialog } from "./CreateProjectDialog";
+import { DeleteProjectDialog } from "./DeleteProjectDialog";
+import { CustomInstructionsDialog } from "./CustomInstructionsDialog";
+import type { Project } from "@/state/ProjectsContext";
+
+interface ProjectDetailPageProps {
+ projectId: string;
+}
+
+interface ConversationData {
+ id: string;
+ created_at: number;
+ metadata?: {
+ title?: string;
+ [key: string]: unknown;
+ };
+}
+
+export function ProjectDetailPage({ projectId }: ProjectDetailPageProps) {
+ const navigate = useNavigate();
+ const isMobile = useIsMobile();
+ const opensecret = useOpenSecret();
+ const {
+ projects,
+ getProjectById,
+ renameProject,
+ deleteProject,
+ updateCustomInstructions,
+ removeChatFromProject,
+ assignChatToProject
+ } = useProjects();
+
+ const project = getProjectById(projectId);
+
+ const [isSidebarOpen, setIsSidebarOpen] = useState(!isMobile);
+ const [isRenameOpen, setIsRenameOpen] = useState(false);
+ const [isDeleteOpen, setIsDeleteOpen] = useState(false);
+ const [isInstructionsOpen, setIsInstructionsOpen] = useState(false);
+ const [selectedChat, setSelectedChat] = useState<{ id: string; title: string } | null>(null);
+ const [isRenameChatOpen, setIsRenameChatOpen] = useState(false);
+ const [isDeleteChatOpen, setIsDeleteChatOpen] = useState(false);
+ const openai = useOpenAI();
+
+ const handleOpenRenameChat = useCallback((chatId: string, title: string) => {
+ setSelectedChat({ id: chatId, title });
+ setIsRenameChatOpen(true);
+ }, []);
+
+ const handleOpenDeleteChat = useCallback((chatId: string, title: string) => {
+ setSelectedChat({ id: chatId, title });
+ setIsDeleteChatOpen(true);
+ }, []);
+
+ const handleRenameChat = useCallback(
+ async (chatId: string, newTitle: string) => {
+ if (!openai) return;
+ await openai.conversations.update(chatId, {
+ metadata: { title: newTitle }
+ });
+ // Update local map
+ setConversationMap((prev) => {
+ const next = new Map(prev);
+ const conv = next.get(chatId);
+ if (conv) {
+ next.set(chatId, { ...conv, metadata: { ...conv.metadata, title: newTitle } });
+ }
+ return next;
+ });
+ },
+ [openai]
+ );
+
+ const handleDeleteChat = useCallback(
+ async (chatId: string) => {
+ if (!openai) return;
+ try {
+ await openai.conversations.delete(chatId);
+ removeChatFromProject(chatId);
+ } catch (error) {
+ console.error("Error deleting conversation:", error);
+ }
+ },
+ [openai, removeChatFromProject]
+ );
+
+ // Fetch conversation metadata for chat names
+ const [conversationMap, setConversationMap] = useState