diff --git a/apps/web/components/select-spaces-modal.tsx b/apps/web/components/select-spaces-modal.tsx index c3834ec3..f82a5d67 100644 --- a/apps/web/components/select-spaces-modal.tsx +++ b/apps/web/components/select-spaces-modal.tsx @@ -1,6 +1,6 @@ "use client" -import { useState, useMemo, useEffect } from "react" +import { useState, useMemo, useEffect, useCallback, useRef } from "react" import Image from "next/image" import { dmSans125ClassName, dmSansClassName } from "@/lib/fonts" import { Dialog, DialogContent } from "@repo/ui/components/dialog" @@ -17,6 +17,8 @@ import { ArrowRight, BookOpen, Loader, + Pencil, + Check, } from "lucide-react" import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" import { toast } from "sonner" @@ -40,6 +42,7 @@ import { type PluginInfo, } from "@/lib/plugin-catalog" import { InstallSteps, PillButton } from "./integrations/install-steps" +import { useProjectMutations } from "@/hooks/use-project-mutations" interface SelectSpacesModalProps { isOpen: boolean @@ -85,6 +88,14 @@ export function SelectSpacesModal({ onDeleteRequest, }: SelectSpacesModalProps) { const [searchQuery, setSearchQuery] = useState("") + const [editingProject, setEditingProject] = useState<{ + id: string + containerTag: string + originalName: string + name: string + } | null>(null) + const editInputRef = useRef(null) + const editingContainerTag = editingProject?.containerTag const currentSelection = selectedProjects[0] ?? "" const pluginTags = useMemo( @@ -181,6 +192,9 @@ export function SelectSpacesModal({ const [activeCategory, setActiveCategory] = useState(defaultCategory) + const activeDiscoverId = activeCategory.startsWith("discover:") + ? activeCategory.slice("discover:".length) + : null useEffect(() => { if (isOpen) setActiveCategory(defaultCategory) @@ -195,6 +209,7 @@ export function SelectSpacesModal({ pluginId: string key: string } | null>(null) + const { updateProjectMutation } = useProjectMutations() const { data: availablePluginsData } = useQuery({ queryKey: ["plugins"], @@ -208,17 +223,17 @@ export function SelectSpacesModal({ return (await res.json()) as { plugins: string[] } }, staleTime: 5 * 60 * 1000, - enabled: isOpen, + enabled: isOpen && !!activeDiscoverId, }) const { data: apiKeys = [] } = useQuery({ queryKey: ["api-keys", org?.id], - enabled: isOpen && !!org?.id, + enabled: isOpen && !!activeDiscoverId && !!org?.id, queryFn: async () => { if (!org?.id) return [] - const data = await authClient.apiKey.list({ + const data = (await authClient.apiKey.list({ fetchOptions: { query: { metadata: { organizationId: org.id } } }, - }) + })) as unknown as { metadata?: Record | null }[] return data.filter((key) => key.metadata?.organizationId === org.id) }, }) @@ -303,20 +318,80 @@ export function SelectSpacesModal({ useEffect(() => { if (!isOpen) { setNewKey(null) + setEditingProject(null) } }, [isOpen]) - const handleOpenChange = (open: boolean) => { - if (!open) { - onClose() - setSearchQuery("") - } - } + useEffect(() => { + if (!editingContainerTag) return + const frame = requestAnimationFrame(() => { + editInputRef.current?.focus() + editInputRef.current?.select() + }) + return () => cancelAnimationFrame(frame) + }, [editingContainerTag]) - const handleSelect = (containerTag: string) => { - onApply([containerTag]) - setSearchQuery("") - } + const handleOpenChange = useCallback( + (open: boolean) => { + if (!open) { + onClose() + setSearchQuery("") + setEditingProject(null) + } + }, + [onClose], + ) + + const handleSelect = useCallback( + (containerTag: string) => { + setEditingProject(null) + onApply([containerTag]) + setSearchQuery("") + }, + [onApply], + ) + + const startEditing = useCallback((project: ContainerTagListType) => { + const name = project.name ?? project.containerTag + setEditingProject({ + id: project.id, + containerTag: project.containerTag, + originalName: name, + name, + }) + }, []) + + const cancelEditing = useCallback(() => { + setEditingProject(null) + }, []) + + const saveEditing = useCallback(() => { + if (!editingProject) return + const nextName = editingProject.name.trim() + const currentName = editingProject.originalName.trim() + if (!nextName || nextName === currentName) return + + updateProjectMutation.mutate( + { containerTag: editingProject.containerTag, name: nextName }, + { + onSuccess: () => setEditingProject(null), + }, + ) + }, [editingProject, updateProjectMutation]) + + const handleEditKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + e.preventDefault() + saveEditing() + } + if (e.key === "Escape") { + e.preventDefault() + cancelEditing() + } + }, + [cancelEditing, saveEditing], + ) const filteredProjects = useMemo(() => { const byCategory = allSpaces.filter((p) => { @@ -367,28 +442,31 @@ export function SelectSpacesModal({ [filteredProjects, recentSet], ) - const renderRow = (project: ContainerTagListType) => { - const isSelected = currentSelection === project.containerTag - const plugin = detectPluginSpace(project.containerTag) - const pluginProjectName = pluginMetaMap.get( - project.containerTag, - )?.projectName - const pluginIdLabel = pluginProjectName || plugin?.projectId - const isDefault = project.containerTag === DEFAULT_PROJECT_ID - return ( -
- - {enableDelete && !isDefault && onDeleteRequest && ( - - )} -
- ) - } + aria-label="Space name" + /> + + + + ) : ( + + )} + {canEdit && !isEditing && ( + + )} + {enableDelete && !isDefault && !isEditing && onDeleteRequest && ( + + )} + + ) + }, + [ + cancelEditing, + currentSelection, + editingProject, + enableDelete, + handleEditKeyDown, + handleSelect, + onDeleteRequest, + pluginMetaMap, + saveEditing, + startEditing, + updateProjectMutation.isPending, + ], + ) return ( @@ -615,21 +767,14 @@ export function SelectSpacesModal({
{activeCategory.startsWith("discover:") ? ( - connectMutation.mutate( - activeCategory.slice("discover:".length), - ) + newKey?.pluginId === activeDiscoverId ? newKey.key : null } + onConnect={() => { + if (activeDiscoverId) connectMutation.mutate(activeDiscoverId) + }} onDismissKey={() => setNewKey(null)} /> ) : ( diff --git a/apps/web/components/space-selector.tsx b/apps/web/components/space-selector.tsx index bc6ddc2e..bbeeace6 100644 --- a/apps/web/components/space-selector.tsx +++ b/apps/web/components/space-selector.tsx @@ -193,12 +193,15 @@ export function SpaceSelector({ const handleSelectSpacesApply = useCallback( (selected: string[]) => { const next = selected.slice(0, 1) - if (next[0]) { - analytics.spaceSwitched({ space_id: next[0] }) - pushRecent(next[0]) - } - onValueChange(next) + const selectedTag = next[0] setShowSelectSpacesModal(false) + onValueChange(next) + if (selectedTag) { + queueMicrotask(() => { + analytics.spaceSwitched({ space_id: selectedTag }) + pushRecent(selectedTag) + }) + } }, [onValueChange, pushRecent], ) diff --git a/apps/web/hooks/use-project-mutations.ts b/apps/web/hooks/use-project-mutations.ts index b6af4df1..71694bab 100644 --- a/apps/web/hooks/use-project-mutations.ts +++ b/apps/web/hooks/use-project-mutations.ts @@ -4,7 +4,7 @@ import { $fetch } from "@lib/api" import { useMutation, useQueryClient } from "@tanstack/react-query" import { toast } from "sonner" import { useProject } from "@/stores" -import type { ContainerTagListType } from "@lib/types" +import type { ContainerTagListType, Project } from "@lib/types" export function useProjectMutations() { const queryClient = useQueryClient() @@ -89,6 +89,101 @@ export function useProjectMutations() { }, }) + const updateProjectMutation = useMutation({ + mutationFn: async ({ + containerTag, + name, + }: { + containerTag: string + name: string + }) => { + const response = await $fetch(`@patch/container-tags/${containerTag}`, { + body: { name }, + }) + + if (response.error) { + throw new Error(response.error?.message || "Failed to update project") + } + + const data = response.data as + | { containerTag?: string; name?: string | null } + | undefined + + return { + containerTag: data?.containerTag ?? containerTag, + name: data?.name ?? name, + } + }, + onMutate: async (variables) => { + await Promise.all([ + queryClient.cancelQueries({ queryKey: ["projects"] }), + queryClient.cancelQueries({ queryKey: ["container-tags"] }), + ]) + + const previousProjects = queryClient.getQueryData(["projects"]) + const previousContainerTags = queryClient.getQueryData< + ContainerTagListType[] + >(["container-tags"]) + + queryClient.setQueryData(["projects"], (current) => + current?.map((project) => + project.containerTag === variables.containerTag + ? { ...project, name: variables.name } + : project, + ), + ) + queryClient.setQueryData( + ["container-tags"], + (current) => + current?.map((project) => + project.containerTag === variables.containerTag + ? { ...project, name: variables.name } + : project, + ), + ) + + return { previousProjects, previousContainerTags } + }, + onSuccess: (data) => { + if (!data) return + queryClient.setQueryData(["projects"], (current) => + current?.map((project) => + project.containerTag === data.containerTag + ? { ...project, name: data.name } + : project, + ), + ) + queryClient.setQueryData( + ["container-tags"], + (current) => + current?.map((project) => + project.containerTag === data.containerTag + ? { ...project, name: data.name } + : project, + ), + ) + toast.success("Space renamed") + }, + onError: (error, _variables, context) => { + if (context?.previousProjects) { + queryClient.setQueryData(["projects"], context.previousProjects) + } + if (context?.previousContainerTags) { + queryClient.setQueryData( + ["container-tags"], + context.previousContainerTags, + ) + } + toast.error("Failed to rename space", { + description: error instanceof Error ? error.message : "Unknown error", + }) + }, + onSettled: () => { + queryClient.invalidateQueries({ queryKey: ["projects"] }) + queryClient.invalidateQueries({ queryKey: ["container-tags"] }) + }, + }) + const switchProject = (containerTag: string) => { setSelectedProject(containerTag) toast.success("Project switched successfully") @@ -97,6 +192,7 @@ export function useProjectMutations() { return { createProjectMutation, deleteProjectMutation, + updateProjectMutation, switchProject, } } diff --git a/packages/lib/api.ts b/packages/lib/api.ts index 721f818f..8a7bfdee 100644 --- a/packages/lib/api.ts +++ b/packages/lib/api.ts @@ -7,6 +7,7 @@ import { BulkDeleteMemoriesResponseSchema, BulkDeleteMemoriesSchema, ConnectionResponseSchema, + ContainerTagSettingsUpdateSchema, CreateProjectSchema, DeleteProjectResponseSchema, DeleteProjectSchema, @@ -25,6 +26,7 @@ import { SearchResponseSchema, type SearchResult, SettingsRequestSchema, + UpdateContainerTagSettingsRequestSchema, } from "../validation/api" // Settings response schema - this is custom to console (not in shared validation) @@ -252,6 +254,13 @@ export const apiSchema = createSchema({ "@get/container-tags/list": { output: ListContainerTagsResponseSchema, }, + "@patch/container-tags/:containerTag": { + input: UpdateContainerTagSettingsRequestSchema, + output: ContainerTagSettingsUpdateSchema, + params: z.object({ + containerTag: z.string(), + }), + }, "@post/projects": { input: CreateProjectSchema, output: ProjectSchema, diff --git a/packages/validation/api.ts b/packages/validation/api.ts index 76f04b68..e1e8c8ef 100644 --- a/packages/validation/api.ts +++ b/packages/validation/api.ts @@ -1280,6 +1280,51 @@ export const CreateProjectSchema = z description: "Request body for creating a new project", }) +export const ContainerTagSettingsUpdateSchema = z + .object({ + containerTag: z.string().openapi({ + description: "The container tag identifier", + example: "sm_project_default", + }), + name: z.string().nullable().openapi({ + description: "Display name for this container tag", + example: "Research Notes", + }), + entityContext: z.string().nullable().openapi({ + description: "Custom context prompt for this container tag", + example: "This project contains research papers about machine learning.", + }), + memoryFilesystemPaths: z.array(z.string()).nullable(), + updatedAt: z.string().datetime().openapi({ + description: "Last update timestamp", + format: "datetime", + }), + }) + .openapi({ + description: "Response after updating container tag settings", + }) + +export const UpdateContainerTagSettingsRequestSchema = z + .object({ + name: z.string().trim().min(1).max(100).optional().openapi({ + description: + "Display name for this container tag. This does not change the container tag identifier.", + example: "Research Notes", + minLength: 1, + maxLength: 100, + }), + entityContext: z.string().max(1500).nullable().optional().openapi({ + description: + "Custom context prompt for this container tag. Used to provide additional context when processing documents in this container. Maximum 1500 characters.", + example: "This project contains research papers about machine learning.", + maxLength: 1500, + }), + memoryFilesystemPaths: z.array(z.string()).nullable().optional(), + }) + .openapi({ + description: "Request body for updating container tag settings", + }) + export const ListProjectsResponseSchema = z .object({ projects: z.array(ProjectSchema).openapi({