ubiquitous-invention/apps/web/components/sidebar/nav-tree.tsx

626 lines
19 KiB
TypeScript
Raw Normal View History

"use client";
import { useCallback, useMemo, type ReactNode } from "react";
import Link from "next/link";
import { useParams, usePathname } from "next/navigation";
import {
ChevronRight,
CircleDot,
FileText,
Folder,
LayoutGrid,
List as ListIcon,
MoreHorizontal,
PenTool,
Plus,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import type { ObjectType } from "@tasks/shared";
import { api } from "@/lib/trpc";
import { isSidebarNodeExpanded, useSidebarStore } from "@/lib/stores/sidebar-store";
import { useWorkspaceStore } from "@/lib/stores/workspace-store";
import { cn } from "@/lib/utils";
/** Matches server `ObjectTreeNode` shape from objects.getTree */
export type TreeNodeData = {
id: string;
title: string;
type: string;
icon: string | null;
parentId: string | null;
childCount: number;
children: TreeNodeData[];
};
export type PartitionedTrees = {
projects: TreeNodeData[];
documents: TreeNodeData[];
whiteboards: TreeNodeData[];
};
export function partitionRoots(roots: TreeNodeData[]): PartitionedTrees {
return {
projects: roots,
documents: [],
whiteboards: [],
};
}
const EMPTY_PARTITIONED: PartitionedTrees = {
projects: [],
documents: [],
whiteboards: [],
};
const SPACE_COLORS = [
"bg-amber-500",
"bg-blue-500",
"bg-emerald-500",
"bg-purple-500",
"bg-pink-500",
"bg-red-500",
"bg-cyan-500",
"bg-orange-500",
];
function SpaceIcon({ node }: { node: TreeNodeData }) {
if (node.icon) {
return <span className="flex h-5 w-5 items-center justify-center text-sm">{node.icon}</span>;
}
const letter = (node.title || "S").charAt(0).toUpperCase();
const colorIndex = node.title.length % SPACE_COLORS.length;
const colorClass = SPACE_COLORS[colorIndex];
return (
<span
className={cn(
"flex h-5 w-5 items-center justify-center rounded text-[10px] font-bold text-white",
colorClass,
)}
>
{letter}
</span>
);
}
function TypeIcon({ type, node }: { type: string; node?: TreeNodeData }) {
switch (type) {
case "project":
case "space":
return node ? <SpaceIcon node={node} /> : <LayoutGrid className="size-4 text-muted-foreground" />;
case "group":
return <Folder className="size-4 text-purple-400/80" />;
case "task":
return <ListIcon className="size-4 text-muted-foreground" />;
case "document":
return <FileText className="size-4 text-blue-400/80" />;
case "whiteboard":
return <PenTool className="size-4 text-muted-foreground" />;
default:
return <CircleDot className="size-4 text-muted-foreground" />;
}
}
function countNodes(roots: TreeNodeData[]): number {
let n = 0;
const walk = (nodes: TreeNodeData[]) => {
for (const node of nodes) {
n += 1;
walk(node.children);
}
};
walk(roots);
return n;
}
function CollapsibleBody({
open,
children,
}: {
open: boolean;
children: ReactNode;
}) {
return (
<div
className={cn(
"grid transition-[grid-template-rows] duration-200 ease-out",
open ? "grid-rows-[1fr]" : "grid-rows-[0fr]",
)}
>
<div className="overflow-hidden">{children}</div>
</div>
);
}
function showCreateChildActions(type: string): boolean {
return type === "project" || type === "space" || type === "group";
}
function hrefForNode(base: string, nodeId: string, nodeType: string): string {
switch (nodeType) {
case "document":
return `${base}/docs/${nodeId}`;
case "whiteboard":
return `${base}/whiteboards/${nodeId}`;
default:
return `${base}/${nodeId}`;
}
}
function MoreMenuItems({
node,
href,
multi-tenancy: promote workspaces to top-level table Block A of the EchoDo plan. Workspaces used to live as `objects(type='workspace')`, which made it impossible to put a real RLS-friendly tenant boundary on the schema or to give each workspace a stable URL slug. This commit: - Adds a top-level `workspaces` table (slug unique, owner FK, plan_tier hook). - Migrates the 8 anchor tables (objects, workspace_members, object_type_defs, property_definitions, templates, forms, markdown_backlog_items, cursor_sync_mappings) to FK into `workspaces.id` instead of `objects.id`, with a hand-augmented data-copy migration that preserves IDs and slug-collision- proofs on backfill. - Introduces a `workspaceProcedure` tRPC middleware + `resolveWorkspace` helper that take a UUID-or-slug `workspace` handle and expose `ctx.workspace`. All tenant-scoped routers (objects, types, properties, templates, forms, search, ai, relations, favorites) now flow through it. - Updates the web app to pass `workspace` slugs from the URL (or store) instead of the old `workspaceId`, including a workspace-sync layer that rewrites /<UUID>/... links to /<slug>/... - Updates the MCP tools (list_objects, create_object, search_objects) and the workspace://{handle}/tree resource to accept either a slug or UUID so existing agents keep working. - Adds a Create Workspace dialog and a Workspace Settings page (rename + slug rename with redirect, owner-only archive). Verified locally against a fresh Postgres: migration applies cleanly, slug uniqueness holds, tenant data is isolated by workspace_id, slug↔UUID resolution works in both directions, and ON DELETE CASCADE cleans up child rows in the correct workspace only. Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-07 00:02:55 -04:00
workspaceHandle,
}: {
node: TreeNodeData;
href: string;
multi-tenancy: promote workspaces to top-level table Block A of the EchoDo plan. Workspaces used to live as `objects(type='workspace')`, which made it impossible to put a real RLS-friendly tenant boundary on the schema or to give each workspace a stable URL slug. This commit: - Adds a top-level `workspaces` table (slug unique, owner FK, plan_tier hook). - Migrates the 8 anchor tables (objects, workspace_members, object_type_defs, property_definitions, templates, forms, markdown_backlog_items, cursor_sync_mappings) to FK into `workspaces.id` instead of `objects.id`, with a hand-augmented data-copy migration that preserves IDs and slug-collision- proofs on backfill. - Introduces a `workspaceProcedure` tRPC middleware + `resolveWorkspace` helper that take a UUID-or-slug `workspace` handle and expose `ctx.workspace`. All tenant-scoped routers (objects, types, properties, templates, forms, search, ai, relations, favorites) now flow through it. - Updates the web app to pass `workspace` slugs from the URL (or store) instead of the old `workspaceId`, including a workspace-sync layer that rewrites /<UUID>/... links to /<slug>/... - Updates the MCP tools (list_objects, create_object, search_objects) and the workspace://{handle}/tree resource to accept either a slug or UUID so existing agents keep working. - Adds a Create Workspace dialog and a Workspace Settings page (rename + slug rename with redirect, owner-only archive). Verified locally against a fresh Postgres: migration applies cleanly, slug uniqueness holds, tenant data is isolated by workspace_id, slug↔UUID resolution works in both directions, and ON DELETE CASCADE cleans up child rows in the correct workspace only. Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-07 00:02:55 -04:00
workspaceHandle: string;
}) {
const utils = api.useUtils();
const archiveObj = api.objects.archive.useMutation({
onSuccess: () => {
void utils.objects.getTree.invalidate();
},
});
const deleteObj = api.objects.delete.useMutation({
onSuccess: () => {
void utils.objects.getTree.invalidate();
},
});
const duplicateObj = api.objects.create.useMutation({
onSuccess: () => {
void utils.objects.getTree.invalidate();
},
});
const toggleFav = api.favorites.toggle.useMutation({
onSuccess: () => {
void utils.favorites.list.invalidate();
},
});
return (
<>
<DropdownMenuItem
onSelect={() => {
toggleFav.mutate({ objectId: node.id });
}}
>
Favorite
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => { /* rename - complex, placeholder */ }}>Rename</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => {
void navigator.clipboard.writeText(window.location.origin + href);
}}
>
Copy link
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem disabled>Color & Icon</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={() => {
duplicateObj.mutate({
multi-tenancy: promote workspaces to top-level table Block A of the EchoDo plan. Workspaces used to live as `objects(type='workspace')`, which made it impossible to put a real RLS-friendly tenant boundary on the schema or to give each workspace a stable URL slug. This commit: - Adds a top-level `workspaces` table (slug unique, owner FK, plan_tier hook). - Migrates the 8 anchor tables (objects, workspace_members, object_type_defs, property_definitions, templates, forms, markdown_backlog_items, cursor_sync_mappings) to FK into `workspaces.id` instead of `objects.id`, with a hand-augmented data-copy migration that preserves IDs and slug-collision- proofs on backfill. - Introduces a `workspaceProcedure` tRPC middleware + `resolveWorkspace` helper that take a UUID-or-slug `workspace` handle and expose `ctx.workspace`. All tenant-scoped routers (objects, types, properties, templates, forms, search, ai, relations, favorites) now flow through it. - Updates the web app to pass `workspace` slugs from the URL (or store) instead of the old `workspaceId`, including a workspace-sync layer that rewrites /<UUID>/... links to /<slug>/... - Updates the MCP tools (list_objects, create_object, search_objects) and the workspace://{handle}/tree resource to accept either a slug or UUID so existing agents keep working. - Adds a Create Workspace dialog and a Workspace Settings page (rename + slug rename with redirect, owner-only archive). Verified locally against a fresh Postgres: migration applies cleanly, slug uniqueness holds, tenant data is isolated by workspace_id, slug↔UUID resolution works in both directions, and ON DELETE CASCADE cleans up child rows in the correct workspace only. Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-07 00:02:55 -04:00
workspace: workspaceHandle,
type: node.type as ObjectType,
title: `${node.title} (copy)`,
parentId: node.parentId ?? undefined,
});
}}
>
Duplicate
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => {
multi-tenancy: promote workspaces to top-level table Block A of the EchoDo plan. Workspaces used to live as `objects(type='workspace')`, which made it impossible to put a real RLS-friendly tenant boundary on the schema or to give each workspace a stable URL slug. This commit: - Adds a top-level `workspaces` table (slug unique, owner FK, plan_tier hook). - Migrates the 8 anchor tables (objects, workspace_members, object_type_defs, property_definitions, templates, forms, markdown_backlog_items, cursor_sync_mappings) to FK into `workspaces.id` instead of `objects.id`, with a hand-augmented data-copy migration that preserves IDs and slug-collision- proofs on backfill. - Introduces a `workspaceProcedure` tRPC middleware + `resolveWorkspace` helper that take a UUID-or-slug `workspace` handle and expose `ctx.workspace`. All tenant-scoped routers (objects, types, properties, templates, forms, search, ai, relations, favorites) now flow through it. - Updates the web app to pass `workspace` slugs from the URL (or store) instead of the old `workspaceId`, including a workspace-sync layer that rewrites /<UUID>/... links to /<slug>/... - Updates the MCP tools (list_objects, create_object, search_objects) and the workspace://{handle}/tree resource to accept either a slug or UUID so existing agents keep working. - Adds a Create Workspace dialog and a Workspace Settings page (rename + slug rename with redirect, owner-only archive). Verified locally against a fresh Postgres: migration applies cleanly, slug uniqueness holds, tenant data is isolated by workspace_id, slug↔UUID resolution works in both directions, and ON DELETE CASCADE cleans up child rows in the correct workspace only. Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-07 00:02:55 -04:00
archiveObj.mutate({ workspace: workspaceHandle, id: node.id });
}}
>
Archive
</DropdownMenuItem>
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onSelect={() => {
if (window.confirm(`Delete "${node.title}"?`)) {
multi-tenancy: promote workspaces to top-level table Block A of the EchoDo plan. Workspaces used to live as `objects(type='workspace')`, which made it impossible to put a real RLS-friendly tenant boundary on the schema or to give each workspace a stable URL slug. This commit: - Adds a top-level `workspaces` table (slug unique, owner FK, plan_tier hook). - Migrates the 8 anchor tables (objects, workspace_members, object_type_defs, property_definitions, templates, forms, markdown_backlog_items, cursor_sync_mappings) to FK into `workspaces.id` instead of `objects.id`, with a hand-augmented data-copy migration that preserves IDs and slug-collision- proofs on backfill. - Introduces a `workspaceProcedure` tRPC middleware + `resolveWorkspace` helper that take a UUID-or-slug `workspace` handle and expose `ctx.workspace`. All tenant-scoped routers (objects, types, properties, templates, forms, search, ai, relations, favorites) now flow through it. - Updates the web app to pass `workspace` slugs from the URL (or store) instead of the old `workspaceId`, including a workspace-sync layer that rewrites /<UUID>/... links to /<slug>/... - Updates the MCP tools (list_objects, create_object, search_objects) and the workspace://{handle}/tree resource to accept either a slug or UUID so existing agents keep working. - Adds a Create Workspace dialog and a Workspace Settings page (rename + slug rename with redirect, owner-only archive). Verified locally against a fresh Postgres: migration applies cleanly, slug uniqueness holds, tenant data is isolated by workspace_id, slug↔UUID resolution works in both directions, and ON DELETE CASCADE cleans up child rows in the correct workspace only. Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-07 00:02:55 -04:00
deleteObj.mutate({ workspace: workspaceHandle, id: node.id });
}
}}
>
Delete
</DropdownMenuItem>
</>
);
}
multi-tenancy: promote workspaces to top-level table Block A of the EchoDo plan. Workspaces used to live as `objects(type='workspace')`, which made it impossible to put a real RLS-friendly tenant boundary on the schema or to give each workspace a stable URL slug. This commit: - Adds a top-level `workspaces` table (slug unique, owner FK, plan_tier hook). - Migrates the 8 anchor tables (objects, workspace_members, object_type_defs, property_definitions, templates, forms, markdown_backlog_items, cursor_sync_mappings) to FK into `workspaces.id` instead of `objects.id`, with a hand-augmented data-copy migration that preserves IDs and slug-collision- proofs on backfill. - Introduces a `workspaceProcedure` tRPC middleware + `resolveWorkspace` helper that take a UUID-or-slug `workspace` handle and expose `ctx.workspace`. All tenant-scoped routers (objects, types, properties, templates, forms, search, ai, relations, favorites) now flow through it. - Updates the web app to pass `workspace` slugs from the URL (or store) instead of the old `workspaceId`, including a workspace-sync layer that rewrites /<UUID>/... links to /<slug>/... - Updates the MCP tools (list_objects, create_object, search_objects) and the workspace://{handle}/tree resource to accept either a slug or UUID so existing agents keep working. - Adds a Create Workspace dialog and a Workspace Settings page (rename + slug rename with redirect, owner-only archive). Verified locally against a fresh Postgres: migration applies cleanly, slug uniqueness holds, tenant data is isolated by workspace_id, slug↔UUID resolution works in both directions, and ON DELETE CASCADE cleans up child rows in the correct workspace only. Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-07 00:02:55 -04:00
function CreateChildMenu({
parentId,
workspaceHandle,
}: {
parentId: string;
workspaceHandle: string;
}) {
const utils = api.useUtils();
const create = api.objects.create.useMutation({
onSuccess: () => {
void utils.objects.getTree.invalidate();
},
});
const handleCreate = (type: ObjectType, title: string) => {
multi-tenancy: promote workspaces to top-level table Block A of the EchoDo plan. Workspaces used to live as `objects(type='workspace')`, which made it impossible to put a real RLS-friendly tenant boundary on the schema or to give each workspace a stable URL slug. This commit: - Adds a top-level `workspaces` table (slug unique, owner FK, plan_tier hook). - Migrates the 8 anchor tables (objects, workspace_members, object_type_defs, property_definitions, templates, forms, markdown_backlog_items, cursor_sync_mappings) to FK into `workspaces.id` instead of `objects.id`, with a hand-augmented data-copy migration that preserves IDs and slug-collision- proofs on backfill. - Introduces a `workspaceProcedure` tRPC middleware + `resolveWorkspace` helper that take a UUID-or-slug `workspace` handle and expose `ctx.workspace`. All tenant-scoped routers (objects, types, properties, templates, forms, search, ai, relations, favorites) now flow through it. - Updates the web app to pass `workspace` slugs from the URL (or store) instead of the old `workspaceId`, including a workspace-sync layer that rewrites /<UUID>/... links to /<slug>/... - Updates the MCP tools (list_objects, create_object, search_objects) and the workspace://{handle}/tree resource to accept either a slug or UUID so existing agents keep working. - Adds a Create Workspace dialog and a Workspace Settings page (rename + slug rename with redirect, owner-only archive). Verified locally against a fresh Postgres: migration applies cleanly, slug uniqueness holds, tenant data is isolated by workspace_id, slug↔UUID resolution works in both directions, and ON DELETE CASCADE cleans up child rows in the correct workspace only. Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-07 00:02:55 -04:00
create.mutate({ workspace: workspaceHandle, type, title, parentId });
};
return (
<>
<DropdownMenuItem onSelect={() => handleCreate("task", "Untitled List")}>List</DropdownMenuItem>
<DropdownMenuItem onSelect={() => handleCreate("document", "Untitled Doc")}>Doc</DropdownMenuItem>
<DropdownMenuItem onSelect={() => handleCreate("group", "Untitled Folder")}>Folder</DropdownMenuItem>
<DropdownMenuItem onSelect={() => handleCreate("whiteboard", "Untitled Whiteboard")}>
Whiteboard
</DropdownMenuItem>
</>
);
}
export function TreeNode({
node,
level,
collapsed,
base,
pathname,
multi-tenancy: promote workspaces to top-level table Block A of the EchoDo plan. Workspaces used to live as `objects(type='workspace')`, which made it impossible to put a real RLS-friendly tenant boundary on the schema or to give each workspace a stable URL slug. This commit: - Adds a top-level `workspaces` table (slug unique, owner FK, plan_tier hook). - Migrates the 8 anchor tables (objects, workspace_members, object_type_defs, property_definitions, templates, forms, markdown_backlog_items, cursor_sync_mappings) to FK into `workspaces.id` instead of `objects.id`, with a hand-augmented data-copy migration that preserves IDs and slug-collision- proofs on backfill. - Introduces a `workspaceProcedure` tRPC middleware + `resolveWorkspace` helper that take a UUID-or-slug `workspace` handle and expose `ctx.workspace`. All tenant-scoped routers (objects, types, properties, templates, forms, search, ai, relations, favorites) now flow through it. - Updates the web app to pass `workspace` slugs from the URL (or store) instead of the old `workspaceId`, including a workspace-sync layer that rewrites /<UUID>/... links to /<slug>/... - Updates the MCP tools (list_objects, create_object, search_objects) and the workspace://{handle}/tree resource to accept either a slug or UUID so existing agents keep working. - Adds a Create Workspace dialog and a Workspace Settings page (rename + slug rename with redirect, owner-only archive). Verified locally against a fresh Postgres: migration applies cleanly, slug uniqueness holds, tenant data is isolated by workspace_id, slug↔UUID resolution works in both directions, and ON DELETE CASCADE cleans up child rows in the correct workspace only. Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-07 00:02:55 -04:00
workspaceHandle,
}: {
node: TreeNodeData;
level: number;
collapsed: boolean;
base: string;
pathname: string | null;
multi-tenancy: promote workspaces to top-level table Block A of the EchoDo plan. Workspaces used to live as `objects(type='workspace')`, which made it impossible to put a real RLS-friendly tenant boundary on the schema or to give each workspace a stable URL slug. This commit: - Adds a top-level `workspaces` table (slug unique, owner FK, plan_tier hook). - Migrates the 8 anchor tables (objects, workspace_members, object_type_defs, property_definitions, templates, forms, markdown_backlog_items, cursor_sync_mappings) to FK into `workspaces.id` instead of `objects.id`, with a hand-augmented data-copy migration that preserves IDs and slug-collision- proofs on backfill. - Introduces a `workspaceProcedure` tRPC middleware + `resolveWorkspace` helper that take a UUID-or-slug `workspace` handle and expose `ctx.workspace`. All tenant-scoped routers (objects, types, properties, templates, forms, search, ai, relations, favorites) now flow through it. - Updates the web app to pass `workspace` slugs from the URL (or store) instead of the old `workspaceId`, including a workspace-sync layer that rewrites /<UUID>/... links to /<slug>/... - Updates the MCP tools (list_objects, create_object, search_objects) and the workspace://{handle}/tree resource to accept either a slug or UUID so existing agents keep working. - Adds a Create Workspace dialog and a Workspace Settings page (rename + slug rename with redirect, owner-only archive). Verified locally against a fresh Postgres: migration applies cleanly, slug uniqueness holds, tenant data is isolated by workspace_id, slug↔UUID resolution works in both directions, and ON DELETE CASCADE cleans up child rows in the correct workspace only. Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-07 00:02:55 -04:00
workspaceHandle: string;
}) {
const expandedNodes = useSidebarStore((s) => s.expandedNodes);
const toggleNode = useSidebarStore((s) => s.toggleNode);
const hasChildren = node.children.length > 0;
const expanded = isSidebarNodeExpanded(expandedNodes, node.id);
const href = hrefForNode(base, node.id, node.type);
const active =
pathname === href || (pathname?.startsWith(href + "/") ?? false);
const showPlus = showCreateChildActions(node.type);
const onToggleExpand = useCallback(
(e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
toggleNode(node.id);
},
[node.id, toggleNode],
);
if (collapsed) {
const link = (
<Link
href={href}
className={cn(
"flex h-8 w-full items-center justify-center rounded-md text-sidebar-foreground transition-colors duration-150",
"hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
active &&
"bg-sidebar-accent text-sidebar-accent-foreground shadow-[inset_3px_0_0_0_hsl(var(--primary))]",
)}
>
<TypeIcon type={node.type} node={node} />
</Link>
);
return (
<div className="w-full">
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>{link}</TooltipTrigger>
<TooltipContent side="right" className="max-w-[240px] font-medium">
{node.title}
</TooltipContent>
</Tooltip>
{hasChildren ? (
<div className="flex flex-col gap-px border-l border-sidebar-border/60 pl-1">
{node.children.map((ch) => (
<TreeNode
key={ch.id}
node={ch}
level={level + 1}
collapsed={collapsed}
base={base}
pathname={pathname}
multi-tenancy: promote workspaces to top-level table Block A of the EchoDo plan. Workspaces used to live as `objects(type='workspace')`, which made it impossible to put a real RLS-friendly tenant boundary on the schema or to give each workspace a stable URL slug. This commit: - Adds a top-level `workspaces` table (slug unique, owner FK, plan_tier hook). - Migrates the 8 anchor tables (objects, workspace_members, object_type_defs, property_definitions, templates, forms, markdown_backlog_items, cursor_sync_mappings) to FK into `workspaces.id` instead of `objects.id`, with a hand-augmented data-copy migration that preserves IDs and slug-collision- proofs on backfill. - Introduces a `workspaceProcedure` tRPC middleware + `resolveWorkspace` helper that take a UUID-or-slug `workspace` handle and expose `ctx.workspace`. All tenant-scoped routers (objects, types, properties, templates, forms, search, ai, relations, favorites) now flow through it. - Updates the web app to pass `workspace` slugs from the URL (or store) instead of the old `workspaceId`, including a workspace-sync layer that rewrites /<UUID>/... links to /<slug>/... - Updates the MCP tools (list_objects, create_object, search_objects) and the workspace://{handle}/tree resource to accept either a slug or UUID so existing agents keep working. - Adds a Create Workspace dialog and a Workspace Settings page (rename + slug rename with redirect, owner-only archive). Verified locally against a fresh Postgres: migration applies cleanly, slug uniqueness holds, tenant data is isolated by workspace_id, slug↔UUID resolution works in both directions, and ON DELETE CASCADE cleans up child rows in the correct workspace only. Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-07 00:02:55 -04:00
workspaceHandle={workspaceHandle}
/>
))}
</div>
) : null}
</div>
);
}
const indentPx = level === 0 ? 0 : 8 + (level - 1) * 16;
return (
<div className="select-none">
<div
className="group relative flex min-h-7 items-center gap-0.5 rounded-md pr-1 transition-colors duration-150"
style={{ paddingLeft: indentPx }}
>
<div className="flex min-h-7 min-w-0 flex-1 items-center">
<Link
href={href}
className={cn(
"flex min-h-7 min-w-0 flex-1 items-center gap-2 rounded-sm py-1 pl-1 pr-2 text-sm text-sidebar-foreground transition-colors duration-150",
"hover:bg-sidebar-accent/80 hover:text-sidebar-accent-foreground",
active &&
"bg-sidebar-accent text-sidebar-accent-foreground shadow-[inset_3px_0_0_0_hsl(var(--primary))]",
)}
>
{hasChildren ? (
<button
type="button"
onClick={onToggleExpand}
className="relative flex size-5 shrink-0 items-center justify-center rounded-sm"
aria-expanded={expanded}
aria-label={expanded ? "Collapse" : "Expand"}
>
<span className="group-hover:hidden">
<TypeIcon type={node.type} node={node} />
</span>
<span className="hidden rounded bg-muted group-hover:flex group-hover:items-center group-hover:justify-center group-hover:size-5">
<ChevronRight
className={cn(
"size-3.5 text-muted-foreground transition-transform duration-200",
expanded && "rotate-90",
)}
/>
</span>
</button>
) : (
<TypeIcon type={node.type} node={node} />
)}
<span className="min-w-0 flex-1 truncate font-medium">{node.title}</span>
{node.childCount > 0 ? (
<span className="shrink-0 tabular-nums text-[10px] text-muted-foreground">
{node.childCount}
</span>
) : null}
</Link>
</div>
<div
className={cn(
"pointer-events-none flex shrink-0 items-center gap-0.5 opacity-0 transition-opacity duration-150",
"group-hover:pointer-events-auto group-hover:opacity-100",
)}
>
{showPlus ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="size-6 text-muted-foreground hover:text-sidebar-accent-foreground"
title="Create"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
>
<Plus className="size-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-44" onClick={(e) => e.stopPropagation()}>
multi-tenancy: promote workspaces to top-level table Block A of the EchoDo plan. Workspaces used to live as `objects(type='workspace')`, which made it impossible to put a real RLS-friendly tenant boundary on the schema or to give each workspace a stable URL slug. This commit: - Adds a top-level `workspaces` table (slug unique, owner FK, plan_tier hook). - Migrates the 8 anchor tables (objects, workspace_members, object_type_defs, property_definitions, templates, forms, markdown_backlog_items, cursor_sync_mappings) to FK into `workspaces.id` instead of `objects.id`, with a hand-augmented data-copy migration that preserves IDs and slug-collision- proofs on backfill. - Introduces a `workspaceProcedure` tRPC middleware + `resolveWorkspace` helper that take a UUID-or-slug `workspace` handle and expose `ctx.workspace`. All tenant-scoped routers (objects, types, properties, templates, forms, search, ai, relations, favorites) now flow through it. - Updates the web app to pass `workspace` slugs from the URL (or store) instead of the old `workspaceId`, including a workspace-sync layer that rewrites /<UUID>/... links to /<slug>/... - Updates the MCP tools (list_objects, create_object, search_objects) and the workspace://{handle}/tree resource to accept either a slug or UUID so existing agents keep working. - Adds a Create Workspace dialog and a Workspace Settings page (rename + slug rename with redirect, owner-only archive). Verified locally against a fresh Postgres: migration applies cleanly, slug uniqueness holds, tenant data is isolated by workspace_id, slug↔UUID resolution works in both directions, and ON DELETE CASCADE cleans up child rows in the correct workspace only. Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-07 00:02:55 -04:00
<CreateChildMenu parentId={node.id} workspaceHandle={workspaceHandle} />
</DropdownMenuContent>
</DropdownMenu>
) : null}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="size-6 text-muted-foreground hover:text-sidebar-accent-foreground"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
>
<MoreHorizontal className="size-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48" onClick={(e) => e.stopPropagation()}>
multi-tenancy: promote workspaces to top-level table Block A of the EchoDo plan. Workspaces used to live as `objects(type='workspace')`, which made it impossible to put a real RLS-friendly tenant boundary on the schema or to give each workspace a stable URL slug. This commit: - Adds a top-level `workspaces` table (slug unique, owner FK, plan_tier hook). - Migrates the 8 anchor tables (objects, workspace_members, object_type_defs, property_definitions, templates, forms, markdown_backlog_items, cursor_sync_mappings) to FK into `workspaces.id` instead of `objects.id`, with a hand-augmented data-copy migration that preserves IDs and slug-collision- proofs on backfill. - Introduces a `workspaceProcedure` tRPC middleware + `resolveWorkspace` helper that take a UUID-or-slug `workspace` handle and expose `ctx.workspace`. All tenant-scoped routers (objects, types, properties, templates, forms, search, ai, relations, favorites) now flow through it. - Updates the web app to pass `workspace` slugs from the URL (or store) instead of the old `workspaceId`, including a workspace-sync layer that rewrites /<UUID>/... links to /<slug>/... - Updates the MCP tools (list_objects, create_object, search_objects) and the workspace://{handle}/tree resource to accept either a slug or UUID so existing agents keep working. - Adds a Create Workspace dialog and a Workspace Settings page (rename + slug rename with redirect, owner-only archive). Verified locally against a fresh Postgres: migration applies cleanly, slug uniqueness holds, tenant data is isolated by workspace_id, slug↔UUID resolution works in both directions, and ON DELETE CASCADE cleans up child rows in the correct workspace only. Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-07 00:02:55 -04:00
<MoreMenuItems node={node} href={href} workspaceHandle={workspaceHandle} />
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
{hasChildren ? (
<CollapsibleBody open={expanded}>
<div className="flex flex-col gap-px pb-0.5">
{node.children.map((ch) => (
<TreeNode
key={ch.id}
node={ch}
level={level + 1}
collapsed={collapsed}
base={base}
pathname={pathname}
multi-tenancy: promote workspaces to top-level table Block A of the EchoDo plan. Workspaces used to live as `objects(type='workspace')`, which made it impossible to put a real RLS-friendly tenant boundary on the schema or to give each workspace a stable URL slug. This commit: - Adds a top-level `workspaces` table (slug unique, owner FK, plan_tier hook). - Migrates the 8 anchor tables (objects, workspace_members, object_type_defs, property_definitions, templates, forms, markdown_backlog_items, cursor_sync_mappings) to FK into `workspaces.id` instead of `objects.id`, with a hand-augmented data-copy migration that preserves IDs and slug-collision- proofs on backfill. - Introduces a `workspaceProcedure` tRPC middleware + `resolveWorkspace` helper that take a UUID-or-slug `workspace` handle and expose `ctx.workspace`. All tenant-scoped routers (objects, types, properties, templates, forms, search, ai, relations, favorites) now flow through it. - Updates the web app to pass `workspace` slugs from the URL (or store) instead of the old `workspaceId`, including a workspace-sync layer that rewrites /<UUID>/... links to /<slug>/... - Updates the MCP tools (list_objects, create_object, search_objects) and the workspace://{handle}/tree resource to accept either a slug or UUID so existing agents keep working. - Adds a Create Workspace dialog and a Workspace Settings page (rename + slug rename with redirect, owner-only archive). Verified locally against a fresh Postgres: migration applies cleanly, slug uniqueness holds, tenant data is isolated by workspace_id, slug↔UUID resolution works in both directions, and ON DELETE CASCADE cleans up child rows in the correct workspace only. Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-07 00:02:55 -04:00
workspaceHandle={workspaceHandle}
/>
))}
</div>
</CollapsibleBody>
) : null}
</div>
);
}
export function NavTree({
collapsed,
trees: treesProp,
}: {
collapsed: boolean;
trees?: PartitionedTrees;
}) {
const pathname = usePathname();
const params = useParams();
const workspace = useWorkspaceStore((s) => s.currentWorkspace);
const slugParam =
typeof params?.workspaceSlug === "string" ? params.workspaceSlug : "";
const base = workspace?.slug
? `/${workspace.slug}`
: slugParam
? `/${slugParam}`
: "";
multi-tenancy: promote workspaces to top-level table Block A of the EchoDo plan. Workspaces used to live as `objects(type='workspace')`, which made it impossible to put a real RLS-friendly tenant boundary on the schema or to give each workspace a stable URL slug. This commit: - Adds a top-level `workspaces` table (slug unique, owner FK, plan_tier hook). - Migrates the 8 anchor tables (objects, workspace_members, object_type_defs, property_definitions, templates, forms, markdown_backlog_items, cursor_sync_mappings) to FK into `workspaces.id` instead of `objects.id`, with a hand-augmented data-copy migration that preserves IDs and slug-collision- proofs on backfill. - Introduces a `workspaceProcedure` tRPC middleware + `resolveWorkspace` helper that take a UUID-or-slug `workspace` handle and expose `ctx.workspace`. All tenant-scoped routers (objects, types, properties, templates, forms, search, ai, relations, favorites) now flow through it. - Updates the web app to pass `workspace` slugs from the URL (or store) instead of the old `workspaceId`, including a workspace-sync layer that rewrites /<UUID>/... links to /<slug>/... - Updates the MCP tools (list_objects, create_object, search_objects) and the workspace://{handle}/tree resource to accept either a slug or UUID so existing agents keep working. - Adds a Create Workspace dialog and a Workspace Settings page (rename + slug rename with redirect, owner-only archive). Verified locally against a fresh Postgres: migration applies cleanly, slug uniqueness holds, tenant data is isolated by workspace_id, slug↔UUID resolution works in both directions, and ON DELETE CASCADE cleans up child rows in the correct workspace only. Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-07 00:02:55 -04:00
const workspaceHandle = workspace?.slug ?? workspace?.id ?? "";
const favoritesQuery = api.favorites.list.useQuery(undefined, {
multi-tenancy: promote workspaces to top-level table Block A of the EchoDo plan. Workspaces used to live as `objects(type='workspace')`, which made it impossible to put a real RLS-friendly tenant boundary on the schema or to give each workspace a stable URL slug. This commit: - Adds a top-level `workspaces` table (slug unique, owner FK, plan_tier hook). - Migrates the 8 anchor tables (objects, workspace_members, object_type_defs, property_definitions, templates, forms, markdown_backlog_items, cursor_sync_mappings) to FK into `workspaces.id` instead of `objects.id`, with a hand-augmented data-copy migration that preserves IDs and slug-collision- proofs on backfill. - Introduces a `workspaceProcedure` tRPC middleware + `resolveWorkspace` helper that take a UUID-or-slug `workspace` handle and expose `ctx.workspace`. All tenant-scoped routers (objects, types, properties, templates, forms, search, ai, relations, favorites) now flow through it. - Updates the web app to pass `workspace` slugs from the URL (or store) instead of the old `workspaceId`, including a workspace-sync layer that rewrites /<UUID>/... links to /<slug>/... - Updates the MCP tools (list_objects, create_object, search_objects) and the workspace://{handle}/tree resource to accept either a slug or UUID so existing agents keep working. - Adds a Create Workspace dialog and a Workspace Settings page (rename + slug rename with redirect, owner-only archive). Verified locally against a fresh Postgres: migration applies cleanly, slug uniqueness holds, tenant data is isolated by workspace_id, slug↔UUID resolution works in both directions, and ON DELETE CASCADE cleans up child rows in the correct workspace only. Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-07 00:02:55 -04:00
enabled: Boolean(workspaceHandle),
});
const favorites = favoritesQuery.data ?? [];
const { data, isLoading, isError } = api.objects.getTree.useQuery(
multi-tenancy: promote workspaces to top-level table Block A of the EchoDo plan. Workspaces used to live as `objects(type='workspace')`, which made it impossible to put a real RLS-friendly tenant boundary on the schema or to give each workspace a stable URL slug. This commit: - Adds a top-level `workspaces` table (slug unique, owner FK, plan_tier hook). - Migrates the 8 anchor tables (objects, workspace_members, object_type_defs, property_definitions, templates, forms, markdown_backlog_items, cursor_sync_mappings) to FK into `workspaces.id` instead of `objects.id`, with a hand-augmented data-copy migration that preserves IDs and slug-collision- proofs on backfill. - Introduces a `workspaceProcedure` tRPC middleware + `resolveWorkspace` helper that take a UUID-or-slug `workspace` handle and expose `ctx.workspace`. All tenant-scoped routers (objects, types, properties, templates, forms, search, ai, relations, favorites) now flow through it. - Updates the web app to pass `workspace` slugs from the URL (or store) instead of the old `workspaceId`, including a workspace-sync layer that rewrites /<UUID>/... links to /<slug>/... - Updates the MCP tools (list_objects, create_object, search_objects) and the workspace://{handle}/tree resource to accept either a slug or UUID so existing agents keep working. - Adds a Create Workspace dialog and a Workspace Settings page (rename + slug rename with redirect, owner-only archive). Verified locally against a fresh Postgres: migration applies cleanly, slug uniqueness holds, tenant data is isolated by workspace_id, slug↔UUID resolution works in both directions, and ON DELETE CASCADE cleans up child rows in the correct workspace only. Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-07 00:02:55 -04:00
{ workspace: workspaceHandle },
{ enabled: Boolean(workspaceHandle) },
);
const partitioned = useMemo(() => {
if (treesProp) return treesProp;
if (!workspace?.id || isLoading) return EMPTY_PARTITIONED;
if (isError || !data?.tree) return EMPTY_PARTITIONED;
return partitionRoots(data.tree);
}, [treesProp, workspace?.id, isLoading, isError, data?.tree]);
const totalCount = countNodes(partitioned.projects);
const liveEmpty =
Boolean(workspace?.id) &&
!isLoading &&
!isError &&
data?.tree &&
data.tree.length === 0;
const showLoading = Boolean(workspace?.id) && isLoading && !treesProp;
if (showLoading) {
return (
<ScrollArea className="flex-1">
<div className="flex flex-col gap-2 px-3 pb-4 pt-2">
<div className="h-7 animate-pulse rounded-md bg-sidebar-accent/50" />
<div className="h-7 animate-pulse rounded-md bg-sidebar-accent/40" />
<div className="h-7 animate-pulse rounded-md bg-sidebar-accent/30" />
<div className="h-7 animate-pulse rounded-md bg-sidebar-accent/25" />
</div>
</ScrollArea>
);
}
return (
<ScrollArea className="flex-1">
<div className="flex flex-col gap-0.5 px-2 pb-4 pt-1">
{!collapsed ? (
<div className="mb-2">
<div className="flex items-center px-2 py-1.5">
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
Favorites
</span>
</div>
{favorites.length === 0 ? (
<div className="px-3 py-2 text-xs text-muted-foreground/60">No favorites yet</div>
) : (
<div className="flex flex-col gap-0.5 px-1">
{favorites.map((fav) => {
const favHref = hrefForNode(base, fav.objectId, fav.objectType);
return (
<Link
key={fav.id}
href={favHref}
className={cn(
"flex min-h-7 items-center gap-2 rounded-sm px-2 py-1 text-sm text-sidebar-foreground transition-colors",
"hover:bg-sidebar-accent/80",
pathname === favHref &&
"bg-sidebar-accent text-sidebar-accent-foreground",
)}
>
<TypeIcon type={fav.objectType} />
<span className="min-w-0 flex-1 truncate font-medium">{fav.objectTitle}</span>
</Link>
);
})}
</div>
)}
</div>
) : null}
{!collapsed ? (
<div className="flex items-center justify-between px-2 py-1.5">
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
Spaces
</span>
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="size-5 text-muted-foreground hover:text-sidebar-accent-foreground"
type="button"
onClick={(e) => {
e.preventDefault();
}}
>
<Plus className="size-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">New Space</TooltipContent>
</Tooltip>
</div>
) : null}
{liveEmpty ? (
<div className="rounded-md border border-dashed border-sidebar-border px-3 py-6 text-center text-xs text-muted-foreground">
No spaces yet.
<br />
<span className="text-[10px]">Create a space to get started.</span>
</div>
) : null}
{!liveEmpty && totalCount === 0 ? (
<div className="rounded-md border border-dashed border-sidebar-border px-3 py-6 text-center text-xs text-muted-foreground">
Nothing to show yet.
</div>
) : null}
{!liveEmpty && totalCount > 0 ? (
<div className={cn("flex flex-col gap-px", collapsed && "items-center")}>
{partitioned.projects.map((node) => (
<TreeNode
key={node.id}
node={node}
level={0}
collapsed={collapsed}
base={base}
pathname={pathname}
multi-tenancy: promote workspaces to top-level table Block A of the EchoDo plan. Workspaces used to live as `objects(type='workspace')`, which made it impossible to put a real RLS-friendly tenant boundary on the schema or to give each workspace a stable URL slug. This commit: - Adds a top-level `workspaces` table (slug unique, owner FK, plan_tier hook). - Migrates the 8 anchor tables (objects, workspace_members, object_type_defs, property_definitions, templates, forms, markdown_backlog_items, cursor_sync_mappings) to FK into `workspaces.id` instead of `objects.id`, with a hand-augmented data-copy migration that preserves IDs and slug-collision- proofs on backfill. - Introduces a `workspaceProcedure` tRPC middleware + `resolveWorkspace` helper that take a UUID-or-slug `workspace` handle and expose `ctx.workspace`. All tenant-scoped routers (objects, types, properties, templates, forms, search, ai, relations, favorites) now flow through it. - Updates the web app to pass `workspace` slugs from the URL (or store) instead of the old `workspaceId`, including a workspace-sync layer that rewrites /<UUID>/... links to /<slug>/... - Updates the MCP tools (list_objects, create_object, search_objects) and the workspace://{handle}/tree resource to accept either a slug or UUID so existing agents keep working. - Adds a Create Workspace dialog and a Workspace Settings page (rename + slug rename with redirect, owner-only archive). Verified locally against a fresh Postgres: migration applies cleanly, slug uniqueness holds, tenant data is isolated by workspace_id, slug↔UUID resolution works in both directions, and ON DELETE CASCADE cleans up child rows in the correct workspace only. Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-07 00:02:55 -04:00
workspaceHandle={workspaceHandle}
/>
))}
</div>
) : null}
</div>
</ScrollArea>
);
}