= (props) => {
return (
= (props) => {
location={location}
recentChats={chats}
/>
+ {
+ if (!open) setProjectDialogProject(undefined);
+ }}
+ onSubmit={handleProjectSubmit}
+ />
+ setProjectPendingDelete(null)}
+ entity="project"
+ name={projectPendingDelete?.name ?? ""}
+ confirmLoading={deleteProjectMutation.isPending}
+ info="Chats in this project will be kept and become independent."
+ />
{onRenameTitle && (
void;
+ readonly onDeleteProject?: (project: ChatProject) => void;
readonly chats: readonly Chat[];
readonly chatErrorReasons: Record;
readonly modelConfigs: readonly ChatModel[];
@@ -100,6 +105,10 @@ interface ChatsPanelProps {
}
export const ChatsPanel: FC = ({
+ projects,
+ isProjectsLoading,
+ onOpenProjectDialog,
+ onDeleteProject,
chats,
chatErrorReasons,
modelConfigs,
@@ -560,6 +569,19 @@ export const ChatsPanel: FC = ({
))}
)}
+ {onOpenProjectDialog && onDeleteProject && (
+ toggleSection("Projects")}
+ onCreate={() => onOpenProjectDialog(null)}
+ onEdit={onOpenProjectDialog}
+ onDelete={onDeleteProject}
+ />
+ )}
+ {isProjectsLoading && (
+
+ )}
{sharedWithYouChats.length > 0 && (
void;
+ readonly onCreate: () => void;
+ readonly onEdit: (project: ChatProject) => void;
+ readonly onDelete: (project: ChatProject) => void;
+};
+
+export const ProjectsSection: FC = ({
+ projects,
+ expanded,
+ onToggle,
+ onCreate,
+ onEdit,
+ onDelete,
+}) => {
+ const location = useLocation();
+
+ return (
+
+
+
+
+
+ {expanded && (
+
+ {projects.map((project) => (
+
+
+
+
+ {project.name}
+
+
+ {project.chat_count}
+
+
+
+
+
+
+ onEdit(project)}>
+ Edit project
+
+ onDelete(project)}
+ >
+ Delete project
+
+
+
+
+
+
+ onEdit(project)}>
+ Edit project
+
+ onDelete(project)}
+ >
+ Delete project
+
+
+
+ ))}
+
+ )}
+
+ );
+};
diff --git a/site/src/pages/AgentsPage/components/ChatsSidebar/dialogs/ChatProjectDialog.stories.tsx b/site/src/pages/AgentsPage/components/ChatsSidebar/dialogs/ChatProjectDialog.stories.tsx
new file mode 100644
index 00000000000..70f9a9a95c6
--- /dev/null
+++ b/site/src/pages/AgentsPage/components/ChatsSidebar/dialogs/ChatProjectDialog.stories.tsx
@@ -0,0 +1,48 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { fn, userEvent, within } from "storybook/test";
+import { MockChatProject } from "#/testHelpers/entities";
+import { ChatProjectDialog } from "./ChatProjectDialog";
+
+const meta = {
+ title: "pages/AgentsPage/ChatProjectDialog",
+ component: ChatProjectDialog,
+ args: {
+ organizationId: MockChatProject.organization_id,
+ open: true,
+ onOpenChange: fn(),
+ onSubmit: fn(async () => undefined),
+ },
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+export const Create: Story = {};
+
+export const Edit: Story = {
+ args: { project: MockChatProject },
+};
+
+export const Submitting: Story = {
+ args: {
+ onSubmit: fn(() => new Promise(() => {})),
+ },
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ await userEvent.type(canvas.getByLabelText("Name"), "Launch");
+ await userEvent.click(canvas.getByRole("button", { name: "Save" }));
+ },
+};
+
+export const DuplicateNameError: Story = {
+ args: {
+ onSubmit: fn(async () => {
+ throw new globalThis.Error("A project with this name already exists.");
+ }),
+ },
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ await userEvent.type(canvas.getByLabelText("Name"), "Launch");
+ await userEvent.click(canvas.getByRole("button", { name: "Save" }));
+ },
+};
diff --git a/site/src/pages/AgentsPage/components/ChatsSidebar/dialogs/ChatProjectDialog.test.tsx b/site/src/pages/AgentsPage/components/ChatsSidebar/dialogs/ChatProjectDialog.test.tsx
new file mode 100644
index 00000000000..d7ddd63550e
--- /dev/null
+++ b/site/src/pages/AgentsPage/components/ChatsSidebar/dialogs/ChatProjectDialog.test.tsx
@@ -0,0 +1,29 @@
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { describe, expect, it, vi } from "vitest";
+import { ChatProjectDialog } from "./ChatProjectDialog";
+
+describe("ChatProjectDialog", () => {
+ it("submits a create request", async () => {
+ const user = userEvent.setup();
+ const onSubmit = vi.fn(async () => {});
+ render(
+ ,
+ );
+
+ await user.type(screen.getByLabelText("Name"), "Launch");
+ await user.type(screen.getByLabelText("Description"), "Release work");
+ await user.click(screen.getByRole("button", { name: "Save" }));
+
+ expect(onSubmit).toHaveBeenCalledWith({
+ organization_id: "organization-1",
+ name: "Launch",
+ description: "Release work",
+ });
+ });
+});
diff --git a/site/src/pages/AgentsPage/components/ChatsSidebar/dialogs/ChatProjectDialog.tsx b/site/src/pages/AgentsPage/components/ChatsSidebar/dialogs/ChatProjectDialog.tsx
new file mode 100644
index 00000000000..dce9a2b080b
--- /dev/null
+++ b/site/src/pages/AgentsPage/components/ChatsSidebar/dialogs/ChatProjectDialog.tsx
@@ -0,0 +1,125 @@
+import { type FC, useId, useState } from "react";
+import { getErrorMessage } from "#/api/errors";
+import type * as TypesGen from "#/api/typesGenerated";
+import { Button } from "#/components/Button/Button";
+import {
+ Dialog,
+ DialogContent,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "#/components/Dialog/Dialog";
+import { Input } from "#/components/Input/Input";
+import { Label } from "#/components/Label/Label";
+import { Spinner } from "#/components/Spinner/Spinner";
+import { Textarea } from "#/components/Textarea/Textarea";
+
+type ChatProjectDialogProps = {
+ readonly organizationId: string;
+ readonly project?: TypesGen.ChatProject | null;
+ readonly open: boolean;
+ readonly onOpenChange: (open: boolean) => void;
+ readonly onSubmit: (
+ request:
+ | TypesGen.CreateChatProjectRequest
+ | TypesGen.UpdateChatProjectRequest,
+ ) => Promise;
+};
+
+export const ChatProjectDialog: FC = ({
+ organizationId,
+ project,
+ open,
+ onOpenChange,
+ onSubmit,
+}) => {
+ const nameId = useId();
+ const descriptionId = useId();
+ const [name, setName] = useState(project?.name ?? "");
+ const [description, setDescription] = useState(project?.description ?? "");
+ const [isSaving, setIsSaving] = useState(false);
+ const [error, setError] = useState();
+ const isEditing = project !== null && project !== undefined;
+
+ const handleOpenChange = (nextOpen: boolean) => {
+ if (!nextOpen && !isSaving) {
+ onOpenChange(false);
+ }
+ };
+
+ const handleSubmit = async (event: React.FormEvent) => {
+ event.preventDefault();
+ const trimmedName = name.trim();
+ if (!trimmedName) {
+ return;
+ }
+ setIsSaving(true);
+ setError(undefined);
+ await onSubmit(
+ isEditing
+ ? { name: trimmedName, description: description.trim() }
+ : {
+ organization_id: organizationId,
+ name: trimmedName,
+ description: description.trim(),
+ },
+ )
+ .then(() => {
+ onOpenChange(false);
+ })
+ .catch((submitError) => {
+ setError(getErrorMessage(submitError, "Failed to save project."));
+ });
+ setIsSaving(false);
+ };
+
+ return (
+
+ );
+};
diff --git a/site/src/pages/AgentsPage/components/ProjectMemorySection.stories.tsx b/site/src/pages/AgentsPage/components/ProjectMemorySection.stories.tsx
new file mode 100644
index 00000000000..cf30f4f41f9
--- /dev/null
+++ b/site/src/pages/AgentsPage/components/ProjectMemorySection.stories.tsx
@@ -0,0 +1,34 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { chatProjectMemoriesKey } from "#/api/queries/chatProjectsKeys";
+import {
+ MockChatProject,
+ MockChatProjectMemory,
+ MockChatProjectMemory2,
+} from "#/testHelpers/entities";
+import { ProjectMemorySection } from "./ProjectMemorySection";
+
+const meta = {
+ title: "pages/AgentsPage/ProjectMemorySection",
+ component: ProjectMemorySection,
+ args: { projectId: MockChatProject.id },
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+export const Empty: Story = {
+ parameters: {
+ queries: [{ key: chatProjectMemoriesKey(MockChatProject.id), data: [] }],
+ },
+};
+
+export const Populated: Story = {
+ parameters: {
+ queries: [
+ {
+ key: chatProjectMemoriesKey(MockChatProject.id),
+ data: [MockChatProjectMemory, MockChatProjectMemory2],
+ },
+ ],
+ },
+};
diff --git a/site/src/pages/AgentsPage/components/ProjectMemorySection.test.tsx b/site/src/pages/AgentsPage/components/ProjectMemorySection.test.tsx
new file mode 100644
index 00000000000..3e3fc05035a
--- /dev/null
+++ b/site/src/pages/AgentsPage/components/ProjectMemorySection.test.tsx
@@ -0,0 +1,137 @@
+import { render, screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { HttpResponse, http } from "msw";
+import type { FC, PropsWithChildren } from "react";
+import { QueryClientProvider } from "react-query";
+import { afterEach, describe, expect, it } from "vitest";
+import { TooltipProvider } from "#/components/Tooltip/Tooltip";
+import { MockChatProject, MockChatProjectMemory } from "#/testHelpers/entities";
+import { createTestQueryClient } from "#/testHelpers/renderHelpers";
+import { server } from "#/testHelpers/server";
+import { ProjectMemorySection } from "./ProjectMemorySection";
+
+const Wrapper: FC = ({ children }) => {
+ const queryClient = createTestQueryClient();
+ return (
+
+ {children}
+
+ );
+};
+
+afterEach(() => server.resetHandlers());
+
+describe("ProjectMemorySection", () => {
+ it("creates a memory with a POST request", async () => {
+ const user = userEvent.setup();
+ let requestBody: unknown;
+ server.use(
+ http.get("/api/experimental/chats/projects/:projectId/memories", () =>
+ HttpResponse.json([]),
+ ),
+ http.post(
+ "/api/experimental/chats/projects/:projectId/memories",
+ async ({ request }) => {
+ requestBody = await request.json();
+ return HttpResponse.json(MockChatProjectMemory, { status: 201 });
+ },
+ ),
+ );
+
+ render(
+
+
+ ,
+ );
+
+ await user.click(await screen.findByRole("button", { name: "Add memory" }));
+ await user.type(screen.getByLabelText("Name"), "durable-fact");
+ await user.type(screen.getByLabelText("Description"), "A durable fact");
+ await user.type(screen.getByLabelText("Body"), "Project memory body");
+ await user.click(screen.getByRole("button", { name: "Save" }));
+
+ await waitFor(() => {
+ expect(requestBody).toEqual({
+ name: "durable-fact",
+ description: "A durable fact",
+ body: "Project memory body",
+ });
+ });
+ });
+
+ it("prefills the edit dialog with the selected memory", async () => {
+ const user = userEvent.setup();
+ server.use(
+ http.get("/api/experimental/chats/projects/:projectId/memories", () =>
+ HttpResponse.json([MockChatProjectMemory]),
+ ),
+ );
+
+ render(
+
+
+ ,
+ );
+
+ await user.click(
+ await screen.findByRole("button", {
+ name: new RegExp(MockChatProjectMemory.name),
+ expanded: false,
+ }),
+ );
+ await user.click(screen.getByRole("button", { name: "Edit" }));
+
+ expect(screen.getByLabelText("Name")).toHaveValue(
+ MockChatProjectMemory.name,
+ );
+ expect(screen.getByLabelText("Description")).toHaveValue(
+ MockChatProjectMemory.description,
+ );
+ expect(screen.getByLabelText("Body")).toHaveValue(
+ MockChatProjectMemory.body,
+ );
+ });
+
+ it("deletes a memory after confirmation", async () => {
+ const user = userEvent.setup();
+ let deletedMemoryID: string | undefined;
+ let deletedMemoryURL: string | undefined;
+ server.use(
+ http.get("/api/experimental/chats/projects/:projectId/memories", () =>
+ HttpResponse.json([MockChatProjectMemory]),
+ ),
+ http.delete("*", ({ request }) => {
+ deletedMemoryURL = request.url;
+ deletedMemoryID = request.url.split("/").at(-1);
+ return new HttpResponse(null, { status: 204 });
+ }),
+ );
+
+ render(
+
+
+ ,
+ );
+
+ // The body and actions only render after expanding the row.
+ await user.click(
+ await screen.findByRole("button", {
+ name: new RegExp(MockChatProjectMemory.name),
+ expanded: false,
+ }),
+ );
+ await user.click(screen.getByRole("button", { name: "Delete" }));
+ await user.type(
+ screen.getByLabelText("Name of the memory to delete"),
+ MockChatProjectMemory.name,
+ );
+ await user.click(screen.getByRole("button", { name: "Delete" }));
+
+ await waitFor(() => {
+ expect(deletedMemoryURL).toContain(
+ `/api/experimental/chats/projects/${MockChatProject.id}/memories/${MockChatProjectMemory.id}`,
+ );
+ expect(deletedMemoryID).toBe(MockChatProjectMemory.id);
+ });
+ });
+});
diff --git a/site/src/pages/AgentsPage/components/ProjectMemorySection.tsx b/site/src/pages/AgentsPage/components/ProjectMemorySection.tsx
new file mode 100644
index 00000000000..f517b41e4df
--- /dev/null
+++ b/site/src/pages/AgentsPage/components/ProjectMemorySection.tsx
@@ -0,0 +1,176 @@
+import { cn } from "cn";
+import { ChevronRightIcon, PlusIcon } from "lucide-react";
+import { type FC, useState } from "react";
+import { useMutation, useQuery, useQueryClient } from "react-query";
+import {
+ chatProjectMemories,
+ createChatProjectMemory,
+ deleteChatProjectMemory,
+ updateChatProjectMemory,
+} from "#/api/queries/chatProjectMemories";
+import type * as TypesGen from "#/api/typesGenerated";
+import { ErrorAlert } from "#/components/Alert/ErrorAlert";
+import { Button } from "#/components/Button/Button";
+import { DeleteDialog } from "#/components/Dialog/DeleteDialog/DeleteDialog";
+import { MemoizedMarkdown } from "#/components/Markdown/Markdown";
+import { Skeleton } from "#/components/Skeleton/Skeleton";
+import { shortRelativeTime } from "#/utils/time";
+import { ChatProjectMemoryDialog } from "./ChatProjectMemoryDialog";
+
+type ProjectMemorySectionProps = {
+ readonly projectId: string;
+};
+
+/**
+ * Lists the memories the agent has saved for a project. Rows are collapsed
+ * to their names by default; the body and actions only appear on expand.
+ * Manual creation is available but intentionally understated: the agent is
+ * the expected writer.
+ */
+export const ProjectMemorySection: FC = ({
+ projectId,
+}) => {
+ const queryClient = useQueryClient();
+ const memoriesQuery = useQuery(chatProjectMemories(projectId));
+ const createMutation = useMutation(createChatProjectMemory(queryClient));
+ const updateMutation = useMutation(updateChatProjectMemory(queryClient));
+ const deleteMutation = useMutation(deleteChatProjectMemory(queryClient));
+ const [editingMemory, setEditingMemory] = useState<
+ TypesGen.ChatProjectMemory | null | undefined
+ >(undefined);
+ const [deletingMemory, setDeletingMemory] =
+ useState(null);
+ const [expandedMemoryID, setExpandedMemoryID] = useState(null);
+
+ if (memoriesQuery.isLoading) {
+ return ;
+ }
+ if (memoriesQuery.error) {
+ return ;
+ }
+ const memories = memoriesQuery.data ?? [];
+
+ return (
+
+
+
+
+ Memory
+
+
+ Facts the agent saved while working in this project. Every chat in
+ the project can read them.
+
+
+
+
+ {memories.length === 0 ? (
+
+ No memories yet. The agent saves them as it learns durable facts about
+ this project.
+
+ ) : (
+
+ {memories.map((memory) => {
+ const expanded = expandedMemoryID === memory.id;
+ return (
+ -
+
+ {expanded && (
+
+
+ {memory.description}
+
+
{memory.body}
+
+
+ Updated {shortRelativeTime(memory.updated_at)} by{" "}
+ {memory.created_by_username || "Unknown"}
+
+
+
+
+
+
+
+ )}
+
+ );
+ })}
+
+ )}
+ {
+ if (!open) setEditingMemory(undefined);
+ }}
+ onSubmit={async (request) => {
+ if (editingMemory) {
+ await updateMutation.mutateAsync({
+ projectId,
+ memoryId: editingMemory.id,
+ request,
+ });
+ return;
+ }
+ await createMutation.mutateAsync({ projectId, request });
+ }}
+ />
+ setDeletingMemory(null)}
+ onConfirm={() => {
+ if (deletingMemory) {
+ deleteMutation.mutate(
+ { projectId, memoryId: deletingMemory.id },
+ { onSuccess: () => setDeletingMemory(null) },
+ );
+ }
+ }}
+ entity="memory"
+ name={deletingMemory?.name ?? ""}
+ confirmLoading={deleteMutation.isPending}
+ />
+
+ );
+};
diff --git a/site/src/pages/AgentsPage/utils/navigation.ts b/site/src/pages/AgentsPage/utils/navigation.ts
index 09b8da4944c..b0231b7d6cf 100644
--- a/site/src/pages/AgentsPage/utils/navigation.ts
+++ b/site/src/pages/AgentsPage/utils/navigation.ts
@@ -9,6 +9,10 @@ export const buildAgentChatPath = ({
return `/agents/${encodeURIComponent(chatId)}`;
};
+export const buildAgentProjectPath = (projectId: string): string => {
+ return `/agents/projects/${encodeURIComponent(projectId)}`;
+};
+
export const safeBuildAgentChatPath = ({
chatId,
}: Readonly<{
diff --git a/site/src/router.tsx b/site/src/router.tsx
index 51fc0659bb2..bc75ddf505e 100644
--- a/site/src/router.tsx
+++ b/site/src/router.tsx
@@ -363,6 +363,9 @@ const AgentsPageLayout = lazy(
() => import("./pages/AgentsPage/AgentsPageLayout"),
);
const AgentChatPage = lazy(() => import("./pages/AgentsPage/AgentChatPage"));
+const AgentProjectPage = lazy(
+ () => import("./pages/AgentsPage/AgentProjectPage"),
+);
const AgentEmbedPage = lazy(() => import("./pages/AgentsPage/AgentEmbedPage"));
const DesktopPopoutPage = lazy(
() => import("./pages/AgentsPage/DesktopPopoutPage"),
@@ -886,6 +889,14 @@ export const router = createBrowserRouter(
element={}
/>
+ }>
+
+
+ }
+ />