From a99f6685fd79acdbcd1120f753706401c1b8a8bf Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Wed, 29 Apr 2026 21:37:44 -0400 Subject: [PATCH 01/15] refactor(workspace): rearchitect shell, fix Zero lifecycle, kill prop drilling Workspace shell was getting stuck on the skeleton with intermittent 403s and "Zero was explicitly closed" errors. Root causes were broader than the symptom: batched query authorization failed for the whole batch, the Zero client's lifecycle wasn't tied to userId properly, the StrictMode double-effect was closing the live client, and three overlapping loading flags produced contradictory branches in the render path. Zero correctness - /api/zero/query: pre-check workspace access per requested workspace and isolate denied IDs so other queries in the same batch still resolve. - client: getZero swaps the singleton when userId changes; destroyZero is a pure teardown. - provider: tear down only on logout transitions (non-null -> null) via a ref. StrictMode no longer closes the live client mid-render. reset() bumps a version so useMemo rebuilds a fresh client. State machine - New useWorkspaceView returns a tagged union (loading | unauthenticated | denied | error | ready). Replaces nested loadingWorkspaces / loadingCurrentWorkspace / isLoadingWorkspace branches. - AccessDenied gains optional title/description/onRetry to surface Zero errors with a "Try again" path that reset()s the client. Single workspace items subscription - New WorkspaceItemsProvider centralizes the one Zero subscription for workspace.items. ChatPanel, MarkdownText, composer-context, ChatProvider, navigateToItem, and the five chat tool UIs now read items from this context instead of calling useWorkspaceState themselves. Cuts ~12 leaf Zero subscriptions to one and decouples those leaves from ZeroProvider. WorkspaceContext as single source of truth for the active workspace - Removed the currentWorkspaceId mirror from useWorkspaceStore; it was a one-render-late copy of context state, kept in sync by an effect that the React Compiler can't optimize and that introduced cascade renders. - Context now derives currentWorkspaceId from currentWorkspace?.id locally and exposes a useCurrentWorkspaceId() shorthand. 14 consumers migrated. - Persisted UI state (currentThreadIdByWorkspace) stays in Zustand. Server-side session gating - /workspace/[slug]/page.tsx is a server component that redirects to sign-in when no session exists, and to /invite/claim/[token] when an invite query param is present. Anonymous sessions provisioned upstream (/home, /share-copy) still pass through. - AnonymousSessionHandler removed from WorkspaceShell. One fewer client gate, no more "session loading" lottie phase on /workspace. Loading affordances - WorkspaceLoader (full-screen lottie) only for pre-shell phases. - WorkspaceCardsLoader: small centered spinner inside the workspace canvas while items load. - WorkspaceHeaderSkeleton: placeholder for the real header during the slug->id roundtrip; layout no longer hides the header. - ChatPanelSkeleton: reserved chat slot during boot; reuses ThreadLoadingSkeleton so the boot loading state matches the mid-thread-switch state. WorkspaceLayout no longer gates the chat slot on currentWorkspaceId, removing the ~25% width snap on first paint. Shell flattening / dead-code removal - WorkspaceShell, WorkspaceLayout, WorkspaceSection no longer drill store-derived UI state through props. Removed key={...} state-reset hacks. Inlined InviteGuard into the page (now server-side). - Deleted: WorkspaceSkeleton, InviteGuard, the metadata=true branch and flag, the unused GET /api/workspaces/[id] handler, the loadingState shim refetch/version on useWorkspaceState, commented-out Joyride scaffolding, and one unused useEffect import. - New useWorkspaceUpload hook replaces duplicated PDF upload logic in WorkspaceShell and WorkspaceSection. - Hoisted EMPTY_ITEMS to module scope in WorkspaceSearchDialog so memo deps stop thrashing when items is null. Tests / typecheck pass. Made-with: Cursor --- src/app/api/workspaces/[id]/route.ts | 47 --- src/app/api/workspaces/slug/[slug]/route.ts | 33 +- src/app/api/zero/query/route.ts | 206 +++++----- src/app/workspace/[slug]/page.tsx | 36 +- src/components/chat/ChatDropzone.tsx | 4 +- src/components/chat/ChatPanel.tsx | 11 +- src/components/chat/ChatProvider.tsx | 4 +- src/components/chat/TextSelectionManager.tsx | 4 +- src/components/chat/composer-context.tsx | 4 +- src/components/chat/parts/MarkdownText.tsx | 6 +- .../chat/tools/CreateDocumentToolUI.tsx | 11 +- .../chat/tools/CreateFlashcardToolUI.tsx | 12 +- .../chat/tools/CreateQuizToolUI.tsx | 11 +- src/components/chat/tools/EditItemToolUI.tsx | 6 +- .../chat/tools/YouTubeSearchToolUI.tsx | 8 +- src/components/layout/SessionHandler.tsx | 48 +-- src/components/layout/WorkspaceLayout.tsx | 95 +++-- .../workspace-canvas/AudioCardContent.tsx | 4 +- .../WorkspaceCanvasDropzone.tsx | 10 +- .../workspace-canvas/WorkspaceContent.tsx | 4 +- .../WorkspaceSearchDialog.tsx | 9 +- .../workspace-canvas/WorkspaceSection.tsx | 337 +++++------------ .../workspace-canvas/WorkspaceSidebar.tsx | 4 +- src/components/workspace/AccessDenied.tsx | 70 ++-- src/components/workspace/InviteGuard.tsx | 26 -- src/components/workspace/SidebarCardList.tsx | 12 +- src/components/workspace/WorkspaceLoader.tsx | 94 +++++ src/components/workspace/WorkspaceShell.tsx | 351 +++++------------- .../workspace/WorkspaceSkeleton.tsx | 59 --- src/contexts/WorkspaceContext.tsx | 27 +- src/hooks/ui/use-navigate-to-item.ts | 6 +- src/hooks/workspace/use-workspace-items.tsx | 66 ++++ src/hooks/workspace/use-workspace-state.ts | 62 +++- src/hooks/workspace/use-workspace-upload.ts | 102 +++++ src/hooks/workspace/use-workspace-view.ts | 64 ++++ .../stores/__tests__/workspace-store.test.ts | 3 - src/lib/stores/workspace-store.ts | 15 +- src/lib/zero/client.ts | 32 +- src/lib/zero/provider.tsx | 103 +++-- 39 files changed, 975 insertions(+), 1031 deletions(-) delete mode 100644 src/components/workspace/InviteGuard.tsx create mode 100644 src/components/workspace/WorkspaceLoader.tsx delete mode 100644 src/components/workspace/WorkspaceSkeleton.tsx create mode 100644 src/hooks/workspace/use-workspace-items.tsx create mode 100644 src/hooks/workspace/use-workspace-upload.ts create mode 100644 src/hooks/workspace/use-workspace-view.ts 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..d016c4d2 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,56 @@ 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)), + ), + db + .select({ id: workspaceCollaborators.workspaceId }) + .from(workspaceCollaborators) + .where( + and( + eq(workspaceCollaborators.userId, userId), + inArray(workspaceCollaborators.workspaceId, candidateIds), + ), ), - ) - .limit(1); + ]); - 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; +} + +function extractWorkspaceId(args: ReadonlyJSONValue | undefined): string | null { + if (!Array.isArray(args)) return null; + const first = args[0]; + if ( + first && + typeof first === "object" && + !Array.isArray(first) && + "workspaceId" in first && + typeof (first as { workspaceId?: unknown }).workspaceId === "string" + ) { + return (first as { workspaceId: string }).workspaceId; + } + return null; } export async function POST(request: NextRequest) { @@ -58,87 +78,77 @@ 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 }); + } + // Zero query request body shape: `[tag, [{ id, name, args: [arg0, ...] }, ...]]`. + // Extract every workspaceId referenced so we can run a single batched access + // check before letting Zero resolve queries. We must NOT throw out of the + // outer scope on a single denied workspace — that would poison the whole + // batch. Instead we throw per-query inside the transformQuery callback; + // Zero turns those into per-query `app` errors and the rest of the batch + // still resolves. + const queryRequests = + Array.isArray(body) && body.length > 1 && Array.isArray(body[1]) + ? (body[1] as QueryRequest[]) + : []; + + const requestedWorkspaceIds = [ + ...new Set( + queryRequests + .map((req) => extractWorkspaceId(req?.args as ReadonlyJSONValue)) + .filter((id): id is string => typeof id === "string"), + ), + ]; + + 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 deniedWorkspaceIds = requestedWorkspaceIds.filter( + (id) => !allowedWorkspaceIds.has(id), + ); + + if (deniedWorkspaceIds.length > 0) { + console.warn("[zero/query] Denied workspace access for user", { + userId, + requestedWorkspaceIds, + deniedWorkspaceIds, + }); + } + + try { const result = await handleQueryRequest( - (name, args) => mustGetQuery(queries, name).fn({ args, ctx }), + (name, args) => { + const workspaceId = extractWorkspaceId(args); + if (workspaceId && !allowedWorkspaceIds.has(workspaceId)) { + // Per-query throw → Zero returns it as an `app` error for THIS query + // only. Other queries in the same batch still resolve normally. + 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..8216fc4d 100644 --- a/src/components/chat/ChatPanel.tsx +++ b/src/components/chat/ChatPanel.tsx @@ -8,7 +8,11 @@ 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 { ChatPanelSkeleton } from "@/components/workspace/WorkspaceLoader"; import { cn } from "@/lib/utils"; interface ChatPanelProps { @@ -32,13 +36,14 @@ export function ChatPanel({ setIsChatMaximized, onReady, }: ChatPanelProps) { - const { state, isLoading } = useWorkspaceState(workspaceId ?? null); + const state = useWorkspaceItems(); + const isLoading = useWorkspaceItemsLoading(); useEffect(() => { if (!isLoading && state && onReady) onReady(); }, [isLoading, state, onReady]); - if (!workspaceId) return null; + if (!workspaceId) 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..e5c8638c 100644 --- a/src/components/layout/WorkspaceLayout.tsx +++ b/src/components/layout/WorkspaceLayout.tsx @@ -1,61 +1,58 @@ +"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 { 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 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} + {currentWorkspaceId ? workspaceHeader : }
- {workspaceSection} + + {workspaceSection} +
- {/* Chat Section - Only when expanded and workspace exists */} {effectiveChatExpanded && ( <> @@ -115,7 +109,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..6a1fc6de 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(() => { diff --git a/src/components/workspace-canvas/WorkspaceSection.tsx b/src/components/workspace-canvas/WorkspaceSection.tsx index c425c14f..5f609d91 100644 --- a/src/components/workspace-canvas/WorkspaceSection.tsx +++ b/src/components/workspace-canvas/WorkspaceSection.tsx @@ -1,25 +1,21 @@ -import React, { RefObject, useState, useCallback } from "react"; +import React, { useRef, useState, useCallback } from "react"; import { toast } from "sonner"; -import type { Item, CardType } from "@/lib/workspace-state/types"; +import type { Item } from "@/lib/workspace-state/types"; import { DEFAULT_CARD_DIMENSIONS } from "@/lib/workspace-state/grid-layout-helpers"; import type { WorkspaceOperations } from "@/hooks/workspace/use-workspace-operations"; import WorkspaceContent from "./WorkspaceContent"; import SelectionActionBar from "./SelectionActionBar"; -import { WorkspaceSkeleton } from "@/components/workspace/WorkspaceSkeleton"; +import { WorkspaceCardsLoader } from "@/components/workspace/WorkspaceLoader"; import { MarqueeSelector } from "./MarqueeSelector"; import { useUIStore } from "@/lib/stores/ui-store"; import { useSelectedCardIds } from "@/hooks/ui/use-selected-card-ids"; -import { useSession } from "@/lib/auth-client"; +import useMediaQuery from "@/hooks/ui/use-media-query"; import { LoginGate } from "@/components/workspace/LoginGate"; import { AccessDenied } from "@/components/workspace/AccessDenied"; +import { useWorkspaceView } from "@/hooks/workspace/use-workspace-view"; +import { useWorkspaceContext } from "@/contexts/WorkspaceContext"; +import { useWorkspaceUpload } from "@/hooks/workspace/use-workspace-upload"; import { uploadFileDirect } from "@/lib/uploads/client-upload"; -import { buildWorkspaceItemDefinitionsFromAssets } from "@/lib/uploads/uploaded-asset"; -import { - getFileSizeLabel, - prepareWorkspaceUploadSelection, - uploadSelectedFiles, -} from "@/lib/uploads/upload-selection"; -import { emitPasswordProtectedPdf } from "@/components/modals/PasswordProtectedPdfDialog"; import MoveToDialog from "@/components/modals/MoveToDialog"; import { @@ -54,91 +50,46 @@ import { AudioRecorderDialog } from "@/components/modals/AudioRecorderDialog"; import { CreateWebsiteDialog } from "@/components/modals/CreateWebsiteDialog"; import { useWorkspaceFilePicker } from "@/hooks/workspace/use-workspace-file-picker"; import { startAudioProcessing } from "@/lib/audio/start-audio-processing"; -import { startAssetProcessing } from "@/lib/uploads/start-asset-processing"; -import { - getDocumentUploadFailureMessage, - getDocumentUploadLoadingMessage, - getDocumentUploadPartialMessage, - getDocumentUploadSuccessMessage, -} from "@/lib/uploads/upload-feedback"; interface WorkspaceSectionProps { - // Loading states - loadingWorkspaces: boolean; - isLoadingWorkspace: boolean; - - // Workspace state - currentWorkspaceId: string | null; - currentSlug: string | null; state: Item[]; - - // Operations - addItem: ( - type: CardType, - name?: string, - initialData?: Partial, - ) => 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({ - callbacks: { - onCreateDocument: () => { - if (addItem) { - const itemId = addItem("document"); - if (handleCreatedItems && itemId) { - handleCreatedItems([itemId]); - } - } - }, - onCreateFolder: () => { - if (addItem) addItem("folder"); - }, - onUpload: () => handleUploadMenuItemClick(), - onAudio: () => openAudioDialog(), - onYouTube: () => setShowYouTubeDialog(true), - onWebsite: () => setShowWebsiteDialog(true), - onFlashcards: () => setShowFlashcardsDialog(true), - onQuiz: () => setShowQuizDialog(true), + + {renderWorkspaceMenuItems({ + callbacks: { + onCreateDocument: () => { + const itemId = addItem("document"); + if (itemId) handleCreatedItems([itemId]); + }, + onCreateFolder: () => { + addItem("folder"); }, - MenuItem: ContextMenuItem, - MenuSub: ContextMenuSub, - MenuSubTrigger: ContextMenuSubTrigger, - MenuSubContent: ContextMenuSubContent, - MenuLabel: ContextMenuLabel, - showUpload: !!(operations && currentWorkspaceId), - })} - - )} + onUpload: () => handleUploadMenuItemClick(), + onAudio: () => openAudioDialog(), + onYouTube: () => setShowYouTubeDialog(true), + onWebsite: () => setShowWebsiteDialog(true), + onFlashcards: () => setShowFlashcardsDialog(true), + onQuiz: () => setShowQuizDialog(true), + }, + MenuItem: ContextMenuItem, + MenuSub: ContextMenuSub, + MenuSubTrigger: ContextMenuSubTrigger, + MenuSubContent: ContextMenuSubContent, + MenuLabel: ContextMenuLabel, + 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..087642ad 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; +} + +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..3794b6e4 --- /dev/null +++ b/src/components/workspace/WorkspaceLoader.tsx @@ -0,0 +1,94 @@ +"use client"; + +import { DotLottieReact } from "@lottiefiles/dotlottie-react"; +import { Loader2 } from "lucide-react"; +import { useTheme } from "next-themes"; + +import { Skeleton } from "@/components/ui/skeleton"; +import { ThreadLoadingSkeleton } from "@/components/chat/ThreadLoadingSkeleton"; + +function useBrandLottieSrc() { + const { resolvedTheme } = useTheme(); + return resolvedTheme === "light" ? "/thinkexlight.lottie" : "/logo.lottie"; +} + +/** + * Full-viewport loader for the **pre-shell** phases — used while there's no + * workspace chrome on screen yet (no session, Zero client not ready). + */ +export function WorkspaceLoader() { + const src = useBrandLottieSrc(); + return ( +
+ +
+ ); +} + +/** + * In-shell loader for `view.kind === "loading"` inside `WorkspaceSection`. + * The header skeleton + chat panel already give the shell its frame; the + * card area just needs a quiet spinner. + */ +export function WorkspaceCardsLoader() { + return ( +
+ +
+ ); +} + +/** + * 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..41df333a 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,10 +100,7 @@ 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`, - ); + const response = await fetch(`/api/workspaces/slug/${currentSlug}`); if (!response.ok) { if (response.status === 404) return null; throw new Error("Failed to fetch workspace"); @@ -123,6 +117,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 +301,7 @@ export function WorkspaceProvider({ loadWorkspaces, updateWorkspaceLocal, currentWorkspace, + currentWorkspaceId, loadingCurrentWorkspace, currentSlug, switchWorkspace, @@ -330,3 +326,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..43e499df --- /dev/null +++ b/src/hooks/workspace/use-workspace-upload.ts @@ -0,0 +1,102 @@ +"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) { + throw new Error("Workspace not available"); + } + + 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); + 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..8da0cd4d --- /dev/null +++ b/src/hooks/workspace/use-workspace-view.ts @@ -0,0 +1,64 @@ +"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" }; + return session?.user?.isAnonymous + ? { kind: "unauthenticated" } + : { 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..6248d587 100644 --- a/src/lib/zero/client.ts +++ b/src/lib/zero/client.ts @@ -7,24 +7,18 @@ export interface ZeroContext { userId: string; } -const appURL = - process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000"; +const appURL = process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000"; -function createZeroInstance(params: { userId: string }) { +function createZeroInstance({ userId }: { userId: string }) { const configError = getZeroConfigError(); - if (configError) { - throw new Error(configError); - } - + 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, - }, + userID: userId, + context: { userId }, mutators, }); } @@ -33,23 +27,23 @@ let zeroInstance: ReturnType | null = null; let zeroUserId: string | null = null; export function destroyZero() { - if (!zeroInstance) { - return; - } - + if (!zeroInstance) return; void zeroInstance.close(); zeroInstance = null; zeroUserId = null; } +/** + * Returns the active Zero client for `userId`. If the user changed (logout, + * anonymous → authed, swap), tears down the previous client first so its + * in-flight requests can't race the new session cookie. + */ export function getZero(params: { userId: string }) { - if (!zeroInstance || zeroUserId !== params.userId) { - destroyZero(); + if (zeroInstance && zeroUserId !== params.userId) destroyZero(); + if (!zeroInstance) { zeroInstance = createZeroInstance(params); zeroUserId = params.userId; - return zeroInstance; } - return zeroInstance; } diff --git a/src/lib/zero/provider.tsx b/src/lib/zero/provider.tsx index 556c1005..91a45daf 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, + useEffect, + useMemo, + useRef, + useState, + type ReactNode, +} from "react"; import { useSession } from "@/lib/auth-client"; import { getZeroConfigError } from "@/lib/self-host-config"; +import { WorkspaceLoader } from "@/components/workspace/WorkspaceLoader"; import { destroyZero, getZero } from "./client"; -/** 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; + 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; +} + +/** Recreate the Zero client for the current user (UI retry path). */ +export function useZeroReset(): () => void { + return useZeroStatus().reset; } export function ZeroProvider({ children }: { children: ReactNode }) { const { data: session, isPending } = useSession(); const userId = session?.user?.id ?? null; - const zeroConfigError = getZeroConfigError(); + const configError = getZeroConfigError(); + + const [resetVersion, setResetVersion] = useState(0); + // Tear down only on logout (non-null → null). User-swap (A → B) is handled + // inside `getZero`, which destroys the previous instance before creating the + // new one. Crucially, we do NOT destroy in an effect cleanup — StrictMode's + // double-invoke would close the live client while children still hold it. + const prevUserIdRef = useRef(null); useEffect(() => { - if (!userId) { - destroyZero(); - } + if (prevUserIdRef.current && !userId) destroyZero(); + prevUserIdRef.current = userId; }, [userId]); - const zero = useMemo(() => { - if (zeroConfigError || !userId) { - return null; - } + const zero = useMemo( + () => (configError || !userId ? null : getZero({ userId })), + // resetVersion is intentional: bumping it forces a new client after reset(). + // eslint-disable-next-line react-hooks/exhaustive-deps + [userId, configError, resetVersion], + ); - return getZero({ userId }); - }, [userId, zeroConfigError]); + const status = useMemo( + () => ({ + isReady: !!zero, + reset: () => { + destroyZero(); + setResetVersion((v) => v + 1); + }, + }), + [zero], + ); - if (zeroConfigError) { + if (configError) { return (
-

Zero is required for local development.

-

- {zeroConfigError} +

+ Zero is required for local development.

+

{configError}

); } 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 ; + return ( + + + + ); } - return {children}; + return ( + + {children} + + ); } From 97fc5ff319cc3f16ae36c01bd105d566ccb56f7b Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Wed, 29 Apr 2026 21:56:32 -0400 Subject: [PATCH 02/15] fix(workspace): address CodeRabbit review on PR #487 - zero/client: destroyZero returns the close() promise; reset() awaits it. Document user-swap as accepted brief overlap (different sessions, no leak). - zero/query route: fail-closed authorization keyed off query name (registry of workspace-scoped extractors), not best-effort arg shape. New workspace.* queries must be registered to be allowed at all. - zero/query route: redact identifiers in the denied-access log; keep counts only. - ChatPanel: guard onReady() on workspaceId so it can't fire while the panel is showing the skeleton. - WorkspaceContext: encodeURIComponent the slug in the metadata fetch URL. - use-workspace-upload: toast + return instead of throwing when workspace isn't ready; pass enforceSizeLimit through to prepareWorkspaceUploadSelection so disabling the limit actually works. - WorkspaceSection: only render context-menu mutation items when view.kind === "ready"; prevents create/upload actions from firing against a non-ready store. - WorkspaceSearchDialog: surface "Workspace unavailable." when the view is denied/error/unauthenticated instead of mislabelling them as loading. Made-with: Cursor --- src/app/api/zero/query/route.ts | 88 ++++++++++++++----- src/components/chat/ChatPanel.tsx | 5 +- .../WorkspaceSearchDialog.tsx | 9 +- .../workspace-canvas/WorkspaceSection.tsx | 49 ++++++----- src/components/workspace/AccessDenied.tsx | 2 +- src/contexts/WorkspaceContext.tsx | 4 +- src/hooks/workspace/use-workspace-upload.ts | 9 +- src/hooks/workspace/use-workspace-view.ts | 2 +- src/lib/zero/client.ts | 22 +++-- src/lib/zero/provider.tsx | 13 +-- 10 files changed, 137 insertions(+), 66 deletions(-) diff --git a/src/app/api/zero/query/route.ts b/src/app/api/zero/query/route.ts index d016c4d2..e8e8dfbc 100644 --- a/src/app/api/zero/query/route.ts +++ b/src/app/api/zero/query/route.ts @@ -53,19 +53,48 @@ async function getAllowedWorkspaceIds( return allowed; } -function extractWorkspaceId(args: ReadonlyJSONValue | undefined): string | null { - if (!Array.isArray(args)) return null; - const first = args[0]; - if ( - first && - typeof first === "object" && - !Array.isArray(first) && - "workspaceId" in first && - typeof (first as { workspaceId?: unknown }).workspaceId === "string" - ) { - return (first as { workspaceId: string }).workspaceId; +/** + * Per-query workspaceId extractors. Fail-closed: a workspace-scoped query + * must be registered here for the route to allow it. Adding a new + * `workspace.*` query without an entry will (correctly) cause the route to + * deny all instances of that query rather than silently bypass authz. + */ +const WORKSPACE_QUERY_EXTRACTORS: Record< + string, + (args: ReadonlyJSONValue | undefined) => string | null +> = { + "workspace.items": (args) => { + if (!Array.isArray(args)) return null; + const first = args[0]; + if ( + first && + typeof first === "object" && + !Array.isArray(first) && + "workspaceId" in first && + typeof (first as { workspaceId?: unknown }).workspaceId === "string" + ) { + return (first as { workspaceId: string }).workspaceId; + } + return null; + }, +}; + +function getWorkspaceIdForQuery( + name: string, + args: ReadonlyJSONValue | undefined, +): { kind: "ok"; workspaceId: string } | { kind: "deny" } | { kind: "global" } { + // Anything under `workspace.*` MUST have a registered extractor and a + // resolvable workspaceId — otherwise we deny by default. + if (name.startsWith("workspace.")) { + const extractor = WORKSPACE_QUERY_EXTRACTORS[name]; + if (!extractor) return { kind: "deny" }; + const workspaceId = extractor(args); + if (!workspaceId) return { kind: "deny" }; + return { kind: "ok", workspaceId }; } - return null; + // Future namespaces with no workspace context (e.g. `user.preferences`) + // bypass workspace authz; per-query authz lives in the resolver itself. + return { kind: "global" }; } export async function POST(request: NextRequest) { @@ -100,9 +129,14 @@ export async function POST(request: NextRequest) { const requestedWorkspaceIds = [ ...new Set( - queryRequests - .map((req) => extractWorkspaceId(req?.args as ReadonlyJSONValue)) - .filter((id): id is string => typeof id === "string"), + queryRequests.flatMap((req) => { + const name = typeof req?.name === "string" ? req.name : ""; + const decision = getWorkspaceIdForQuery( + name, + req?.args as ReadonlyJSONValue, + ); + return decision.kind === "ok" ? [decision.workspaceId] : []; + }), ), ]; @@ -120,23 +154,29 @@ export async function POST(request: NextRequest) { ); } - const deniedWorkspaceIds = requestedWorkspaceIds.filter( + const deniedCount = requestedWorkspaceIds.filter( (id) => !allowedWorkspaceIds.has(id), - ); + ).length; - if (deniedWorkspaceIds.length > 0) { - console.warn("[zero/query] Denied workspace access for user", { - userId, - requestedWorkspaceIds, - deniedWorkspaceIds, + if (deniedCount > 0) { + // Counts only — raw user/workspace IDs are deliberately excluded from logs. + console.warn("[zero/query] Denied workspace access", { + requested: requestedWorkspaceIds.length, + denied: deniedCount, }); } try { const result = await handleQueryRequest( (name, args) => { - const workspaceId = extractWorkspaceId(args); - if (workspaceId && !allowedWorkspaceIds.has(workspaceId)) { + const decision = getWorkspaceIdForQuery(name, args); + if (decision.kind === "deny") { + throw new Error(WORKSPACE_ACCESS_DENIED_ERROR); + } + if ( + decision.kind === "ok" && + !allowedWorkspaceIds.has(decision.workspaceId) + ) { // Per-query throw → Zero returns it as an `app` error for THIS query // only. Other queries in the same batch still resolve normally. throw new Error(WORKSPACE_ACCESS_DENIED_ERROR); diff --git a/src/components/chat/ChatPanel.tsx b/src/components/chat/ChatPanel.tsx index 8216fc4d..2c043887 100644 --- a/src/components/chat/ChatPanel.tsx +++ b/src/components/chat/ChatPanel.tsx @@ -40,8 +40,9 @@ export function ChatPanel({ const isLoading = useWorkspaceItemsLoading(); useEffect(() => { - if (!isLoading && state && onReady) onReady(); - }, [isLoading, state, onReady]); + if (!workspaceId || isLoading || !onReady) return; + onReady(); + }, [workspaceId, isLoading, onReady]); if (!workspaceId) return ; diff --git a/src/components/workspace-canvas/WorkspaceSearchDialog.tsx b/src/components/workspace-canvas/WorkspaceSearchDialog.tsx index 6a1fc6de..d0544dd9 100644 --- a/src/components/workspace-canvas/WorkspaceSearchDialog.tsx +++ b/src/components/workspace-canvas/WorkspaceSearchDialog.tsx @@ -85,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..."; } @@ -95,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 ( - {/* Right-Click Context Menu */} + {/* Right-Click Context Menu — only render mutation items when the + workspace is fully loaded; otherwise the menu would expose + create/upload actions that fail against a not-yet-ready store. */} - {renderWorkspaceMenuItems({ - callbacks: { - onCreateDocument: () => { - const itemId = addItem("document"); - if (itemId) handleCreatedItems([itemId]); + {view.kind === "ready" && + renderWorkspaceMenuItems({ + callbacks: { + onCreateDocument: () => { + const itemId = addItem("document"); + if (itemId) handleCreatedItems([itemId]); + }, + onCreateFolder: () => { + addItem("folder"); + }, + onUpload: () => handleUploadMenuItemClick(), + onAudio: () => openAudioDialog(), + onYouTube: () => setShowYouTubeDialog(true), + onWebsite: () => setShowWebsiteDialog(true), + onFlashcards: () => setShowFlashcardsDialog(true), + onQuiz: () => setShowQuizDialog(true), }, - onCreateFolder: () => { - addItem("folder"); - }, - onUpload: () => handleUploadMenuItemClick(), - onAudio: () => openAudioDialog(), - onYouTube: () => setShowYouTubeDialog(true), - onWebsite: () => setShowWebsiteDialog(true), - onFlashcards: () => setShowFlashcardsDialog(true), - onQuiz: () => setShowQuizDialog(true), - }, - MenuItem: ContextMenuItem, - MenuSub: ContextMenuSub, - MenuSubTrigger: ContextMenuSubTrigger, - MenuSubContent: ContextMenuSubContent, - MenuLabel: ContextMenuLabel, - showUpload: !!currentWorkspaceId, - })} + MenuItem: ContextMenuItem, + MenuSub: ContextMenuSub, + MenuSubTrigger: ContextMenuSubTrigger, + MenuSubContent: ContextMenuSubContent, + MenuLabel: ContextMenuLabel, + showUpload: !!currentWorkspaceId, + })} {/* Selection Action Bar - show when cards are selected */} diff --git a/src/components/workspace/AccessDenied.tsx b/src/components/workspace/AccessDenied.tsx index 087642ad..7894ea4b 100644 --- a/src/components/workspace/AccessDenied.tsx +++ b/src/components/workspace/AccessDenied.tsx @@ -15,7 +15,7 @@ export interface AccessDeniedProps { * 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; + onRetry?: () => void | Promise; } export function AccessDenied({ diff --git a/src/contexts/WorkspaceContext.tsx b/src/contexts/WorkspaceContext.tsx index 41df333a..52c81a8a 100644 --- a/src/contexts/WorkspaceContext.tsx +++ b/src/contexts/WorkspaceContext.tsx @@ -100,7 +100,9 @@ export function WorkspaceProvider({ queryKey: ["workspace-by-slug", currentSlug], queryFn: async () => { if (!currentSlug) return null; - const response = await fetch(`/api/workspaces/slug/${currentSlug}`); + const response = await fetch( + `/api/workspaces/slug/${encodeURIComponent(currentSlug)}`, + ); if (!response.ok) { if (response.status === 404) return null; throw new Error("Failed to fetch workspace"); diff --git a/src/hooks/workspace/use-workspace-upload.ts b/src/hooks/workspace/use-workspace-upload.ts index 43e499df..d4aff670 100644 --- a/src/hooks/workspace/use-workspace-upload.ts +++ b/src/hooks/workspace/use-workspace-upload.ts @@ -40,7 +40,8 @@ export function useWorkspaceUpload({ return useCallback( async (files: File[]) => { if (!operations || !currentWorkspaceId) { - throw new Error("Workspace not available"); + toast.error("Workspace isn't ready yet — try again in a moment."); + return; } let candidates = files; @@ -56,7 +57,11 @@ export function useWorkspaceUpload({ } const { acceptedFiles, protectedPdfNames } = - await prepareWorkspaceUploadSelection(candidates); + await prepareWorkspaceUploadSelection(candidates, { + maxFileSizeBytes: enforceSizeLimit + ? MAX_FILE_SIZE_BYTES + : Number.POSITIVE_INFINITY, + }); if (protectedPdfNames.length > 0) emitPasswordProtectedPdf(protectedPdfNames); if (acceptedFiles.length === 0) return; diff --git a/src/hooks/workspace/use-workspace-view.ts b/src/hooks/workspace/use-workspace-view.ts index 8da0cd4d..072087b3 100644 --- a/src/hooks/workspace/use-workspace-view.ts +++ b/src/hooks/workspace/use-workspace-view.ts @@ -16,7 +16,7 @@ export type WorkspaceView = | { kind: "loading" } | { kind: "unauthenticated" } | { kind: "denied" } - | { kind: "error"; message: string; retry: () => void } + | { kind: "error"; message: string; retry: () => Promise } | { kind: "ready"; items: Item[]; workspace: WorkspaceWithState }; export function useWorkspaceView(): WorkspaceView { diff --git a/src/lib/zero/client.ts b/src/lib/zero/client.ts index 6248d587..c1195c31 100644 --- a/src/lib/zero/client.ts +++ b/src/lib/zero/client.ts @@ -26,20 +26,30 @@ function createZeroInstance({ userId }: { userId: string }) { let zeroInstance: ReturnType | null = null; let zeroUserId: string | null = null; -export function destroyZero() { - if (!zeroInstance) return; - void zeroInstance.close(); +/** + * Tear down the active client. `Zero.close()` is async (Replicache shutdown + + * WebSocket abort), so callers that *must* avoid overlap (e.g. the explicit + * `reset()` retry path) should `await` this. The user-swap path inside + * `getZero` does not await: clients are scoped per-user so any tail-end + * traffic from the old session can't leak into the new one's data. + */ +export function destroyZero(): Promise { + const inst = zeroInstance; + if (!inst) return Promise.resolve(); zeroInstance = null; zeroUserId = null; + return inst.close(); } /** * Returns the active Zero client for `userId`. If the user changed (logout, - * anonymous → authed, swap), tears down the previous client first so its - * in-flight requests can't race the new session cookie. + * anonymous → authed, swap), kicks off teardown of the previous client; the + * new client is created immediately so the React render that triggered the + * swap has a value to commit. Brief client-side overlap during the old + * client's async close is acceptable. */ export function getZero(params: { userId: string }) { - if (zeroInstance && zeroUserId !== params.userId) destroyZero(); + if (zeroInstance && zeroUserId !== params.userId) void destroyZero(); if (!zeroInstance) { zeroInstance = createZeroInstance(params); zeroUserId = params.userId; diff --git a/src/lib/zero/provider.tsx b/src/lib/zero/provider.tsx index 91a45daf..76e77d38 100644 --- a/src/lib/zero/provider.tsx +++ b/src/lib/zero/provider.tsx @@ -17,7 +17,8 @@ import { destroyZero, getZero } from "./client"; interface ZeroStatus { isReady: boolean; - reset: () => void; + /** Returns a promise so callers can await full teardown if they need to. */ + reset: () => Promise; } const ZeroStatusContext = createContext(null); @@ -29,7 +30,7 @@ export function useZeroStatus(): ZeroStatus { } /** Recreate the Zero client for the current user (UI retry path). */ -export function useZeroReset(): () => void { +export function useZeroReset(): () => Promise { return useZeroStatus().reset; } @@ -46,7 +47,7 @@ export function ZeroProvider({ children }: { children: ReactNode }) { // double-invoke would close the live client while children still hold it. const prevUserIdRef = useRef(null); useEffect(() => { - if (prevUserIdRef.current && !userId) destroyZero(); + if (prevUserIdRef.current && !userId) void destroyZero(); prevUserIdRef.current = userId; }, [userId]); @@ -60,8 +61,10 @@ export function ZeroProvider({ children }: { children: ReactNode }) { const status = useMemo( () => ({ isReady: !!zero, - reset: () => { - destroyZero(); + // Await the old client's async close so the next useMemo doesn't + // create the replacement before the previous WebSocket has shut down. + reset: async () => { + await destroyZero(); setResetVersion((v) => v + 1); }, }), From 6de6f86bb90e71b5e63ab19389e8c50559864844 Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Wed, 29 Apr 2026 21:59:36 -0400 Subject: [PATCH 03/15] fix(zero/query): switch-based authz dispatch to satisfy CodeQL CodeQL flagged the lookup-table dispatch as 'unvalidated dynamic method call' even though the table is a closed object literal. Inline the dispatch as a switch so the analyzer can see it's bounded. Made-with: Cursor --- src/app/api/zero/query/route.ts | 75 ++++++++++++++++++--------------- 1 file changed, 40 insertions(+), 35 deletions(-) diff --git a/src/app/api/zero/query/route.ts b/src/app/api/zero/query/route.ts index e8e8dfbc..424c49f9 100644 --- a/src/app/api/zero/query/route.ts +++ b/src/app/api/zero/query/route.ts @@ -53,48 +53,53 @@ async function getAllowedWorkspaceIds( return allowed; } +function extractWorkspaceIdArg( + args: ReadonlyJSONValue | undefined, +): string | null { + if (!Array.isArray(args)) return null; + const first = args[0]; + if ( + first && + typeof first === "object" && + !Array.isArray(first) && + "workspaceId" in first && + typeof (first as { workspaceId?: unknown }).workspaceId === "string" + ) { + return (first as { workspaceId: string }).workspaceId; + } + return null; +} + /** - * Per-query workspaceId extractors. Fail-closed: a workspace-scoped query - * must be registered here for the route to allow it. Adding a new - * `workspace.*` query without an entry will (correctly) cause the route to - * deny all instances of that query rather than silently bypass authz. + * Fail-closed authorization decision per query. + * + * Anything under `workspace.*` must be explicitly handled in the switch + * below with a known argument shape — adding a new workspace-scoped query + * without updating this function will (correctly) cause the route to deny + * every instance of it rather than silently bypass authz. + * + * Implemented as a literal switch (not a lookup table) so CodeQL can see + * the dispatch is bounded to the names listed here. */ -const WORKSPACE_QUERY_EXTRACTORS: Record< - string, - (args: ReadonlyJSONValue | undefined) => string | null -> = { - "workspace.items": (args) => { - if (!Array.isArray(args)) return null; - const first = args[0]; - if ( - first && - typeof first === "object" && - !Array.isArray(first) && - "workspaceId" in first && - typeof (first as { workspaceId?: unknown }).workspaceId === "string" - ) { - return (first as { workspaceId: string }).workspaceId; - } - return null; - }, -}; - function getWorkspaceIdForQuery( name: string, args: ReadonlyJSONValue | undefined, ): { kind: "ok"; workspaceId: string } | { kind: "deny" } | { kind: "global" } { - // Anything under `workspace.*` MUST have a registered extractor and a - // resolvable workspaceId — otherwise we deny by default. - if (name.startsWith("workspace.")) { - const extractor = WORKSPACE_QUERY_EXTRACTORS[name]; - if (!extractor) return { kind: "deny" }; - const workspaceId = extractor(args); - if (!workspaceId) return { kind: "deny" }; - return { kind: "ok", workspaceId }; + switch (name) { + case "workspace.items": { + const workspaceId = extractWorkspaceIdArg(args); + return workspaceId + ? { kind: "ok", workspaceId } + : { kind: "deny" }; + } + default: + // Anything else under workspace.* is unrecognized → deny. + // Other namespaces (e.g. `user.preferences`) skip workspace authz; + // per-query authz lives in those resolvers themselves. + return name.startsWith("workspace.") + ? { kind: "deny" } + : { kind: "global" }; } - // Future namespaces with no workspace context (e.g. `user.preferences`) - // bypass workspace authz; per-query authz lives in the resolver itself. - return { kind: "global" }; } export async function POST(request: NextRequest) { From ab9fb70afbb467b10479c135a57933e0050e09d0 Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Wed, 29 Apr 2026 22:03:29 -0400 Subject: [PATCH 04/15] chore: trim over-defensive comments and route authz dispatch Made-with: Cursor --- src/app/api/zero/query/route.ts | 61 +++++-------------- .../workspace-canvas/WorkspaceSection.tsx | 4 +- src/lib/zero/client.ts | 16 +---- src/lib/zero/provider.tsx | 8 +-- 4 files changed, 20 insertions(+), 69 deletions(-) diff --git a/src/app/api/zero/query/route.ts b/src/app/api/zero/query/route.ts index 424c49f9..b76bc396 100644 --- a/src/app/api/zero/query/route.ts +++ b/src/app/api/zero/query/route.ts @@ -71,34 +71,19 @@ function extractWorkspaceIdArg( } /** - * Fail-closed authorization decision per query. - * - * Anything under `workspace.*` must be explicitly handled in the switch - * below with a known argument shape — adding a new workspace-scoped query - * without updating this function will (correctly) cause the route to deny - * every instance of it rather than silently bypass authz. - * - * Implemented as a literal switch (not a lookup table) so CodeQL can see - * the dispatch is bounded to the names listed here. + * 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 getWorkspaceIdForQuery( +function getRequiredWorkspaceId( name: string, args: ReadonlyJSONValue | undefined, -): { kind: "ok"; workspaceId: string } | { kind: "deny" } | { kind: "global" } { +): string | null { switch (name) { - case "workspace.items": { - const workspaceId = extractWorkspaceIdArg(args); - return workspaceId - ? { kind: "ok", workspaceId } - : { kind: "deny" }; - } + case "workspace.items": + return extractWorkspaceIdArg(args); default: - // Anything else under workspace.* is unrecognized → deny. - // Other namespaces (e.g. `user.preferences`) skip workspace authz; - // per-query authz lives in those resolvers themselves. - return name.startsWith("workspace.") - ? { kind: "deny" } - : { kind: "global" }; + return null; } } @@ -120,13 +105,9 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: "Bad request" }, { status: 400 }); } - // Zero query request body shape: `[tag, [{ id, name, args: [arg0, ...] }, ...]]`. - // Extract every workspaceId referenced so we can run a single batched access - // check before letting Zero resolve queries. We must NOT throw out of the - // outer scope on a single denied workspace — that would poison the whole - // batch. Instead we throw per-query inside the transformQuery callback; - // Zero turns those into per-query `app` errors and the rest of the batch - // still resolves. + // 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[]) @@ -136,11 +117,8 @@ export async function POST(request: NextRequest) { ...new Set( queryRequests.flatMap((req) => { const name = typeof req?.name === "string" ? req.name : ""; - const decision = getWorkspaceIdForQuery( - name, - req?.args as ReadonlyJSONValue, - ); - return decision.kind === "ok" ? [decision.workspaceId] : []; + const id = getRequiredWorkspaceId(name, req?.args as ReadonlyJSONValue); + return id ? [id] : []; }), ), ]; @@ -164,7 +142,6 @@ export async function POST(request: NextRequest) { ).length; if (deniedCount > 0) { - // Counts only — raw user/workspace IDs are deliberately excluded from logs. console.warn("[zero/query] Denied workspace access", { requested: requestedWorkspaceIds.length, denied: deniedCount, @@ -174,16 +151,8 @@ export async function POST(request: NextRequest) { try { const result = await handleQueryRequest( (name, args) => { - const decision = getWorkspaceIdForQuery(name, args); - if (decision.kind === "deny") { - throw new Error(WORKSPACE_ACCESS_DENIED_ERROR); - } - if ( - decision.kind === "ok" && - !allowedWorkspaceIds.has(decision.workspaceId) - ) { - // Per-query throw → Zero returns it as an `app` error for THIS query - // only. Other queries in the same batch still resolve normally. + const workspaceId = getRequiredWorkspaceId(name, args); + if (!workspaceId || !allowedWorkspaceIds.has(workspaceId)) { throw new Error(WORKSPACE_ACCESS_DENIED_ERROR); } return mustGetQuery(queries, name).fn({ args, ctx }); diff --git a/src/components/workspace-canvas/WorkspaceSection.tsx b/src/components/workspace-canvas/WorkspaceSection.tsx index 88b95e09..cbff540d 100644 --- a/src/components/workspace-canvas/WorkspaceSection.tsx +++ b/src/components/workspace-canvas/WorkspaceSection.tsx @@ -415,9 +415,7 @@ export function WorkspaceSection({
- {/* Right-Click Context Menu — only render mutation items when the - workspace is fully loaded; otherwise the menu would expose - create/upload actions that fail against a not-yet-ready store. */} + {/* Mutation items only when the workspace is ready. */} {view.kind === "ready" && renderWorkspaceMenuItems({ diff --git a/src/lib/zero/client.ts b/src/lib/zero/client.ts index c1195c31..af0e2d15 100644 --- a/src/lib/zero/client.ts +++ b/src/lib/zero/client.ts @@ -26,13 +26,7 @@ function createZeroInstance({ userId }: { userId: string }) { let zeroInstance: ReturnType | null = null; let zeroUserId: string | null = null; -/** - * Tear down the active client. `Zero.close()` is async (Replicache shutdown + - * WebSocket abort), so callers that *must* avoid overlap (e.g. the explicit - * `reset()` retry path) should `await` this. The user-swap path inside - * `getZero` does not await: clients are scoped per-user so any tail-end - * traffic from the old session can't leak into the new one's data. - */ +/** `Zero.close()` is async; await this when overlap matters (e.g. reset()). */ export function destroyZero(): Promise { const inst = zeroInstance; if (!inst) return Promise.resolve(); @@ -41,13 +35,7 @@ export function destroyZero(): Promise { return inst.close(); } -/** - * Returns the active Zero client for `userId`. If the user changed (logout, - * anonymous → authed, swap), kicks off teardown of the previous client; the - * new client is created immediately so the React render that triggered the - * swap has a value to commit. Brief client-side overlap during the old - * client's async close is acceptable. - */ +/** Returns the active client for `userId`, swapping it out on user change. */ export function getZero(params: { userId: string }) { if (zeroInstance && zeroUserId !== params.userId) void destroyZero(); if (!zeroInstance) { diff --git a/src/lib/zero/provider.tsx b/src/lib/zero/provider.tsx index 76e77d38..03dbecc5 100644 --- a/src/lib/zero/provider.tsx +++ b/src/lib/zero/provider.tsx @@ -41,10 +41,8 @@ export function ZeroProvider({ children }: { children: ReactNode }) { const [resetVersion, setResetVersion] = useState(0); - // Tear down only on logout (non-null → null). User-swap (A → B) is handled - // inside `getZero`, which destroys the previous instance before creating the - // new one. Crucially, we do NOT destroy in an effect cleanup — StrictMode's - // double-invoke would close the live client while children still hold it. + // Logout-only teardown. User-swap is handled inside getZero. Don't destroy + // in an effect cleanup — StrictMode would close the live client mid-render. const prevUserIdRef = useRef(null); useEffect(() => { if (prevUserIdRef.current && !userId) void destroyZero(); @@ -61,8 +59,6 @@ export function ZeroProvider({ children }: { children: ReactNode }) { const status = useMemo( () => ({ isReady: !!zero, - // Await the old client's async close so the next useMemo doesn't - // create the replacement before the previous WebSocket has shut down. reset: async () => { await destroyZero(); setResetVersion((v) => v + 1); From 4d5d444d36ff66f840ccf1c434cb5f1d9a1f4ef2 Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Wed, 29 Apr 2026 22:08:24 -0400 Subject: [PATCH 05/15] fix(zero/query): read workspaceId from unwrapped first arg in callback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit handleQueryRequest invokes the callback with args=argsArray[0] (already unwrapped). The shared extractor was checking Array.isArray first, which meant the per-query authz check inside the callback always returned null and denied every workspace.items query — including ones the upfront batched access check had just approved. Split into readWorkspaceId (object shape) and unwrap manually upfront. Made-with: Cursor --- src/app/api/zero/query/route.ts | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/src/app/api/zero/query/route.ts b/src/app/api/zero/query/route.ts index b76bc396..65ba3cd0 100644 --- a/src/app/api/zero/query/route.ts +++ b/src/app/api/zero/query/route.ts @@ -53,19 +53,16 @@ async function getAllowedWorkspaceIds( return allowed; } -function extractWorkspaceIdArg( - args: ReadonlyJSONValue | undefined, -): string | null { - if (!Array.isArray(args)) return null; - const first = args[0]; +/** Reads `workspaceId` off a single arg object (the unwrapped first arg). */ +function readWorkspaceId(arg: ReadonlyJSONValue | undefined): string | null { if ( - first && - typeof first === "object" && - !Array.isArray(first) && - "workspaceId" in first && - typeof (first as { workspaceId?: unknown }).workspaceId === "string" + arg && + typeof arg === "object" && + !Array.isArray(arg) && + "workspaceId" in arg && + typeof (arg as { workspaceId?: unknown }).workspaceId === "string" ) { - return (first as { workspaceId: string }).workspaceId; + return (arg as { workspaceId: string }).workspaceId; } return null; } @@ -77,11 +74,11 @@ function extractWorkspaceIdArg( */ function getRequiredWorkspaceId( name: string, - args: ReadonlyJSONValue | undefined, + arg: ReadonlyJSONValue | undefined, ): string | null { switch (name) { case "workspace.items": - return extractWorkspaceIdArg(args); + return readWorkspaceId(arg); default: return null; } @@ -117,7 +114,10 @@ export async function POST(request: NextRequest) { ...new Set( queryRequests.flatMap((req) => { const name = typeof req?.name === "string" ? req.name : ""; - const id = getRequiredWorkspaceId(name, req?.args as ReadonlyJSONValue); + const firstArg = Array.isArray(req?.args) + ? (req.args[0] as ReadonlyJSONValue | undefined) + : undefined; + const id = getRequiredWorkspaceId(name, firstArg); return id ? [id] : []; }), ), From ac1db706aa049c3343532a70e045cc5f3f66e3cd Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Wed, 29 Apr 2026 22:10:30 -0400 Subject: [PATCH 06/15] fix(workspace-view): redirect expired sessions to sign-in instead of denied When a session expires mid-use, session is null but sessionPending is false, so the unresolved-workspace branch fell through to 'denied' instead of prompting sign-in. Treat null session the same as anonymous. Made-with: Cursor --- src/hooks/workspace/use-workspace-view.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/hooks/workspace/use-workspace-view.ts b/src/hooks/workspace/use-workspace-view.ts index 072087b3..a4413f2b 100644 --- a/src/hooks/workspace/use-workspace-view.ts +++ b/src/hooks/workspace/use-workspace-view.ts @@ -32,9 +32,10 @@ export function useWorkspaceView(): WorkspaceView { if (currentSlug && !currentWorkspace) { if (loadingCurrentWorkspace) return { kind: "loading" }; - return session?.user?.isAnonymous - ? { kind: "unauthenticated" } - : { kind: "denied" }; + if (!session || session.user?.isAnonymous) { + return { kind: "unauthenticated" }; + } + return { kind: "denied" }; } if (!currentWorkspace) return { kind: "loading" }; From c50230c71f911549a7af78e064e0ca15421ef999 Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Wed, 29 Apr 2026 22:16:57 -0400 Subject: [PATCH 07/15] refactor(zero): switch to managed ZeroProvider mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The library's ZeroProvider already handles client lifecycle reactively when you pass config props directly (userID, schema, mutators, cacheURL, etc.). We were running in manual mode (`zero={instance}`) and reimplementing all of that in client.ts — the singleton, user-swap teardown, logout effect, async destroy, resetVersion bump. The 'Zero was explicitly closed' bug earlier this session was a direct symptom of doing lifecycle by hand under StrictMode. Switching to managed mode: - client.ts collapses to just the ZeroContext type - destroyZero/getZero gone - reset becomes a key bump on ZeroProvider - StrictMode handling delegated to the library Also: bento-grid skeleton for WorkspaceCardsLoader (replaces spinner). Made-with: Cursor --- src/components/workspace/WorkspaceLoader.tsx | 32 ++++++++-- src/hooks/workspace/use-workspace-view.ts | 2 +- src/lib/zero/client.ts | 50 ++------------- src/lib/zero/provider.tsx | 65 ++++++++++---------- 4 files changed, 66 insertions(+), 83 deletions(-) diff --git a/src/components/workspace/WorkspaceLoader.tsx b/src/components/workspace/WorkspaceLoader.tsx index 3794b6e4..867ec3c6 100644 --- a/src/components/workspace/WorkspaceLoader.tsx +++ b/src/components/workspace/WorkspaceLoader.tsx @@ -1,7 +1,6 @@ "use client"; import { DotLottieReact } from "@lottiefiles/dotlottie-react"; -import { Loader2 } from "lucide-react"; import { useTheme } from "next-themes"; import { Skeleton } from "@/components/ui/skeleton"; @@ -33,13 +32,36 @@ export function WorkspaceLoader() { /** * In-shell loader for `view.kind === "loading"` inside `WorkspaceSection`. - * The header skeleton + chat panel already give the shell its frame; the - * card area just needs a quiet spinner. + * Bento-style placeholder grid that mirrors the freeform card layout — + * 1/2/3/4 columns at sm/md/lg with mixed col/row spans. */ +const BENTO_TILES: ReadonlyArray<{ col: string; row: string }> = [ + { col: "lg:col-span-2", row: "row-span-2" }, + { col: "lg:col-span-1", row: "row-span-1" }, + { col: "lg:col-span-1", row: "row-span-3" }, + { col: "lg:col-span-1", row: "row-span-2" }, + { col: "lg:col-span-2", row: "row-span-1" }, + { col: "lg:col-span-1", row: "row-span-2" }, + { col: "lg:col-span-2", row: "row-span-2" }, + { col: "lg:col-span-1", row: "row-span-1" }, + { col: "lg:col-span-1", row: "row-span-2" }, + { col: "lg:col-span-2", row: "row-span-1" }, +]; + export function WorkspaceCardsLoader() { return ( -
- +
+
+ {BENTO_TILES.map((tile, i) => ( + + ))} +
); } diff --git a/src/hooks/workspace/use-workspace-view.ts b/src/hooks/workspace/use-workspace-view.ts index a4413f2b..6d5c759d 100644 --- a/src/hooks/workspace/use-workspace-view.ts +++ b/src/hooks/workspace/use-workspace-view.ts @@ -16,7 +16,7 @@ export type WorkspaceView = | { kind: "loading" } | { kind: "unauthenticated" } | { kind: "denied" } - | { kind: "error"; message: string; retry: () => Promise } + | { kind: "error"; message: string; retry: () => void } | { kind: "ready"; items: Item[]; workspace: WorkspaceWithState }; export function useWorkspaceView(): WorkspaceView { diff --git a/src/lib/zero/client.ts b/src/lib/zero/client.ts index af0e2d15..79c58a88 100644 --- a/src/lib/zero/client.ts +++ b/src/lib/zero/client.ts @@ -1,48 +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({ userId }: { 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: userId, - context: { userId }, - mutators, - }); -} - -let zeroInstance: ReturnType | null = null; -let zeroUserId: string | null = null; - -/** `Zero.close()` is async; await this when overlap matters (e.g. reset()). */ -export function destroyZero(): Promise { - const inst = zeroInstance; - if (!inst) return Promise.resolve(); - zeroInstance = null; - zeroUserId = null; - return inst.close(); -} - -/** Returns the active client for `userId`, swapping it out on user change. */ -export function getZero(params: { userId: string }) { - if (zeroInstance && zeroUserId !== params.userId) void destroyZero(); - if (!zeroInstance) { - zeroInstance = createZeroInstance(params); - zeroUserId = params.userId; - } - return zeroInstance; -} - -export type ZeroInstance = ReturnType; diff --git a/src/lib/zero/provider.tsx b/src/lib/zero/provider.tsx index 03dbecc5..c50e708a 100644 --- a/src/lib/zero/provider.tsx +++ b/src/lib/zero/provider.tsx @@ -4,21 +4,20 @@ import { ZeroProvider as BaseZeroProvider } from "@rocicorp/zero/react"; import { createContext, useContext, - useEffect, useMemo, - useRef, useState, type ReactNode, } from "react"; import { useSession } from "@/lib/auth-client"; import { getZeroConfigError } from "@/lib/self-host-config"; import { WorkspaceLoader } from "@/components/workspace/WorkspaceLoader"; -import { destroyZero, getZero } from "./client"; +import { mutators } from "./mutators"; +import { schema } from "./zero-schema.gen"; interface ZeroStatus { isReady: boolean; - /** Returns a promise so callers can await full teardown if they need to. */ - reset: () => Promise; + /** Force a fresh Zero client (UI retry path). */ + reset: () => void; } const ZeroStatusContext = createContext(null); @@ -29,58 +28,49 @@ export function useZeroStatus(): ZeroStatus { return ctx; } -/** Recreate the Zero client for the current user (UI retry path). */ -export function useZeroReset(): () => Promise { +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 userId = session?.user?.id; const configError = getZeroConfigError(); + const cacheURL = process.env.NEXT_PUBLIC_ZERO_SERVER; - const [resetVersion, setResetVersion] = useState(0); - - // Logout-only teardown. User-swap is handled inside getZero. Don't destroy - // in an effect cleanup — StrictMode would close the live client mid-render. - const prevUserIdRef = useRef(null); - useEffect(() => { - if (prevUserIdRef.current && !userId) void destroyZero(); - prevUserIdRef.current = userId; - }, [userId]); + const [resetKey, setResetKey] = useState(0); - const zero = useMemo( - () => (configError || !userId ? null : getZero({ userId })), - // resetVersion is intentional: bumping it forces a new client after reset(). - // eslint-disable-next-line react-hooks/exhaustive-deps - [userId, configError, resetVersion], + const context = useMemo<{ userId: string } | null>( + () => (userId ? { userId } : null), + [userId], ); const status = useMemo( () => ({ - isReady: !!zero, - reset: async () => { - await destroyZero(); - setResetVersion((v) => v + 1); - }, + isReady: !!userId && !configError, + reset: () => setResetKey((k) => k + 1), }), - [zero], + [userId, configError], ); - if (configError) { + if (configError || !cacheURL) { return (

Zero is required for local development.

-

{configError}

+

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

); } - if (isPending || !zero) { + if (isPending || !userId || !context) { return ( @@ -90,7 +80,18 @@ export function ZeroProvider({ children }: { children: ReactNode }) { return ( - {children} + + {children} + ); } From c49d321e9635fda2d8aa557aab747bf9755a7175 Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Wed, 29 Apr 2026 22:19:46 -0400 Subject: [PATCH 08/15] feat(workspace): unified shell skeleton (header + bento) for all loading phases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the lottie WorkspaceLoader with the same header-bar + bento-grid skeleton used in-shell. Now the user sees the same chrome from the very first frame through every loading phase — no lottie→blank→pop transition between session-resolved and Zero-mounted. Made-with: Cursor --- src/components/workspace/WorkspaceLoader.tsx | 28 +++++++------------- 1 file changed, 9 insertions(+), 19 deletions(-) diff --git a/src/components/workspace/WorkspaceLoader.tsx b/src/components/workspace/WorkspaceLoader.tsx index 867ec3c6..92a21a73 100644 --- a/src/components/workspace/WorkspaceLoader.tsx +++ b/src/components/workspace/WorkspaceLoader.tsx @@ -1,31 +1,21 @@ "use client"; -import { DotLottieReact } from "@lottiefiles/dotlottie-react"; -import { useTheme } from "next-themes"; - import { Skeleton } from "@/components/ui/skeleton"; import { ThreadLoadingSkeleton } from "@/components/chat/ThreadLoadingSkeleton"; -function useBrandLottieSrc() { - const { resolvedTheme } = useTheme(); - return resolvedTheme === "light" ? "/thinkexlight.lottie" : "/logo.lottie"; -} - /** - * Full-viewport loader for the **pre-shell** phases — used while there's no - * workspace chrome on screen yet (no session, Zero client not ready). + * Full-shell skeleton: header bar + bento card grid. Used by `ZeroProvider` + * while the session/Zero client are bootstrapping (when there's no live + * `WorkspaceLayout` yet) so the user sees the same chrome that will appear + * once the shell mounts. */ export function WorkspaceLoader() { - const src = useBrandLottieSrc(); return ( -
- +
+ +
+ +
); } From 82fa7c095e0975490397f3fbdc4813b192246b90 Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Wed, 29 Apr 2026 22:20:50 -0400 Subject: [PATCH 09/15] fix(workspace-loader): reserve chat panel slot to prevent layout snap Made-with: Cursor --- src/components/workspace/WorkspaceLoader.tsx | 40 ++++++++++++++++---- 1 file changed, 32 insertions(+), 8 deletions(-) diff --git a/src/components/workspace/WorkspaceLoader.tsx b/src/components/workspace/WorkspaceLoader.tsx index 92a21a73..fe3dcd24 100644 --- a/src/components/workspace/WorkspaceLoader.tsx +++ b/src/components/workspace/WorkspaceLoader.tsx @@ -2,20 +2,44 @@ import { Skeleton } from "@/components/ui/skeleton"; import { ThreadLoadingSkeleton } from "@/components/chat/ThreadLoadingSkeleton"; +import { PANEL_DEFAULTS } from "@/lib/layout-constants"; +import { useUIStore } from "@/lib/stores/ui-store"; /** - * Full-shell skeleton: header bar + bento card grid. Used by `ZeroProvider` - * while the session/Zero client are bootstrapping (when there's no live - * `WorkspaceLayout` yet) so the user sees the same chrome that will appear - * once the shell mounts. + * 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}
); } From fadc18be0494a81c21b1777e2673d220a6ee4973 Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Wed, 29 Apr 2026 22:23:59 -0400 Subject: [PATCH 10/15] polish(workspace-loader): respect real grid min-height in bento skeleton Made-with: Cursor --- src/components/workspace/WorkspaceLoader.tsx | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/components/workspace/WorkspaceLoader.tsx b/src/components/workspace/WorkspaceLoader.tsx index fe3dcd24..d62e6d18 100644 --- a/src/components/workspace/WorkspaceLoader.tsx +++ b/src/components/workspace/WorkspaceLoader.tsx @@ -50,16 +50,16 @@ export function WorkspaceLoader() { * 1/2/3/4 columns at sm/md/lg with mixed col/row spans. */ const BENTO_TILES: ReadonlyArray<{ col: string; row: string }> = [ - { col: "lg:col-span-2", row: "row-span-2" }, - { col: "lg:col-span-1", row: "row-span-1" }, - { col: "lg:col-span-1", row: "row-span-3" }, + { col: "lg:col-span-2", row: "row-span-3" }, { col: "lg:col-span-1", row: "row-span-2" }, - { col: "lg:col-span-2", row: "row-span-1" }, + { col: "lg:col-span-1", row: "row-span-3" }, { col: "lg:col-span-1", row: "row-span-2" }, { col: "lg:col-span-2", row: "row-span-2" }, - { col: "lg:col-span-1", row: "row-span-1" }, + { col: "lg:col-span-1", row: "row-span-3" }, + { col: "lg:col-span-2", row: "row-span-2" }, { col: "lg:col-span-1", row: "row-span-2" }, - { col: "lg:col-span-2", row: "row-span-1" }, + { col: "lg:col-span-1", row: "row-span-3" }, + { col: "lg:col-span-2", row: "row-span-2" }, ]; export function WorkspaceCardsLoader() { @@ -67,7 +67,7 @@ export function WorkspaceCardsLoader() {
{BENTO_TILES.map((tile, i) => ( Date: Wed, 29 Apr 2026 22:24:56 -0400 Subject: [PATCH 11/15] polish(workspace-loader): more varied bento layout (1/2/3 col widths, dense pack) Made-with: Cursor --- src/components/workspace/WorkspaceLoader.tsx | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/components/workspace/WorkspaceLoader.tsx b/src/components/workspace/WorkspaceLoader.tsx index d62e6d18..76a4c159 100644 --- a/src/components/workspace/WorkspaceLoader.tsx +++ b/src/components/workspace/WorkspaceLoader.tsx @@ -52,13 +52,12 @@ export function WorkspaceLoader() { const BENTO_TILES: ReadonlyArray<{ col: string; row: string }> = [ { col: "lg:col-span-2", row: "row-span-3" }, { col: "lg:col-span-1", row: "row-span-2" }, - { col: "lg:col-span-1", row: "row-span-3" }, + { col: "lg:col-span-1", row: "row-span-4" }, { col: "lg:col-span-1", row: "row-span-2" }, - { col: "lg:col-span-2", row: "row-span-2" }, + { col: "lg:col-span-3", row: "row-span-2" }, + { col: "lg:col-span-2", row: "row-span-3" }, { col: "lg:col-span-1", row: "row-span-3" }, - { col: "lg:col-span-2", row: "row-span-2" }, { col: "lg:col-span-1", row: "row-span-2" }, - { col: "lg:col-span-1", row: "row-span-3" }, { col: "lg:col-span-2", row: "row-span-2" }, ]; @@ -66,7 +65,7 @@ export function WorkspaceCardsLoader() { return (
{BENTO_TILES.map((tile, i) => ( From ba5bc518eec40e73602b3ed3aa54f1d4577c3712 Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Wed, 29 Apr 2026 22:25:59 -0400 Subject: [PATCH 12/15] polish(workspace-loader): uniform 4-col card grid skeleton Made-with: Cursor --- src/components/workspace/WorkspaceLoader.tsx | 27 +++----------------- 1 file changed, 4 insertions(+), 23 deletions(-) diff --git a/src/components/workspace/WorkspaceLoader.tsx b/src/components/workspace/WorkspaceLoader.tsx index 76a4c159..30e43ebe 100644 --- a/src/components/workspace/WorkspaceLoader.tsx +++ b/src/components/workspace/WorkspaceLoader.tsx @@ -46,33 +46,14 @@ export function WorkspaceLoader() { /** * In-shell loader for `view.kind === "loading"` inside `WorkspaceSection`. - * Bento-style placeholder grid that mirrors the freeform card layout — - * 1/2/3/4 columns at sm/md/lg with mixed col/row spans. + * Uniform 1/2/3/4-column card grid at sm/md/lg. */ -const BENTO_TILES: ReadonlyArray<{ col: string; row: string }> = [ - { col: "lg:col-span-2", row: "row-span-3" }, - { col: "lg:col-span-1", row: "row-span-2" }, - { col: "lg:col-span-1", row: "row-span-4" }, - { col: "lg:col-span-1", row: "row-span-2" }, - { col: "lg:col-span-3", row: "row-span-2" }, - { col: "lg:col-span-2", row: "row-span-3" }, - { col: "lg:col-span-1", row: "row-span-3" }, - { col: "lg:col-span-1", row: "row-span-2" }, - { col: "lg:col-span-2", row: "row-span-2" }, -]; - export function WorkspaceCardsLoader() { return (
-
- {BENTO_TILES.map((tile, i) => ( - +
+ {Array.from({ length: 8 }).map((_, i) => ( + ))}
From 24c1cf8dd18b823c0f3dfc0ffb63147322d56b09 Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Wed, 29 Apr 2026 22:27:20 -0400 Subject: [PATCH 13/15] polish(chat-skeleton): match ThreadBody wrapper padding/inset for seamless transition Made-with: Cursor --- src/components/workspace/WorkspaceLoader.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/components/workspace/WorkspaceLoader.tsx b/src/components/workspace/WorkspaceLoader.tsx index 30e43ebe..a4dd690f 100644 --- a/src/components/workspace/WorkspaceLoader.tsx +++ b/src/components/workspace/WorkspaceLoader.tsx @@ -2,6 +2,7 @@ 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"; @@ -75,7 +76,10 @@ export function ChatPanelSkeleton() {
-
+
From 41c413ee569f71fd5c19abf1695bc0d284c41315 Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Wed, 29 Apr 2026 22:31:52 -0400 Subject: [PATCH 14/15] fix(workspace-layout): show header skeleton until workspace is ready, not just metadata-loaded MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The header gate was `currentWorkspaceId ? real : skeleton` — but on workspace switch, react-query serves cached metadata instantly so currentWorkspaceId flips to the new workspace immediately while items are still loading. Result: header shows the new workspace's name while the cards are still bento skeletons, which feels disjointed. Tie the header to view.kind === 'ready' so header and cards skeleton together through the entire transition, then swap together when items resolve. Made-with: Cursor --- src/components/layout/WorkspaceLayout.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/components/layout/WorkspaceLayout.tsx b/src/components/layout/WorkspaceLayout.tsx index e5c8638c..703192f9 100644 --- a/src/components/layout/WorkspaceLayout.tsx +++ b/src/components/layout/WorkspaceLayout.tsx @@ -15,6 +15,7 @@ import { WorkspaceCanvasDropzone } from "@/components/workspace-canvas/Workspace import { PANEL_DEFAULTS } from "@/lib/layout-constants"; 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 { @@ -33,6 +34,8 @@ export function WorkspaceLayout({ }: WorkspaceLayoutProps) { 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); @@ -74,7 +77,7 @@ export function WorkspaceLayout({ } >
- {currentWorkspaceId ? workspaceHeader : } + {isWorkspaceReady ? workspaceHeader : }
Date: Wed, 29 Apr 2026 22:35:03 -0400 Subject: [PATCH 15/15] fix(chat-panel): keep skeleton until workspace is ready (no stale chat on switch) Same edge case as the workspace header: workspaceId flips to the new id instantly on switch (cached metadata), so ChatPanel rendered the new workspace's chat chrome with the old workspace's threads/messages briefly before things caught up. Gate on view.kind === 'ready' so chat skeletons through the entire transition. Made-with: Cursor --- src/components/chat/ChatPanel.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/components/chat/ChatPanel.tsx b/src/components/chat/ChatPanel.tsx index 2c043887..df8bdf95 100644 --- a/src/components/chat/ChatPanel.tsx +++ b/src/components/chat/ChatPanel.tsx @@ -12,6 +12,7 @@ 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"; @@ -38,13 +39,15 @@ export function ChatPanel({ }: ChatPanelProps) { const state = useWorkspaceItems(); const isLoading = useWorkspaceItemsLoading(); + const view = useWorkspaceView(); + const isReady = view.kind === "ready"; useEffect(() => { if (!workspaceId || isLoading || !onReady) return; onReady(); }, [workspaceId, isLoading, onReady]); - if (!workspaceId) return ; + if (!workspaceId || !isReady) return ; const handleToggleMaximize = () => { setIsChatMaximized?.(!isChatMaximized);