feat(editor): hierarchical function-tree Items browse (#345)

* feat(editor): hierarchical function-tree Items browse

Add an optional DB-driven taxonomy browse to the Items panel. When the
embedder supplies a `functionTree`, the panel renders roots as category
tabs and child tags as a secondary chip row, filtering items by the
selected node and any descendant slug; otherwise it falls back to the
legacy hardcoded category path untouched.

Adds `AssetInput.functionTags` so items can carry their function-axis
tag slugs, and exports `FunctionTreeNode` for embedders to type the tree.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* chore(editor): sync bun.lock (@pascal-app/mcp 0.3.0)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Wassim SAMAD
2026-05-28 14:47:40 -04:00
committed by GitHub
co-authored by Claude Opus 4.7
parent 8a5f08b842
commit a144502c04
5 changed files with 293 additions and 1 deletions
+1 -1
View File
@@ -211,7 +211,7 @@
}, },
"packages/mcp": { "packages/mcp": {
"name": "@pascal-app/mcp", "name": "@pascal-app/mcp",
"version": "0.2.0", "version": "0.3.0",
"bin": { "bin": {
"pascal-mcp": "./dist/bin/pascal-mcp.js", "pascal-mcp": "./dist/bin/pascal-mcp.js",
}, },
+4
View File
@@ -99,6 +99,10 @@ const assetSchema = z.object({
dimensions: z.tuple([z.number(), z.number(), z.number()]).default([1, 1, 1]), // [w, h, d] dimensions: z.tuple([z.number(), z.number(), z.number()]).default([1, 1, 1]), // [w, h, d]
attachTo: z.enum(['wall', 'wall-side', 'ceiling']).optional(), attachTo: z.enum(['wall', 'wall-side', 'ceiling']).optional(),
tags: z.array(z.string()).optional(), tags: z.array(z.string()).optional(),
// Function-axis tag slugs from the taxonomy. Drives the hierarchical
// Items-tab browse: a tree node matches when any of its descendant slugs
// appears here. Absent for the seeded built-in catalog.
functionTags: z.array(z.string()).optional(),
// These are "Corrective" transforms to normalize the GLB // These are "Corrective" transforms to normalize the GLB
offset: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), offset: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
rotation: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), rotation: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
@@ -0,0 +1,243 @@
'use client'
import type { AssetInput } from '@pascal-app/core'
import NextImage from 'next/image'
import { useMemo, useState } from 'react'
import { cn } from '../../../../../lib/utils'
import { ItemCatalog } from '../../../item-catalog/item-catalog'
/** A function-axis taxonomy node, assembled into a tree by the embedder. */
export type FunctionTreeNode = {
slug: string
name: string
iconUrl?: string | null
children: FunctionTreeNode[]
}
const SOURCE_CHIPS: Array<{ id: NonNullable<AssetInput['source']>; label: string }> = [
{ id: 'library', label: 'Library' },
{ id: 'community', label: 'Community' },
{ id: 'mine', label: 'Mine' },
]
/** Every slug at or below `node`, so a non-leaf selection matches descendants. */
function descendantSlugs(node: FunctionTreeNode): Set<string> {
const out = new Set<string>()
const walk = (n: FunctionTreeNode) => {
out.add(n.slug)
for (const child of n.children) walk(child)
}
walk(node)
return out
}
function itemFunctionSlugs(item: AssetInput): string[] {
if (item.functionTags && item.functionTags.length > 0) return item.functionTags
return item.category ? [item.category] : []
}
/**
* DB-driven hierarchical Items browse. Roots render as the category tab bar;
* a selected root with children exposes those children as a secondary chip
* row. Selecting any node shows items tagged with that node or any descendant.
* Library / Community / Mine narrows by source on top of the tree selection.
*/
export function FunctionTreePanel({
functionTree,
items,
onSearchChange,
searchResults,
leadingTile,
emptyState,
}: {
functionTree: FunctionTreeNode[]
items?: AssetInput[]
onSearchChange?: (query: string) => void
searchResults?: AssetInput[] | null
leadingTile?: React.ReactNode
emptyState?: React.ReactNode
}) {
const [activeRootSlug, setActiveRootSlug] = useState<string | null>(
functionTree[0]?.slug ?? null,
)
const [activeChildSlug, setActiveChildSlug] = useState<string | null>(null)
const [activeSource, setActiveSource] = useState<AssetInput['source'] | null>('library')
const [search, setSearch] = useState('')
const isServerSearch = onSearchChange !== undefined
const isSearchPending = isServerSearch && search.length > 0 && searchResults === null
const activeRoot = functionTree.find((n) => n.slug === activeRootSlug) ?? functionTree[0]
const activeNode =
(activeChildSlug && activeRoot?.children.find((c) => c.slug === activeChildSlug)) || activeRoot
const matchesSource = (item: AssetInput) => {
if (!activeSource) return true
const itemSource = item.source ?? 'library'
if (activeSource === 'mine') return itemSource === 'mine'
if (activeSource === 'library') return itemSource === 'library'
if (activeSource === 'community') {
if (itemSource === 'community') return true
if (itemSource === 'mine') return !item.isDraft
return false
}
return true
}
const treeItems = useMemo(() => {
const base = items ?? []
if (!activeNode) return base.filter(matchesSource)
const slugs = descendantSlugs(activeNode)
return base.filter(
(item) => matchesSource(item) && itemFunctionSlugs(item).some((s) => slugs.has(s)),
)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [items, activeNode, activeSource])
const searchItems = useMemo(() => {
if (!(isServerSearch && search && searchResults)) return null
return activeSource ? searchResults.filter(matchesSource) : searchResults
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isServerSearch, search, searchResults, activeSource])
function selectRoot(slug: string) {
setActiveRootSlug(slug)
setActiveChildSlug(null)
setSearch('')
onSearchChange?.('')
}
return (
<div className="flex h-full flex-col">
{/* Root nodes as category tabs */}
<div className="flex shrink-0 gap-1 overflow-x-auto border-border/70 border-b p-2">
{functionTree.map((root) => {
const isActive = activeRoot?.slug === root.slug
return (
<button
className={cn(
'flex shrink-0 flex-col items-center gap-1 rounded-xl px-3 py-2 transition-colors',
isActive
? 'bg-sidebar-accent text-sidebar-accent-foreground'
: 'text-muted-foreground hover:bg-sidebar-accent/50 hover:text-foreground',
)}
key={root.slug}
onClick={() => selectRoot(root.slug)}
type="button"
>
{root.iconUrl ? (
<NextImage
alt={root.name}
className={cn('size-7 object-contain', !isActive && 'opacity-60 grayscale')}
height={28}
src={root.iconUrl}
width={28}
/>
) : (
<div className="flex size-7 items-center justify-center rounded-lg bg-muted/50 font-semibold text-[11px] uppercase">
{root.name.slice(0, 2)}
</div>
)}
<span className="font-medium text-[10px] leading-none">{root.name}</span>
</button>
)
})}
</div>
{/* Search + source filter */}
<div className="flex shrink-0 flex-col gap-2 border-border/70 border-b p-2">
<div className="flex items-center gap-1.5">
<input
className="w-1/2 min-w-0 shrink-0 rounded-lg bg-muted px-2.5 py-1.5 text-xs placeholder:text-muted-foreground focus:outline-none"
onChange={(e) => {
setSearch(e.target.value)
onSearchChange?.(e.target.value)
}}
placeholder="Search..."
type="text"
value={search}
/>
<div className="flex w-1/2 min-w-0 shrink-0 rounded-lg bg-muted p-0.5">
{SOURCE_CHIPS.map((chip) => {
const isActive = activeSource === chip.id
return (
<button
className={cn(
'min-w-0 flex-1 truncate rounded-md px-1 py-1 text-center font-medium text-[10px] transition-colors',
isActive
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground',
)}
key={chip.id}
onClick={() => setActiveSource(isActive ? null : chip.id)}
type="button"
>
{chip.label}
</button>
)
})}
</div>
</div>
{/* Child nodes of the active root as a secondary chip row */}
{!search && activeRoot && activeRoot.children.length > 0 && (
<div className="flex flex-wrap gap-1">
<button
className={cn(
'cursor-pointer rounded-md px-2 py-0.5 font-medium text-xs transition-colors',
activeChildSlug === null
? 'bg-violet-500 text-white'
: 'bg-muted text-muted-foreground hover:bg-muted/80 hover:text-foreground',
)}
onClick={() => setActiveChildSlug(null)}
type="button"
>
All
</button>
{activeRoot.children.map((child) => {
const isActive = activeChildSlug === child.slug
return (
<button
className={cn(
'cursor-pointer rounded-md px-2 py-0.5 font-medium text-xs capitalize transition-colors',
isActive
? 'bg-violet-500 text-white'
: 'bg-muted text-muted-foreground hover:bg-muted/80 hover:text-foreground',
)}
key={child.slug}
onClick={() => setActiveChildSlug(isActive ? null : child.slug)}
type="button"
>
{child.name}
</button>
)
})}
</div>
)}
</div>
{/* Item grid */}
<div className="min-h-0 flex-1 overflow-y-auto p-3">
{isSearchPending ? (
<div className="flex h-full items-center justify-center">
<div className="size-5 animate-spin rounded-full border-2 border-muted-foreground/20 border-t-muted-foreground" />
</div>
) : isServerSearch && search && searchResults?.length === 0 ? (
(emptyState ?? (
<div className="flex h-full items-center justify-center text-muted-foreground text-xs">
No results for &ldquo;{search}&rdquo;
</div>
))
) : (
<ItemCatalog
category={'furnish' as never}
emptyState={emptyState}
key={activeNode?.slug ?? 'all'}
leadingTile={leadingTile}
overrideItems={isServerSearch && search ? (searchItems ?? undefined) : treeItems}
/>
)}
</div>
</div>
)
}
@@ -9,6 +9,7 @@ import useEditor from '../../../../../store/use-editor'
import { furnishTools } from '../../../action-menu/furnish-tools' import { furnishTools } from '../../../action-menu/furnish-tools'
import { CATALOG_ITEMS } from '../../../item-catalog/catalog-items' import { CATALOG_ITEMS } from '../../../item-catalog/catalog-items'
import { ItemCatalog } from '../../../item-catalog/item-catalog' import { ItemCatalog } from '../../../item-catalog/item-catalog'
import { type FunctionTreeNode, FunctionTreePanel } from './function-tree-panel'
const PLACEMENT_TAGS = new Set(['floor', 'wall', 'ceiling', 'countertop']) const PLACEMENT_TAGS = new Set(['floor', 'wall', 'ceiling', 'countertop'])
@@ -18,6 +19,7 @@ export function ItemsPanel({
searchResults, searchResults,
leadingTile, leadingTile,
emptyState, emptyState,
functionTree,
}: { }: {
items?: AssetInput[] items?: AssetInput[]
/** Called when the search query changes (community edition uses this for server-side search) */ /** Called when the search query changes (community edition uses this for server-side search) */
@@ -34,6 +36,48 @@ export function ItemsPanel({
* or no search results). Replaces the default "No results" message. * or no search results). Replaces the default "No results" message.
*/ */
emptyState?: React.ReactNode emptyState?: React.ReactNode
/**
* DB-driven function taxonomy. When provided, the panel renders the
* hierarchical tree browse instead of the legacy hardcoded category tabs.
*/
functionTree?: FunctionTreeNode[]
}) {
// When the embedder supplies a function taxonomy, the hierarchical browse
// replaces the legacy `furnishTools` category tabs entirely.
if (functionTree && functionTree.length > 0) {
return (
<FunctionTreePanel
emptyState={emptyState}
functionTree={functionTree}
items={items}
leadingTile={leadingTile}
onSearchChange={onSearchChange}
searchResults={searchResults}
/>
)
}
return <LegacyItemsPanel
emptyState={emptyState}
items={items}
leadingTile={leadingTile}
onSearchChange={onSearchChange}
searchResults={searchResults}
/>
}
function LegacyItemsPanel({
items,
onSearchChange,
searchResults,
leadingTile,
emptyState,
}: {
items?: AssetInput[]
onSearchChange?: (query: string) => void
searchResults?: AssetInput[] | null
leadingTile?: React.ReactNode
emptyState?: React.ReactNode
}) { }) {
const mode = useEditor((s) => s.mode) const mode = useEditor((s) => s.mode)
const catalogCategory = useEditor((s) => s.catalogCategory) const catalogCategory = useEditor((s) => s.catalogCategory)
+1
View File
@@ -143,6 +143,7 @@ export { Slider } from './components/ui/primitives/slider'
export { SceneLoader } from './components/ui/scene-loader' export { SceneLoader } from './components/ui/scene-loader'
export type { ExtraPanel } from './components/ui/sidebar/icon-rail' export type { ExtraPanel } from './components/ui/sidebar/icon-rail'
export { ItemsPanel } from './components/ui/sidebar/panels/items-panel' export { ItemsPanel } from './components/ui/sidebar/panels/items-panel'
export type { FunctionTreeNode } from './components/ui/sidebar/panels/items-panel/function-tree-panel'
export { export {
type ProjectVisibility, type ProjectVisibility,
SettingsPanel, SettingsPanel,