Skip to content
Merged

Dev #19

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
16 changes: 11 additions & 5 deletions app/api/liveblocks-auth/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,18 +64,24 @@ export async function POST(request: Request) {
const avatar = clerkUser?.avatarUrl ?? "";
const color = getUserColor(currentUser.userId);

// Ensure the room exists. We use ID tokens, so the room must have at
// least one access entry — `defaultAccesses: []` would lock everyone
// out, and `["room:write"]` would let any token holder join.
// Project membership is enforced by step 2 above, so we keep the room
// private (empty defaults) and rely on per-user access.
// Ensure the room exists, then grant this user write access.
// getOrCreateRoom only applies usersAccesses on *create* — if the owner
// already created the room, a collaborator would still be locked out
// (Liveblocks 4001 / "You have no access to this room"). Always update
// after create-or-get so every verified member receives room:write.
// Project membership is enforced above; rooms stay private by default.
try {
await liveblocks.getOrCreateRoom(roomId, {
defaultAccesses: [],
usersAccesses: {
[currentUser.userId]: ["room:write"],
},
});
await liveblocks.updateRoom(roomId, {
usersAccesses: {
[currentUser.userId]: ["room:write"],
},
});
} catch (error) {
console.error("Failed to ensure Liveblocks room exists", error);
return NextResponse.json(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ export async function DELETE(
// Find the collaborator
const collaborator = await prisma.projectCollaborator.findUnique({
where: { id: collaboratorId },
select: { id: true, projectId: true },
select: { id: true, projectId: true, email: true },
});

if (!collaborator || collaborator.projectId !== projectId) {
Expand All @@ -49,5 +49,29 @@ export async function DELETE(
where: { id: collaboratorId },
});

// Revoke Liveblocks room access for the removed collaborator when we
// can resolve their Clerk user ID. Best-effort — DB membership is source
// of truth and auth will refuse tokens without a collaborator row.
try {
const { clerkClient } = await import("@clerk/nextjs/server");
const { liveblocks } = await import("@/lib/liveblocks");
const clerk = await clerkClient();
const list = await clerk.users.getUserList({
emailAddress: [collaborator.email.toLowerCase()],
limit: 1,
});
const removedId = (list as unknown as { data: { id: string }[] }).data?.[0]
?.id;
if (removedId) {
await liveblocks.updateRoom(project.id, {
usersAccesses: {
[removedId]: null,
},
});
}
} catch (error) {
console.error("Failed to revoke Liveblocks access on remove", error);
}
Comment on lines +52 to +74

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Locate the route and nearby Liveblocks/Clerk integration points.
git ls-files 'app/api/projects/[projectId]/collaborators/[collaboratorId]/route.ts' \
  'app/api/projects/**' \
  'lib/**' \
  'node_modules/next/dist/docs/**' | sed -n '1,200p'

echo '--- route outline ---'
ast-grep outline app/api/projects/[projectId]/collaborators/[collaboratorId]/route.ts --view expanded

echo '--- route file ---'
cat -n app/api/projects/[projectId]/collaborators/[collaboratorId]/route.ts

echo '--- liveblocks/clerk refs ---'
rg -n "updateRoom|usersAccesses|clerkClient|getUserList|collaborator.email|Failed to revoke Liveblocks access|auth will refuse tokens" app lib -S

echo '--- next docs candidates ---'
fd -a 'docs' node_modules/next/dist | head -n 50

Repository: tejasM17/ghost-dev

Length of output: 6629


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- app/api/liveblocks-auth/route.ts ---'
cat -n app/api/liveblocks-auth/route.ts

echo '--- lib/auth.ts (relevant sections) ---'
sed -n '1,240p' lib/auth.ts | cat -n

echo '--- app/api/projects/[projectId]/collaborators/route.ts (relevant sections) ---'
sed -n '1,260p' app/api/projects/[projectId]/collaborators/route.ts | cat -n

Repository: tejasM17/ghost-dev

Length of output: 19044


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- lib/liveblocks.ts ---'
cat -n lib/liveblocks.ts

echo '--- search for Liveblocks auth/access comments and 4001 ---'
rg -n "4001|no access to this room|getOrCreateRoom|updateRoom|usersAccesses|identifyUser|authEndpoint|room:write|liveblocks" app lib -S

echo '--- any liveblocks docs files in repo ---'
git ls-files | rg 'liveblocks|LIVEBLOCKS|docs'

Repository: tejasM17/ghost-dev

Length of output: 9367


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- liveblocks ID-token docs ---'
sed -n '1,220p' .agents/skills/liveblocks-best-practices/references/authenticating-with-id-tokens.md

echo '--- liveblocks access-token docs ---'
sed -n '1,220p' .agents/skills/liveblocks-best-practices/references/authenticating-with-access-tokens.md

echo '--- auth-endpoint callback docs ---'
sed -n '1,220p' .agents/skills/liveblocks-best-practices/references/auth-endpoint-callback.md

Repository: tejasM17/ghost-dev

Length of output: 6991


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- search docs for token expiration / reconnect / TTL ---'
rg -n "expire|expiration|expires|ttl|reconnect|retry|token" .agents/skills/liveblocks-best-practices/references app lib context -S | head -n 200

echo '--- inspect relevant best-practice docs for authentication details ---'
sed -n '1,220p' .agents/skills/liveblocks-best-practices/references/handling-connection-errors.md
printf '\n---\n'
sed -n '1,220p' .agents/skills/liveblocks-best-practices/references/handling-unstable-connections.md

Repository: tejasM17/ghost-dev

Length of output: 12762


🌐 Web query:

Liveblocks ID token lifetime room access usersAccesses reconnect permissions docs

💡 Result:

In Liveblocks, authentication is handled through either ID tokens or access tokens, each with distinct approaches to room permissions and session management. ID Tokens ID tokens function as a secure user identity [1][2]. They do not contain explicit permissions [1]. Instead, authorization is handled server-side by Liveblocks, which checks permissions defined via the REST API for each room [3][1]. Permissions are managed using the defaultAccesses, groupsAccesses, and usersAccesses properties when creating or updating a room [3][4]. This method is recommended for complex permission systems as it centralizes the source of truth [3][5]. Access Tokens Access tokens (JWTs) explicitly grant a user permission to enter specific rooms at the time of authentication [5]. You must manually track and grant these permissions in your authentication endpoint using session.allow [5][6]. While you can grant access to individual rooms [7], it is recommended to use naming patterns and wildcards to scale access across groups or organizations [5][6]. Access tokens do not automatically adapt to new room permissions; they require re-authorization if access needs to change [5]. Reconnection and Permissions If a client's token expires or needs to be refreshed, the Liveblocks client handles re-authorization automatically or through manual triggers [8]: - Token Expiration: When a token expires (error code 4109), the client will automatically attempt to re-authorize [8]. - Manual Reconnection: You can trigger a manual re-authentication by calling room.reconnect on the client side [9]. This allows users to update their permissions or credentials without refreshing the page or losing local application state, such as undo/redo history [9]. Management API Tokens Note that Management API access tokens—used for administrative tasks via the Liveblocks API—are distinct from user authentication tokens [10]. These tokens are team-linked, require manual generation, and have configurable expiration periods ranging from 7 days to 1 year [10]. Top Results: [3][5][9][8][10]

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- context/feature-specs/10-liveblocks-setup.md ---'
sed -n '1,220p' context/feature-specs/10-liveblocks-setup.md

echo '--- context/current-issues-liveblocks-canvas.md ---'
sed -n '1,220p' context/current-issues-liveblocks-canvas.md

echo '--- context/progress-tracker.md (liveblocks lines) ---'
sed -n '50,80p' context/progress-tracker.md

Repository: tejasM17/ghost-dev

Length of output: 5092


🌐 Web query:

Liveblocks ID tokens expire refresh reconnect authentication docs

💡 Result:

In Liveblocks, authentication tokens (both ID tokens and access tokens) are managed automatically by the Liveblocks client [1][2][3]. You should never cache these tokens manually on the client side, as the Liveblocks client is designed to cache them for you and will automatically request a fresh token from your authentication endpoint only when necessary, such as when the current token has expired [1][2]. Key mechanisms regarding token lifecycle and reconnection include: 1. Token Refresh: The Liveblocks client transparently handles token expiration [1][3]. By providing an authEndpoint (either as a string URL or an async callback function) to your client configuration, the client will invoke this endpoint to fetch a new, valid token whenever required [1][4]. 2. Reauthentication: If you need to force a reauthentication (for example, if a user logs out and signs back into your application, or you need to update user metadata without a page refresh), you can use the room.reconnect() method [5]. This triggers a reconnection and reauthorization flow while maintaining the current client state, such as undo/redo history [5]. 3. Client Logout: The client.logout() method can be used to purge all authentication tokens from the client's memory [4]. This forces any currently connected rooms to reauthorize, which is useful in single-page applications (SPAs) when a user explicitly logs out [4]. When implementing your authentication endpoint, you must return a valid token in the format expected by Liveblocks (e.g., as returned by liveblocks.identifyUser or liveblocks.prepareSession) [1][2][4]. If your endpoint returns a forbidden error, the client will stop attempting to reauthorize [1][4].

Citations:


Fail closed when Liveblocks revoke fails.
Returning 204 here hides a failed updateRoom, so a removed collaborator can keep the stale room grant until their client reauthorizes. Revoke before deleting the DB row, or return an error / queue a retry on revoke failures.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/api/projects/`[projectId]/collaborators/[collaboratorId]/route.ts around
lines 52 - 74, The collaborator removal flow currently swallows Liveblocks
revocation failures and still returns success. Update the route’s Liveblocks
revoke block to fail closed: perform the revoke before deleting the database
membership, and propagate an error or queue a retry when clerk lookup or
liveblocks.updateRoom fails instead of only logging it. Preserve successful
removal behavior when revocation completes.


return new NextResponse(null, { status: 204 });
}
52 changes: 46 additions & 6 deletions app/api/projects/[projectId]/collaborators/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,11 @@ export async function GET(
const primaryEmail = user.emailAddresses.find(
(e) => e.id === user.primaryEmailAddressId
);
const userEmail = primaryEmail?.emailAddress ?? "";
const userEmail = (
primaryEmail?.emailAddress ??
user.emailAddresses[0]?.emailAddress ??
""
).toLowerCase();

const actualCollaborator = await prisma.projectCollaborator.findUnique({
where: {
Expand Down Expand Up @@ -123,14 +127,17 @@ export async function POST(
body = null;
}

const email = readEmail(body);
if (!email) {
const rawEmail = readEmail(body);
if (!rawEmail) {
return NextResponse.json(
{ error: "Valid email is required" },
{ status: 400 },
);
}

// Collaborator emails are always stored lowercased for consistent lookups.
const email = rawEmail.toLowerCase();

// Check if already a collaborator
const existing = await prisma.projectCollaborator.findUnique({
where: {
Expand All @@ -155,7 +162,7 @@ export async function POST(
const ownerEmail = owner.emailAddresses.find(
(e) => e.id === owner.primaryEmailAddressId
);
if (ownerEmail?.emailAddress.toLowerCase() === email.toLowerCase()) {
if (ownerEmail?.emailAddress.toLowerCase() === email) {
return NextResponse.json(
{ error: "Cannot invite the project owner" },
{ status: 400 },
Expand All @@ -165,13 +172,46 @@ export async function POST(
const collaborator = await prisma.projectCollaborator.create({
data: {
projectId: project.id,
email: email.toLowerCase(),
email,
},
});

// If the invitee already has a Clerk account, grant Liveblocks room
// access immediately so they can open the project without waiting for
// a subsequent auth-endpoint updateRoom call. Auth still re-grants
// access on every join (covers users who sign up after the invite).
try {
const { liveblocks } = await import("@/lib/liveblocks");
const inviteeList = await clerk.users.getUserList({
emailAddress: [email],
limit: 1,
});
const inviteeData = (inviteeList as unknown as { data: { id: string }[] })
.data;
const inviteeId = inviteeData?.[0]?.id;
if (inviteeId) {
await liveblocks.getOrCreateRoom(project.id, {
defaultAccesses: [],
usersAccesses: {
[userId]: ["room:write"],
[inviteeId]: ["room:write"],
},
});
await liveblocks.updateRoom(project.id, {
usersAccesses: {
[inviteeId]: ["room:write"],
},
});
}
} catch (error) {
// Membership is already in Postgres; Liveblocks access will be
// granted when the collaborator hits /api/liveblocks-auth.
console.error("Failed to grant Liveblocks access on invite", error);
}

// Try to get Clerk user data for the response
const { getClerkUserByEmail } = await import("@/lib/auth");
const clerkUser = await getClerkUserByEmail(email.toLowerCase());
const clerkUser = await getClerkUserByEmail(email);

return NextResponse.json(
{
Expand Down
2 changes: 1 addition & 1 deletion components/editor/canvas-controls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ export function CanvasControls() {
}, [reactFlow]);

return (
<div className="pointer-events-none fixed bottom-6 left-6 z-50">
<div className="pointer-events-none fixed bottom-6 left-6 z-20">
<div
className="pointer-events-auto flex items-center gap-1 rounded-full border border-border-default bg-bg-surface/95 px-1.5 py-1.5 shadow-xl backdrop-blur-md"
role="toolbar"
Expand Down
92 changes: 87 additions & 5 deletions components/editor/canvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,13 @@ import { ShapePanel, type ShapeDragPayload } from "./shape-panel";
import { CanvasNodeRenderer } from "./canvas-node";
import { CanvasEdgeRenderer } from "./canvas-edge";
import { CanvasControls } from "./canvas-controls";
import { LiveCursors, useCursorPresence } from "./live-cursors";
import { PresenceAvatars } from "./presence-avatars";
import { StarterTemplatesModal } from "./starter-templates-modal";
import {
cloneTemplateWithFreshIds,
type CanvasTemplate,
} from "./starter-templates";

/** Default style for newly created edges — light stroke, rounded ends, arrow. */
const DEFAULT_EDGE_OPTIONS: DefaultEdgeOptions = {
Expand All @@ -67,6 +74,9 @@ const DEFAULT_EDGE_OPTIONS: DefaultEdgeOptions = {

interface CanvasProps {
roomId: string;
/** Controlled open state for the starter templates import modal. */
templatesOpen?: boolean;
onTemplatesOpenChange?: (open: boolean) => void;
}

/**
Expand All @@ -77,17 +87,24 @@ interface CanvasProps {
* the foundation of the editor — node/edge rendering and persistence
* land in later features.
*/
export function Canvas({ roomId }: CanvasProps) {
export function Canvas({
roomId,
templatesOpen = false,
onTemplatesOpenChange,
}: CanvasProps) {
return (
<LiveblocksProvider authEndpoint="/api/liveblocks-auth">
<RoomProvider
id={roomId}
initialPresence={{ cursor: null, isThinking: false }}
initialPresence={{ cursor: null, thinking: false }}
>
<CanvasErrorBoundary>
<ClientSideSuspense fallback={<CanvasLoading />}>
<ReactFlowProvider>
<CollaborativeFlow />
<CollaborativeFlow
templatesOpen={templatesOpen}
onTemplatesOpenChange={onTemplatesOpenChange}
/>
</ReactFlowProvider>
</ClientSideSuspense>
</CanvasErrorBoundary>
Expand Down Expand Up @@ -155,13 +172,23 @@ function generateNodeId(shape: NodeShape): string {
return `${shape}-${timestamp}-${nodeIdCounter}`;
}

interface CollaborativeFlowProps {
templatesOpen: boolean;
onTemplatesOpenChange?: (open: boolean) => void;
}

/**
* The actual React Flow surface, rendered after Liveblocks has connected
* and the storage layer is ready (via Suspense).
*/
function CollaborativeFlow() {
function CollaborativeFlow({
templatesOpen,
onTemplatesOpenChange,
}: CollaborativeFlowProps) {
const wrapperRef = useRef<HTMLDivElement>(null);
const { screenToFlowPosition } = useReactFlow();
const { screenToFlowPosition, fitView } = useReactFlow();
const { onPointerMove: onCursorMove, onPointerLeave: onCursorLeave } =
useCursorPresence();

// `useLiveblocksFlow` returns the synced nodes/edges plus change
// handlers. With `suspense: true`, `nodes` and `edges` are guaranteed
Expand All @@ -187,6 +214,48 @@ function CollaborativeFlow() {
[lbOnNodesChange],
);

/**
* Replace the entire collaborative graph with a starter template.
* Clears existing nodes/edges first, then adds the template graph and
* fits the view once the new elements are in place.
*/
const handleImportTemplate = useCallback(
(template: CanvasTemplate) => {
const { nodes: nextNodes, edges: nextEdges } =
cloneTemplateWithFreshIds(template);

// Remove every existing edge, then every existing node.
if (edges.length > 0) {
onEdgesChange(
edges.map((edge) => ({ type: "remove" as const, id: edge.id })),
);
}
if (nodes.length > 0) {
onNodesChange(
nodes.map((node) => ({ type: "remove" as const, id: node.id })),
);
}

// Add template graph into collaborative storage.
if (nextNodes.length > 0) {
onNodesChange(
nextNodes.map((node) => ({ type: "add" as const, item: node })),
);
}
if (nextEdges.length > 0) {
onEdgesChange(
nextEdges.map((edge) => ({ type: "add" as const, item: edge })),
);
}
Comment on lines +227 to +249

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
fd -a '^(package\.json|.*lock.*)$' . -x sh -c \
  'echo "### $1"; rg -n "liveblocks|react-flow|xyflow" "$1"' sh {}

rg -n -C5 'batch|transaction|pauseHistory|resumeHistory|useLiveblocksFlow' \
  --type=ts --type=tsx .

Repository: tejasM17/ghost-dev

Length of output: 3922


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Find the target file and nearby Liveblocks/XYFlow usage.
fd -a 'canvas\.tsx$|liveblocks\.config\.ts$|route\.ts$|SKILL\.md$' .

echo '--- package.json ---'
cat -n package.json | sed -n '1,120p'

echo '--- relevant Next/Liveblocks docs in node_modules/next/dist/docs (if present) ---'
fd -a 'node_modules/next/dist/docs' . -t d
fd -a 'node_modules/next/dist/docs' . -t f | sed -n '1,40p'

echo '--- canvas.tsx outline ---'
ast-grep outline components/editor/canvas.tsx --view expanded

echo '--- canvas.tsx relevant slice ---'
cat -n components/editor/canvas.tsx | sed -n '180,290p'

echo '--- search for Liveblocks/React Flow batching APIs in repo files ---'
rg -n -C 3 'batch|transaction|pauseHistory|resumeHistory|useLiveblocksFlow|onNodesChange|onEdgesChange|applyNodeChanges|applyEdgeChanges|createStore|room\.update|storage\.update' components lib app context .agents .claude 2>/dev/null || true

Repository: tejasM17/ghost-dev

Length of output: 2843


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- components/editor/canvas.tsx slice ---'
cat -n components/editor/canvas.tsx | sed -n '200,270p'

echo '--- Liveblocks/React Flow API references in repo ---'
rg -n -C 2 'useLiveblocksFlow|pauseHistory|resumeHistory|transaction|batch|useMutation|useStorage|onNodesChange|onEdgesChange' \
  components lib app . 2>/dev/null || true

echo '--- package lock entries for `@liveblocks/react-flow` ---'
rg -n -C 4 '"node_modules/@liveblocks/react-flow"|`@liveblocks/react-flow`' package-lock.json

echo '--- installed package docs (if present) ---'
fd --full-path 'node_modules/@liveblocks/react-flow' . 2>/dev/null || true
fd --full-path 'node_modules/@liveblocks/client' . 2>/dev/null || true

Repository: tejasM17/ghost-dev

Length of output: 18375


🌐 Web query:

Liveblocks React Flow v3.22.0 useLiveblocksFlow transaction batching history undo API

💡 Result:

In Liveblocks (including when using React Flow), transaction batching and undo/redo history are managed through specific APIs that allow you to group operations into single undoable actions. Batching and Undo/Redo API When using @liveblocks/react-flow, standard undo and redo functionality is automatically enabled for flow state changes like node/edge additions, removals, position updates, and data updates [1][2]. For custom logic or complex mutations, you can control the undo/redo stack using the following approaches: 1. room.batch(callback): This function executes a callback and merges all storage modifications made during its execution into a single history entry [3][4]. It ensures that all changes are sent to other clients in one message and that subscribers are notified only once after the batch finishes [3][4]. 2. History Pause/Resume: For asynchronous operations or complex sequences where room.batch cannot be used directly, you can use room.history.pause and room.history.resume [5]. All mutations performed between these calls will be merged into a single undoable item [4][5]. It is recommended to wrap these in a try/finally block to ensure history is properly resumed [5]. 3. Hooks: In React, you access these features using standard hooks from @liveblocks/react, such as useHistory, useUndo, and useRedo [1][2][6]. The useHistory hook provides access to the history instance, allowing you to trigger undo/redo programmatically [2]. Version History As of v3.22.0, Liveblocks introduced enhanced version history support for Storage [7]. Key additions include: - useHistoryVersions: Lists available room versions [7]. - useRestoreToStorageVersion(id): Restores the room’s Storage to a specific version as a single, undoable action [7][8]. - useHistoryVersionStorageData(id): Returns a read-only snapshot of Storage for a specific version, useful for diffing or visualization [7]. These APIs allow you to manage both local user-level undo/redo stacks and server-side document versioning effectively [7][9][4].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- top of components/editor/canvas.tsx ---'
cat -n components/editor/canvas.tsx | sed -n '1,120p'

echo '--- search for room/history hooks in canvas.tsx ---'
rg -n 'useRoom|useHistory|history\.|room\.|batch\(|pause|resume|undo|redo' components/editor/canvas.tsx

echo '--- search for Liveblocks room/history hooks elsewhere in repo ---'
rg -n 'useRoom|useHistory|history\.|room\.|batch\(|pause|resume|undo|redo' components lib app context . 2>/dev/null || true

Repository: tejasM17/ghost-dev

Length of output: 10734


🌐 Web query:

site:liveblocks.io @liveblocks/react-flow onNodesChange history batch transaction undo redo

💡 Result:

When using @liveblocks/react-flow, undo and redo functionality for your flow state is automatically enabled and handled by Liveblocks [1][2]. You do not need to manually implement history batching for standard React Flow operations like dragging, resizing, or deleting nodes [1][3]. Key points regarding history and transactions in @liveblocks/react-flow: - Automatic Batching: Liveblocks automatically batches frequent updates into single undoable actions [1][3]. For example, dragging or resizing a node produces many live state updates, but these are recorded as a single action on the undo stack [1][3]. Similarly, deleting a node along with its connected edges is handled atomically, ensuring they are undone together in one step [1][3]. - Implementation: To expose undo/redo controls in your UI, use the useUndo and useRedo hooks (or the useHistory hook) from the @liveblocks/react or @liveblocks/react/suspense package [1][2][4]. Example implementation: import { useUndo, useRedo } from "@liveblocks/react/suspense"; function Toolbar { const undo = useUndo; const redo = useRedo; return (

Undo Redo
); } The @liveblocks/react-flow hook useLiveblocksFlow provides the necessary onNodesChange, onEdgesChange, and onDelete handlers that you pass directly to the React Flow component to ensure all changes are synced and history-aware [1][5]. Local-only properties, however, are not recorded on the undo stack [1].

Citations:


🌐 Web query:

site:liveblocks.io useLiveblocksFlow multiple onNodesChange calls same callback single undo history entry

💡 Result:

When using useLiveblocksFlow, you do not need to manually manage callback grouping for undo history, as the hook handles this automatically for standard React Flow interactions [1][2]. The useLiveblocksFlow hook is designed to automatically record synced changes to nodes and edges on the undo stack in an intuitive way [1][2]: 1. Atomic Actions: Actions like deleting a node and its related edges are handled atomically, meaning they are recorded as a single undoable event [1][2]. 2. Continuous Updates: For high-frequency events like dragging or resizing, Liveblocks automatically consolidates these numerous intermediate state changes into a single action on the undo history stack [1][2]. This prevents the undo stack from becoming cluttered with granular coordinate changes. Because this behavior is built into the hook, passing the provided onNodesChange, onEdgesChange, and onDelete functions directly to your React Flow component is sufficient to achieve this behavior [1][3]. You do not need to write a custom wrapper callback to group these updates yourself [1][2]. If you require advanced control over history (for instance, to pause or resume history recording during complex custom operations), you can use the useHistory hook from @liveblocks/react [4][2]. This allows you to call pause before a series of mutations and resume afterward to ensure they are captured as a single history entry [4].

Citations:


Group the template import into one Liveblocks history entry. These four storage updates are still separate mutations; wrap the swap in a Liveblocks batch/history pause-resume so ⌘Z reverts the whole replacement in one step.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@components/editor/canvas.tsx` around lines 227 - 249, Wrap the existing
edge/node removal and template insertion updates in the canvas swap flow with a
single Liveblocks batch/history pause-resume operation. Ensure all four
onEdgesChange and onNodesChange calls execute within that grouped history entry
so undo reverts the entire replacement at once.


// Fit after React Flow has applied the new graph.
window.setTimeout(() => {
void fitView({ duration: 200, padding: 0.15 });
}, 50);
Comment on lines +251 to +254

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== Next.js docs candidates =="
git ls-files 'node_modules/next/dist/docs/**' | sed -n '1,120p'

echo
echo "== Canvas file outline =="
ast-grep outline components/editor/canvas.tsx --view expanded || true

echo
echo "== Relevant canvas excerpt =="
sed -n '1,340p' components/editor/canvas.tsx

Repository: tejasM17/ghost-dev

Length of output: 10289


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Narrowly inspect the editor canvas and any state used to gate fitView.
rg -n "fitView|setTimeout|imported|initialized|nodesInitialized|onInit|onNodesChange|nodes.*ready|ready.*nodes" components/editor/canvas.tsx

echo
echo "== canvas.tsx with line numbers =="
cat -n components/editor/canvas.tsx | sed -n '1,340p'

echo
echo "== Next.js docs under node_modules/next/dist/docs =="
find node_modules/next/dist/docs -maxdepth 2 -type f | sed -n '1,120p'

Repository: tejasM17/ghost-dev

Length of output: 12726


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== repository files matching canvas/editor =="
git ls-files | rg '^components/editor/canvas\.tsx$|^components/editor/.*'

echo
echo "== components/editor/canvas.tsx (selected range) =="
nl -ba components/editor/canvas.tsx | sed -n '1,340p'

echo
echo "== Next.js docs files with client/component/init/reactflow keywords =="
find node_modules/next/dist/docs -type f \( -name '*client*' -o -name '*component*' -o -name '*app*' -o -name '*routing*' \) | sed -n '1,120p'

Repository: tejasM17/ghost-dev

Length of output: 1125


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Show only the code around the reported lines and any initialization gate.
nl -ba components/editor/canvas.tsx | sed -n '220,280p'

Repository: tejasM17/ghost-dev

Length of output: 196


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== canvas tail around ReactFlow props =="
sed -n '330,430p' components/editor/canvas.tsx

echo
echo "== initialization-related symbols in editor components =="
rg -n "useNodesInitialized|onInit|onNodesInitialized|nodesInitialized|fitView|initialized" components/editor

echo
echo "== fitView usage across repo =="
rg -n "fitView" components

Repository: tejasM17/ghost-dev

Length of output: 4107


Wait for the imported graph to be ready before fitting. The fixed 50 ms timeout can still fire before the template nodes finish mounting/measuring, so fitView may run against an incomplete graph. Trigger it from the node-initialized state instead.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@components/editor/canvas.tsx` around lines 251 - 254, Replace the fixed 50 ms
timeout around fitView in the graph import flow with a trigger tied to the
node-initialized state. Ensure fitView runs only after all imported template
nodes have mounted and been measured, while preserving the existing duration and
padding options.

},
[edges, nodes, onEdgesChange, onNodesChange, fitView],
);

// Memoized type maps — stable refs so React Flow does not remount nodes/edges
const nodeTypes = useMemo<NodeTypes>(
() => ({
Expand Down Expand Up @@ -272,6 +341,8 @@ function CollaborativeFlow() {
onEdgesChange={onEdgesChange}
onConnect={onConnect}
onDelete={onDelete}
onMouseMove={onCursorMove}
onMouseLeave={onCursorLeave}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
defaultEdgeOptions={DEFAULT_EDGE_OPTIONS}
Expand All @@ -288,9 +359,20 @@ function CollaborativeFlow() {
/>
</ReactFlow>

<LiveCursors />
<PresenceAvatars />

{/* Zoom + undo/redo bar (bottom-left); shape palette (bottom-center) */}
<CanvasControls />
<ShapePanel />

{onTemplatesOpenChange ? (
<StarterTemplatesModal
open={templatesOpen}
onOpenChange={onTemplatesOpenChange}
onImport={handleImportTemplate}
/>
) : null}
</div>
);
}
Expand Down
12 changes: 6 additions & 6 deletions components/editor/editor-navbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ export function EditorNavbar({
onToggleSidebar,
}: EditorNavbarProps) {
return (
<header className="flex h-14 shrink-0 items-center justify-between border-b border-border-default bg-bg-surface px-4">
<div className="flex items-center">
<header className="pointer-events-none absolute inset-x-0 top-0 z-30 flex h-14 shrink-0 items-center justify-between px-3 pt-3">
<div className="pointer-events-auto flex items-center gap-1 rounded-2xl border border-border-default bg-bg-surface/95 px-1.5 py-1.5 shadow-xl backdrop-blur-md">
<button
type="button"
onClick={onToggleSidebar}
Expand All @@ -29,15 +29,15 @@ export function EditorNavbar({
)}
</button>
</div>
<div className="flex items-center" />
<div className="flex items-center">
<div className="pointer-events-auto flex items-center rounded-2xl border border-border-default bg-bg-surface/95 px-1.5 py-1.5 shadow-xl backdrop-blur-md">
<UserButton
appearance={{
elements: {
userButtonBox: "h-9 w-9",
userButtonTrigger:
"h-9 w-9 rounded-xl ring-1 ring-border-default hover:ring-border-subtle transition",
userButtonAvatarBox: "h-9 w-9 rounded-xl",
"h-9 w-9 rounded-full ring-2 ring-bg-base hover:ring-border-subtle transition-all overflow-hidden focus:outline-none focus:ring-2 focus:ring-accent-primary/50",
userButtonAvatarBox: "h-full w-full rounded-full",
userButtonAvatarImage: "h-full w-full object-cover",
},
}}
/>
Expand Down
28 changes: 14 additions & 14 deletions components/editor/editor-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,24 +43,24 @@ export function EditorShell({ initialOwned, initialShared }: EditorShellProps) {
const isDeleteOpen = dialog.kind === "delete";

return (
<div className="flex h-screen w-full flex-col overflow-hidden bg-bg-base text-text-primary">
<div className="relative h-screen w-full overflow-hidden bg-bg-base text-text-primary">
<main className="absolute inset-0 overflow-hidden">
<EditorHome onCreateProject={openCreate} />
</main>
<EditorNavbar
isSidebarOpen={isSidebarOpen}
onToggleSidebar={() => setIsSidebarOpen((open) => !open)}
/>
<main className="relative flex-1 overflow-hidden">
<ProjectSidebar
isOpen={isSidebarOpen}
onClose={() => setIsSidebarOpen(false)}
ownedProjects={ownedProjects}
sharedProjects={sharedProjects}
onCreateProject={openCreate}
onRenameProject={openRename}
onDeleteProject={openDelete}
onOpenProject={(projectId) => router.push(`/editor/${projectId}`)}
/>
<EditorHome onCreateProject={openCreate} />
</main>
<ProjectSidebar
isOpen={isSidebarOpen}
onClose={() => setIsSidebarOpen(false)}
ownedProjects={ownedProjects}
sharedProjects={sharedProjects}
onCreateProject={openCreate}
onRenameProject={openRename}
onDeleteProject={openDelete}
onOpenProject={(projectId) => router.push(`/editor/${projectId}`)}
/>

<CreateProjectDialog
open={isCreateOpen}
Expand Down
Loading