fix(tables): header "T…" flicker — emcn barrel Table component shadowed the Table icon in loading fallbacks (#5007)

This commit is contained in:
Waleed
2026-06-12 14:20:41 -07:00
committed by GitHub
parent ccda764ae2
commit 0c2dbac066
8 changed files with 44 additions and 236 deletions
@@ -1565,7 +1565,10 @@ export function Files() {
}, [router, workspaceId])
const loadingBreadcrumbs = useMemo(
() => [{ label: 'Files', onClick: handleNavigateToFiles }, { label: '...' }],
(): BreadcrumbItem[] => [
{ label: 'Files', onClick: handleNavigateToFiles },
{ label: '…', terminal: true },
],
[handleNavigateToFiles]
)
@@ -4,5 +4,4 @@ export { DeleteKnowledgeBaseModal } from './delete-knowledge-base-modal'
export { EditKnowledgeBaseModal } from './edit-knowledge-base-modal'
export { getDocumentIcon } from './icons'
export { KnowledgeBaseContextMenu } from './knowledge-base-context-menu'
export { KnowledgeHeader } from './knowledge-header'
export { KnowledgeListContextMenu } from './knowledge-list-context-menu'
@@ -1 +0,0 @@
export { KnowledgeHeader } from './knowledge-header'
@@ -1,204 +0,0 @@
'use client'
import { useState } from 'react'
import { createLogger } from '@sim/logger'
import { AlertTriangle, LibraryBig, MoreHorizontal } from 'lucide-react'
import Link from 'next/link'
import {
Button,
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
Tooltip,
} from '@/components/emcn'
import { ChevronDown } from '@/components/emcn/icons'
import { Trash } from '@/components/emcn/icons/trash'
import { filterButtonClass } from '@/app/workspace/[workspaceId]/knowledge/components/constants'
import { useUpdateKnowledgeBase } from '@/hooks/queries/kb/knowledge'
import { useWorkspacesQuery } from '@/hooks/queries/workspace'
const logger = createLogger('KnowledgeHeader')
interface BreadcrumbItem {
label: string
href?: string
id?: string
}
const HEADER_STYLES = {
container: 'flex items-center justify-between px-6 pt-3.5 pb-6',
breadcrumbs: 'flex items-center gap-2',
icon: 'size-[18px] text-[var(--text-icon)] transition-colors',
link: 'group flex items-center gap-2 font-medium text-sm text-[var(--text-body)] transition-colors hover-hover:text-[var(--text-secondary)]',
label: 'font-medium text-sm text-[var(--text-body)]',
separator: 'text-[var(--text-icon)]',
actionsContainer: 'flex items-center gap-2',
} as const
interface KnowledgeHeaderOptions {
knowledgeBaseId?: string
currentWorkspaceId?: string | null
onWorkspaceChange?: (workspaceId: string | null) => void | Promise<void>
onDeleteKnowledgeBase?: () => void
}
interface KnowledgeHeaderProps {
breadcrumbs: BreadcrumbItem[]
options?: KnowledgeHeaderOptions
}
export function KnowledgeHeader({ breadcrumbs, options }: KnowledgeHeaderProps) {
const [isActionsMenuOpen, setIsActionsMenuOpen] = useState(false)
const [isWorkspaceMenuOpen, setIsWorkspaceMenuOpen] = useState(false)
const { data: allWorkspaces = [], isLoading: isLoadingWorkspaces } = useWorkspacesQuery(
!!options?.knowledgeBaseId
)
const workspaces = allWorkspaces.filter(
(ws) => ws.permissions === 'write' || ws.permissions === 'admin'
)
const updateKnowledgeBase = useUpdateKnowledgeBase()
const handleWorkspaceChange = async (workspaceId: string | null) => {
if (updateKnowledgeBase.isPending || !options?.knowledgeBaseId) return
setIsWorkspaceMenuOpen(false)
updateKnowledgeBase.mutate(
{
knowledgeBaseId: options.knowledgeBaseId,
updates: { workspaceId },
},
{
onSuccess: () => {
logger.info(
`Knowledge base workspace updated: ${options.knowledgeBaseId} -> ${workspaceId}`
)
options.onWorkspaceChange?.(workspaceId)
},
onError: (err) => {
logger.error('Error updating workspace:', err)
},
}
)
}
const currentWorkspace = workspaces.find((ws) => ws.id === options?.currentWorkspaceId)
const hasWorkspace = !!options?.currentWorkspaceId
return (
<div className={HEADER_STYLES.container}>
<div className={HEADER_STYLES.breadcrumbs}>
{breadcrumbs.map((breadcrumb, index) => {
const key = breadcrumb.id || `${breadcrumb.label}-${breadcrumb.href || index}`
return (
<div key={key} className='flex items-center gap-2'>
{index === 0 && <LibraryBig className={HEADER_STYLES.icon} />}
{breadcrumb.href ? (
<Link href={breadcrumb.href} prefetch={true} className={HEADER_STYLES.link}>
<span>{breadcrumb.label}</span>
</Link>
) : (
<span className={HEADER_STYLES.label}>{breadcrumb.label}</span>
)}
{index < breadcrumbs.length - 1 && <span className={HEADER_STYLES.separator}>/</span>}
</div>
)
})}
</div>
{/* Actions Area */}
{options && (
<div className={HEADER_STYLES.actionsContainer}>
{/* Workspace Selector */}
{options.knowledgeBaseId && (
<div className='flex items-center gap-2'>
{/* Warning icon for unassigned knowledge bases */}
{!hasWorkspace && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<AlertTriangle className='size-4 text-amber-500' />
</Tooltip.Trigger>
<Tooltip.Content side='top'>Not assigned to workspace</Tooltip.Content>
</Tooltip.Root>
)}
{/* Workspace selector dropdown */}
<DropdownMenu open={isWorkspaceMenuOpen} onOpenChange={setIsWorkspaceMenuOpen}>
<DropdownMenuTrigger asChild>
<Button
variant='outline'
disabled={isLoadingWorkspaces || updateKnowledgeBase.isPending}
className={filterButtonClass}
>
<span className='truncate'>
{isLoadingWorkspaces
? 'Loading...'
: updateKnowledgeBase.isPending
? 'Updating...'
: currentWorkspace?.name || 'No workspace'}
</span>
<ChevronDown className='ml-2 size-4 text-[var(--text-icon)]' />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align='end' side='bottom' sideOffset={4}>
<DropdownMenuCheckboxItem
checked={!options.currentWorkspaceId}
onSelect={() => handleWorkspaceChange(null)}
>
<span className='text-[var(--text-secondary)]'>No workspace</span>
</DropdownMenuCheckboxItem>
{workspaces.map((workspace) => (
<DropdownMenuCheckboxItem
key={workspace.id}
checked={options.currentWorkspaceId === workspace.id}
onSelect={() => handleWorkspaceChange(workspace.id)}
>
{workspace.name}
</DropdownMenuCheckboxItem>
))}
{workspaces.length === 0 && !isLoadingWorkspaces && (
<DropdownMenuItem disabled>
<span className='text-[var(--text-secondary)] text-xs'>
No workspaces with write access
</span>
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
)}
{/* Actions Menu */}
{options.onDeleteKnowledgeBase && (
<DropdownMenu open={isActionsMenuOpen} onOpenChange={setIsActionsMenuOpen}>
<DropdownMenuTrigger asChild>
<Button
variant='outline'
className={filterButtonClass}
aria-label='Knowledge base actions menu'
>
<MoreHorizontal className='size-4' />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align='end' side='bottom' sideOffset={4}>
<DropdownMenuItem onSelect={() => options.onDeleteKnowledgeBase?.()}>
<Trash className='size-4' />
<span>Delete Knowledge Base</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
)}
</div>
)
}
@@ -1,6 +1,6 @@
'use client'
import { Table as TableIcon } from '@/components/emcn'
import { Table as TableIcon } from '@/components/emcn/icons'
import {
type BreadcrumbItem,
ResourceChromeFallback,
@@ -12,6 +12,7 @@ import type { ColumnDefinition, Filter, TableRow as TableRowType, WorkflowGroup
import { getColumnId } from '@/lib/table/column-keys'
import { TABLE_LIMITS } from '@/lib/table/constants'
import {
type BreadcrumbItem,
type ColumnOption,
Resource,
type SortConfig,
@@ -485,34 +486,36 @@ export function Table({
}
const breadcrumbs = useMemo(
() => [
(): BreadcrumbItem[] => [
{ label: 'Tables', onClick: handleNavigateBack },
{
label: tableData?.name ?? '',
editing: tableHeaderRename.editingId
? {
isEditing: true,
value: tableHeaderRename.editValue,
onChange: tableHeaderRename.setEditValue,
onSubmit: tableHeaderRename.submitRename,
onCancel: tableHeaderRename.cancelRename,
}
: undefined,
dropdownItems: [
{
label: 'Rename',
icon: Pencil,
disabled: !tableData,
onClick: handleStartTableRename,
},
{
label: 'Delete',
icon: Trash,
disabled: !tableData,
onClick: onRequestDeleteTable,
},
],
},
// While the table loads, mirror this route's loading.tsx (terminal "…" crumb)
// so no empty-label / orphaned-chevron frame renders in between.
tableData
? {
label: tableData.name,
editing: tableHeaderRename.editingId
? {
isEditing: true,
value: tableHeaderRename.editValue,
onChange: tableHeaderRename.setEditValue,
onSubmit: tableHeaderRename.submitRename,
onCancel: tableHeaderRename.cancelRename,
}
: undefined,
dropdownItems: [
{
label: 'Rename',
icon: Pencil,
onClick: handleStartTableRename,
},
{
label: 'Delete',
icon: Trash,
onClick: onRequestDeleteTable,
},
],
}
: { label: '…', terminal: true },
],
[
handleNavigateBack,
@@ -1,6 +1,7 @@
'use client'
import { Plus, Table as TableIcon, Upload } from '@/components/emcn'
import { Plus, Upload } from '@/components/emcn'
import { Table as TableIcon } from '@/components/emcn/icons'
import {
type ChromeActionSpec,
ResourceChromeFallback,
+7
View File
@@ -1,4 +1,11 @@
export * from './components'
/**
* `Table` exists in BOTH `./components` (data-table element) and `./icons`
* (glyph). This explicit re-export resolves the ambiguity to the COMPONENT —
* always import the icon from `@/components/emcn/icons`. Rendering the
* component as an icon paints an empty `w-full` table that squeezes its
* siblings (shipped as the tables-header "T…" flicker).
*/
export {
Table,
TableBody,