Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions components/editor/canvas-wrapper.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { Component, type ReactNode } from "react";
import { LiveblocksProvider, RoomProvider, ClientSideSuspense } from "@liveblocks/react/suspense";

import { CanvasFlow } from "@/components/editor/canvas-flow";
import { DraftlyLoader } from "@/components/ui/marketing/draftly-loader";
import type { CanvasSaveStatus } from "@/hooks/use-canvas-autosave";

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -85,9 +86,9 @@ export function CanvasWrapper({
<CanvasErrorBoundary>
<ClientSideSuspense
fallback={
<div className="flex h-full w-full items-center justify-center">
<p className="font-brand text-sm text-ink-soft">Connecting to canvas…</p>
</div>
// Fixed over the whole editor: the navbar and AI sidebar render outside this boundary.
// z-45 sits above the sidebars (z-20/z-40) and below dialogs (z-50).
<DraftlyLoader caption="Connecting to canvas" className="fixed inset-0 z-45" />
}
>
<CanvasFlow
Expand Down
34 changes: 30 additions & 4 deletions components/editor/project-dialogs.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
"use client";

import { Loader2 } from "lucide-react";

import {
PaperDialog,
paperDestructiveButtonClass,
Expand All @@ -22,6 +24,20 @@ interface ProjectDialogsProps {
onDelete: () => Promise<void>;
}

/** Button label that swaps to a spinner and progress text while the action runs. */
function ActionLabel({ isLoading, idle, busy }: { isLoading: boolean; idle: string; busy: string }) {
if (!isLoading) {
return idle;
}

return (
<>
<Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />
{busy}
</>
);
}

export function ProjectDialogs({
activeDialog,
projectName,
Expand Down Expand Up @@ -50,8 +66,14 @@ export function ProjectDialogs({
<button type="button" className={paperSecondaryButtonClass} onClick={onClose} disabled={isLoading}>
Cancel
</button>
<button type="submit" form="create-project-form" className={paperPrimaryButtonClass} disabled={isLoading}>
Create project
<button
type="submit"
form="create-project-form"
className={paperPrimaryButtonClass}
disabled={isLoading}
aria-busy={isLoading}
>
<ActionLabel isLoading={isLoading} idle="Create project" busy="Creating…" />
</button>
</>
}
Expand All @@ -73,6 +95,7 @@ export function ProjectDialogs({
onChange={(event) => onProjectNameChange(event.target.value)}
placeholder="e.g. Payment Gateway Modernization"
className={paperInputClass}
disabled={isLoading}
autoFocus
/>
<p className="flex flex-wrap items-baseline gap-x-2.5 font-mono text-xs">
Expand All @@ -99,8 +122,9 @@ export function ProjectDialogs({
form="rename-project-form"
className={paperPrimaryButtonClass}
disabled={isLoading || projectName.trim().length === 0}
aria-busy={isLoading}
>
Save
<ActionLabel isLoading={isLoading} idle="Save" busy="Saving…" />
</button>
</>
}
Expand All @@ -121,6 +145,7 @@ export function ProjectDialogs({
value={projectName}
onChange={(event) => onProjectNameChange(event.target.value)}
className={paperInputClass}
disabled={isLoading}
autoFocus
/>
</form>
Expand All @@ -145,8 +170,9 @@ export function ProjectDialogs({
className={paperDestructiveButtonClass}
onClick={() => void onDelete()}
disabled={isLoading}
aria-busy={isLoading}
>
Delete project
<ActionLabel isLoading={isLoading} idle="Delete project" busy="Deleting…" />
</button>
</>
}
Expand Down
13 changes: 13 additions & 0 deletions context/progress-tracker.md
Original file line number Diff line number Diff line change
Expand Up @@ -1147,3 +1147,16 @@ Update this file whenever the current phase, active feature, or implementation s
- Type check and lint pass. A tsx script over all 13 templates confirmed: every edge has handles, no node side
holds two edges, max 4 edges per node, no overlapping nodes. It also covered `canConnect` cases and AI graph
handles. Not checked in a browser.
- Loading states for canvas connect and project actions (2026-09-15):
- `canvas-wrapper.tsx`: the `ClientSideSuspense` fallback is now `DraftlyLoader` (caption "Connecting to canvas",
`fixed inset-0 z-45`) instead of plain text. It covers the whole editor, navbar and AI sidebar included
(they render outside the Suspense boundary); z-45 is above the sidebars (z-20/z-40), below dialogs (z-50).
The connection error message is unchanged.
- `hooks/use-project-actions.ts`: `isLoading` = request in flight OR router transition pending. The
`router.push` / `router.refresh` after a successful create, rename or delete runs in `useTransition`
with `setActiveDialog(null)`, so the dialog stays open and busy until the new page / refreshed names
render. `closeDialog` and the submit handlers ignore calls while loading (no dismiss mid-action, no
double submit). Failed requests still leave the dialog open with no error message (unchanged).
- `project-dialogs.tsx`: primary buttons show a `Loader2` spinner with "Creating…", "Saving…",
"Deleting…" and `aria-busy`; name inputs are disabled while loading.
- Type check and lint pass. Not checked in a browser.
2 changes: 1 addition & 1 deletion context/ui-context.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ barrel: `MarketingNavbar`, `MarketingButton`, `HandwrittenNote`,
`SkillPill`, `DraftlyWordmark` (the two-face text lockup), `MatGrid` (the cutting-mat
grid overlay), `DraftlyLoader` (the brand loading screen: swinging amber sticky note on
notebook paper with a rotating Caveat status word; `tone` craft/plain, `pace`, `caption`, `fullScreen`;
intended for waits like dashboard → editor and connecting to the canvas, not yet wired in). The auth layout sits on the mat with the wordmark and mini-canvas. Sign-in
used as the canvas connecting fallback in `CanvasWrapper`; also intended for dashboard → editor waits). The auth layout sits on the mat with the wordmark and mini-canvas. Sign-in
and sign-up are custom paper cards (`components/auth/sign-in-card.tsx`, `sign-up-card.tsx`)
built on Clerk's `useSignIn()` / `useSignUp()` hooks, not the prebuilt `<SignIn />` /
`<SignUp />`. Both share the card shell, SSO buttons, fields and CTA from
Expand Down
64 changes: 39 additions & 25 deletions hooks/use-project-actions.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client";

import { useMemo, useState } from "react";
import { useMemo, useState, useTransition } from "react";
import { usePathname, useRouter } from "next/navigation";

import type { SidebarProject } from "@/lib/project-data";
Expand All @@ -21,6 +21,7 @@ export interface ProjectActionsController {
selectedProject: SidebarProject | null;
projectName: string;
roomIdPreview: string;
/** True from submit until the follow-up navigation or refresh has rendered. */
isLoading: boolean;
setProjectName: (value: string) => void;
openCreateDialog: () => void;
Expand Down Expand Up @@ -60,7 +61,10 @@ export function useProjectActions(
const [selectedProjectId, setSelectedProjectId] = useState<string | null>(null);
const [projectName, setProjectName] = useState("");
const [createSuffix, setCreateSuffix] = useState(createShortSuffix);
const [isLoading, setIsLoading] = useState(false);
const [isRequesting, setIsRequesting] = useState(false);
// Router updates run in a transition, so the dialog stays busy (and open) until the new UI renders.
const [isNavigating, startNavigation] = useTransition();
const isLoading = isRequesting || isNavigating;

const selectedProject = useMemo(
() => ownedProjects.find((project) => project.id === selectedProjectId) ?? null,
Expand Down Expand Up @@ -101,16 +105,24 @@ export function useProjectActions(
setActiveDialog("delete");
};

// A dialog can't be dismissed mid-action, otherwise a finished create could still navigate away.
const closeDialog = () => {
if (isLoading) {
return;
}

setActiveDialog(null);
setIsLoading(false);
};

const submitCreate = async () => {
if (isLoading) {
return;
}

const nextName = projectName.trim();
const roomId = roomIdPreview;

setIsLoading(true);
setIsRequesting(true);
try {
const response = await fetch("/api/projects", {
method: "POST",
Expand All @@ -128,20 +140,22 @@ export function useProjectActions(
}

const payload = (await response.json()) as CreateProjectResponse;
closeDialog();
router.push(`/editor/${payload.project.id}`);
router.refresh();
startNavigation(() => {
setActiveDialog(null);
router.push(`/editor/${payload.project.id}`);
router.refresh();
});
} finally {
setIsLoading(false);
setIsRequesting(false);
}
};

const submitRename = async () => {
if (!selectedProject) {
if (!selectedProject || isLoading) {
return;
}

setIsLoading(true);
setIsRequesting(true);
try {
const response = await fetch(`/api/projects/${selectedProject.id}`, {
method: "PATCH",
Expand All @@ -157,19 +171,21 @@ export function useProjectActions(
return;
}

closeDialog();
router.refresh();
startNavigation(() => {
setActiveDialog(null);
router.refresh();
});
} finally {
setIsLoading(false);
setIsRequesting(false);
}
};

const submitDelete = async () => {
if (!selectedProject) {
if (!selectedProject || isLoading) {
return;
}

setIsLoading(true);
setIsRequesting(true);
try {
const response = await fetch(`/api/projects/${selectedProject.id}`, {
method: "DELETE",
Expand All @@ -179,18 +195,16 @@ export function useProjectActions(
return;
}

closeDialog();

const activeWorkspaceProjectId = getActiveWorkspaceProjectId(pathname);
if (activeWorkspaceProjectId === selectedProject.id) {
router.push("/editor");
const isDeletingActiveProject = getActiveWorkspaceProjectId(pathname) === selectedProject.id;
startNavigation(() => {
setActiveDialog(null);
if (isDeletingActiveProject) {
router.push("/editor");
}
router.refresh();
return;
}

router.refresh();
});
} finally {
setIsLoading(false);
setIsRequesting(false);
}
};

Expand Down
Loading