feat(sidebar): add Tables and Files flyouts to the collapsed rail (#6882)

* feat(sidebar): add Tables and Files flyouts to the collapsed rail

Chats and Workflows already open a hover flyout on the collapsed rail;
Tables and Files were plain links. Both now list their contents, with
folders as submenus and the open resource marked.

The chip stays a real link, so clicking still opens the list page and
right-click still reaches the nav context menu. Each flyout owns its
queries and mounts only when the menu opens: a hook on the sidebar keeps
its cache subscription on every workspace route even when disabled, so
an unrelated writer would re-render the whole sidebar for a closed
flyout.

Rows are ordered by the shared sortResources, so pinned rows float and
the flyout reads in the same order as the page it links into.

Also removes two dead components (CollapsedFileFolderItems, FileList)
that were exported but never rendered, and extracts SidebarNavChip so
the rail chip has one definition.

* fix(emcn): stop ordinary menus scrolling at the shared height cap

Every DropdownMenuContent was capped at a flat 240px. A menu is 28px per
row, 13px per separator, plus 12px padding, so a 7-row action menu with
3 separators measures 247px and scrolled for 7px while the 7-row menu
beside it with 1 separator did not.

Raises the cap to 420px, which clears every hand-authored action menu,
and clamps it with min() against the space Radix measures so a menu near
a viewport edge stays on screen — which the flat value never did. The
cap still exists so a long data-driven list scrolls instead of running
the height of the screen.

* fix(sidebar): hold the rail flyout until its lists resolve for this workspace

Both the resource and folder queries keep the previous workspace's rows as
placeholder data across a switch. Gating only on isPending let the flyout
build a tree from one workspace's resources against another's folders, where
no folder id resolves — which the builder reads as "archived out from under
it" and files the whole list at the root.

Gate on isPlaceholderData too, matching foldersResolved in
use-folder-ancestors. An error settles a query without resolving it and is
deliberately not held: the flyout then renders flat, which still reaches
every row.

* improvement(sidebar): mark pinned rows in the rail flyout

The flyout sorts pinned rows to the top via the shared sortResources, but
rendered no indicator, so that ordering read as arbitrary — the exact
pairing Resource's own label cell documents. Carry `pinned` on each row
and render the same non-interactive glyph, on folders as well as
resources.

Adds folder-structure coverage alongside it: per-level ordering, the full
depth of a nested chain, and an empty folder staying in the tree.
This commit is contained in:
Waleed
2026-08-19 18:52:59 -07:00
committed by GitHub
parent f17938c09e
commit 02ae2b4c33
17 changed files with 1119 additions and 423 deletions
@@ -0,0 +1,197 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import { buildFlyoutEntries } from '@/app/workspace/[workspaceId]/components/folders/flyout-entries'
function folder(id: string, name: string, parentId: string | null, updatedAt: string) {
return { id, name, parentId, updatedAt: new Date(updatedAt) }
}
function item(id: string, name: string, folderId: string | null, updatedAt: string) {
return { id, name, folderId, updatedAt: new Date(updatedAt) }
}
const NONE: ReadonlySet<string> = new Set()
function build(
folders: ReturnType<typeof folder>[],
items: ReturnType<typeof item>[],
pinned?: { folders?: ReadonlySet<string>; items?: ReadonlySet<string> }
) {
return buildFlyoutEntries({
folders,
items,
pinnedFolderIds: pinned?.folders ?? NONE,
pinnedItemIds: pinned?.items ?? NONE,
hrefForItem: (row) => `/x/${row.id}`,
})
}
describe('buildFlyoutEntries', () => {
it('orders folders and items together, most-recently-updated first', () => {
const entries = build(
[
folder('f1', 'Older folder', null, '2026-01-01'),
folder('f2', 'Newest', null, '2026-03-01'),
],
[item('i1', 'Middle', null, '2026-02-01')]
)
expect(entries.map((entry) => entry.id)).toEqual(['f2', 'i1', 'f1'])
})
it('floats pinned rows above newer unpinned ones, matching the list pages', () => {
const entries = build(
[folder('f1', 'Folder', null, '2026-03-01')],
[item('i1', 'Pinned', null, '2026-01-01'), item('i2', 'Newest', null, '2026-04-01')],
{ items: new Set(['i1']) }
)
expect(entries.map((entry) => entry.id)).toEqual(['i1', 'i2', 'f1'])
})
it('breaks ties on name', () => {
const entries = build(
[],
[
item('b', 'Beta', null, '2026-01-01'),
item('c', 'Alpha', null, '2026-01-01'),
item('a', 'Gamma', null, '2026-01-01'),
]
)
expect(entries.map((entry) => entry.id)).toEqual(['c', 'b', 'a'])
})
it('nests items under their folder and links each one', () => {
const entries = build(
[folder('f1', 'Reports', null, '2026-01-01'), folder('f2', 'Q1', 'f1', '2026-01-02')],
[item('i1', 'Revenue', 'f2', '2026-01-03')]
)
expect(entries).toEqual([
{
kind: 'folder',
id: 'f1',
name: 'Reports',
pinned: false,
children: [
{
kind: 'folder',
id: 'f2',
name: 'Q1',
pinned: false,
children: [{ kind: 'item', id: 'i1', name: 'Revenue', pinned: false, href: '/x/i1' }],
},
],
},
])
})
it('hoists a folder and an item whose parent folder is gone to the root', () => {
const entries = build(
[folder('f1', 'Orphan', 'archived-folder', '2026-01-02')],
[item('i1', 'Loose', 'archived-folder', '2026-01-01')]
)
expect(entries.map((entry) => entry.id)).toEqual(['f1', 'i1'])
expect(entries[0]).toMatchObject({ kind: 'folder', children: [] })
})
it('drops folders reachable only through a parent cycle instead of descending it', () => {
const entries = build(
[
folder('a', 'A', 'b', '2026-01-01'),
folder('b', 'B', 'a', '2026-01-01'),
folder('root', 'Root', null, '2026-01-01'),
],
[]
)
expect(entries.map((entry) => entry.id)).toEqual(['root'])
})
it('accepts serialized date strings and sorts undated rows last', () => {
const entries = buildFlyoutEntries({
folders: [],
items: [
{ id: 'i1', name: 'Undated', folderId: null, updatedAt: 'not-a-date' },
{ id: 'i2', name: 'Dated', folderId: null, updatedAt: '2026-01-01T00:00:00.000Z' },
],
pinnedFolderIds: NONE,
pinnedItemIds: NONE,
hrefForItem: (row) => `/x/${row.id}`,
})
expect(entries.map((entry) => entry.id)).toEqual(['i2', 'i1'])
})
it('treats a missing folderId as the root', () => {
const entries = buildFlyoutEntries({
folders: [],
items: [{ id: 'i1', name: 'Rootless', updatedAt: new Date('2026-01-01') }],
pinnedFolderIds: NONE,
pinnedItemIds: NONE,
hrefForItem: (row) => `/x/${row.id}`,
})
expect(entries).toEqual([
{ kind: 'item', id: 'i1', name: 'Rootless', pinned: false, href: '/x/i1' },
])
})
it('keeps each nesting level ordered independently, not just the root', () => {
const entries = build(
[folder('f1', 'Root folder', null, '2026-05-01')],
[
item('deep-old', 'Deep old', 'f1', '2026-01-01'),
item('deep-new', 'Deep new', 'f1', '2026-04-01'),
item('root-mid', 'Root mid', null, '2026-03-01'),
]
)
expect(entries.map((entry) => entry.id)).toEqual(['f1', 'root-mid'])
const nested = entries[0]
expect(nested.kind).toBe('folder')
if (nested.kind !== 'folder') throw new Error('expected a folder')
expect(nested.children.map((child) => child.id)).toEqual(['deep-new', 'deep-old'])
})
it('preserves the full depth of the folder chain', () => {
const entries = build(
[
folder('a', 'A', null, '2026-01-01'),
folder('b', 'B', 'a', '2026-01-01'),
folder('c', 'C', 'b', '2026-01-01'),
],
[item('leaf', 'Leaf', 'c', '2026-01-01')]
)
const depth = (rows: ReturnType<typeof build>): number => {
const nested = rows.find((row) => row.kind === 'folder')
return nested && nested.kind === 'folder' ? 1 + depth(nested.children) : 0
}
expect(depth(entries)).toBe(3)
})
it('keeps an empty folder in the tree rather than dropping it', () => {
const entries = build(
[folder('empty', 'Nothing here', null, '2026-01-01')],
[item('i1', 'Loose', null, '2026-01-02')]
)
expect(entries.map((entry) => entry.id)).toEqual(['i1', 'empty'])
expect(entries[1]).toMatchObject({ kind: 'folder', children: [] })
})
it('marks pinned folders and pinned resources so the ordering is legible', () => {
const entries = build(
[folder('f1', 'Folder', null, '2026-01-01')],
[item('i1', 'Table', null, '2026-01-02')],
{ folders: new Set(['f1']), items: new Set(['i1']) }
)
expect(entries.map((entry) => entry.pinned)).toEqual([true, true])
})
})
@@ -0,0 +1,112 @@
import {
type SortableResource,
sortResources,
} from '@/app/workspace/[workspaceId]/components/folders/resource-sort'
/** A folder row a resource flyout can render, from any foldered workspace surface. */
interface FlyoutFolderSource {
id: string
name: string
parentId: string | null
updatedAt: Date | string
}
/** A resource row a flyout can render, from any foldered workspace surface. */
interface FlyoutItemSource {
id: string
name: string
folderId?: string | null
updatedAt: Date | string
}
/** One row of a resource flyout: a folder that recurses, or a linked resource. */
export type FlyoutEntry =
| { kind: 'folder'; id: string; name: string; pinned: boolean; children: FlyoutEntry[] }
| { kind: 'item'; id: string; name: string; pinned: boolean; href: string }
export interface BuildFlyoutEntriesParams<Item extends FlyoutItemSource> {
folders: FlyoutFolderSource[]
items: Item[]
pinnedFolderIds: ReadonlySet<string>
pinnedItemIds: ReadonlySet<string>
hrefForItem: (item: Item) => string
}
function flyoutSortTime(value: Date | string): number {
const time = value instanceof Date ? value.getTime() : Date.parse(value)
return Number.isNaN(time) ? 0 : time
}
/**
* Builds the ordered row tree a foldered resource's flyout renders.
*
* Each level is sorted by the shared {@link sortResources}, on the most-recently-updated
* key its list page defaults to — so pinned rows float, folders interleave with the
* resources beside them, and the flyout keeps reading in the same order as the page it
* links into rather than carrying a second copy of that rule. `pinned` rides along on each
* row because that ordering reads as arbitrary without the indicator the rows render from
* it — the same pairing `Resource`'s own cells make.
*
* A folder whose parent no longer exists, and a resource whose `folderId` names no live
* folder, surface at the root — the same fallback the list pages apply when a folder is
* archived out from under its contents, so neither goes unreachable. A folder only
* reachable through a parent cycle is dropped, as it is by the sidebar's folder tree: the
* client folder cache is written optimistically, so a cycle is reachable there even though
* the server rejects one, and descending it would hang the tab.
*/
export function buildFlyoutEntries<Item extends FlyoutItemSource>({
folders,
items,
pinnedFolderIds,
pinnedItemIds,
hrefForItem,
}: BuildFlyoutEntriesParams<Item>): FlyoutEntry[] {
const folderIds = new Set(folders.map((folder) => folder.id))
const foldersByParent = new Map<string | null, FlyoutFolderSource[]>()
for (const folder of folders) {
const parentId = folder.parentId && folderIds.has(folder.parentId) ? folder.parentId : null
const siblings = foldersByParent.get(parentId)
if (siblings) siblings.push(folder)
else foldersByParent.set(parentId, [folder])
}
const itemsByFolder = new Map<string | null, Item[]>()
for (const item of items) {
const folderId = item.folderId && folderIds.has(item.folderId) ? item.folderId : null
const siblings = itemsByFolder.get(folderId)
if (siblings) siblings.push(item)
else itemsByFolder.set(folderId, [item])
}
const buildLevel = (parentId: string | null): FlyoutEntry[] => {
const rows: SortableResource<FlyoutEntry>[] = []
for (const folder of foldersByParent.get(parentId) ?? []) {
const pinned = pinnedFolderIds.has(folder.id)
rows.push({
item: {
kind: 'folder',
id: folder.id,
name: folder.name,
pinned,
children: buildLevel(folder.id),
},
pinned,
name: folder.name,
key: flyoutSortTime(folder.updatedAt),
})
}
for (const item of itemsByFolder.get(parentId) ?? []) {
const pinned = pinnedItemIds.has(item.id)
rows.push({
item: { kind: 'item', id: item.id, name: item.name, pinned, href: hrefForItem(item) },
pinned,
name: item.name,
key: flyoutSortTime(item.updatedAt),
})
}
return sortResources(rows, 'desc').map((row) => row.item)
}
return buildLevel(null)
}
@@ -1,4 +1,4 @@
import type { ElementType } from 'react'
import type { ComponentType } from 'react'
import { Database, File as FileIcon, Table as TableIcon } from '@sim/emcn/icons'
import type { FolderResourceType } from '@/lib/api/contracts/folders'
import { folderListHref } from '@/app/workspace/[workspaceId]/components/folders/search-params'
@@ -17,7 +17,7 @@ export interface FolderedResourceHeaderMeta {
/** Root crumb label, and the page title at the workspace root. */
rootLabel: string
/** Icon on the root crumb, which is also what opens the header's "Path" popover. */
rootIcon: ElementType
rootIcon: ComponentType<{ className?: string }>
/** Path segment of the list page under `/workspace/[workspaceId]/`. */
listSegment: string
}
@@ -1,4 +1,6 @@
export { readRowDragPayload, writeRowDragPayload } from './drag-payload'
export type { BuildFlyoutEntriesParams, FlyoutEntry } from './flyout-entries'
export { buildFlyoutEntries } from './flyout-entries'
export type { BreadcrumbFolder, FolderBreadcrumbItemsOptions } from './folder-breadcrumbs'
export { breadcrumbFolderChain, folderBreadcrumbItems } from './folder-breadcrumbs'
export { FolderContextMenu } from './folder-context-menu'
@@ -0,0 +1,153 @@
/**
* @vitest-environment jsdom
*/
import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('next/link', () => ({
default: ({ href, children, ...props }: { href: string; children: React.ReactNode }) => (
<a href={href} {...props}>
{children}
</a>
),
}))
import { Table } from '@sim/emcn/icons'
import {
CollapsedResourceFlyout,
CollapsedSidebarMenu,
} from '@/app/workspace/[workspaceId]/w/components/sidebar/components/collapsed-sidebar-menu'
function stubHoverMenu(isOpen: boolean) {
return {
isOpen,
open: vi.fn(),
close: vi.fn(),
setLocked: vi.fn(),
triggerProps: { onMouseEnter: vi.fn(), onMouseLeave: vi.fn() },
contentProps: {
onMouseEnter: vi.fn(),
onMouseLeave: vi.fn(),
onCloseAutoFocus: vi.fn(),
},
} as unknown as Parameters<typeof CollapsedSidebarMenu>[0]['hover']
}
describe('CollapsedSidebarMenu nav-link trigger', () => {
let container: HTMLDivElement
let root: Root
beforeEach(() => {
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
vi.stubGlobal(
'ResizeObserver',
class {
observe() {}
unobserve() {}
disconnect() {}
}
)
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
})
afterEach(() => {
act(() => root.unmount())
container.remove()
vi.unstubAllGlobals()
})
function renderMenu(
options: { isOpen?: boolean; onContextMenu?: (e: unknown, href: string) => void } = {}
) {
act(() => {
root.render(
<CollapsedSidebarMenu
hover={stubHoverMenu(options.isOpen ?? false)}
navLink={{
item: { id: 'tables', label: 'Tables', icon: Table, href: '/workspace/w1/tables' },
active: false,
onContextMenu: options.onContextMenu,
}}
>
<CollapsedResourceFlyout
entries={[
{
kind: 'item',
id: 't1',
name: 'Leads',
pinned: false,
href: '/workspace/w1/tables/t1',
},
{
kind: 'item',
id: 't2',
name: 'Pinned table',
pinned: true,
href: '/workspace/w1/tables/t2',
},
]}
icon={Table}
emptyLabel='No tables yet'
/>
</CollapsedSidebarMenu>
)
})
const trigger = container.querySelector('a')
if (!trigger) throw new Error('trigger anchor not rendered')
return trigger
}
it('renders the rail chip as a real link, not the primitive button', () => {
const trigger = renderMenu()
expect(trigger.getAttribute('href')).toBe('/workspace/w1/tables')
expect(trigger.textContent).toContain('Tables')
expect(container.querySelector('button')).toBeNull()
/* Radix's trigger is a button primitive; its `type` must not leak onto the anchor. */
expect(trigger.hasAttribute('type')).toBe(false)
})
it('activates the link on Enter, which Radix would otherwise swallow to toggle the menu', () => {
const trigger = renderMenu()
const onClick = vi.fn((e: Event) => e.preventDefault())
trigger.addEventListener('click', onClick)
act(() => {
trigger.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true })
)
})
expect(onClick).toHaveBeenCalledTimes(1)
})
it('forwards a right-click to the nav item context menu with its href', () => {
const onContextMenu = vi.fn()
const trigger = renderMenu({ onContextMenu })
act(() => {
trigger.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true }))
})
expect(onContextMenu).toHaveBeenCalledWith(expect.anything(), '/workspace/w1/tables')
})
it('lists the resource rows once the flyout is open', () => {
renderMenu({ isOpen: true })
const row = document.querySelector('a[href="/workspace/w1/tables/t1"]')
expect(row?.textContent).toContain('Leads')
})
it('marks a pinned row, so sorting it to the top does not read as arbitrary', () => {
renderMenu({ isOpen: true })
const pinnedRow = document.querySelector('a[href="/workspace/w1/tables/t2"]')
const plainRow = document.querySelector('a[href="/workspace/w1/tables/t1"]')
expect(pinnedRow?.querySelector('[aria-label="Pinned"]')).not.toBeNull()
expect(plainRow?.querySelector('[aria-label="Pinned"]')).toBeNull()
})
})
@@ -1,5 +1,6 @@
import { type MouseEvent as ReactMouseEvent, useState } from 'react'
import { type ComponentType, type MouseEvent as ReactMouseEvent, useState } from 'react'
import {
Chip,
chipVariants,
cn,
DropdownMenu,
@@ -11,110 +12,117 @@ import {
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
Loader,
} from '@sim/emcn'
import { File, Folder, MoreHorizontal, Pencil, Plus, SquareArrowUpRight } from '@sim/emcn/icons'
import { Folder, MoreHorizontal, Pencil, Pin, Plus, SquareArrowUpRight } from '@sim/emcn/icons'
import Link from 'next/link'
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
import { ConversationListItem } from '@/app/workspace/[workspaceId]/components'
import type { FlyoutEntry } from '@/app/workspace/[workspaceId]/components/folders'
import {
SidebarNavChip,
type SidebarNavItemData,
} from '@/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-nav-chip'
import { SIDEBAR_RAIL_CHIP_CLASS } from '@/app/workspace/[workspaceId]/w/components/sidebar/constants'
import type { useHoverMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
import { interleaveSiblings } from '@/app/workspace/[workspaceId]/w/components/sidebar/utils'
import type { WorkspaceFileFolderApi } from '@/hooks/queries/workspace-file-folders'
import type { FolderTreeNode } from '@/stores/folders/types'
import type { WorkflowMetadata } from '@/stores/workflows/registry/types'
interface FileFolderFlyoutNode extends WorkspaceFileFolderApi {
children: FileFolderFlyoutNode[]
files: WorkspaceFileRecord[]
interface CollapsedResourceFlyoutProps {
entries: FlyoutEntry[]
/** Icon for the resource rows. Folders always carry the folder glyph. */
icon: ComponentType<{ className?: string }>
/** Resource open on the current route, so its row reads as selected. */
currentItemId?: string
/**
* True until the lists that decide which rows EXIST have resolved once — the resources and
* their folders. Both are needed before anything renders: a resource whose folder has not
* arrived yet would show at the root and then jump into it. Pins are deliberately not
* waited on, since they only reorder rows that are already correct.
*/
isLoading?: boolean
emptyLabel: string
}
type FileFlyoutEntry =
| { kind: 'folder'; id: string; name: string; folder: FileFolderFlyoutNode }
| { kind: 'file'; id: string; name: string; file: WorkspaceFileRecord }
/**
* Orders one level of the file flyout as a single list. Folders are not hoisted
* above the files beside them — the Files page sorts folders and files together,
* and a flyout that partitioned them would contradict the page it links into.
* Rail flyout body for a foldered workspace resource (Tables, Files). Every row
* is a link — the flyout is a jump list, so folders open as submenus rather than
* navigating, and an empty one has nowhere to go and is inert.
*/
function fileFlyoutEntries(
folders: FileFolderFlyoutNode[],
files: WorkspaceFileRecord[]
): FileFlyoutEntry[] {
const entries: FileFlyoutEntry[] = [
...folders.map(
(folder): FileFlyoutEntry => ({
kind: 'folder',
id: folder.id,
name: folder.name,
folder,
})
),
...files.map(
(file): FileFlyoutEntry => ({
kind: 'file',
id: file.id,
name: file.name,
file,
})
),
]
return entries.sort((a, b) => a.name.localeCompare(b.name) || a.id.localeCompare(b.id))
export function CollapsedResourceFlyout({
entries,
icon,
currentItemId,
isLoading = false,
emptyLabel,
}: CollapsedResourceFlyoutProps) {
if (isLoading) {
return (
<DropdownMenuItem disabled>
<Loader className='size-[14px]' animate />
Loading...
</DropdownMenuItem>
)
}
if (entries.length === 0) {
return <DropdownMenuItem disabled>{emptyLabel}</DropdownMenuItem>
}
return <CollapsedFlyoutRows entries={entries} icon={icon} currentItemId={currentItemId} />
}
const FILE_FLYOUT_ICON = (
<File className='size-[14px] flex-shrink-0 text-[var(--text-icon)]' aria-hidden='true' />
)
/**
* Matches the glyph `Resource`'s label cell renders: pinned rows sort to the top of every
* list, and the ordering reads as arbitrary without it. Non-interactive here too — pinning
* is an action on the row's own menu, not something a jump list offers.
*/
function PinnedGlyph() {
return (
<Pin className='size-[12px] shrink-0 text-[var(--text-icon)]' role='img' aria-label='Pinned' />
)
}
export function CollapsedFileFolderItems({
nodes,
rootFiles,
workspaceId,
currentFileId,
}: {
nodes: FileFolderFlyoutNode[]
rootFiles?: WorkspaceFileRecord[]
workspaceId: string
currentFileId?: string
}) {
function CollapsedFlyoutRows({
entries,
icon: Icon,
currentItemId,
}: Pick<CollapsedResourceFlyoutProps, 'entries' | 'icon' | 'currentItemId'>) {
return (
<>
{fileFlyoutEntries(nodes, rootFiles ?? []).map((entry) => {
if (entry.kind === 'file') {
{entries.map((entry) => {
if (entry.kind === 'item') {
return (
<DropdownMenuItem key={entry.id} asChild active={currentFileId === entry.file.id}>
<Link href={`/workspace/${workspaceId}/files/${entry.file.id}`}>
{FILE_FLYOUT_ICON}
<DropdownMenuItem key={entry.id} asChild active={currentItemId === entry.id}>
<Link href={entry.href}>
<Icon className='size-[14px]' />
<span className='truncate'>{entry.name}</span>
{entry.pinned && <PinnedGlyph />}
</Link>
</DropdownMenuItem>
)
}
const folder = entry.folder
const hasChildren = folder.children.length > 0 || folder.files.length > 0
if (!hasChildren) {
if (entry.children.length === 0) {
return (
<DropdownMenuItem key={folder.id} disabled>
<DropdownMenuItem key={entry.id} disabled>
<Folder className='size-[14px]' />
<span className='truncate'>{folder.name}</span>
<span className='truncate'>{entry.name}</span>
{entry.pinned && <PinnedGlyph />}
</DropdownMenuItem>
)
}
return (
<DropdownMenuSub key={folder.id}>
<DropdownMenuSub key={entry.id}>
<DropdownMenuSubTrigger>
<Folder className='size-[14px]' />
<span className='truncate'>{folder.name}</span>
<span className='truncate'>{entry.name}</span>
{entry.pinned && <PinnedGlyph />}
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<CollapsedFileFolderItems
nodes={folder.children}
rootFiles={folder.files}
workspaceId={workspaceId}
currentFileId={currentFileId}
<CollapsedFlyoutRows
entries={entry.children}
icon={Icon}
currentItemId={currentItemId}
/>
</DropdownMenuSubContent>
</DropdownMenuSub>
@@ -124,16 +132,28 @@ export function CollapsedFileFolderItems({
)
}
interface CollapsedSidebarMenuProps {
icon: React.ReactNode
/**
* Rail trigger for a menu whose nav item also has a page of its own. The chip stays the
* ordinary nav chip, so the flyout is purely additive: clicking still opens the resource's
* list page, and right-click still reaches the nav item's context menu.
*/
export interface CollapsedSidebarMenuNavLink {
item: SidebarNavItemData
active: boolean
onContextMenu?: (e: ReactMouseEvent, href: string) => void
}
type CollapsedSidebarMenuProps = {
hover: ReturnType<typeof useHoverMenu>
ariaLabel?: string
children: React.ReactNode
primaryAction?: {
label: string
onSelect: () => void
}
}
} & (
| { icon: React.ReactNode; ariaLabel?: string; navLink?: never }
| { icon?: never; ariaLabel?: never; navLink: CollapsedSidebarMenuNavLink }
)
interface CollapsedChatFlyoutItemProps {
chat: { id: string; href: string; name: string; isActive?: boolean; isUnread?: boolean }
@@ -188,48 +208,76 @@ const EDIT_ROW_CLASS = cn(
'min-w-0 cursor-default select-none text-small'
)
/**
* Radix's menu trigger swallows Enter to toggle the menu, which would leave a rail nav chip
* with no keyboard route to its own page. The flyout opens on hover, so Enter belongs to the
* link — and defaulting the event is what keeps Radix's composed handler from running.
*/
function activateLinkOnEnter(e: React.KeyboardEvent<HTMLElement>) {
if (e.key !== 'Enter') return
e.preventDefault()
e.currentTarget.click()
}
/**
* Hover-opened rail flyout. The component owns only the trigger and the menu —
* the caller places it, so spacing stays with the surrounding list.
*/
export function CollapsedSidebarMenu({
icon,
hover,
ariaLabel,
children,
primaryAction,
navLink,
}: CollapsedSidebarMenuProps) {
return (
<div className='flex flex-col px-2'>
<DropdownMenu
open={hover.isOpen}
onOpenChange={(open) => {
if (open) hover.open()
else hover.close()
}}
modal={false}
>
<div {...hover.triggerProps}>
<DropdownMenuTrigger asChild>
<button
type='button'
<DropdownMenu
open={hover.isOpen}
onOpenChange={(open) => {
if (open) hover.open()
else hover.close()
}}
modal={false}
>
<div {...hover.triggerProps}>
<DropdownMenuTrigger asChild>
{navLink ? (
<SidebarNavChip
item={navLink.item}
active={navLink.active}
onContextMenu={
navLink.onContextMenu && navLink.item.href
? (e) => navLink.onContextMenu?.(e, navLink.item.href as string)
: undefined
}
onKeyDown={activateLinkOnEnter}
/>
) : (
<Chip
aria-label={ariaLabel}
className={cn(chipVariants({ fullWidth: true }), SIDEBAR_RAIL_CHIP_CLASS)}
>
{icon}
</button>
</DropdownMenuTrigger>
</div>
<DropdownMenuContent side='right' align='start' sideOffset={8} {...hover.contentProps}>
{primaryAction && (
<>
<DropdownMenuItem onSelect={primaryAction.onSelect}>
<Plus className='size-[14px]' />
{primaryAction.label}
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
/* `leftAdornment`, not children: a chip wraps children in its label span, which
would stretch the bare rail glyph across the pill. */
leftAdornment={icon}
fullWidth
className={SIDEBAR_RAIL_CHIP_CLASS}
/>
)}
{children}
</DropdownMenuContent>
</DropdownMenu>
</div>
</DropdownMenuTrigger>
</div>
<DropdownMenuContent side='right' align='start' sideOffset={8} {...hover.contentProps}>
{primaryAction && (
<>
<DropdownMenuItem onSelect={primaryAction.onSelect}>
<Plus className='size-[14px]' />
{primaryAction.label}
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
)}
{children}
</DropdownMenuContent>
</DropdownMenu>
)
}
@@ -1,7 +1,7 @@
export {
CollapsedChatFlyoutItem,
CollapsedFileFolderItems,
CollapsedFolderItems,
CollapsedResourceFlyout,
CollapsedSidebarMenu,
CollapsedWorkflowFlyoutItem,
} from './collapsed-sidebar-menu'
@@ -1,162 +0,0 @@
'use client'
import { memo, useMemo, useState } from 'react'
import { chipContentIconClass, cn, disclosureChevronClass } from '@sim/emcn'
import { ChevronRight, File, Folder, FolderOpen } from '@sim/emcn/icons'
import Link from 'next/link'
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
import type { WorkspaceFileFolderApi } from '@/hooks/queries/workspace-file-folders'
type FileTreeNode =
| { kind: 'folder'; id: string; name: string; children: FileTreeNode[] }
| { kind: 'file'; id: string; name: string; file: WorkspaceFileRecord }
/**
* Nests folders and files into one tree, ordering each level by name with
* folders and files interleaved. A folder is not hoisted above the file beside
* it — the Files page sorts them as one list, and a sidebar that partitioned
* them would order the same folder's contents differently from the page.
*/
function buildFileTree(
folders: WorkspaceFileFolderApi[],
files: WorkspaceFileRecord[],
parentId: string | null = null
): FileTreeNode[] {
const nodes: FileTreeNode[] = [
...folders
.filter((folder) => (folder.parentId ?? null) === parentId)
.map(
(folder): FileTreeNode => ({
kind: 'folder',
id: folder.id,
name: folder.name,
children: buildFileTree(folders, files, folder.id),
})
),
...files
.filter((file) => (file.folderId ?? null) === parentId)
.map((file): FileTreeNode => ({ kind: 'file', id: file.id, name: file.name, file })),
]
return nodes.sort((a, b) => a.name.localeCompare(b.name) || a.id.localeCompare(b.id))
}
const INDENT_PER_LEVEL = 16
/**
* A file row has no chevron, so it indents past where a folder row's would be —
* the chevron's own width plus the row's `gap-1` — putting the two rows' icons on
* the same x. Keep in step with `disclosureChevronClass` (14px) and the row gap.
*/
const CHEVRON_LEADING_WIDTH = 14 + 4
interface FileTreeNodeItemProps {
node: FileTreeNode
workspaceId: string
currentFileId: string | undefined
pathname: string | null
level: number
}
const FileTreeNodeItem = memo(function FileTreeNodeItem({
node,
workspaceId,
currentFileId,
pathname,
level,
}: FileTreeNodeItemProps) {
const [isExpanded, setIsExpanded] = useState(true)
if (node.kind === 'file') {
const href = `/workspace/${workspaceId}/files/${node.file.id}`
const isActive = currentFileId === node.file.id || pathname === href
return (
<Link
href={href}
className={cn(
'group mx-0.5 flex h-[30px] items-center gap-1 rounded-lg text-sm',
!isActive && 'hover-hover:bg-[var(--surface-hover)]',
isActive && 'bg-[var(--surface-active)]'
)}
style={{ paddingLeft: `${8 + level * INDENT_PER_LEVEL + CHEVRON_LEADING_WIDTH}px` }}
>
<File className={chipContentIconClass} aria-hidden='true' />
<span className='min-w-0 flex-1 truncate text-[var(--text-body)]'>{node.name}</span>
</Link>
)
}
const hasChildren = node.children.length > 0
return (
<div>
<button
type='button'
className='group mx-0.5 flex h-[30px] w-[calc(100%-4px)] items-center gap-1 rounded-lg px-2 text-sm hover-hover:bg-[var(--surface-hover)]'
style={{ paddingLeft: `${8 + level * INDENT_PER_LEVEL}px` }}
onClick={() => setIsExpanded((prev) => !prev)}
>
<ChevronRight
className={cn(
disclosureChevronClass,
isExpanded && hasChildren && 'rotate-90',
!hasChildren && 'opacity-0'
)}
aria-hidden='true'
/>
{isExpanded && hasChildren ? (
<FolderOpen className={chipContentIconClass} aria-hidden='true' />
) : (
<Folder className={chipContentIconClass} aria-hidden='true' />
)}
<span className='min-w-0 flex-1 truncate text-left text-[var(--text-body)]'>
{node.name}
</span>
</button>
{isExpanded &&
node.children.map((child) => (
<FileTreeNodeItem
key={child.id}
node={child}
workspaceId={workspaceId}
currentFileId={currentFileId}
pathname={pathname}
level={level + 1}
/>
))}
</div>
)
})
interface FileListProps {
workspaceId: string
currentFileId?: string
pathname: string | null
folders: WorkspaceFileFolderApi[]
files: WorkspaceFileRecord[]
}
export const FileList = memo(function FileList({
workspaceId,
currentFileId,
pathname,
folders,
files,
}: FileListProps) {
const rootNodes = useMemo(() => buildFileTree(folders, files, null), [folders, files])
return (
<div className='flex flex-col'>
{rootNodes.map((node) => (
<FileTreeNodeItem
key={node.id}
node={node}
workspaceId={workspaceId}
currentFileId={currentFileId}
pathname={pathname}
level={0}
/>
))}
</div>
)
})
@@ -1 +0,0 @@
export { FileList } from './file-list'
@@ -1,16 +1,18 @@
export {
CollapsedChatFlyoutItem,
CollapsedFileFolderItems,
CollapsedFolderItems,
CollapsedResourceFlyout,
CollapsedSidebarMenu,
CollapsedWorkflowFlyoutItem,
} from './collapsed-sidebar-menu'
export { FileList } from './file-list'
export { HelpModal } from './help-modal'
export { NavItemContextMenu } from './nav-item-context-menu'
export { FilesRailFlyout, TablesRailFlyout } from './rail-resource-flyout'
export { SearchModal } from './search-modal'
export { SettingsSidebar } from './settings-sidebar'
export { SidebarFooter } from './sidebar-footer'
export type { SidebarNavItemData } from './sidebar-nav-chip'
export { SidebarNavChip } from './sidebar-nav-chip'
export { SidebarSection } from './sidebar-section'
export { WorkflowList } from './workflow-list'
export { WorkspaceHeader } from './workspace-header'
@@ -0,0 +1 @@
export { FilesRailFlyout, TablesRailFlyout } from './rail-resource-flyout'
@@ -0,0 +1,133 @@
/**
* @vitest-environment jsdom
*/
import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const { mockUseTablesList, mockUseFolders, mockUsePinnedIds } = vi.hoisted(() => ({
mockUseTablesList: vi.fn(),
mockUseFolders: vi.fn(),
mockUsePinnedIds: vi.fn(),
}))
vi.mock('next/link', () => ({
default: ({ href, children }: { href: string; children: React.ReactNode }) => (
<a href={href}>{children}</a>
),
}))
vi.mock('next/navigation', () => ({ useParams: () => ({ workspaceId: 'w1' }) }))
vi.mock('@/hooks/queries/tables', () => ({ useTablesList: mockUseTablesList }))
vi.mock('@/hooks/queries/folders', () => ({ useFolders: mockUseFolders }))
vi.mock('@/hooks/queries/pinned-items', () => ({ usePinnedIds: mockUsePinnedIds }))
vi.mock('@/hooks/queries/workspace-files', () => ({ useWorkspaceFiles: vi.fn() }))
vi.mock('@/hooks/queries/workspace-file-folders', () => ({ useWorkspaceFileFolders: vi.fn() }))
import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from '@sim/emcn'
import { TablesRailFlyout } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/rail-resource-flyout'
type QueryStub = { data?: unknown; isPending: boolean; isPlaceholderData: boolean }
const resolved = (data: unknown): QueryStub => ({
data,
isPending: false,
isPlaceholderData: false,
})
const placeholder = (data: unknown): QueryStub => ({
data,
isPending: false,
isPlaceholderData: true,
})
const TABLE = {
id: 't1',
name: 'Leads',
folderId: 'f1',
updatedAt: new Date('2026-01-01'),
}
const FOLDER = { id: 'f1', name: 'Sales', parentId: null, updatedAt: new Date('2026-01-01') }
describe('TablesRailFlyout', () => {
let container: HTMLDivElement
let root: Root
beforeEach(() => {
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
vi.stubGlobal(
'ResizeObserver',
class {
observe() {}
unobserve() {}
disconnect() {}
}
)
mockUsePinnedIds.mockReturnValue(new Set<string>())
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
})
afterEach(() => {
act(() => root.unmount())
container.remove()
vi.clearAllMocks()
vi.unstubAllGlobals()
})
function render() {
act(() => {
root.render(
<DropdownMenu open modal={false}>
<DropdownMenuTrigger asChild>
<button type='button'>rail</button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<TablesRailFlyout workspaceId='w1' />
</DropdownMenuContent>
</DropdownMenu>
)
})
return document.body.textContent ?? ''
}
it('nests a table under its folder once both lists have resolved', () => {
mockUseTablesList.mockReturnValue(resolved([TABLE]))
mockUseFolders.mockReturnValue(resolved([FOLDER]))
const text = render()
expect(text).toContain('Sales')
expect(text).not.toContain('Loading...')
/* The table sits inside the folder's submenu, which is closed until hovered. */
expect(document.querySelector('a[href="/workspace/w1/tables/t1"]')).toBeNull()
})
it('waits rather than filing tables at the root while the folders are the previous workspace’s', () => {
mockUseTablesList.mockReturnValue(resolved([TABLE]))
mockUseFolders.mockReturnValue(placeholder([]))
const text = render()
expect(text).toContain('Loading...')
expect(text).not.toContain('Leads')
})
it('waits while the tables themselves are still placeholder data', () => {
mockUseTablesList.mockReturnValue(placeholder([TABLE]))
mockUseFolders.mockReturnValue(resolved([FOLDER]))
expect(render()).toContain('Loading...')
})
it('still lists every table when the folder query failed outright', () => {
mockUseTablesList.mockReturnValue(resolved([TABLE]))
mockUseFolders.mockReturnValue({ data: undefined, isPending: false, isPlaceholderData: false })
const text = render()
expect(text).not.toContain('Loading...')
expect(document.querySelector('a[href="/workspace/w1/tables/t1"]')?.textContent).toContain(
'Leads'
)
})
})
@@ -0,0 +1,106 @@
'use client'
/**
* Rail flyout bodies for the foldered workspace resources.
*
* Both flyouts mount only while their rail menu is open — Radix does not force-mount menu
* content — which is the whole reason they own their queries instead of the sidebar. A hook
* on the sidebar stays subscribed to its cache key on every workspace route even with
* `enabled: false`, so an unrelated writer (the table-import poller ticks every 2s) would
* re-render the entire sidebar to feed a flyout nobody has opened.
*/
import { useMemo } from 'react'
import { useParams } from 'next/navigation'
import {
buildFlyoutEntries,
FOLDERED_RESOURCE_HEADERS,
} from '@/app/workspace/[workspaceId]/components/folders'
import { CollapsedResourceFlyout } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/collapsed-sidebar-menu'
import { useFolders } from '@/hooks/queries/folders'
import { usePinnedIds } from '@/hooks/queries/pinned-items'
import { useTablesList } from '@/hooks/queries/tables'
import { useWorkspaceFileFolders } from '@/hooks/queries/workspace-file-folders'
import { useWorkspaceFiles } from '@/hooks/queries/workspace-files'
const TABLE_META = FOLDERED_RESOURCE_HEADERS.table
const FILE_META = FOLDERED_RESOURCE_HEADERS.file
/**
* A list is usable only once it has resolved for THIS workspace. Every list here keeps the
* previous workspace's rows as placeholder data across a switch, and a tree built from one
* workspace's resources against another's folders resolves no folder id at all — which the
* builder reads as "archived out from under it" and files the whole list at the root. So the
* rows wait for both queries rather than render a shape that is wrong and then jumps.
*
* `isPlaceholderData` is what separates that from a real result; a pending-only check is the
* exact gate `tables.tsx` warns against. An error settles a query without resolving it, and is
* deliberately not held here: the flyout then renders flat, which still reaches every row.
*/
function isResolving(query: { isPending: boolean; isPlaceholderData: boolean }): boolean {
return query.isPending || query.isPlaceholderData
}
export function TablesRailFlyout({ workspaceId }: { workspaceId: string }) {
const params = useParams()
const tablesQuery = useTablesList(workspaceId)
const foldersQuery = useFolders(workspaceId, { resourceType: 'table' })
const pinnedTableIds = usePinnedIds(workspaceId, 'table')
const pinnedFolderIds = usePinnedIds(workspaceId, 'folder')
const { data: tables } = tablesQuery
const { data: folders } = foldersQuery
const entries = useMemo(
() =>
buildFlyoutEntries({
folders: folders ?? [],
items: tables ?? [],
pinnedFolderIds,
pinnedItemIds: pinnedTableIds,
hrefForItem: (table) => `/workspace/${workspaceId}/${TABLE_META.listSegment}/${table.id}`,
}),
[folders, tables, pinnedFolderIds, pinnedTableIds, workspaceId]
)
return (
<CollapsedResourceFlyout
entries={entries}
icon={TABLE_META.rootIcon}
currentItemId={typeof params.tableId === 'string' ? params.tableId : undefined}
isLoading={isResolving(tablesQuery) || isResolving(foldersQuery)}
emptyLabel='No tables yet'
/>
)
}
export function FilesRailFlyout({ workspaceId }: { workspaceId: string }) {
const params = useParams()
const filesQuery = useWorkspaceFiles(workspaceId)
const foldersQuery = useWorkspaceFileFolders(workspaceId)
const pinnedFileIds = usePinnedIds(workspaceId, 'file')
const pinnedFolderIds = usePinnedIds(workspaceId, 'folder')
const { data: files } = filesQuery
const { data: folders } = foldersQuery
const entries = useMemo(
() =>
buildFlyoutEntries({
folders: folders ?? [],
items: files ?? [],
pinnedFolderIds,
pinnedItemIds: pinnedFileIds,
hrefForItem: (file) => `/workspace/${workspaceId}/${FILE_META.listSegment}/${file.id}`,
}),
[folders, files, pinnedFolderIds, pinnedFileIds, workspaceId]
)
return (
<CollapsedResourceFlyout
entries={entries}
icon={FILE_META.rootIcon}
currentItemId={typeof params.fileId === 'string' ? params.fileId : undefined}
isLoading={isResolving(filesQuery) || isResolving(foldersQuery)}
emptyLabel='No files yet'
/>
)
}
@@ -0,0 +1,2 @@
export type { SidebarNavItemData } from './sidebar-nav-chip'
export { SidebarNavChip } from './sidebar-nav-chip'
@@ -0,0 +1,84 @@
'use client'
import { forwardRef } from 'react'
import { Chip, ChipLink, cn } from '@sim/emcn'
import { SIDEBAR_RAIL_CHIP_CLASS } from '@/app/workspace/[workspaceId]/w/components/sidebar/constants'
export interface SidebarNavItemData {
id: string
label: string
icon: React.ComponentType<{ className?: string }>
href?: string
onClick?: () => void
/** Extra path prefixes that should also mark this item as active (e.g. sibling tabs). */
additionalActivePaths?: string[]
}
interface SidebarNavChipProps extends React.HTMLAttributes<HTMLElement> {
item: SidebarNavItemData
active: boolean
}
/**
* The rail's nav chip, and the only definition of it.
*
* A nav item renders this either bare, under its collapsed tooltip, or as the trigger of a
* hover flyout — so it forwards its ref and passes extra props through. Radix anchors a menu
* to the element it is handed, and a trigger that swallowed the ref would leave the menu
* positioned at the page origin.
*
* `className` is merged rather than spread over: Radix's `asChild` slot rewrites the prop on
* the way in, so letting it through the spread would blank the rail geometry.
*/
export const SidebarNavChip = forwardRef<HTMLElement, SidebarNavChipProps>(function SidebarNavChip(
{ item, active, className, ...props },
ref
) {
const chipClassName = cn(SIDEBAR_RAIL_CHIP_CLASS, className)
if (item.href) {
return (
<ChipLink
ref={ref as React.Ref<HTMLAnchorElement>}
href={item.href}
data-item-id={item.id}
leftIcon={item.icon}
active={active}
fullWidth
className={chipClassName}
onClick={
item.onClick
? (e) => {
if (e.ctrlKey || e.metaKey || e.shiftKey) return
e.preventDefault()
item.onClick?.()
}
: undefined
}
{...props}
/* Radix's menu trigger is a button primitive; its `type` is meaningless on the
anchor this renders through, so it never reaches the DOM. */
type={undefined}
>
{item.label}
</ChipLink>
)
}
if (!item.onClick) return null
return (
<Chip
ref={ref as React.Ref<HTMLButtonElement>}
data-item-id={item.id}
leftIcon={item.icon}
active={active}
fullWidth
className={chipClassName}
{...props}
onClick={item.onClick}
>
{item.label}
</Chip>
)
})
@@ -4,7 +4,6 @@ import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useStat
import {
Button,
Chip,
ChipLink,
chipVariants,
cn,
DropdownMenu,
@@ -54,12 +53,16 @@ import {
CollapsedFolderItems,
CollapsedSidebarMenu,
CollapsedWorkflowFlyoutItem,
FilesRailFlyout,
HelpModal,
NavItemContextMenu,
SearchModal,
SettingsSidebar,
SidebarFooter,
SidebarNavChip,
type SidebarNavItemData,
SidebarSection,
TablesRailFlyout,
WorkflowList,
WorkspaceHeader,
} from '@/app/workspace/[workspaceId]/w/components/sidebar/components'
@@ -77,7 +80,6 @@ import {
SIDEBAR_DIVIDER_PAD_ABOVE_CLASS,
SIDEBAR_DIVIDER_PAD_BELOW_CLASS,
SIDEBAR_ITEM_GAP_CLASS,
SIDEBAR_RAIL_CHIP_CLASS,
SIDEBAR_SECTION_GAP_CLASS,
} from '@/app/workspace/[workspaceId]/w/components/sidebar/constants'
import {
@@ -317,16 +319,6 @@ const SidebarChatItem = memo(function SidebarChatItem({
)
})
interface SidebarNavItemData {
id: string
label: string
icon: React.ComponentType<{ className?: string }>
href?: string
onClick?: () => void
/** Extra path prefixes that should also mark this item as active (e.g. sibling tabs). */
additionalActivePaths?: string[]
}
/**
* Returns true when the current pathname matches `item.href` or any
* `additionalActivePaths` at a segment boundary (avoids `/foo` matching `/foo-bar`).
@@ -349,45 +341,17 @@ const SidebarNavItem = memo(function SidebarNavItem({
showCollapsedTooltips: boolean
onContextMenu?: (e: React.MouseEvent, href: string) => void
}) {
const element = item.href ? (
<ChipLink
href={item.href}
data-item-id={item.id}
leftIcon={item.icon}
active={active}
fullWidth
className={SIDEBAR_RAIL_CHIP_CLASS}
onClick={
item.onClick
? (e) => {
if (e.ctrlKey || e.metaKey || e.shiftKey) return
e.preventDefault()
item.onClick!()
}
: undefined
}
onContextMenu={onContextMenu ? (e) => onContextMenu(e, item.href!) : undefined}
>
{item.label}
</ChipLink>
) : item.onClick ? (
<Chip
data-item-id={item.id}
leftIcon={item.icon}
active={active}
fullWidth
className={SIDEBAR_RAIL_CHIP_CLASS}
onClick={item.onClick}
>
{item.label}
</Chip>
) : null
if (!element) return null
if (!item.href && !item.onClick) return null
return (
<SidebarTooltip label={item.label} enabled={showCollapsedTooltips}>
{element}
<SidebarNavChip
item={item}
active={active}
onContextMenu={
onContextMenu && item.href ? (e) => onContextMenu(e, item.href as string) : undefined
}
/>
</SidebarTooltip>
)
})
@@ -701,6 +665,8 @@ export const Sidebar = memo(function Sidebar({
const setChatPinnedMutation = useSetMothershipChatPinned(workspaceId)
const chatsHover = useHoverMenu()
const workflowsHover = useHoverMenu()
const tablesHover = useHoverMenu()
const filesHover = useHoverMenu()
const {
isOpen: isChatContextMenuOpen,
@@ -868,6 +834,19 @@ export const Sidebar = memo(function Sidebar({
]
)
/**
* Rail flyouts by nav id; a nav item without one stays a plain link. Each element is only
* built here — Radix mounts menu content on open, so the flyout's queries do not run (and
* do not subscribe) until the user actually hovers the chip.
*/
const railFlyouts: Record<
string,
{ hover: ReturnType<typeof useHoverMenu>; content: React.ReactNode } | undefined
> = {
tables: { hover: tablesHover, content: <TablesRailFlyout workspaceId={workspaceId} /> },
files: { hover: filesHover, content: <FilesRailFlyout workspaceId={workspaceId} /> },
}
const handleOpenSettings = useCallback(
(section: SettingsSection) => {
if (!isCollapsedRef.current) {
@@ -1512,39 +1491,41 @@ export const Sidebar = memo(function Sidebar({
className='chats-section flex-shrink-0'
>
{isCollapsed ? (
<CollapsedSidebarMenu
icon={chatsCollapsedIcon}
hover={chatsHover}
ariaLabel='Chats'
>
{chatsLoading ? (
<DropdownMenuItem disabled>
<Loader className='size-[14px]' animate />
Loading...
</DropdownMenuItem>
) : chats.length === 0 ? (
<DropdownMenuItem disabled>No chats yet</DropdownMenuItem>
) : (
chats.map((chat) => (
<CollapsedChatFlyoutItem
key={chat.id}
chat={chat}
isCurrentRoute={pathname === chat.href}
isMenuOpen={menuOpenChatId === chat.id}
isEditing={chat.id === chatFlyoutRename.editingId}
editValue={chatFlyoutRename.value}
inputRef={chatFlyoutRename.inputRef}
isRenaming={chatFlyoutRename.isSaving}
onEditValueChange={chatFlyoutRename.setValue}
onEditKeyDown={chatFlyoutRename.handleKeyDown}
onEditBlur={handleChatRenameBlur}
onContextMenu={handleChatContextMenu}
onMorePointerDown={handleChatMorePointerDown}
onMoreClick={handleChatMoreClick}
/>
))
)}
</CollapsedSidebarMenu>
<div className='px-2'>
<CollapsedSidebarMenu
icon={chatsCollapsedIcon}
hover={chatsHover}
ariaLabel='Chats'
>
{chatsLoading ? (
<DropdownMenuItem disabled>
<Loader className='size-[14px]' animate />
Loading...
</DropdownMenuItem>
) : chats.length === 0 ? (
<DropdownMenuItem disabled>No chats yet</DropdownMenuItem>
) : (
chats.map((chat) => (
<CollapsedChatFlyoutItem
key={chat.id}
chat={chat}
isCurrentRoute={pathname === chat.href}
isMenuOpen={menuOpenChatId === chat.id}
isEditing={chat.id === chatFlyoutRename.editingId}
editValue={chatFlyoutRename.value}
inputRef={chatFlyoutRename.inputRef}
isRenaming={chatFlyoutRename.isSaving}
onEditValueChange={chatFlyoutRename.setValue}
onEditKeyDown={chatFlyoutRename.handleKeyDown}
onEditBlur={handleChatRenameBlur}
onContextMenu={handleChatContextMenu}
onMorePointerDown={handleChatMorePointerDown}
onMoreClick={handleChatMoreClick}
/>
))
)}
</CollapsedSidebarMenu>
</div>
) : (
<div className={cn(SIDEBAR_ITEM_GAP_CLASS, 'flex flex-col px-2')}>
{chatsLoading ? (
@@ -1636,15 +1617,33 @@ export const Sidebar = memo(function Sidebar({
className={cn(SIDEBAR_SECTION_GAP_CLASS, 'flex-shrink-0')}
>
<div className={cn(SIDEBAR_ITEM_GAP_CLASS, 'flex flex-col px-2')}>
{workspaceNavItems.map((item) => (
<SidebarNavItem
key={item.id}
item={item}
active={isNavItemActive(item, pathname)}
showCollapsedTooltips={showCollapsedTooltips}
onContextMenu={handleNavItemContextMenu}
/>
))}
{workspaceNavItems.map((item) => {
const active = isNavItemActive(item, pathname)
const flyout = isCollapsed ? railFlyouts[item.id] : undefined
/* The flyout replaces the collapsed tooltip rather than
stacking on it: both open on the same hover. */
return flyout ? (
<CollapsedSidebarMenu
key={item.id}
hover={flyout.hover}
navLink={{
item,
active,
onContextMenu: handleNavItemContextMenu,
}}
>
{flyout.content}
</CollapsedSidebarMenu>
) : (
<SidebarNavItem
key={item.id}
item={item}
active={active}
showCollapsedTooltips={showCollapsedTooltips}
onContextMenu={handleNavItemContextMenu}
/>
)
})}
</div>
</SidebarSection>
@@ -1723,64 +1722,68 @@ export const Sidebar = memo(function Sidebar({
}
>
{isCollapsed ? (
<CollapsedSidebarMenu
icon={workflowsCollapsedIcon}
hover={workflowsHover}
ariaLabel='Workflows'
primaryAction={workflowsPrimaryAction}
>
{workflowsLoading && regularWorkflows.length === 0 ? (
<DropdownMenuItem disabled>
<Loader className='h-[14px] w-[14px]' animate />
Loading...
</DropdownMenuItem>
) : regularWorkflows.length === 0 ? (
<DropdownMenuItem disabled>No workflows yet</DropdownMenuItem>
) : (
<>
{collapsedRootItems.map((item) =>
item.kind === 'folder' ? (
<CollapsedFolderItems
key={item.id}
nodes={[item.node]}
workflowsByFolder={workflowsByFolder}
workspaceId={workspaceId}
currentWorkflowId={workflowId}
editingWorkflowId={workflowFlyoutRename.editingId}
editingValue={workflowFlyoutRename.value}
editInputRef={workflowFlyoutRename.inputRef}
isRenamingWorkflow={workflowFlyoutRename.isSaving}
onEditValueChange={workflowFlyoutRename.setValue}
onEditKeyDown={workflowFlyoutRename.handleKeyDown}
onEditBlur={handleWorkflowRenameBlur}
onWorkflowOpenInNewTab={handleCollapsedWorkflowOpenInNewTab}
onWorkflowRename={handleCollapsedWorkflowRename}
canRenameWorkflow={canEdit}
/>
) : (
<CollapsedWorkflowFlyoutItem
key={item.id}
workflow={item.workflow}
href={`/workspace/${workspaceId}/w/${item.workflow.id}`}
isCurrentRoute={item.workflow.id === workflowId}
isEditing={item.workflow.id === workflowFlyoutRename.editingId}
editValue={workflowFlyoutRename.value}
inputRef={workflowFlyoutRename.inputRef}
isRenaming={workflowFlyoutRename.isSaving}
onEditValueChange={workflowFlyoutRename.setValue}
onEditKeyDown={workflowFlyoutRename.handleKeyDown}
onEditBlur={handleWorkflowRenameBlur}
onOpenInNewTab={() =>
handleCollapsedWorkflowOpenInNewTab(item.workflow)
}
onRename={() => handleCollapsedWorkflowRename(item.workflow)}
canRename={canEdit}
/>
)
)}
</>
)}
</CollapsedSidebarMenu>
<div className='px-2'>
<CollapsedSidebarMenu
icon={workflowsCollapsedIcon}
hover={workflowsHover}
ariaLabel='Workflows'
primaryAction={workflowsPrimaryAction}
>
{workflowsLoading && regularWorkflows.length === 0 ? (
<DropdownMenuItem disabled>
<Loader className='size-[14px]' animate />
Loading...
</DropdownMenuItem>
) : regularWorkflows.length === 0 ? (
<DropdownMenuItem disabled>No workflows yet</DropdownMenuItem>
) : (
<>
{collapsedRootItems.map((item) =>
item.kind === 'folder' ? (
<CollapsedFolderItems
key={item.id}
nodes={[item.node]}
workflowsByFolder={workflowsByFolder}
workspaceId={workspaceId}
currentWorkflowId={workflowId}
editingWorkflowId={workflowFlyoutRename.editingId}
editingValue={workflowFlyoutRename.value}
editInputRef={workflowFlyoutRename.inputRef}
isRenamingWorkflow={workflowFlyoutRename.isSaving}
onEditValueChange={workflowFlyoutRename.setValue}
onEditKeyDown={workflowFlyoutRename.handleKeyDown}
onEditBlur={handleWorkflowRenameBlur}
onWorkflowOpenInNewTab={handleCollapsedWorkflowOpenInNewTab}
onWorkflowRename={handleCollapsedWorkflowRename}
canRenameWorkflow={canEdit}
/>
) : (
<CollapsedWorkflowFlyoutItem
key={item.id}
workflow={item.workflow}
href={`/workspace/${workspaceId}/w/${item.workflow.id}`}
isCurrentRoute={item.workflow.id === workflowId}
isEditing={
item.workflow.id === workflowFlyoutRename.editingId
}
editValue={workflowFlyoutRename.value}
inputRef={workflowFlyoutRename.inputRef}
isRenaming={workflowFlyoutRename.isSaving}
onEditValueChange={workflowFlyoutRename.setValue}
onEditKeyDown={workflowFlyoutRename.handleKeyDown}
onEditBlur={handleWorkflowRenameBlur}
onOpenInNewTab={() =>
handleCollapsedWorkflowOpenInNewTab(item.workflow)
}
onRename={() => handleCollapsedWorkflowRename(item.workflow)}
canRename={canEdit}
/>
)
)}
</>
)}
</CollapsedSidebarMenu>
</div>
) : (
<div className='px-2'>
{workflowsLoading && regularWorkflows.length === 0 ? (