diff --git a/src/app/api/workspaces/[id]/route.ts b/src/app/api/workspaces/[id]/route.ts index e9dbb39d..4b6395e0 100644 --- a/src/app/api/workspaces/[id]/route.ts +++ b/src/app/api/workspaces/[id]/route.ts @@ -1,60 +1,13 @@ import { NextRequest, NextResponse } from "next/server"; import { db, workspaces } from "@/lib/db/client"; import { eq, and, ne } from "drizzle-orm"; -import { loadWorkspaceState } from "@/lib/workspace/workspace-state-read"; import { requireAuth, verifyWorkspaceOwnership, - verifyWorkspaceAccess, withErrorHandling, } from "@/lib/api/workspace-helpers"; import { generateSlug } from "@/lib/workspace/slug"; -/** - * GET /api/workspaces/[id] - * Get a specific workspace with its state - * Supports owner and collaborators - */ -async function handleGET( - request: NextRequest, - { params }: { params: Promise<{ id: string }> }, -) { - // Start independent operations in parallel - const paramsPromise = params; - const authPromise = requireAuth(); - - const { id } = await paramsPromise; - const userId = await authPromise; - - // Check access (owner or collaborator) - const accessInfo = await verifyWorkspaceAccess(id, userId, "viewer"); - - // Get workspace data - const [workspace] = await db - .select() - .from(workspaces) - .where(eq(workspaces.id, id)) - .limit(1); - - if (!workspace) { - return NextResponse.json({ error: "Workspace not found" }, { status: 404 }); - } - - // Get current workspace state from projection tables - const state = await loadWorkspaceState(id, { userId }); - - return NextResponse.json({ - workspace: { - ...workspace, - state, - isShared: !accessInfo.isOwner, - permissionLevel: accessInfo.permissionLevel, - }, - }); -} - -export const GET = withErrorHandling(handleGET, "GET /api/workspaces/[id]"); - /** * PATCH /api/workspaces/[id] * Update workspace metadata (owner only) diff --git a/src/app/api/workspaces/slug/[slug]/route.ts b/src/app/api/workspaces/slug/[slug]/route.ts index 0b62811c..47badab0 100644 --- a/src/app/api/workspaces/slug/[slug]/route.ts +++ b/src/app/api/workspaces/slug/[slug]/route.ts @@ -1,32 +1,27 @@ import { NextRequest, NextResponse } from "next/server"; import { db, workspaces } from "@/lib/db/client"; import { workspaceCollaborators } from "@/lib/db/schema"; -import { eq, and, or, inArray } from "drizzle-orm"; -import { loadWorkspaceState } from "@/lib/workspace/workspace-state-read"; +import { eq, and, inArray } from "drizzle-orm"; import { requireAuth, withErrorHandling } from "@/lib/api/workspace-helpers"; /** * GET /api/workspaces/slug/[slug] - * Get a workspace by slug (more user-friendly than UUID) - * Supports owner and collaborators * - * Query params: - * - metadata=true: Return only workspace metadata (faster, for initial load) + * Resolves a workspace by slug and returns its metadata + * (id, name, slug, icon, color, isShared, permissionLevel). Used by + * `WorkspaceContext` to map URL slug → workspace id so Zero can subscribe + * to items. Item state itself is sync'd by Zero, not returned here. */ async function handleGET( - request: NextRequest, + _request: NextRequest, { params }: { params: Promise<{ slug: string }> }, ) { - // Start independent operations in parallel const paramsPromise = params; const authPromise = requireAuth(); const { slug } = await paramsPromise; const userId = await authPromise; - // Check if metadata-only mode is requested (faster path for initial workspace load) - const metadataOnly = request.nextUrl.searchParams.get("metadata") === "true"; - // Get workspace by slug - first check ownership const [ownedWorkspace] = await db .select() @@ -84,25 +79,9 @@ async function handleGET( return NextResponse.json({ error: "Workspace not found" }, { status: 404 }); } - // For metadata-only requests, return just the workspace record (no state loading) - // This is much faster and used for initial workspace identification - if (metadataOnly) { - return NextResponse.json({ - workspace: { - ...workspace, - isShared, - permissionLevel, - }, - }); - } - - // Get current workspace state from projection tables - const state = await loadWorkspaceState(workspace.id, { userId }); - return NextResponse.json({ workspace: { ...workspace, - state, isShared, permissionLevel, }, diff --git a/src/app/api/zero/query/route.ts b/src/app/api/zero/query/route.ts index a450475f..65ba3cd0 100644 --- a/src/app/api/zero/query/route.ts +++ b/src/app/api/zero/query/route.ts @@ -1,7 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import { mustGetQuery, type ReadonlyJSONValue } from "@rocicorp/zero"; import { handleQueryRequest } from "@rocicorp/zero/server"; -import { and, eq } from "drizzle-orm"; +import { and, eq, inArray } from "drizzle-orm"; import { auth } from "@/lib/auth"; import { db } from "@/lib/db/client"; import { workspaceCollaborators, workspaces } from "@/lib/db/schema"; @@ -16,36 +16,72 @@ type QueryRequest = { args?: readonly unknown[]; }; -async function hasWorkspaceReadAccess( - workspaceId: string, +/** + * Fetch the set of workspace IDs (out of `candidateIds`) that `userId` is allowed + * to read. Done in two batched queries (owner + collaborator) so we don't fan out + * to N round trips when Zero batches queries on the client. + */ +async function getAllowedWorkspaceIds( + candidateIds: readonly string[], userId: string, -): Promise { - const [workspace] = await db - .select({ ownerId: workspaces.userId }) - .from(workspaces) - .where(eq(workspaces.id, workspaceId)) - .limit(1); - - if (!workspace) { - return false; +): Promise> { + if (candidateIds.length === 0) { + return new Set(); } - if (workspace.ownerId === userId) { - return true; - } - - const [collaborator] = await db - .select({ id: workspaceCollaborators.id }) - .from(workspaceCollaborators) - .where( - and( - eq(workspaceCollaborators.workspaceId, workspaceId), - eq(workspaceCollaborators.userId, userId), + const [ownedRows, collaboratorRows] = await Promise.all([ + db + .select({ id: workspaces.id }) + .from(workspaces) + .where( + and(eq(workspaces.userId, userId), inArray(workspaces.id, candidateIds)), ), - ) - .limit(1); + db + .select({ id: workspaceCollaborators.workspaceId }) + .from(workspaceCollaborators) + .where( + and( + eq(workspaceCollaborators.userId, userId), + inArray(workspaceCollaborators.workspaceId, candidateIds), + ), + ), + ]); - return !!collaborator; + const allowed = new Set(); + for (const row of ownedRows) allowed.add(row.id); + for (const row of collaboratorRows) allowed.add(row.id); + return allowed; +} + +/** Reads `workspaceId` off a single arg object (the unwrapped first arg). */ +function readWorkspaceId(arg: ReadonlyJSONValue | undefined): string | null { + if ( + arg && + typeof arg === "object" && + !Array.isArray(arg) && + "workspaceId" in arg && + typeof (arg as { workspaceId?: unknown }).workspaceId === "string" + ) { + return (arg as { workspaceId: string }).workspaceId; + } + return null; +} + +/** + * Returns the workspaceId a query needs access to, or null to deny. + * Switch (not lookup table) keeps the dispatch bounded for CodeQL. + * New queries must be added here explicitly. + */ +function getRequiredWorkspaceId( + name: string, + arg: ReadonlyJSONValue | undefined, +): string | null { + switch (name) { + case "workspace.items": + return readWorkspaceId(arg); + default: + return null; + } } export async function POST(request: NextRequest) { @@ -58,87 +94,75 @@ export async function POST(request: NextRequest) { const userId = session.user.id; const ctx = { userId }; + let body: unknown; try { - /** - * `handleQueryRequest` calls the `TransformQueryFunction` callback - * synchronously, so we cannot do async workspace access checks inside that - * callback. We must extract workspace IDs up front and verify access before - * handing the request to Zero. - * - * Zero query request bodies are encoded as: - * `[tag, [{ id, name, args: [arg0, ...] }, ...]]`. - */ - const body = (await request.json()) as unknown; - const queryRequests = - Array.isArray(body) && body.length > 1 && Array.isArray(body[1]) - ? (body[1] as QueryRequest[]) - : []; - - if (queryRequests.length === 0) { - console.warn( - "Zero query request body did not match expected protocol format", - { body }, - ); - } - - const workspaceIds = [ - ...new Set( - queryRequests.flatMap((queryRequest) => { - if (!queryRequest || typeof queryRequest !== "object") { - return []; - } - - const args = Array.isArray(queryRequest.args) - ? queryRequest.args - : []; - const firstArg = args?.[0]; - - if ( - firstArg && - typeof firstArg === "object" && - "workspaceId" in firstArg && - typeof firstArg.workspaceId === "string" - ) { - return [firstArg.workspaceId]; - } - - if (args.length > 0) { - console.warn("Zero query request args missing workspaceId", { - queryRequest, - }); - } - - return []; - }), - ), - ]; - - // When workspace IDs are found, verify access. When none are found - // (e.g. Zero's initial connection handshake), pass through to - // handleQueryRequest — it will resolve the queries or return errors - // for any that require args. - for (const workspaceId of workspaceIds) { - const hasAccess = await hasWorkspaceReadAccess(workspaceId, userId); - if (!hasAccess) { - throw new Error(WORKSPACE_ACCESS_DENIED_ERROR); - } - } + body = await request.json(); + } catch (error) { + console.error("[zero/query] Failed to parse body:", error); + return NextResponse.json({ error: "Bad request" }, { status: 400 }); + } + + // Body shape: `[tag, [{ id, name, args: [arg0, ...] }, ...]]`. + // Authz throws happen per-query inside the callback so a single denied + // workspace doesn't poison the whole batch. + const queryRequests = + Array.isArray(body) && body.length > 1 && Array.isArray(body[1]) + ? (body[1] as QueryRequest[]) + : []; + + const requestedWorkspaceIds = [ + ...new Set( + queryRequests.flatMap((req) => { + const name = typeof req?.name === "string" ? req.name : ""; + const firstArg = Array.isArray(req?.args) + ? (req.args[0] as ReadonlyJSONValue | undefined) + : undefined; + const id = getRequiredWorkspaceId(name, firstArg); + return id ? [id] : []; + }), + ), + ]; + + let allowedWorkspaceIds = new Set(); + try { + allowedWorkspaceIds = await getAllowedWorkspaceIds( + requestedWorkspaceIds, + userId, + ); + } catch (error) { + console.error("[zero/query] Access check failed:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 }, + ); + } + + const deniedCount = requestedWorkspaceIds.filter( + (id) => !allowedWorkspaceIds.has(id), + ).length; + + if (deniedCount > 0) { + console.warn("[zero/query] Denied workspace access", { + requested: requestedWorkspaceIds.length, + denied: deniedCount, + }); + } + try { const result = await handleQueryRequest( - (name, args) => mustGetQuery(queries, name).fn({ args, ctx }), + (name, args) => { + const workspaceId = getRequiredWorkspaceId(name, args); + if (!workspaceId || !allowedWorkspaceIds.has(workspaceId)) { + throw new Error(WORKSPACE_ACCESS_DENIED_ERROR); + } + return mustGetQuery(queries, name).fn({ args, ctx }); + }, schema, body as ReadonlyJSONValue, ); return NextResponse.json(result); } catch (error) { - if ( - error instanceof Error && - error.message === WORKSPACE_ACCESS_DENIED_ERROR - ) { - return NextResponse.json({ error: "Forbidden" }, { status: 403 }); - } - console.error("[zero/query] Unhandled error:", error); return NextResponse.json( { error: "Internal server error" }, diff --git a/src/app/workspace/[slug]/page.tsx b/src/app/workspace/[slug]/page.tsx index 09f1ca0b..52976f62 100644 --- a/src/app/workspace/[slug]/page.tsx +++ b/src/app/workspace/[slug]/page.tsx @@ -1,7 +1,37 @@ -"use client"; - +import type { Metadata } from "next"; +import { headers } from "next/headers"; +import { redirect } from "next/navigation"; +import { auth } from "@/lib/auth"; import { WorkspaceShell } from "@/components/workspace/WorkspaceShell"; -export default function WorkspacePage() { +export const metadata: Metadata = { + title: "Workspace", + description: "ThinkEx workspace canvas", +}; + +interface WorkspacePageProps { + params: Promise<{ slug: string }>; + searchParams: Promise<{ invite?: string }>; +} + +export default async function WorkspacePage({ + params, + searchParams, +}: WorkspacePageProps) { + const { invite } = await searchParams; + if (invite) redirect(`/invite/claim/${invite}`); + + // Workspaces are never publicly accessible. Anyone arriving without a + // session (no /home visit, no share-copy import, no auth flow) gets sent + // to sign-in. Anonymous sessions provisioned upstream (/home, /share-copy) + // pass through; access is gated per-workspace inside the shell. + const session = await auth.api.getSession({ headers: await headers() }); + if (!session) { + const { slug } = await params; + redirect( + `/auth/sign-in?redirect_url=${encodeURIComponent(`/workspace/${slug}`)}`, + ); + } + return ; } diff --git a/src/components/chat/ChatDropzone.tsx b/src/components/chat/ChatDropzone.tsx index a50f2e42..ed5ccd4a 100644 --- a/src/components/chat/ChatDropzone.tsx +++ b/src/components/chat/ChatDropzone.tsx @@ -6,7 +6,7 @@ import { useDropzone } from "react-dropzone"; import { toast } from "sonner"; import { useOptionalComposer } from "@/components/chat/composer-context"; -import { useWorkspaceStore } from "@/lib/stores/workspace-store"; +import { useCurrentWorkspaceId } from "@/contexts/WorkspaceContext"; import { OFFICE_DOCUMENT_ACCEPT } from "@/lib/uploads/office-document-validation"; interface ChatDropzoneProps { @@ -20,7 +20,7 @@ interface ChatDropzoneProps { */ export function ChatDropzone({ children }: ChatDropzoneProps) { const composer = useOptionalComposer(); - const currentWorkspaceId = useWorkspaceStore((s) => s.currentWorkspaceId); + const currentWorkspaceId = useCurrentWorkspaceId(); const [isDragging, setIsDragging] = useState(false); const isProcessingRef = useRef(false); diff --git a/src/components/chat/ChatPanel.tsx b/src/components/chat/ChatPanel.tsx index c6938f5d..df8bdf95 100644 --- a/src/components/chat/ChatPanel.tsx +++ b/src/components/chat/ChatPanel.tsx @@ -8,7 +8,12 @@ import { MultimodalInput } from "@/components/chat/MultimodalInput"; import { TextSelectionManager } from "@/components/chat/TextSelectionManager"; import { ThreadBody } from "@/components/chat/ThreadBody"; import { ThreadWelcome } from "@/components/chat/ThreadWelcome"; -import { useWorkspaceState } from "@/hooks/workspace/use-workspace-state"; +import { + useWorkspaceItems, + useWorkspaceItemsLoading, +} from "@/hooks/workspace/use-workspace-items"; +import { useWorkspaceView } from "@/hooks/workspace/use-workspace-view"; +import { ChatPanelSkeleton } from "@/components/workspace/WorkspaceLoader"; import { cn } from "@/lib/utils"; interface ChatPanelProps { @@ -32,13 +37,17 @@ export function ChatPanel({ setIsChatMaximized, onReady, }: ChatPanelProps) { - const { state, isLoading } = useWorkspaceState(workspaceId ?? null); + const state = useWorkspaceItems(); + const isLoading = useWorkspaceItemsLoading(); + const view = useWorkspaceView(); + const isReady = view.kind === "ready"; useEffect(() => { - if (!isLoading && state && onReady) onReady(); - }, [isLoading, state, onReady]); + if (!workspaceId || isLoading || !onReady) return; + onReady(); + }, [workspaceId, isLoading, onReady]); - if (!workspaceId) return null; + if (!workspaceId || !isReady) return ; const handleToggleMaximize = () => { setIsChatMaximized?.(!isChatMaximized); diff --git a/src/components/chat/ChatProvider.tsx b/src/components/chat/ChatProvider.tsx index b53c826f..3fe855cb 100644 --- a/src/components/chat/ChatProvider.tsx +++ b/src/components/chat/ChatProvider.tsx @@ -9,7 +9,7 @@ import { useShallow } from "zustand/react/shallow"; import { useWorkspaceContext } from "@/contexts/WorkspaceContext"; import { useViewingItemIds } from "@/hooks/ui/use-viewing-item-ids"; import { useWorkspaceContextProvider } from "@/hooks/ai/use-workspace-context-provider"; -import { useWorkspaceState } from "@/hooks/workspace/use-workspace-state"; +import { useWorkspaceItems } from "@/hooks/workspace/use-workspace-items"; import { chatDebug, summarizeRoster } from "@/lib/chat/debug"; import { chatQueryKeys, fetchThreadMessages, type ThreadListItem } from "@/lib/chat/queries"; import { createChatTransport } from "@/lib/chat/transport"; @@ -213,7 +213,7 @@ export function ChatProvider({ workspaceId, children }: ChatProviderProps) { const activePdfPageByItemId = useUIStore( useShallow((state) => state.activePdfPageByItemId), ); - const { state: workspaceState } = useWorkspaceState(workspaceId); + const workspaceState = useWorkspaceItems(); const viewingItemIds = useViewingItemIds(); const { currentWorkspace } = useWorkspaceContext(); const systemPrompt = useWorkspaceContextProvider( diff --git a/src/components/chat/TextSelectionManager.tsx b/src/components/chat/TextSelectionManager.tsx index 63cd73cd..92e5c9a1 100644 --- a/src/components/chat/TextSelectionManager.tsx +++ b/src/components/chat/TextSelectionManager.tsx @@ -10,7 +10,7 @@ import { } from "@/components/chat/parts/AssistantThreadSelection"; import { SelectionTooltip } from "@/components/ui/selection-tooltip"; import { useUIStore } from "@/lib/stores/ui-store"; -import { useWorkspaceStore } from "@/lib/stores/workspace-store"; +import { useCurrentWorkspaceId } from "@/contexts/WorkspaceContext"; import { cn } from "@/lib/utils"; /** @@ -22,7 +22,7 @@ import { cn } from "@/lib/utils"; export function TextSelectionManager({ className }: { className?: string }) { const { threadId } = useChatContext(); const composer = useOptionalComposer(); - const workspaceId = useWorkspaceStore((s) => s.currentWorkspaceId); + const workspaceId = useCurrentWorkspaceId(); const addReplySelection = useUIStore((s) => s.addReplySelection); const clearReplySelections = useUIStore((s) => s.clearReplySelections); diff --git a/src/components/chat/composer-context.tsx b/src/components/chat/composer-context.tsx index 22c14dc5..f3ecec7c 100644 --- a/src/components/chat/composer-context.tsx +++ b/src/components/chat/composer-context.tsx @@ -27,7 +27,7 @@ import { isPasswordProtectedPdf } from "@/lib/uploads/pdf-validation"; import { processPdfAttachmentsInBackground } from "@/lib/uploads/process-pdf-attachments-in-background"; import { useShallow } from "zustand/react/shallow"; -import { useWorkspaceState } from "@/hooks/workspace/use-workspace-state"; +import { useWorkspaceItems } from "@/hooks/workspace/use-workspace-items"; import { useWorkspaceOperations } from "@/hooks/workspace/use-workspace-operations"; const MAX_FILE_SIZE_BYTES = 50 * 1024 * 1024; @@ -167,7 +167,7 @@ export function ComposerProvider({ children }: ComposerProviderProps) { const clearReplySelections = useUIStore( (state) => state.clearReplySelections, ); - const { state: workspaceState } = useWorkspaceState(workspaceId); + const workspaceState = useWorkspaceItems(); const operations = useWorkspaceOperations(workspaceId, workspaceState); const hasUploadingAttachments = useAttachmentUploadStore( diff --git a/src/components/chat/parts/MarkdownText.tsx b/src/components/chat/parts/MarkdownText.tsx index 951436e6..8074e2e2 100644 --- a/src/components/chat/parts/MarkdownText.tsx +++ b/src/components/chat/parts/MarkdownText.tsx @@ -24,10 +24,9 @@ import { import { Badge } from "@/components/ui/badge"; import { MarkdownLink } from "@/components/ui/markdown-link"; import { useNavigateToItem } from "@/hooks/ui/use-navigate-to-item"; -import { useWorkspaceState } from "@/hooks/workspace/use-workspace-state"; +import { useWorkspaceItems } from "@/hooks/workspace/use-workspace-items"; import { resolveItemByPath } from "@/lib/ai/tools/workspace-search-utils"; import { useUIStore } from "@/lib/stores/ui-store"; -import { useWorkspaceStore } from "@/lib/stores/workspace-store"; import { cn } from "@/lib/utils"; import { getCitationUrl, @@ -95,8 +94,7 @@ function extractCitationText(children: ReactNode): string { const CitationRenderer = memo(({ children }: { children?: ReactNode }) => { const ref = extractCitationText(children); - const workspaceId = useWorkspaceStore((s) => s.currentWorkspaceId); - const { state: workspaceState } = useWorkspaceState(workspaceId); + const workspaceState = useWorkspaceItems(); const navigateToItem = useNavigateToItem(); const openWorkspaceItem = useUIStore((s) => s.openWorkspaceItem); const setCitationHighlightQuery = useUIStore( diff --git a/src/components/chat/tools/CreateDocumentToolUI.tsx b/src/components/chat/tools/CreateDocumentToolUI.tsx index 29888301..07225bfe 100644 --- a/src/components/chat/tools/CreateDocumentToolUI.tsx +++ b/src/components/chat/tools/CreateDocumentToolUI.tsx @@ -1,10 +1,10 @@ "use client"; import { useState, useMemo } from "react"; -import { useWorkspaceState } from "@/hooks/workspace/use-workspace-state"; +import { useWorkspaceItems } from "@/hooks/workspace/use-workspace-items"; import type { ChatToolUIProps } from "@/lib/chat/tool-ui-types"; import { X, Eye, FolderInput, FileText } from "lucide-react"; -import { useWorkspaceStore } from "@/lib/stores/workspace-store"; +import { useCurrentWorkspaceId } from "@/contexts/WorkspaceContext"; import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; import MoveToDialog from "@/components/modals/MoveToDialog"; @@ -52,8 +52,7 @@ const CreateDocumentReceipt = ({ workspaceIcon, workspaceColor, }: CreateDocumentReceiptProps) => { - const workspaceId = useWorkspaceStore((state) => state.currentWorkspaceId); - const { state: workspaceState } = useWorkspaceState(workspaceId); + const workspaceState = useWorkspaceItems(); const navigateToItem = useNavigateToItem(); const openWorkspaceItem = useUIStore((state) => state.openWorkspaceItem); const [showMoveDialog, setShowMoveDialog] = useState(false); @@ -191,8 +190,8 @@ function CreateDocumentToolRenderer({ result, status, }: CreateDocumentToolRendererProps) { - const workspaceId = useWorkspaceStore((state) => state.currentWorkspaceId); - const { state: workspaceState } = useWorkspaceState(workspaceId); + const workspaceId = useCurrentWorkspaceId(); + const workspaceState = useWorkspaceItems(); const operations = useWorkspaceOperations( workspaceId, workspaceState || initialItems, diff --git a/src/components/chat/tools/CreateFlashcardToolUI.tsx b/src/components/chat/tools/CreateFlashcardToolUI.tsx index 8bc86f54..2166c58a 100644 --- a/src/components/chat/tools/CreateFlashcardToolUI.tsx +++ b/src/components/chat/tools/CreateFlashcardToolUI.tsx @@ -1,12 +1,12 @@ "use client"; import { useEffect, useState, useMemo } from "react"; -import { useWorkspaceState } from "@/hooks/workspace/use-workspace-state"; +import { useWorkspaceItems } from "@/hooks/workspace/use-workspace-items"; import type { ChatToolUIProps } from "@/lib/chat/tool-ui-types"; import { X, Eye, FolderInput } from "lucide-react"; import { PiCardsThreeBold } from "react-icons/pi"; import { logger } from "@/lib/utils/logger"; -import { useWorkspaceStore } from "@/lib/stores/workspace-store"; +import { useCurrentWorkspaceId } from "@/contexts/WorkspaceContext"; import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; import MoveToDialog from "@/components/modals/MoveToDialog"; @@ -66,8 +66,8 @@ const CreateFlashcardReceipt = ({ workspaceIcon, workspaceColor, }: CreateFlashcardReceiptProps) => { - const workspaceId = useWorkspaceStore((state) => state.currentWorkspaceId); - const { state: workspaceState } = useWorkspaceState(workspaceId); + const workspaceId = useCurrentWorkspaceId(); + const workspaceState = useWorkspaceItems(); const navigateToItem = useNavigateToItem(); // State for MoveToDialog @@ -231,8 +231,8 @@ function CreateFlashcardToolRenderer({ result, status, }: CreateFlashcardToolRendererProps) { - const workspaceId = useWorkspaceStore((state) => state.currentWorkspaceId); - const { state: workspaceState } = useWorkspaceState(workspaceId); + const workspaceId = useCurrentWorkspaceId(); + const workspaceState = useWorkspaceItems(); const operations = useWorkspaceOperations( workspaceId, workspaceState || initialItems, diff --git a/src/components/chat/tools/CreateQuizToolUI.tsx b/src/components/chat/tools/CreateQuizToolUI.tsx index d6cc24e5..138e12e1 100644 --- a/src/components/chat/tools/CreateQuizToolUI.tsx +++ b/src/components/chat/tools/CreateQuizToolUI.tsx @@ -2,11 +2,11 @@ import type { MouseEvent, ReactNode } from "react"; import { useEffect, useState, useMemo } from "react"; -import { useWorkspaceState } from "@/hooks/workspace/use-workspace-state"; +import { useWorkspaceItems } from "@/hooks/workspace/use-workspace-items"; import type { ChatToolUIProps } from "@/lib/chat/tool-ui-types"; import { X, Eye, FolderInput, Brain } from "lucide-react"; import { logger } from "@/lib/utils/logger"; -import { useWorkspaceStore } from "@/lib/stores/workspace-store"; +import { useCurrentWorkspaceId } from "@/contexts/WorkspaceContext"; import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; import MoveToDialog from "@/components/modals/MoveToDialog"; @@ -53,8 +53,7 @@ const CreateQuizReceipt = ({ workspaceIcon, workspaceColor, }: CreateQuizReceiptProps) => { - const workspaceId = useWorkspaceStore((state) => state.currentWorkspaceId); - const { state: workspaceState } = useWorkspaceState(workspaceId); + const workspaceState = useWorkspaceItems(); const navigateToItem = useNavigateToItem(); // State for MoveToDialog @@ -210,8 +209,8 @@ function CreateQuizToolRenderer({ result, status, }: CreateQuizToolRendererProps) { - const workspaceId = useWorkspaceStore((state) => state.currentWorkspaceId); - const { state: workspaceState } = useWorkspaceState(workspaceId); + const workspaceId = useCurrentWorkspaceId(); + const workspaceState = useWorkspaceItems(); const operations = useWorkspaceOperations( workspaceId, workspaceState || initialItems, diff --git a/src/components/chat/tools/EditItemToolUI.tsx b/src/components/chat/tools/EditItemToolUI.tsx index a15286e9..711ebf2a 100644 --- a/src/components/chat/tools/EditItemToolUI.tsx +++ b/src/components/chat/tools/EditItemToolUI.tsx @@ -2,11 +2,10 @@ import type { ReactNode } from "react"; import { useMemo } from "react"; -import { useWorkspaceState } from "@/hooks/workspace/use-workspace-state"; +import { useWorkspaceItems } from "@/hooks/workspace/use-workspace-items"; import type { ChatToolUIProps } from "@/lib/chat/tool-ui-types"; import { X, Eye } from "lucide-react"; import { Pencil } from "lucide-react"; -import { useWorkspaceStore } from "@/lib/stores/workspace-store"; import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; import { useUIStore } from "@/lib/stores/ui-store"; @@ -48,8 +47,7 @@ interface EditItemReceiptProps { const EditItemReceipt = ({ args, result, status }: EditItemReceiptProps) => { const openWorkspaceItem = useUIStore((s) => s.openWorkspaceItem); - const workspaceId = useWorkspaceStore((s) => s.currentWorkspaceId); - const { state: workspaceState } = useWorkspaceState(workspaceId); + const workspaceState = useWorkspaceItems(); const navigateToItem = useNavigateToItem(); const card = useMemo(() => { diff --git a/src/components/chat/tools/YouTubeSearchToolUI.tsx b/src/components/chat/tools/YouTubeSearchToolUI.tsx index a5df94f1..315c3db4 100644 --- a/src/components/chat/tools/YouTubeSearchToolUI.tsx +++ b/src/components/chat/tools/YouTubeSearchToolUI.tsx @@ -6,8 +6,8 @@ import { YouTubeMark } from "@/components/icons/YouTubeMark"; import { Button } from "@/components/ui/button"; import { useState, useEffect, type FC, type PropsWithChildren } from "react"; import { toast } from "sonner"; -import { useWorkspaceStore } from "@/lib/stores/workspace-store"; -import { useWorkspaceState } from "@/hooks/workspace/use-workspace-state"; +import { useCurrentWorkspaceId } from "@/contexts/WorkspaceContext"; +import { useWorkspaceItems } from "@/hooks/workspace/use-workspace-items"; import { useWorkspaceOperations } from "@/hooks/workspace/use-workspace-operations"; import { initialItems } from "@/lib/workspace-state/state"; import { useNavigateToItem } from "@/hooks/ui/use-navigate-to-item"; @@ -192,8 +192,8 @@ const YouTubeSearchContent: FC<{ status: { type: string }; result: SearchYoutubeResult | null; }> = ({ args, status, result }) => { - const workspaceId = useWorkspaceStore((state) => state.currentWorkspaceId); - const { state: workspaceState } = useWorkspaceState(workspaceId); + const workspaceId = useCurrentWorkspaceId(); + const workspaceState = useWorkspaceItems(); const operations = useWorkspaceOperations(workspaceId, workspaceState || initialItems); const [addedVideos, setAddedVideos] = useState>(new Set()); diff --git a/src/components/layout/SessionHandler.tsx b/src/components/layout/SessionHandler.tsx index 9fc1e31d..d60fd823 100644 --- a/src/components/layout/SessionHandler.tsx +++ b/src/components/layout/SessionHandler.tsx @@ -1,42 +1,32 @@ "use client"; -import { useEffect } from "react"; +import { useEffect, useRef } from "react"; import { useSession, signIn } from "@/lib/auth-client"; -import { SidebarProvider } from "@/components/ui/sidebar"; +import { WorkspaceLoader } from "@/components/workspace/WorkspaceLoader"; /** - * Handles anonymous session creation. - * Creates anonymous session if needed. No loading screen - renders children immediately. + * Ensures we never render Zero/auth-dependent UI before a session cookie is in + * place. When no session is found, kicks off `signIn.anonymous()` exactly once + * (StrictMode-safe) and shows the loader until the cookie lands. Without this + * gate, the first Zero query can race the anonymous-cookie write and 401. */ -export function AnonymousSessionHandler({ children }: { children: React.ReactNode }) { +export function AnonymousSessionHandler({ + children, +}: { + children: React.ReactNode; +}) { const { data: session, isPending } = useSession(); + const inFlight = useRef(false); useEffect(() => { - // If no session and not loading, create anonymous session - if (!isPending && !session) { - signIn.anonymous().catch((error) => { - console.error("Failed to create anonymous session:", error); - }); - } + if (isPending || session || inFlight.current) return; + inFlight.current = true; + signIn.anonymous().catch((error) => { + console.error("Failed to create anonymous session:", error); + inFlight.current = false; + }); }, [session, isPending]); - // Always render children - no loading screen + if (isPending || !session) return ; return <>{children}; } - -/** - * Wrapper for sidebar provider with default open state. - */ -export function SidebarCoordinator({ - children, - defaultOpen = false -}: { - children: React.ReactNode; - defaultOpen?: boolean; -}) { - return ( - - {children} - - ); -} diff --git a/src/components/layout/WorkspaceLayout.tsx b/src/components/layout/WorkspaceLayout.tsx index d07d8e79..703192f9 100644 --- a/src/components/layout/WorkspaceLayout.tsx +++ b/src/components/layout/WorkspaceLayout.tsx @@ -1,61 +1,61 @@ +"use client"; + +import React from "react"; import { Sidebar, SidebarInset } from "@/components/ui/sidebar"; -import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from "@/components/ui/resizable"; +import { + ResizablePanelGroup, + ResizablePanel, + ResizableHandle, +} from "@/components/ui/resizable"; import WorkspaceSidebar from "@/components/workspace-canvas/WorkspaceSidebar"; import { ChatPanel } from "@/components/chat/ChatPanel"; import { ChatProvider } from "@/components/chat/ChatProvider"; import { ComposerProvider } from "@/components/chat/composer-context"; import { WorkspaceCanvasDropzone } from "@/components/workspace-canvas/WorkspaceCanvasDropzone"; import { PANEL_DEFAULTS } from "@/lib/layout-constants"; -import React from "react"; +import { useUIStore } from "@/lib/stores/ui-store"; +import { useWorkspaceContext } from "@/contexts/WorkspaceContext"; +import { useWorkspaceView } from "@/hooks/workspace/use-workspace-view"; +import { WorkspaceHeaderSkeleton } from "@/components/workspace/WorkspaceLoader"; interface WorkspaceLayoutProps { - // Workspace sidebar - currentWorkspaceId: string | null; - onWorkspaceSwitch: (slug: string) => void; - showCreateModal: boolean; - setShowCreateModal: (show: boolean) => void; - - // Chat state - isDesktop: boolean; - isChatExpanded: boolean; - isChatMaximized: boolean; - setIsChatExpanded: (expanded: boolean) => void; - setIsChatMaximized: (maximized: boolean) => void; - - // Component slots workspaceSection: React.ReactNode; - workspaceHeader?: React.ReactNode; // Header that spans above sidebar + workspace + /** Header rendered above sidebar + canvas. Pass `undefined` to hide. */ + workspaceHeader?: React.ReactNode; } /** - * Main workspace layout component. - * Handles the overall structure including sidebars and layout animations. + * Main workspace layout. Reads chat/sidebar state from stores and the active + * workspace from context; only takes the visual slot props. */ export function WorkspaceLayout({ - currentWorkspaceId, - onWorkspaceSwitch, - showCreateModal, - setShowCreateModal, - isChatExpanded, - isChatMaximized, - setIsChatExpanded, - setIsChatMaximized, workspaceSection, workspaceHeader, }: WorkspaceLayoutProps) { - // Render logic - // Ensure chat is only shown when a workspace is active - const effectiveChatExpanded = isChatExpanded && !!currentWorkspaceId; - const effectiveChatMaximized = isChatMaximized && !!currentWorkspaceId; + const { currentWorkspace, switchWorkspace } = useWorkspaceContext(); + const currentWorkspaceId = currentWorkspace?.id ?? null; + const view = useWorkspaceView(); + const isWorkspaceReady = view.kind === "ready"; + + const isChatExpanded = useUIStore((s) => s.isChatExpanded); + const isChatMaximized = useUIStore((s) => s.isChatMaximized); + const setIsChatExpanded = useUIStore((s) => s.setIsChatExpanded); + const setIsChatMaximized = useUIStore((s) => s.setIsChatMaximized); + const showCreateModal = useUIStore((s) => s.showCreateWorkspaceModal); + const setShowCreateModal = useUIStore((s) => s.setShowCreateWorkspaceModal); + + // Reserve the chat slot as soon as the user wants chat open, even before + // `currentWorkspaceId` lands. Otherwise the workspace canvas paints full-width + // for the slug→id roundtrip and then suddenly shrinks ~25% when chat snaps in. + const effectiveChatExpanded = isChatExpanded; + const effectiveChatMaximized = isChatMaximized; const content = (
- {/* MAXIMIZED MODE: Show only chat (workspace completely hidden) */} {effectiveChatMaximized ? (
) : ( - - {/* Left Area: Workspace area (header + sidebar + workspace canvas) */} + { - if (!effectiveChatExpanded) return "100%"; - return `${100 - PANEL_DEFAULTS.CHAT}%`; - })()} - minSize={effectiveChatExpanded ? `${PANEL_DEFAULTS.WORKSPACE_MIN}%` : "100%"} + defaultSize={ + effectiveChatExpanded ? `${100 - PANEL_DEFAULTS.CHAT}%` : "100%" + } + minSize={ + effectiveChatExpanded + ? `${PANEL_DEFAULTS.WORKSPACE_MIN}%` + : "100%" + } >
- {!!currentWorkspaceId && workspaceHeader} + {isWorkspaceReady ? workspaceHeader : }
- {workspaceSection} + + {workspaceSection} +
- {/* Chat Section - Only when expanded and workspace exists */} {effectiveChatExpanded && ( <> @@ -115,7 +112,6 @@ export function WorkspaceLayout({ maxSize={`${PANEL_DEFAULTS.CHAT_MAX}%`} > state.currentWorkspaceId); + const workspaceId = useCurrentWorkspaceId(); const audioData = item.data as AudioData; const [isRetrying, setIsRetrying] = useState(false); diff --git a/src/components/workspace-canvas/WorkspaceCanvasDropzone.tsx b/src/components/workspace-canvas/WorkspaceCanvasDropzone.tsx index 22c9e523..cda7f08a 100644 --- a/src/components/workspace-canvas/WorkspaceCanvasDropzone.tsx +++ b/src/components/workspace-canvas/WorkspaceCanvasDropzone.tsx @@ -1,11 +1,11 @@ "use client"; import { useDropzone } from "react-dropzone"; -import { useWorkspaceStore } from "@/lib/stores/workspace-store"; -import { useWorkspaceState } from "@/hooks/workspace/use-workspace-state"; +import { useCurrentWorkspaceId } from "@/contexts/WorkspaceContext"; +import { useWorkspaceItems } from "@/hooks/workspace/use-workspace-items"; import { useWorkspaceOperations } from "@/hooks/workspace/use-workspace-operations"; import { CgNotes } from "react-icons/cg"; -import { useCallback, useState, useRef, useEffect } from "react"; +import { useCallback, useState, useRef } from "react"; import { toast } from "sonner"; import { DEFAULT_CARD_DIMENSIONS } from "@/lib/workspace-state/grid-layout-helpers"; import { useReactiveNavigation } from "@/hooks/ui/use-reactive-navigation"; @@ -39,8 +39,8 @@ interface WorkspaceCanvasDropzoneProps { * Accepts supported workspace files and creates corresponding cards when dropped. */ export function WorkspaceCanvasDropzone({ children }: WorkspaceCanvasDropzoneProps) { - const currentWorkspaceId = useWorkspaceStore((state) => state.currentWorkspaceId); - const { state: workspaceState } = useWorkspaceState(currentWorkspaceId); + const currentWorkspaceId = useCurrentWorkspaceId(); + const workspaceState = useWorkspaceItems(); const operations = useWorkspaceOperations(currentWorkspaceId, workspaceState); const [isDragging, setIsDragging] = useState(false); diff --git a/src/components/workspace-canvas/WorkspaceContent.tsx b/src/components/workspace-canvas/WorkspaceContent.tsx index 1cccec17..a0a9b4bb 100644 --- a/src/components/workspace-canvas/WorkspaceContent.tsx +++ b/src/components/workspace-canvas/WorkspaceContent.tsx @@ -1,6 +1,6 @@ import { useMemo, useCallback, useRef, useEffect } from "react"; import { useQueryClient } from "@tanstack/react-query"; -import { useWorkspaceStore } from "@/lib/stores/workspace-store"; +import { useCurrentWorkspaceId } from "@/contexts/WorkspaceContext"; import { Plus, Upload } from "lucide-react"; import { EmptyState } from "@/components/empty-state"; import type { Item, CardType } from "@/lib/workspace-state/types"; @@ -62,7 +62,7 @@ export default function WorkspaceContent({ onItemCreated, }: WorkspaceContentProps) { const queryClient = useQueryClient(); - const workspaceId = useWorkspaceStore((state) => state.currentWorkspaceId); + const workspaceId = useCurrentWorkspaceId(); const localScrollContainerRef = useRef(null); const scrollContainerRef = diff --git a/src/components/workspace-canvas/WorkspaceSearchDialog.tsx b/src/components/workspace-canvas/WorkspaceSearchDialog.tsx index ee853a7d..d0544dd9 100644 --- a/src/components/workspace-canvas/WorkspaceSearchDialog.tsx +++ b/src/components/workspace-canvas/WorkspaceSearchDialog.tsx @@ -16,13 +16,15 @@ import { } from "@/lib/workspace-state/search"; import { getCardTypeIcon } from "@/components/chat/WorkspaceItemPicker"; import { useUIStore } from "@/lib/stores/ui-store"; +import { useWorkspaceView } from "@/hooks/workspace/use-workspace-view"; + +const EMPTY_ITEMS: Item[] = []; interface WorkspaceSearchDialogProps { open: boolean; onOpenChange: (open: boolean) => void; items: Item[]; currentWorkspaceId: string | null; - isLoadingWorkspace: boolean; } export function WorkspaceSearchDialog({ @@ -30,14 +32,15 @@ export function WorkspaceSearchDialog({ onOpenChange, items, currentWorkspaceId, - isLoadingWorkspace, }: WorkspaceSearchDialogProps) { + const view = useWorkspaceView(); + const isLoadingWorkspace = view.kind === "loading"; const [query, setQuery] = useState(""); const navigateToFolder = useUIStore((state) => state.navigateToFolder); const setActiveFolderId = useUIStore((state) => state.setActiveFolderId); const openWorkspaceItem = useUIStore((state) => state.openWorkspaceItem); - const safeItems = items ?? []; + const safeItems = items ?? EMPTY_ITEMS; // Reset query when workspace changes useEffect(() => { @@ -82,6 +85,13 @@ export function WorkspaceSearchDialog({ ); const emptyMessage = useMemo(() => { + if ( + view.kind === "denied" || + view.kind === "unauthenticated" || + view.kind === "error" + ) { + return "Workspace unavailable."; + } if (isLoadingWorkspace || !currentWorkspaceId) { return "Loading..."; } @@ -92,7 +102,7 @@ export function WorkspaceSearchDialog({ return `No results for "${query}".`; } return "Type to search..."; - }, [isLoadingWorkspace, currentWorkspaceId, safeItems.length, query]); + }, [view.kind, isLoadingWorkspace, currentWorkspaceId, safeItems.length, query]); return ( , - ) => string; - updateItem: (itemId: string, updates: Partial) => void; - deleteItem: (itemId: string) => void; - updateAllItems: (items: Item[]) => void; - - // Full operations object for advanced functionality - operations?: WorkspaceOperations; - - // Layout state - isChatMaximized: boolean; - - // Chat state - isDesktop?: boolean; - isChatExpanded?: boolean; - setIsChatExpanded?: (expanded: boolean) => void; - // Modal state - openWorkspaceItem: (itemId: string | null) => void; - - // Refs - titleInputRef: RefObject; - scrollAreaRef: RefObject; - - // Workspace metadata - workspaceTitle?: string; - workspaceIcon?: string | null; - workspaceColor?: string | null; + operations: WorkspaceOperations; /** Full-screen open-item viewer (PDF / card shells), mounted above the grid scroll area */ openItemView?: React.ReactNode; } /** * Workspace section component that encapsulates the main workspace area. - * Includes header, content, and action bar. + * Reads workspace metadata, chat/UI state, and current workspace from + * context/stores rather than prop-drilling. */ export function WorkspaceSection({ - loadingWorkspaces, - isLoadingWorkspace, - currentWorkspaceId, - currentSlug, state, - addItem, - updateItem, - deleteItem, - updateAllItems, - isChatMaximized, - isDesktop, - isChatExpanded, - setIsChatExpanded, - openWorkspaceItem, - titleInputRef, - scrollAreaRef, - workspaceTitle, - workspaceIcon, - workspaceColor, operations, openItemView, }: WorkspaceSectionProps) { - // Card selection state from UI store - // Use array selector with shallow comparison to prevent unnecessary re-renders and SSR issues + const { currentWorkspace } = useWorkspaceContext(); + const currentWorkspaceId = currentWorkspace?.id ?? null; + const workspaceTitle = currentWorkspace?.name; + const workspaceIcon = currentWorkspace?.icon ?? null; + const workspaceColor = currentWorkspace?.color ?? null; + + const isDesktop = useMediaQuery("(min-width: 768px)"); + const isChatMaximized = useUIStore((s) => s.isChatMaximized); + const isChatExpanded = useUIStore((s) => s.isChatExpanded); + const setIsChatExpanded = useUIStore((s) => s.setIsChatExpanded); + const openWorkspaceItem = useUIStore((s) => s.openWorkspaceItem); + + const addItem = operations.createItem; + const updateItem = operations.updateItem; + const deleteItem = operations.deleteItem; + const updateAllItems = operations.updateAllItems; + + const scrollAreaRef = useRef(null); + const { selectedCardIdsArray, selectedCardIds } = useSelectedCardIds(); - const clearCardSelection = useUIStore((state) => state.clearCardSelection); - const { data: session } = useSession(); + const clearCardSelection = useUIStore((s) => s.clearCardSelection); + const view = useWorkspaceView(); // Get active folder info from UI store const activeFolderId = useUIStore((uiState) => uiState.activeFolderId); @@ -166,16 +117,13 @@ export function WorkspaceSection({ const handleYouTubeCreate = useCallback( (url: string, name: string, thumbnail?: string) => { - if (addItem) { - addItem("youtube", name, { url, thumbnail }); - } + addItem("youtube", name, { url, thumbnail }); }, [addItem], ); const handleWebsiteCreate = useCallback( (url: string, name: string, favicon?: string) => { - if (!operations) return; operations.createItems([ { type: "website", @@ -242,14 +190,6 @@ export function WorkspaceSection({ return; } - // Blur workspace title input if it's focused - if ( - titleInputRef?.current && - document.activeElement === titleInputRef.current - ) { - titleInputRef.current.blur(); - } - // Blur any active textarea (card titles) when clicking on background or card (but not on the textarea itself) // This ensures card titles save when clicking away, even if clicking on another card if ( @@ -293,19 +233,13 @@ export function WorkspaceSection({ } }; - // Handle move selected items to folder const handleMoveSelected = () => { - if (!operations || selectedCardIdsArray.length === 0) { - return; - } + if (selectedCardIdsArray.length === 0) return; setShowMoveDialog(true); }; - // Handle move confirmation from dialog const handleMoveConfirm = (itemIds: string[], folderId: string | null) => { - if (!operations || itemIds.length === 0) { - return; - } + if (itemIds.length === 0) return; operations.moveItemsToFolder(itemIds, folderId); clearCardSelection(); setShowMoveDialog(false); @@ -313,11 +247,8 @@ export function WorkspaceSection({ toast.success(`Moved ${count} ${count === 1 ? "item" : "items"}`); }; - // Handle creating a new folder from selected cards const handleCreateFolderFromSelection = () => { - if (!operations || selectedCardIdsArray.length === 0) { - return; - } + if (selectedCardIdsArray.length === 0) return; // Prevent cycles: exclude active folder and its ancestors from selection // (e.g. when searching, user could select the active folder or a parent folder) @@ -343,88 +274,11 @@ export function WorkspaceSection({ // Note: FolderCard auto-focuses the title when name is "New Folder" }; - // Handle file upload from workspace pickers/empty states - const handlePDFUpload = useCallback( - async (files: File[]) => { - if (!operations || !currentWorkspaceId) { - throw new Error("Workspace operations not available"); - } - - const MAX_FILE_SIZE_MB = 50; - const MAX_FILE_SIZE_BYTES = MAX_FILE_SIZE_MB * 1024 * 1024; - const oversizedFiles = files.filter( - (file) => file.size > MAX_FILE_SIZE_BYTES, - ); - const validFiles = files.filter( - (file) => file.size <= MAX_FILE_SIZE_BYTES, - ); - - if (oversizedFiles.length > 0) { - toast.error( - `The following file${oversizedFiles.length > 1 ? "s" : ""} exceed${oversizedFiles.length === 1 ? "s" : ""} the ${MAX_FILE_SIZE_MB}MB limit:\n${oversizedFiles - .map((file) => getFileSizeLabel(file)) - .join("\n")}`, - ); - } - - if (validFiles.length === 0) { - return; - } - - const { acceptedFiles: filesToUpload, protectedPdfNames } = - await prepareWorkspaceUploadSelection(validFiles); - if (protectedPdfNames.length > 0) { - emitPasswordProtectedPdf(protectedPdfNames); - } - if (filesToUpload.length === 0) { - return; - } - - const uploadToastId = toast.loading( - getDocumentUploadLoadingMessage(filesToUpload.length), - ); - const { uploads, failedFiles } = await uploadSelectedFiles(filesToUpload); - - toast.dismiss(uploadToastId); - - if (uploads.length === 0) { - if (failedFiles.length > 0) { - toast.error(getDocumentUploadFailureMessage(failedFiles.length)); - } - return; - } - - const itemDefinitions = buildWorkspaceItemDefinitionsFromAssets(uploads, { - imageLayout: DEFAULT_CARD_DIMENSIONS.image, - }); - - const createdIds = operations.createItems(itemDefinitions, { - showSuccessToast: false, - }); - handleCreatedItems(createdIds); - - void startAssetProcessing({ - workspaceId: currentWorkspaceId, - assets: uploads, - itemIds: createdIds, - onOcrError: (error) => { - console.error( - "[WORKSPACE_PROCESSING] Failed to start processing:", - error, - ); - }, - }); - - if (failedFiles.length === 0) { - toast.success(getDocumentUploadSuccessMessage(uploads.length)); - } else { - toast.warning( - getDocumentUploadPartialMessage(uploads.length, failedFiles.length), - ); - } - }, - [currentWorkspaceId, handleCreatedItems, operations], - ); + const handlePDFUpload = useWorkspaceUpload({ + currentWorkspaceId, + operations, + onItemsCreated: handleCreatedItems, + }); const { fileInputRef, @@ -439,8 +293,6 @@ export function WorkspaceSection({ }, [openFilePicker]); const handleAudioReady = useCallback( async (file: File) => { - if (!addItem) return; - const loadingToastId = toast.loading("Uploading audio..."); try { @@ -470,7 +322,7 @@ export function WorkspaceSection({ processingStatus: "processing", } as Partial); - if (handleCreatedItems && itemId) { + if (itemId) { handleCreatedItems([itemId]); } @@ -515,26 +367,21 @@ export function WorkspaceSection({ className="flex-1 overflow-auto scrollbar-thin scrollbar-thumb-gray-600 scrollbar-track-transparent" >
- {/* Show skeleton until workspace content is loaded */} - {(!currentWorkspaceId && currentSlug) || - (currentWorkspaceId && isLoadingWorkspace) ? ( - // If it's taking too long or we have no workspace ID but have a slug, - // check if we're anonymous to show login gate, or authenticated to show access denied - !isLoadingWorkspace && - !loadingWorkspaces && - !currentWorkspaceId ? ( - session?.user?.isAnonymous ? ( - - ) : ( - - ) - ) : ( - - ) + {view.kind === "loading" ? ( + + ) : view.kind === "unauthenticated" ? ( + + ) : view.kind === "denied" ? ( + + ) : view.kind === "error" ? ( + ) : ( - /* Workspace content - assumes workspace exists (home route handles no-workspace state) */ + /* view.kind === "ready" — workspace exists, items in hand */ item.id)} - isGridDragging={isGridDragging} - /> - )} + {!isChatMaximized && view.kind === "ready" && ( + item.id)} + isGridDragging={isGridDragging} + /> + )}
- {/* Right-Click Context Menu */} - {addItem && ( - - {renderWorkspaceMenuItems({ + {/* Mutation items only when the workspace is ready. */} + + {view.kind === "ready" && + renderWorkspaceMenuItems({ callbacks: { onCreateDocument: () => { - if (addItem) { - const itemId = addItem("document"); - if (handleCreatedItems && itemId) { - handleCreatedItems([itemId]); - } - } + const itemId = addItem("document"); + if (itemId) handleCreatedItems([itemId]); }, onCreateFolder: () => { - if (addItem) addItem("folder"); + addItem("folder"); }, onUpload: () => handleUploadMenuItemClick(), onAudio: () => openAudioDialog(), @@ -598,10 +439,9 @@ export function WorkspaceSection({ MenuSubTrigger: ContextMenuSubTrigger, MenuSubContent: ContextMenuSubContent, MenuLabel: ContextMenuLabel, - showUpload: !!(operations && currentWorkspaceId), + showUpload: !!currentWorkspaceId, })} - - )} + {/* Selection Action Bar - show when cards are selected */} {state.length > 0 && !isChatMaximized && selectedCardIds.size > 0 && ( diff --git a/src/components/workspace-canvas/WorkspaceSidebar.tsx b/src/components/workspace-canvas/WorkspaceSidebar.tsx index c4642c7b..04e0827e 100644 --- a/src/components/workspace-canvas/WorkspaceSidebar.tsx +++ b/src/components/workspace-canvas/WorkspaceSidebar.tsx @@ -34,7 +34,7 @@ import WorkspaceSettingsModal from "@/components/workspace/WorkspaceSettingsModa import ShareWorkspaceDialog from "@/components/workspace/ShareWorkspaceDialog"; import { AccountModal } from "@/components/auth/AccountModal"; import { useWorkspaceContext } from "@/contexts/WorkspaceContext"; -import { useWorkspaceStore } from "@/lib/stores/workspace-store"; +import { useCurrentWorkspaceId } from "@/contexts/WorkspaceContext"; import { useUIStore } from "@/lib/stores/ui-store"; // import { useJoyride } from "@/contexts/JoyrideContext"; import type { WorkspaceWithState } from "@/lib/workspace-state/types"; @@ -70,7 +70,7 @@ function WorkspaceSidebar(props: WorkspaceSidebarProps) { } = useWorkspaceContext(); // Get current workspace ID from Zustand store (moved in Phase 3) - const currentWorkspaceId = useWorkspaceStore((state) => state.currentWorkspaceId); + const currentWorkspaceId = useCurrentWorkspaceId(); const setActiveFolderId = useUIStore((state) => state.setActiveFolderId); const navigateToRoot = useUIStore((state) => state.navigateToRoot); diff --git a/src/components/workspace/AccessDenied.tsx b/src/components/workspace/AccessDenied.tsx index 40ef92e8..7894ea4b 100644 --- a/src/components/workspace/AccessDenied.tsx +++ b/src/components/workspace/AccessDenied.tsx @@ -1,33 +1,53 @@ "use client"; import { Button } from "@/components/ui/button"; -import { ArrowLeft, ShieldAlert } from "lucide-react"; +import { ArrowLeft, RotateCw, ShieldAlert } from "lucide-react"; import Link from "next/link"; -export function AccessDenied() { - return ( -
-
- -
+export interface AccessDeniedProps { + /** + * Override the headline. Defaults to "Access Denied" (true 403/404 case). + * For the Zero-error / timeout case, pass "Couldn't load workspace". + */ + title?: string; + description?: string; + /** + * If provided, renders a primary "Try again" button that calls this. Used by + * the Zero error/timeout state — keeps page state instead of reloading. + */ + onRetry?: () => void | Promise; +} + +export function AccessDenied({ + title = "Access Denied", + description = "You don't have permission to view this workspace, or it doesn't exist.", + onRetry, +}: AccessDeniedProps) { + return ( +
+
+ +
-
-

- Access Denied -

-

- You don't have permission to view this workspace, or it doesn't exist. -

-
+
+

{title}

+

{description}

+
-
- -
-
- ); +
+ {onRetry && ( + + )} + +
+
+ ); } diff --git a/src/components/workspace/InviteGuard.tsx b/src/components/workspace/InviteGuard.tsx deleted file mode 100644 index e75c47a4..00000000 --- a/src/components/workspace/InviteGuard.tsx +++ /dev/null @@ -1,26 +0,0 @@ -"use client"; - -import { useEffect } from "react"; -import { useRouter, useSearchParams } from "next/navigation"; - -interface InviteGuardProps { - children: React.ReactNode; -} - -export function InviteGuard({ children }: InviteGuardProps) { - const router = useRouter(); - const searchParams = useSearchParams(); - const inviteToken = searchParams.get("invite"); - - useEffect(() => { - if (inviteToken) { - router.replace(`/invite/claim/${inviteToken}`); - } - }, [inviteToken, router]); - - if (inviteToken) { - return null; - } - - return <>{children}; -} diff --git a/src/components/workspace/SidebarCardList.tsx b/src/components/workspace/SidebarCardList.tsx index 6336688d..1ce1278a 100644 --- a/src/components/workspace/SidebarCardList.tsx +++ b/src/components/workspace/SidebarCardList.tsx @@ -29,8 +29,11 @@ import { AlertDialogHeader, AlertDialogTitle, } from "@/components/ui/alert-dialog"; -import { useWorkspaceStore } from "@/lib/stores/workspace-store"; -import { useWorkspaceState } from "@/hooks/workspace/use-workspace-state"; +import { useCurrentWorkspaceId } from "@/contexts/WorkspaceContext"; +import { + useWorkspaceItems, + useWorkspaceItemsLoading, +} from "@/hooks/workspace/use-workspace-items"; import { useWorkspaceOperations } from "@/hooks/workspace/use-workspace-operations"; import { useUIStore } from "@/lib/stores/ui-store"; import type { Item, CardType } from "@/lib/workspace-state/types"; @@ -766,8 +769,9 @@ function SidebarFolderItem({ * SidebarCardList - Shows all folders in collapsible sections */ function SidebarCardList() { - const currentWorkspaceId = useWorkspaceStore((state) => state.currentWorkspaceId); - const { state, isLoading } = useWorkspaceState(currentWorkspaceId); + const currentWorkspaceId = useCurrentWorkspaceId(); + const state = useWorkspaceItems(); + const isLoading = useWorkspaceItemsLoading(); const { workspaces } = useWorkspaceContext(); const activeFolderId = useUIStore((state) => state.activeFolderId); const setActiveFolderId = useUIStore((state) => state.setActiveFolderId); diff --git a/src/components/workspace/WorkspaceLoader.tsx b/src/components/workspace/WorkspaceLoader.tsx new file mode 100644 index 00000000..a4dd690f --- /dev/null +++ b/src/components/workspace/WorkspaceLoader.tsx @@ -0,0 +1,114 @@ +"use client"; + +import { Skeleton } from "@/components/ui/skeleton"; +import { ThreadLoadingSkeleton } from "@/components/chat/ThreadLoadingSkeleton"; +import { THREAD_TOP_INSET } from "@/components/chat/thread-layout"; +import { PANEL_DEFAULTS } from "@/lib/layout-constants"; +import { useUIStore } from "@/lib/stores/ui-store"; + +/** + * Full-shell skeleton: header bar + bento card grid + (when chat is open) + * a chat-panel slot. Used by `ZeroProvider` while the session/Zero client + * are bootstrapping. Reads the same chat-expanded state as `WorkspaceLayout` + * so the canvas doesn't paint full-width and then snap when the real layout + * mounts with chat open. + */ +export function WorkspaceLoader() { + const isChatExpanded = useUIStore((s) => s.isChatExpanded); + const isChatMaximized = useUIStore((s) => s.isChatMaximized); + + if (isChatMaximized) { + return ( +
+ +
+ ); + } + + return ( +
+
+ +
+ +
+
+ {isChatExpanded ? ( +
+ +
+ ) : null} +
+ ); +} + +/** + * In-shell loader for `view.kind === "loading"` inside `WorkspaceSection`. + * Uniform 1/2/3/4-column card grid at sm/md/lg. + */ +export function WorkspaceCardsLoader() { + return ( +
+
+ {Array.from({ length: 8 }).map((_, i) => ( + + ))} +
+
+ ); +} + +/** + * Placeholder for `ChatPanel` while the active workspace is being resolved. + * Reserves the chat panel's slot so the workspace canvas doesn't paint + * full-width and then suddenly shrink when the real chat panel mounts. + * Reuses `ThreadLoadingSkeleton` for the body so the loading shape matches + * what `ThreadBody` already shows mid-thread-switch. + */ +export function ChatPanelSkeleton() { + return ( +
+
+ + + +
+
+ +
+
+ +
+
+ ); +} + +/** + * Placeholder for `WorkspaceHeader` while the active workspace is being + * resolved. Mirrors the real header's shape: sidebar-toned bar, left chrome + * (logo + sidebar toggle + breadcrumb pill), right action buttons. + */ +export function WorkspaceHeaderSkeleton() { + return ( +
+
+
+ + + +
+
+ + + +
+
+
+ ); +} diff --git a/src/components/workspace/WorkspaceShell.tsx b/src/components/workspace/WorkspaceShell.tsx index c7b0e871..d3da4ecc 100644 --- a/src/components/workspace/WorkspaceShell.tsx +++ b/src/components/workspace/WorkspaceShell.tsx @@ -1,93 +1,62 @@ "use client"; -import { useCallback, useEffect, useRef, useState } from "react"; -import type { WorkspaceWithState } from "@/lib/workspace-state/types"; +import { useEffect, useRef, useState } from "react"; +import { useSession } from "@/lib/auth-client"; import { useKeyboardShortcuts } from "@/hooks/ui/use-keyboard-shortcuts"; -import useMediaQuery from "@/hooks/ui/use-media-query"; -import { useWorkspaceState } from "@/hooks/workspace/use-workspace-state"; +import { useFolderUrl } from "@/hooks/ui/use-folder-url"; +import { useReactiveNavigation } from "@/hooks/ui/use-reactive-navigation"; +import { + WorkspaceItemsProvider, + useWorkspaceItems, + useWorkspaceItemsLoading, +} from "@/hooks/workspace/use-workspace-items"; import { useWorkspaceOperations } from "@/hooks/workspace/use-workspace-operations"; +import { useWorkspaceUpload } from "@/hooks/workspace/use-workspace-upload"; import { WorkspaceProvider, useWorkspaceContext, } from "@/contexts/WorkspaceContext"; -// import { JoyrideProvider } from "@/contexts/JoyrideContext"; import { selectWorkspaceOpenMode, useUIStore } from "@/lib/stores/ui-store"; -import { useWorkspaceStore } from "@/lib/stores/workspace-store"; -import { useSession } from "@/lib/auth-client"; -import { WorkspaceSection } from "@/components/workspace-canvas/WorkspaceSection"; -import { OpenWorkspaceItemView } from "@/components/workspace-canvas/OpenWorkspaceItemView"; +import { useAudioRecordingStore } from "@/lib/stores/audio-recording-store"; import { WorkspaceLayout } from "@/components/layout/WorkspaceLayout"; +import { WorkspaceSection } from "@/components/workspace-canvas/WorkspaceSection"; import WorkspaceHeader from "@/components/workspace-canvas/WorkspaceHeader"; import { WorkspaceSearchDialog } from "@/components/workspace-canvas/WorkspaceSearchDialog"; -import { useSidebar } from "@/components/ui/sidebar"; +import { OpenWorkspaceItemView } from "@/components/workspace-canvas/OpenWorkspaceItemView"; +import { useSidebar, SidebarProvider } from "@/components/ui/sidebar"; import { MobileWarning } from "@/components/ui/MobileWarning"; -import { - AnonymousSessionHandler, - SidebarCoordinator, -} from "@/components/layout/SessionHandler"; -// import { OnboardingVideoDialog } from "@/components/onboarding/OnboardingVideoDialog"; -// import { useOnboardingStatus } from "@/hooks/user/use-onboarding-status"; +import { useConnectionState } from "@rocicorp/zero/react"; import { PdfEngineWrapper } from "@/components/pdf/PdfEngineWrapper"; import WorkspaceSettingsModal from "@/components/workspace/WorkspaceSettingsModal"; import ShareWorkspaceDialog from "@/components/workspace/ShareWorkspaceDialog"; import { RealtimeProvider } from "@/contexts/RealtimeContext"; import { ZeroProvider } from "@/lib/zero/provider"; -import { useConnectionState } from "@rocicorp/zero/react"; -import { toast } from "sonner"; -import { InviteGuard } from "@/components/workspace/InviteGuard"; -import { useReactiveNavigation } from "@/hooks/ui/use-reactive-navigation"; -import { useFolderUrl } from "@/hooks/ui/use-folder-url"; -import { useAudioRecordingStore } from "@/lib/stores/audio-recording-store"; -import { buildWorkspaceItemDefinitionsFromAssets } from "@/lib/uploads/uploaded-asset"; -import { - prepareWorkspaceUploadSelection, - uploadSelectedFiles, -} from "@/lib/uploads/upload-selection"; -import { startAssetProcessing } from "@/lib/uploads/start-asset-processing"; -import { emitPasswordProtectedPdf } from "@/components/modals/PasswordProtectedPdfDialog"; -import { - getDocumentUploadFailureMessage, - getDocumentUploadLoadingMessage, - getDocumentUploadPartialMessage, - getDocumentUploadSuccessMessage, -} from "@/lib/uploads/upload-feedback"; - -// Main workspace content component -interface WorkspaceContentProps { - currentWorkspace: WorkspaceWithState | null; - loadingCurrentWorkspace: boolean; -} -function WorkspaceContent({ - currentWorkspace, - loadingCurrentWorkspace, -}: WorkspaceContentProps) { +/** + * Inner shell — runs after auth + Zero are bootstrapped. Holds the workspace + * UI, modals, keyboard shortcuts, and side-effects that depend on the active + * workspace. + */ +function WorkspaceContent() { const { data: session } = useSession(); + const { currentWorkspace } = useWorkspaceContext(); - const currentWorkspaceId = currentWorkspace?.id || null; + const currentWorkspaceId = currentWorkspace?.id ?? null; const currentWorkspaceTitle = currentWorkspace?.name; const currentWorkspaceIcon = currentWorkspace?.icon; const currentWorkspaceColor = currentWorkspace?.color; - // Check onboarding status - // const { shouldShowOnboarding, isLoading: isLoadingOnboarding } = useOnboardingStatus(); - // const [showOnboardingDialog, setShowOnboardingDialog] = useState(false); - - // Show onboarding dialog when user hasn't completed onboarding - // useEffect(() => { - // if (!isLoadingOnboarding && shouldShowOnboarding) { - // setShowOnboardingDialog(true); - // } - // }, [shouldShowOnboarding, isLoadingOnboarding]); - // Get workspace context (now only manages workspace list) - const { currentSlug, switchWorkspace } = useWorkspaceContext(); const zeroConnectionState = useConnectionState(); const isSaving = zeroConnectionState.name === "connecting"; - const { state, isLoading: isLoadingWorkspace } = - useWorkspaceState(currentWorkspaceId); + const state = useWorkspaceItems(); + const isLoadingWorkspace = useWorkspaceItemsLoading(); + const operations = useWorkspaceOperations(currentWorkspaceId, state); + + // Reactive navigation (auto-scroll/select on new items) + const { handleCreatedItems } = useReactiveNavigation(state); - // Open audio recorder when landing from home Record flow (store flag set before navigate). + // Audio dialog: open when landing from "record" flow, close on workspace switch. const openAudioDialog = useAudioRecordingStore((s) => s.openDialog); const closeAudioDialog = useAudioRecordingStore((s) => s.closeDialog); const shouldOpenOnWorkspaceLoad = useAudioRecordingStore( @@ -97,7 +66,6 @@ function WorkspaceContent({ (s) => s.setShouldOpenOnWorkspaceLoad, ); const prevWorkspaceIdRef = useRef(null); - useEffect(() => { if (!currentWorkspaceId || isLoadingWorkspace) return; @@ -105,8 +73,6 @@ function WorkspaceContent({ setShouldOpenOnWorkspaceLoad(false); openAudioDialog(); } - - // Close audio dialog when switching to a different workspace (dialog state is global) if ( prevWorkspaceIdRef.current !== null && prevWorkspaceIdRef.current !== currentWorkspaceId @@ -123,49 +89,15 @@ function WorkspaceContent({ setShouldOpenOnWorkspaceLoad, ]); - // Workspace operations - const operations = useWorkspaceOperations(currentWorkspaceId, state); - - // Workspace settings/share modals (lifted so header can open them) + // Lifted modal state (header opens these). const [showWorkspaceSettings, setShowWorkspaceSettings] = useState(false); const [showWorkspaceShare, setShowWorkspaceShare] = useState(false); - - // Get sidebar state and controls - const { toggleSidebar } = useSidebar(); - - // UI State from Zustand stores - using individual selectors to prevent unnecessary re-renders - // NOTE: Each selector only triggers a re-render when that specific value changes - const isDesktop = useMediaQuery("(min-width: 768px)"); - const isChatExpanded = useUIStore((state) => state.isChatExpanded); - const isChatMaximized = useUIStore((state) => state.isChatMaximized); - const showCreateWorkspaceModal = useUIStore( - (state) => state.showCreateWorkspaceModal, - ); - const setIsChatExpanded = useUIStore((state) => state.setIsChatExpanded); - const setIsChatMaximized = useUIStore((state) => state.setIsChatMaximized); - const openWorkspaceItem = useUIStore((state) => state.openWorkspaceItem); - const setShowCreateWorkspaceModal = useUIStore( - (state) => state.setShowCreateWorkspaceModal, - ); - - const toggleChatExpanded = useUIStore((state) => state.toggleChatExpanded); - const toggleChatMaximized = useUIStore((state) => state.toggleChatMaximized); - - const primaryOpenItemId = useUIStore((state) => state.openItems.primary); - const workspaceOpenMode = useUIStore(selectWorkspaceOpenMode); - const closeWorkspaceItem = useUIStore((state) => state.closeWorkspaceItem); - const setActiveFolderId = useUIStore((state) => state.setActiveFolderId); - const navigateToRoot = useUIStore((state) => state.navigateToRoot); - const navigateToFolder = useUIStore((state) => state.navigateToFolder); - - // Refs and custom hooks - const scrollAreaRef = useRef(null); - const titleInputRef = useRef(null); - - // Search dialog state for Cmd+K command palette const [searchDialogOpen, setSearchDialogOpen] = useState(false); - // Keyboard shortcuts + // Keyboard shortcuts use the sidebar + chat toggles. + const { toggleSidebar } = useSidebar(); + const toggleChatExpanded = useUIStore((s) => s.toggleChatExpanded); + const toggleChatMaximized = useUIStore((s) => s.toggleChatMaximized); useKeyboardShortcuts(toggleChatExpanded, { onToggleSidebar: toggleSidebar, onToggleChatMaximize: toggleChatMaximized, @@ -176,110 +108,42 @@ function WorkspaceContent({ }, }); - // CopilotKit actions removed - now using Assistant-UI directly - - const openWorkspaceItemView = ( - - ); - - // Handle reactive navigation for new items - const { handleCreatedItems } = useReactiveNavigation(state); - - const handleWorkspacePdfUpload = useCallback( - async (files: File[]) => { - if (!currentWorkspaceId) { - throw new Error("Workspace not available"); - } - - const { acceptedFiles: filesToUpload, protectedPdfNames } = - await prepareWorkspaceUploadSelection(files); - if (protectedPdfNames.length > 0) { - emitPasswordProtectedPdf(protectedPdfNames); - } - if (filesToUpload.length === 0) { - return; - } - - const uploadToastId = toast.loading( - getDocumentUploadLoadingMessage(filesToUpload.length), - ); - const { uploads, failedFiles } = await uploadSelectedFiles(filesToUpload); - - toast.dismiss(uploadToastId); - - if (uploads.length === 0) { - if (failedFiles.length > 0) { - toast.error(getDocumentUploadFailureMessage(failedFiles.length)); - } - return; - } - - const pdfCardDefinitions = - buildWorkspaceItemDefinitionsFromAssets(uploads); - - const createdIds = operations.createItems(pdfCardDefinitions, { - showSuccessToast: false, - }); - handleCreatedItems(createdIds); - - void startAssetProcessing({ - workspaceId: currentWorkspaceId, - assets: uploads, - itemIds: createdIds, - onOcrError: (error) => { - console.error( - "[WORKSPACE_PROCESSING] Failed to start processing:", - error, - ); - }, - }); - - if (uploads.length > 0 && failedFiles.length === 0) { - toast.success(getDocumentUploadSuccessMessage(uploads.length)); - } else if (uploads.length > 0) { - toast.warning( - getDocumentUploadPartialMessage(uploads.length, failedFiles.length), - ); - } - }, - [operations, currentWorkspaceId, handleCreatedItems], - ); + // Header-only state (read here to avoid drilling through WorkspaceSection). + const isChatMaximized = useUIStore((s) => s.isChatMaximized); + const primaryOpenItemId = useUIStore((s) => s.openItems.primary); + const workspaceOpenMode = useUIStore(selectWorkspaceOpenMode); + const closeWorkspaceItem = useUIStore((s) => s.closeWorkspaceItem); + const setActiveFolderId = useUIStore((s) => s.setActiveFolderId); + const navigateToRoot = useUIStore((s) => s.navigateToRoot); + const navigateToFolder = useUIStore((s) => s.navigateToFolder); + const isChatExpanded = useUIStore((s) => s.isChatExpanded); + const setIsChatExpanded = useUIStore((s) => s.setIsChatExpanded); + + // Single shared upload pipeline (matches the legacy WorkspaceShell behavior: + // no per-file size limit; the per-section path enforces 50MB). + const uploadHeaderFiles = useWorkspaceUpload({ + currentWorkspaceId, + operations, + onItemsCreated: handleCreatedItems, + enforceSizeLimit: false, + }); return ( - {/* */} setSearchDialogOpen(true)} currentWorkspaceId={currentWorkspaceId} isSaving={isSaving} - isDesktop={isDesktop} isChatExpanded={isChatExpanded} setIsChatExpanded={setIsChatExpanded} workspaceName={currentWorkspaceTitle} workspaceIcon={currentWorkspaceIcon} workspaceColor={currentWorkspaceColor} addItem={operations.createItem} - onPDFUpload={handleWorkspacePdfUpload} + onPDFUpload={uploadHeaderFiles} onItemCreated={handleCreatedItems} items={state} onRenameFolder={(folderId, newName) => { @@ -323,27 +187,16 @@ function WorkspaceContent({ } workspaceSection={ } operations={operations} - scrollAreaRef={scrollAreaRef as React.RefObject} - workspaceTitle={currentWorkspaceTitle} - workspaceIcon={currentWorkspaceIcon} - workspaceColor={currentWorkspaceColor} - openItemView={openWorkspaceItemView} + openItemView={ + + } /> } /> @@ -362,82 +215,54 @@ function WorkspaceContent({ onOpenChange={setSearchDialogOpen} items={state} currentWorkspaceId={currentWorkspaceId} - isLoadingWorkspace={isLoadingWorkspace} /> ); } -// Main page component (wrapper) -export function WorkspacePage() { - return ( - - - - ); -} - -// Inner component with all the workspace hooks -// Only rendered when InviteGuard allows (authenticated + invite processed) -function WorkspaceView() { - // Get workspace context - currentWorkspace is loaded directly by slug (fast path) - const { currentWorkspace, loadingCurrentWorkspace, markWorkspaceOpened } = - useWorkspaceContext(); - - const currentWorkspaceId = currentWorkspace?.id || null; +/** + * Side-effect bridge that lives between providers and the workspace UI: + * - calls `markWorkspaceOpened` once per workspace + * - syncs active folder with the URL via `useFolderUrl` + */ +function WorkspaceShellInner() { + const { currentWorkspaceId, markWorkspaceOpened } = useWorkspaceContext(); - // Sync workspace ID to store - const setCurrentWorkspaceId = useWorkspaceStore( - (state) => state.setCurrentWorkspaceId, - ); - useEffect(() => { - setCurrentWorkspaceId(currentWorkspaceId); - }, [currentWorkspaceId, setCurrentWorkspaceId]); - - // Track workspace opens for sorting - const lastTrackedWorkspaceIdRef = useRef(null); + const lastTrackedRef = useRef(null); useEffect(() => { if (!currentWorkspaceId) return; - - // Only track if this is a different workspace than last time - if (lastTrackedWorkspaceIdRef.current === currentWorkspaceId) return; - - // Track the open via context (optimistically updates cache) + if (lastTrackedRef.current === currentWorkspaceId) return; markWorkspaceOpened(currentWorkspaceId); - - lastTrackedWorkspaceIdRef.current = currentWorkspaceId; + lastTrackedRef.current = currentWorkspaceId; }, [currentWorkspaceId, markWorkspaceOpened]); - // Sync active folder with URL query param (?folder=) - // Reset folder/panels when workspace changes - // and enables browser-native back/forward for folder navigation useFolderUrl(); return ( - + - + - + ); } +/** + * Public entry point. The page-level server component already guarantees a + * session exists (anonymous or real). Flat provider stack from there: + * WorkspaceProvider → SidebarProvider → ZeroProvider → workspace tree. + */ export function WorkspaceShell() { return ( <> - - - {/* */} - - - - {/* */} - - + + + + + + + ); } diff --git a/src/components/workspace/WorkspaceSkeleton.tsx b/src/components/workspace/WorkspaceSkeleton.tsx deleted file mode 100644 index e26b845d..00000000 --- a/src/components/workspace/WorkspaceSkeleton.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import { Skeleton } from "@/components/ui/skeleton"; - -/** - * Skeleton loading state for workspace content - * Shows placeholder cards while workspace data is loading - */ -export function WorkspaceSkeleton() { - return ( -
- {/* Header skeleton */} -
-
- {/* Left: Save indicator */} -
- -
- - {/* Center: Search and Filter */} -
- - -
-
-
- - {/* Cards grid skeleton */} -
-
- {[...Array(8)].map((_, i) => ( -
- {/* Card header */} -
- - -
- - {/* Card content */} -
- - - -
- - {/* Card footer */} -
- - -
-
- ))} -
-
-
- ); -} - diff --git a/src/contexts/WorkspaceContext.tsx b/src/contexts/WorkspaceContext.tsx index 3138fd90..52c81a8a 100644 --- a/src/contexts/WorkspaceContext.tsx +++ b/src/contexts/WorkspaceContext.tsx @@ -5,16 +5,15 @@ import { useRouter, usePathname } from "next/navigation"; import { toast } from "sonner"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import type { WorkspaceWithState } from "@/lib/workspace-state/types"; -import { useWorkspaceStore } from "@/lib/stores/workspace-store"; /** - * Simplified WorkspaceContext - ONLY manages workspace list + * WorkspaceContext is the single source of truth for the active workspace. * * State management responsibilities: - * - Workspace list: WorkspaceContext (this file) - * - Current workspace & save status: Zustand (workspace-store.ts) - * - UI state: Zustand (ui-store.ts) - * - Workspace data: Zero sync + React Query + * - Workspace list + active workspace + slug: WorkspaceContext (this file) + * - Per-workspace persisted UI state (e.g. last thread): Zustand workspace-store + * - Other UI state: Zustand ui-store + * - Workspace items: Zero sync (via WorkspaceItemsProvider) */ interface WorkspaceContextType { // Workspace list @@ -28,6 +27,7 @@ interface WorkspaceContextType { // Current workspace (loaded by slug for direct access) currentWorkspace: WorkspaceWithState | null; + currentWorkspaceId: string | null; loadingCurrentWorkspace: boolean; // Current slug (derived from URL) @@ -51,9 +51,6 @@ export function WorkspaceProvider({ }) { const router = useRouter(); const pathname = usePathname(); - const currentWorkspaceId = useWorkspaceStore( - (state) => state.currentWorkspaceId, - ); const queryClient = useQueryClient(); // Derive current slug synchronously from pathname (no useEffect delay) @@ -103,9 +100,8 @@ export function WorkspaceProvider({ queryKey: ["workspace-by-slug", currentSlug], queryFn: async () => { if (!currentSlug) return null; - // Use metadata=true for faster loading - skip state replay const response = await fetch( - `/api/workspaces/slug/${currentSlug}?metadata=true`, + `/api/workspaces/slug/${encodeURIComponent(currentSlug)}`, ); if (!response.ok) { if (response.status === 404) return null; @@ -123,6 +119,7 @@ export function WorkspaceProvider({ }); const currentWorkspace = currentWorkspaceData || null; + const currentWorkspaceId = currentWorkspace?.id ?? null; // Merge current workspace into list if not already present const workspaces = useMemo(() => { @@ -306,6 +303,7 @@ export function WorkspaceProvider({ loadWorkspaces, updateWorkspaceLocal, currentWorkspace, + currentWorkspaceId, loadingCurrentWorkspace, currentSlug, switchWorkspace, @@ -330,3 +328,8 @@ export function useWorkspaceContext() { } return context; } + +/** Shorthand for `useWorkspaceContext().currentWorkspaceId`. */ +export function useCurrentWorkspaceId(): string | null { + return useWorkspaceContext().currentWorkspaceId; +} diff --git a/src/hooks/ui/use-navigate-to-item.ts b/src/hooks/ui/use-navigate-to-item.ts index 0772dee0..5697e760 100644 --- a/src/hooks/ui/use-navigate-to-item.ts +++ b/src/hooks/ui/use-navigate-to-item.ts @@ -2,8 +2,7 @@ import { useCallback } from "react"; import { useUIStore } from "@/lib/stores/ui-store"; -import { useWorkspaceStore } from "@/lib/stores/workspace-store"; -import { useWorkspaceState } from "@/hooks/workspace/use-workspace-state"; +import { useWorkspaceItems } from "@/hooks/workspace/use-workspace-items"; import { toast } from "sonner"; let activeCleanup: (() => void) | null = null; @@ -12,8 +11,7 @@ let activeCleanup: (() => void) | null = null; * Hook that provides a function to navigate to and highlight an item in the workspace. */ export function useNavigateToItem() { - const workspaceId = useWorkspaceStore((state) => state.currentWorkspaceId); - const { state: workspaceState } = useWorkspaceState(workspaceId); + const workspaceState = useWorkspaceItems(); const setActiveFolderId = useUIStore((state) => state.setActiveFolderId); const navigateToItem = useCallback( diff --git a/src/hooks/workspace/use-workspace-items.tsx b/src/hooks/workspace/use-workspace-items.tsx new file mode 100644 index 00000000..de98ae60 --- /dev/null +++ b/src/hooks/workspace/use-workspace-items.tsx @@ -0,0 +1,66 @@ +"use client"; + +import { createContext, useContext, useMemo, type ReactNode } from "react"; +import type { Item } from "@/lib/workspace-state/types"; +import { useCurrentWorkspaceId } from "@/contexts/WorkspaceContext"; +import { + useWorkspaceState, + type WorkspaceStateStatus, +} from "@/hooks/workspace/use-workspace-state"; + +/** + * Single shared subscription to the current workspace's items. + * + * Before this context, `useWorkspaceState(workspaceId)` was called from ~12 + * deep leaves (every chat tool, MarkdownText, the dropzone, sidebar card list, + * etc.). Each one re-fanned the same Zero query and required the consumer to + * be mounted under `ZeroProvider`. Now the subscription lives once in + * `WorkspaceItemsProvider`; presentational consumers read plain data and don't + * know Zero exists. + */ +interface WorkspaceItemsValue { + items: Item[]; + status: WorkspaceStateStatus; + error: Error | null; +} + +const WorkspaceItemsContext = createContext(null); + +export function WorkspaceItemsProvider({ children }: { children: ReactNode }) { + const currentWorkspaceId = useCurrentWorkspaceId(); + const { state, status, error } = useWorkspaceState(currentWorkspaceId); + + const value = useMemo( + () => ({ items: state, status, error }), + [state, status, error], + ); + + return ( + + {children} + + ); +} + +const EMPTY: WorkspaceItemsValue = { + items: [], + status: "idle", + error: null, +}; + +/** + * Read the current workspace's items. Returns an empty array outside the + * provider (keeps tests and isolated stories from crashing). + */ +export function useWorkspaceItems(): Item[] { + return (useContext(WorkspaceItemsContext) ?? EMPTY).items; +} + +export function useWorkspaceItemsLoading(): boolean { + return (useContext(WorkspaceItemsContext) ?? EMPTY).status === "loading"; +} + +/** Full status for the gating layer (`useWorkspaceView`). */ +export function useWorkspaceItemsStatus() { + return useContext(WorkspaceItemsContext) ?? EMPTY; +} diff --git a/src/hooks/workspace/use-workspace-state.ts b/src/hooks/workspace/use-workspace-state.ts index f5cd5e73..f3f8be10 100644 --- a/src/hooks/workspace/use-workspace-state.ts +++ b/src/hooks/workspace/use-workspace-state.ts @@ -1,4 +1,4 @@ -import { useMemo } from "react"; +import { useEffect, useMemo, useState } from "react"; import { useQuery } from "@rocicorp/zero/react"; import { rehydrateWorkspaceItem } from "@/lib/workspace/workspace-item-model"; import type { WorkspaceItemContentProjection } from "@/lib/workspace/workspace-item-model-types"; @@ -15,14 +15,52 @@ type WorkspaceItemQueryRow = WorkspaceItemsRow & { | WorkspaceItemContentRow; }; -export function useWorkspaceState(workspaceId: string | null) { - const [rawRows, status] = useQuery( +const LOADING_TIMEOUT_MS = 8_000; + +export type WorkspaceStateStatus = + | "idle" // no workspaceId yet + | "loading" // Zero hasn't returned a result + | "ready" // results in hand (possibly empty) + | "error" // Zero reported an error + | "timeout"; // still in 'unknown' after LOADING_TIMEOUT_MS + +export interface UseWorkspaceStateResult { + state: Item[]; + status: WorkspaceStateStatus; + isLoading: boolean; + isReady: boolean; + error: Error | null; +} + +export function useWorkspaceState( + workspaceId: string | null, +): UseWorkspaceStateResult { + const [rawRows, queryStatus] = useQuery( workspaceId ? queries.workspace.items({ workspaceId }) : null, ); const rows = rawRows as unknown as | readonly WorkspaceItemQueryRow[] | undefined; + const [timedOut, setTimedOut] = useState(false); + + // Reset the timer whenever workspace or query status changes. + useEffect(() => { + setTimedOut(false); + if (!workspaceId) return; + if (queryStatus.type !== "unknown") return; + + const timer = setTimeout(() => setTimedOut(true), LOADING_TIMEOUT_MS); + return () => clearTimeout(timer); + }, [workspaceId, queryStatus.type]); + + const status = useMemo(() => { + if (!workspaceId) return "idle"; + if (queryStatus.type === "error") return "error"; + if (queryStatus.type === "unknown") return timedOut ? "timeout" : "loading"; + return "ready"; + }, [workspaceId, queryStatus.type, timedOut]); + const state = useMemo(() => { if (!rows) { return []; @@ -66,18 +104,20 @@ export function useWorkspaceState(workspaceId: string | null) { }, [rows]); const error = useMemo(() => { - if (status.type !== "error") { - return null; + if (queryStatus.type === "error") { + return new Error(queryStatus.error.message); } - - return new Error(status.error.message); - }, [status]); + if (status === "timeout") { + return new Error("Workspace data timed out"); + } + return null; + }, [queryStatus, status]); return { state, - isLoading: Boolean(workspaceId) && status.type === "unknown", + status, + isLoading: status === "loading", + isReady: status === "ready", error, - version: 0, - refetch: async () => {}, }; } diff --git a/src/hooks/workspace/use-workspace-upload.ts b/src/hooks/workspace/use-workspace-upload.ts new file mode 100644 index 00000000..d4aff670 --- /dev/null +++ b/src/hooks/workspace/use-workspace-upload.ts @@ -0,0 +1,107 @@ +"use client"; + +import { useCallback } from "react"; +import { toast } from "sonner"; +import type { WorkspaceOperations } from "@/hooks/workspace/use-workspace-operations"; +import { DEFAULT_CARD_DIMENSIONS } from "@/lib/workspace-state/grid-layout-helpers"; +import { buildWorkspaceItemDefinitionsFromAssets } from "@/lib/uploads/uploaded-asset"; +import { + getFileSizeLabel, + prepareWorkspaceUploadSelection, + uploadSelectedFiles, +} from "@/lib/uploads/upload-selection"; +import { startAssetProcessing } from "@/lib/uploads/start-asset-processing"; +import { + getDocumentUploadFailureMessage, + getDocumentUploadLoadingMessage, + getDocumentUploadPartialMessage, + getDocumentUploadSuccessMessage, +} from "@/lib/uploads/upload-feedback"; +import { emitPasswordProtectedPdf } from "@/components/modals/PasswordProtectedPdfDialog"; + +const MAX_FILE_SIZE_MB = 50; +const MAX_FILE_SIZE_BYTES = MAX_FILE_SIZE_MB * 1024 * 1024; + +interface UseWorkspaceUploadParams { + currentWorkspaceId: string | null; + operations: WorkspaceOperations | null | undefined; + onItemsCreated?: (createdIds: string[]) => void; + /** Reject files >50MB with a toast. Header path doesn't enforce; canvas does. */ + enforceSizeLimit?: boolean; +} + +/** Single PDF/asset upload pipeline. Replaces two near-identical handlers. */ +export function useWorkspaceUpload({ + currentWorkspaceId, + operations, + onItemsCreated, + enforceSizeLimit = true, +}: UseWorkspaceUploadParams) { + return useCallback( + async (files: File[]) => { + if (!operations || !currentWorkspaceId) { + toast.error("Workspace isn't ready yet — try again in a moment."); + return; + } + + let candidates = files; + if (enforceSizeLimit) { + const oversized = files.filter((f) => f.size > MAX_FILE_SIZE_BYTES); + candidates = files.filter((f) => f.size <= MAX_FILE_SIZE_BYTES); + if (oversized.length > 0) { + toast.error( + `${oversized.length} file${oversized.length === 1 ? "" : "s"} exceed the ${MAX_FILE_SIZE_MB}MB limit:\n${oversized.map(getFileSizeLabel).join("\n")}`, + ); + } + if (candidates.length === 0) return; + } + + const { acceptedFiles, protectedPdfNames } = + await prepareWorkspaceUploadSelection(candidates, { + maxFileSizeBytes: enforceSizeLimit + ? MAX_FILE_SIZE_BYTES + : Number.POSITIVE_INFINITY, + }); + if (protectedPdfNames.length > 0) emitPasswordProtectedPdf(protectedPdfNames); + if (acceptedFiles.length === 0) return; + + const toastId = toast.loading( + getDocumentUploadLoadingMessage(acceptedFiles.length), + ); + const { uploads, failedFiles } = await uploadSelectedFiles(acceptedFiles); + toast.dismiss(toastId); + + if (uploads.length === 0) { + if (failedFiles.length > 0) { + toast.error(getDocumentUploadFailureMessage(failedFiles.length)); + } + return; + } + + const createdIds = operations.createItems( + buildWorkspaceItemDefinitionsFromAssets(uploads, { + imageLayout: DEFAULT_CARD_DIMENSIONS.image, + }), + { showSuccessToast: false }, + ); + onItemsCreated?.(createdIds); + + void startAssetProcessing({ + workspaceId: currentWorkspaceId, + assets: uploads, + itemIds: createdIds, + onOcrError: (e) => + console.error("[WORKSPACE_PROCESSING] Failed to start processing:", e), + }); + + if (failedFiles.length === 0) { + toast.success(getDocumentUploadSuccessMessage(uploads.length)); + } else { + toast.warning( + getDocumentUploadPartialMessage(uploads.length, failedFiles.length), + ); + } + }, + [currentWorkspaceId, operations, onItemsCreated, enforceSizeLimit], + ); +} diff --git a/src/hooks/workspace/use-workspace-view.ts b/src/hooks/workspace/use-workspace-view.ts new file mode 100644 index 00000000..6d5c759d --- /dev/null +++ b/src/hooks/workspace/use-workspace-view.ts @@ -0,0 +1,65 @@ +"use client"; + +import { useMemo } from "react"; +import { useSession } from "@/lib/auth-client"; +import { useWorkspaceContext } from "@/contexts/WorkspaceContext"; +import { useWorkspaceItemsStatus } from "@/hooks/workspace/use-workspace-items"; +import { useZeroReset, useZeroStatus } from "@/lib/zero/provider"; +import type { Item, WorkspaceWithState } from "@/lib/workspace-state/types"; + +/** + * Single source of truth for the workspace shell view state. Replaces a tangle + * of `loadingWorkspaces`/`loadingCurrentWorkspace`/`isLoadingWorkspace` flags + * that produced contradictory branches in `WorkspaceSection`. + */ +export type WorkspaceView = + | { kind: "loading" } + | { kind: "unauthenticated" } + | { kind: "denied" } + | { kind: "error"; message: string; retry: () => void } + | { kind: "ready"; items: Item[]; workspace: WorkspaceWithState }; + +export function useWorkspaceView(): WorkspaceView { + const { data: session, isPending: sessionPending } = useSession(); + const { isReady: zeroReady } = useZeroStatus(); + const reset = useZeroReset(); + const { currentWorkspace, loadingCurrentWorkspace, currentSlug } = + useWorkspaceContext(); + const { items: state, status, error } = useWorkspaceItemsStatus(); + + return useMemo(() => { + if (sessionPending || !zeroReady) return { kind: "loading" }; + + if (currentSlug && !currentWorkspace) { + if (loadingCurrentWorkspace) return { kind: "loading" }; + if (!session || session.user?.isAnonymous) { + return { kind: "unauthenticated" }; + } + return { kind: "denied" }; + } + if (!currentWorkspace) return { kind: "loading" }; + + if (status === "error" || status === "timeout") { + return { + kind: "error", + retry: reset, + message: + error?.message ?? "Workspace data could not be loaded.", + }; + } + if (status !== "ready") return { kind: "loading" }; + + return { kind: "ready", items: state, workspace: currentWorkspace }; + }, [ + sessionPending, + zeroReady, + currentSlug, + currentWorkspace, + loadingCurrentWorkspace, + session?.user?.isAnonymous, + status, + state, + error, + reset, + ]); +} diff --git a/src/lib/stores/__tests__/workspace-store.test.ts b/src/lib/stores/__tests__/workspace-store.test.ts index f950b670..36ca9962 100644 --- a/src/lib/stores/__tests__/workspace-store.test.ts +++ b/src/lib/stores/__tests__/workspace-store.test.ts @@ -37,7 +37,6 @@ describe("workspace store chat thread state", () => { const storeModule = await import("../workspace-store"); const store = storeModule.useWorkspaceStore; - store.getState().setCurrentWorkspaceId("workspace-1"); store.getState().setCurrentThreadId("workspace-1", "thread-1"); const persisted = JSON.parse( @@ -45,7 +44,6 @@ describe("workspace store chat thread state", () => { ) as { state?: { currentThreadIdByWorkspace?: Record; - currentWorkspaceId?: string | null; }; }; @@ -57,7 +55,6 @@ describe("workspace store chat thread state", () => { const reloadedModule = await import("../workspace-store"); const reloadedState = reloadedModule.useWorkspaceStore.getState(); - expect(reloadedState.currentWorkspaceId).toBeNull(); expect( reloadedModule.selectCurrentThreadId("workspace-1")(reloadedState), ).toBe("thread-1"); diff --git a/src/lib/stores/workspace-store.ts b/src/lib/stores/workspace-store.ts index 6547ae71..37f69309 100644 --- a/src/lib/stores/workspace-store.ts +++ b/src/lib/stores/workspace-store.ts @@ -1,10 +1,16 @@ import { create } from "zustand"; import { createJSONStorage, devtools, persist } from "zustand/middleware"; +/** + * Persistent UI state keyed by workspace. + * + * NOTE: `currentWorkspaceId` is intentionally NOT in this store. The active + * workspace is derived from the URL slug inside `WorkspaceContext`; mirroring + * it into Zustand would create two sources of truth and cascading renders. + * Read it via `useCurrentWorkspaceId()` from `@/contexts/WorkspaceContext`. + */ interface WorkspaceStoreState { - currentWorkspaceId: string | null; currentThreadIdByWorkspace: Record; - setCurrentWorkspaceId: (id: string | null) => void; setCurrentThreadId: (workspaceId: string, threadId: string) => void; clearCurrentThreadId: (workspaceId: string, threadId?: string) => void; } @@ -13,9 +19,7 @@ export const useWorkspaceStore = create()( devtools( persist( (set) => ({ - currentWorkspaceId: null, currentThreadIdByWorkspace: {}, - setCurrentWorkspaceId: (id) => set({ currentWorkspaceId: id }), setCurrentThreadId: (workspaceId, threadId) => set((state) => ({ currentThreadIdByWorkspace: { @@ -46,9 +50,6 @@ export const useWorkspaceStore = create()( ), ); -export const selectCurrentWorkspaceId = (state: WorkspaceStoreState) => - state.currentWorkspaceId; - export const selectCurrentThreadId = (workspaceId: string | null) => (state: WorkspaceStoreState) => workspaceId ? state.currentThreadIdByWorkspace[workspaceId] : undefined; diff --git a/src/lib/zero/client.ts b/src/lib/zero/client.ts index 468a4727..79c58a88 100644 --- a/src/lib/zero/client.ts +++ b/src/lib/zero/client.ts @@ -1,56 +1,8 @@ -import { Zero } from "@rocicorp/zero"; -import { getZeroConfigError } from "@/lib/self-host-config"; -import { mutators } from "./mutators"; -import { schema } from "./zero-schema.gen"; - +/** + * Shared Zero context type used by `zero-schema.gen.ts` for typed query/mutator + * registries. The actual `Zero` client lifecycle is managed by `ZeroProvider` + * (see `./provider.tsx`), which uses the library's managed mode. + */ export interface ZeroContext { userId: string; } - -const appURL = - process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000"; - -function createZeroInstance(params: { userId: string }) { - const configError = getZeroConfigError(); - if (configError) { - throw new Error(configError); - } - - return new Zero({ - schema, - cacheURL: process.env.NEXT_PUBLIC_ZERO_SERVER!, - mutateURL: `${appURL}/api/zero/mutate`, - queryURL: `${appURL}/api/zero/query`, - userID: params.userId, - context: { - userId: params.userId, - }, - mutators, - }); -} - -let zeroInstance: ReturnType | null = null; -let zeroUserId: string | null = null; - -export function destroyZero() { - if (!zeroInstance) { - return; - } - - void zeroInstance.close(); - zeroInstance = null; - zeroUserId = null; -} - -export function getZero(params: { userId: string }) { - if (!zeroInstance || zeroUserId !== params.userId) { - destroyZero(); - zeroInstance = createZeroInstance(params); - zeroUserId = params.userId; - return zeroInstance; - } - - return zeroInstance; -} - -export type ZeroInstance = ReturnType; diff --git a/src/lib/zero/provider.tsx b/src/lib/zero/provider.tsx index 556c1005..c50e708a 100644 --- a/src/lib/zero/provider.tsx +++ b/src/lib/zero/provider.tsx @@ -1,70 +1,97 @@ "use client"; -import { DotLottieReact } from "@lottiefiles/dotlottie-react"; import { ZeroProvider as BaseZeroProvider } from "@rocicorp/zero/react"; -import type { ReactNode } from "react"; -import { useEffect, useMemo } from "react"; -import { useTheme } from "next-themes"; +import { + createContext, + useContext, + useMemo, + useState, + type ReactNode, +} from "react"; import { useSession } from "@/lib/auth-client"; import { getZeroConfigError } from "@/lib/self-host-config"; -import { destroyZero, getZero } from "./client"; +import { WorkspaceLoader } from "@/components/workspace/WorkspaceLoader"; +import { mutators } from "./mutators"; +import { schema } from "./zero-schema.gen"; -/** Same ThinkEx animation as chat “Thinking…” — shown while session / Zero client bootstrap. */ -function ZeroBootstrapLottie() { - const { resolvedTheme } = useTheme(); - const lottieSrc = - resolvedTheme === "light" ? "/thinkexlight.lottie" : "/logo.lottie"; +interface ZeroStatus { + isReady: boolean; + /** Force a fresh Zero client (UI retry path). */ + reset: () => void; +} - return ( -
- -
- ); +const ZeroStatusContext = createContext(null); + +export function useZeroStatus(): ZeroStatus { + const ctx = useContext(ZeroStatusContext); + if (!ctx) throw new Error("useZeroStatus must be used within ZeroProvider"); + return ctx; } +export function useZeroReset(): () => void { + return useZeroStatus().reset; +} + +const appURL = process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000"; +const mutateURL = `${appURL}/api/zero/mutate`; +const queryURL = `${appURL}/api/zero/query`; + export function ZeroProvider({ children }: { children: ReactNode }) { const { data: session, isPending } = useSession(); - const userId = session?.user?.id ?? null; - const zeroConfigError = getZeroConfigError(); + const userId = session?.user?.id; + const configError = getZeroConfigError(); + const cacheURL = process.env.NEXT_PUBLIC_ZERO_SERVER; - useEffect(() => { - if (!userId) { - destroyZero(); - } - }, [userId]); + const [resetKey, setResetKey] = useState(0); - const zero = useMemo(() => { - if (zeroConfigError || !userId) { - return null; - } + const context = useMemo<{ userId: string } | null>( + () => (userId ? { userId } : null), + [userId], + ); - return getZero({ userId }); - }, [userId, zeroConfigError]); + const status = useMemo( + () => ({ + isReady: !!userId && !configError, + reset: () => setResetKey((k) => k + 1), + }), + [userId, configError], + ); - if (zeroConfigError) { + if (configError || !cacheURL) { return (
-

Zero is required for local development.

-

- {zeroConfigError} +

+ Zero is required for local development.

+

{configError ?? "NEXT_PUBLIC_ZERO_SERVER is not set."}

); } - if (isPending || !zero) { - // Avoid blank flash during session check / anonymous session creation. - // We cannot render children here — they use useQuery from @rocicorp/zero/react. - return ; + if (isPending || !userId || !context) { + return ( + + + + ); } - return {children}; + return ( + + + {children} + + + ); }