From 5c9ba080a085c5901853c714378b380942b13a48 Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Fri, 30 Jan 2026 21:41:58 -0500 Subject: [PATCH 1/2] feat: migrate workspace creation to TanStack mutations - Add useCreateWorkspace mutation hook with optimistic updates - Replace direct fetch calls in HomePromptInput, HomeContent, and CreateWorkspaceModal - Provide automatic rollback on errors and consistent state management - Include useGenerateWorkspaceTitle and useCreateWorkspaceFromPrompt hooks - Clean up unused imports and manual loading states Benefits: - Immediate UI feedback with optimistic updates - Automatic error recovery - Consistent architecture with existing mutations - Better debugging with React Query devtools --- src/components/home/HomeContent.tsx | 57 +++--- src/components/home/HomePromptInput.tsx | 70 ++----- .../workspace/CreateWorkspaceModal.tsx | 124 ++++++------ src/hooks/workspace/use-create-workspace.ts | 180 ++++++++++++++++++ 4 files changed, 277 insertions(+), 154 deletions(-) create mode 100644 src/hooks/workspace/use-create-workspace.ts diff --git a/src/components/home/HomeContent.tsx b/src/components/home/HomeContent.tsx index 190a0692..fb4a4785 100644 --- a/src/components/home/HomeContent.tsx +++ b/src/components/home/HomeContent.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useRef, useEffect, useCallback, createContext, useContext } from "react"; +import { useState, useRef, useEffect, createContext, useContext } from "react"; import { HomePromptInput } from "./HomePromptInput"; import { DynamicTagline } from "./DynamicTagline"; import { WorkspaceGrid } from "./WorkspaceGrid"; @@ -9,9 +9,9 @@ import { FloatingWorkspaceCards } from "@/components/landing/FloatingWorkspaceCa import { HeroGlow } from "./HeroGlow"; import { Button } from "@/components/ui/button"; import { useRouter } from "next/navigation"; -import { useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; import { FolderPlus } from "lucide-react"; +import { useCreateWorkspace } from "@/hooks/workspace/use-create-workspace"; // Context for section visibility - allows child components to know when to focus const SectionVisibilityContext = createContext<{ @@ -23,11 +23,11 @@ export const useSectionVisibility = () => useContext(SectionVisibilityContext); export function HomeContent() { const router = useRouter(); - const queryClient = useQueryClient(); const [scrollY, setScrollY] = useState(0); const [searchQuery, setSearchQuery] = useState(""); - const [isCreatingWorkspace, setIsCreatingWorkspace] = useState(false); const [heroVisible, setHeroVisible] = useState(true); + + const createWorkspace = useCreateWorkspace(); const [workspacesVisible, setWorkspacesVisible] = useState(false); const scrollRef = useRef(null); const heroRef = useRef(null); @@ -74,37 +74,26 @@ export function HomeContent() { return () => observer.disconnect(); }, []); - const handleCreateBlankWorkspace = async () => { + const handleCreateBlankWorkspace = () => { // Guard against multiple rapid clicks - if (isCreatingWorkspace) return; - - setIsCreatingWorkspace(true); - try { - const createRes = await fetch("/api/workspaces", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - name: "Blank Workspace", - icon: null, - color: null, - }), - }); - if (!createRes.ok) { - const err = await createRes.json().catch(() => ({})); - throw new Error(err.error || "Failed to create workspace"); + if (createWorkspace.isPending) return; + + createWorkspace.mutate( + { + name: "Blank Workspace", + icon: null, + color: null, + }, + { + onSuccess: ({ workspace }) => { + router.push(`/workspace/${workspace.slug}`); + }, + onError: (err) => { + const msg = err instanceof Error ? err.message : "Something went wrong"; + toast.error("Could not create workspace", { description: msg }); + }, } - const { workspace } = (await createRes.json()) as { workspace: { slug: string } }; - - // Invalidate workspaces cache so the new workspace is available immediately - await queryClient.invalidateQueries({ queryKey: ['workspaces'] }); - - router.push(`/workspace/${workspace.slug}`); - } catch (err) { - const msg = err instanceof Error ? err.message : "Something went wrong"; - toast.error("Could not create workspace", { description: msg }); - } finally { - setIsCreatingWorkspace(false); - } + ); }; return ( @@ -155,7 +144,7 @@ export function HomeContent() { variant="ghost" size="sm" onClick={handleCreateBlankWorkspace} - disabled={isCreatingWorkspace} + disabled={createWorkspace.isPending} className="text-sm text-white/70 hover:text-white hover:bg-white/5 transition-all duration-200 gap-2 disabled:opacity-50" > diff --git a/src/components/home/HomePromptInput.tsx b/src/components/home/HomePromptInput.tsx index 0c50d5ba..a4ef1693 100644 --- a/src/components/home/HomePromptInput.tsx +++ b/src/components/home/HomePromptInput.tsx @@ -2,8 +2,8 @@ import { useState, useRef, useMemo, useEffect } from "react"; import { useRouter } from "next/navigation"; -import { useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; +import { useCreateWorkspaceFromPrompt } from "@/hooks/workspace/use-create-workspace"; import { ArrowUp, Loader2 } from "lucide-react"; import { cn } from "@/lib/utils"; import { Input } from "@/components/ui/input"; @@ -54,11 +54,11 @@ interface HomePromptInputProps { export function HomePromptInput({ shouldFocus }: HomePromptInputProps) { const router = useRouter(); - const queryClient = useQueryClient(); const [value, setValue] = useState(""); - const [isLoading, setIsLoading] = useState(false); const inputRef = useRef(null); const typingKeyRef = useRef(0); + + const createFromPrompt = useCreateWorkspaceFromPrompt(); // Shuffle options with random start for variety const shuffledOptions = useMemo(() => { @@ -88,51 +88,21 @@ export function HomePromptInput({ shouldFocus }: HomePromptInputProps) { const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); const prompt = value.trim(); - if (!prompt || isLoading) return; - - setIsLoading(true); - try { - const titleRes = await fetch("/api/workspaces/generate-title", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ prompt }), - }); - if (!titleRes.ok) { - const err = await titleRes.json().catch(() => ({})); - throw new Error(err.error || "Failed to generate title"); - } - const { title, icon, color } = (await titleRes.json()) as { title: string; icon?: string; color?: string }; - - const createRes = await fetch("/api/workspaces", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - name: title, - icon: icon || null, - color: color || null, - template: "getting_started", // Auto-include sample content (quiz/flashcards) for home prompt (magic feeling) - }), - }); - if (!createRes.ok) { - const err = await createRes.json().catch(() => ({})); - throw new Error(err.error || "Failed to create workspace"); - } - const { workspace } = (await createRes.json()) as { workspace: { slug: string } }; - - // Invalidate workspaces cache so the new workspace is available immediately - await queryClient.invalidateQueries({ queryKey: ['workspaces'] }); - - // Reset typing animation by changing key - typingKeyRef.current += 1; - router.push( - `/workspace/${workspace.slug}?createFrom=${encodeURIComponent(prompt)}` - ); - } catch (err) { - const msg = err instanceof Error ? err.message : "Something went wrong"; - toast.error("Could not create workspace", { description: msg }); - } finally { - setIsLoading(false); - } + if (!prompt || createFromPrompt.isLoading) return; + + await createFromPrompt.mutate(prompt, { + template: "getting_started", // Auto-include sample content (quiz/flashcards) for home prompt (magic feeling) + onSuccess: (workspace) => { + // Reset typing animation by changing key + typingKeyRef.current += 1; + router.push( + `/workspace/${workspace.slug}?createFrom=${encodeURIComponent(prompt)}` + ); + }, + onError: (err) => { + toast.error("Could not create workspace", { description: err.message }); + }, + }); }; return ( @@ -230,7 +200,7 @@ export function HomePromptInput({ shouldFocus }: HomePromptInputProps) { {/* Submit arrow */} diff --git a/src/hooks/workspace/use-create-workspace.ts b/src/hooks/workspace/use-create-workspace.ts new file mode 100644 index 00000000..7a7361bf --- /dev/null +++ b/src/hooks/workspace/use-create-workspace.ts @@ -0,0 +1,180 @@ +"use client"; + +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import type { WorkspaceWithState, WorkspaceTemplate } from "@/lib/workspace-state/types"; +import type { CardColor } from "@/lib/workspace-state/colors"; +import type { AgentState } from "@/lib/workspace-state/types"; + +interface CreateWorkspaceParams { + name: string; + icon?: string | null; + color?: CardColor | null; + template?: WorkspaceTemplate; + description?: string; + is_public?: boolean; + initialState?: AgentState; +} + +interface CreateWorkspaceResponse { + workspace: { + id: string; + slug: string; + name: string; + state?: { + items?: Array; + }; + }; +} + +interface GenerateTitleParams { + prompt: string; +} + +interface GenerateTitleResponse { + title: string; + icon?: string; + color?: string; +} + +/** + * Mutation hook for creating a workspace + * Provides optimistic updates and automatic cache invalidation + */ +export function useCreateWorkspace() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (params: CreateWorkspaceParams): Promise => { + const response = await fetch("/api/workspaces", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(params), + }); + + if (!response.ok) { + const err = await response.json().catch(() => ({})); + throw new Error(err.error || "Failed to create workspace"); + } + + return response.json(); + }, + + onMutate: async (newWorkspace) => { + // Cancel any outgoing refetches to avoid overwriting optimistic update + await queryClient.cancelQueries({ queryKey: ["workspaces"] }); + + // Snapshot the previous value for rollback + const previous = queryClient.getQueryData(["workspaces"]); + + // Optimistically add the new workspace to the list + queryClient.setQueryData(["workspaces"], (old) => { + if (!old) return old; + + const optimisticWorkspace: WorkspaceWithState = { + id: `temp-${Date.now()}`, + slug: `temp-${Date.now()}`, + name: newWorkspace.name, + description: newWorkspace.description || "", + template: newWorkspace.template || "blank", + isPublic: newWorkspace.is_public || false, + icon: newWorkspace.icon || null, + color: newWorkspace.color || null, + userId: "", + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + sortOrder: old.length, + lastOpenedAt: null, + }; + + return [optimisticWorkspace, ...old]; + }); + + return { previous }; + }, + + onError: (_err, _newWorkspace, context) => { + // Rollback to previous state on error + if (context?.previous) { + queryClient.setQueryData(["workspaces"], context.previous); + } + }, + + onSuccess: () => { + // Invalidate to get the real data from server + queryClient.invalidateQueries({ queryKey: ["workspaces"] }); + }, + }); +} + +/** + * Mutation hook for generating a workspace title from a prompt + */ +export function useGenerateWorkspaceTitle() { + return useMutation({ + mutationFn: async (params: GenerateTitleParams): Promise => { + const response = await fetch("/api/workspaces/generate-title", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(params), + }); + + if (!response.ok) { + const err = await response.json().catch(() => ({})); + throw new Error(err.error || "Failed to generate title"); + } + + return response.json(); + }, + }); +} + +/** + * Combined hook for creating a workspace from a prompt + * First generates title/icon/color, then creates the workspace + */ +export function useCreateWorkspaceFromPrompt() { + const generateTitle = useGenerateWorkspaceTitle(); + const createWorkspace = useCreateWorkspace(); + + const mutate = async ( + prompt: string, + options?: { + template?: WorkspaceTemplate; + onSuccess?: (workspace: CreateWorkspaceResponse["workspace"]) => void; + onError?: (error: Error) => void; + } + ) => { + try { + // Step 1: Generate title, icon, and color from prompt + const { title, icon, color } = await generateTitle.mutateAsync({ prompt }); + + // Step 2: Create the workspace with generated metadata + const result = await createWorkspace.mutateAsync({ + name: title, + icon: icon || null, + color: (color as CardColor) || null, + template: options?.template || "getting_started", + }); + + options?.onSuccess?.(result.workspace); + return result; + } catch (error) { + const err = error instanceof Error ? error : new Error("Something went wrong"); + options?.onError?.(err); + throw err; + } + }; + + return { + mutate, + mutateAsync: mutate, + isLoading: generateTitle.isPending || createWorkspace.isPending, + isGeneratingTitle: generateTitle.isPending, + isCreatingWorkspace: createWorkspace.isPending, + error: generateTitle.error || createWorkspace.error, + reset: () => { + generateTitle.reset(); + createWorkspace.reset(); + }, + }; +} From f59fc6ef9cc1535bc8435aed2986a6f77053e423 Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Fri, 30 Jan 2026 21:48:33 -0500 Subject: [PATCH 2/2] Update src/components/home/HomePromptInput.tsx Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --- src/components/home/HomePromptInput.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/home/HomePromptInput.tsx b/src/components/home/HomePromptInput.tsx index a4ef1693..e7d8f8f7 100644 --- a/src/components/home/HomePromptInput.tsx +++ b/src/components/home/HomePromptInput.tsx @@ -90,7 +90,7 @@ export function HomePromptInput({ shouldFocus }: HomePromptInputProps) { const prompt = value.trim(); if (!prompt || createFromPrompt.isLoading) return; - await createFromPrompt.mutate(prompt, { + createFromPrompt.mutate(prompt, { template: "getting_started", // Auto-include sample content (quiz/flashcards) for home prompt (magic feeling) onSuccess: (workspace) => { // Reset typing animation by changing key