feat(mothership): knowledge base resource extraction + Resource/ResourceTable refactor

- Extract KB resources from knowledge subagent respond format (knowledge_bases array)
- Add knowledge_base tool to RESOURCE_TOOL_NAMES and TOOL_UI_METADATA
- Extract ResourceTable as independently composable memoized component
- Move contentOverride/overlay to Resource shell level (not table primitive)
- Remove redundant disableHeaderSort and loadingRows props
- Rename internal sort state for clarity (sort → internalSort, sortOverride → externalSort)
- Export ResourceTable and ResourceTableProps from barrel

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
waleed
2026-03-11 17:55:28 -07:00
parent 38ee79da85
commit 8927807398
8 changed files with 285 additions and 192 deletions

View File

@@ -21,6 +21,7 @@ export type {
ResourceCell,
ResourceColumn,
ResourceRow,
ResourceTableProps,
SelectableConfig,
} from './resource/resource'
export { Resource } from './resource/resource'
export { Resource, ResourceTable } from './resource/resource'

View File

@@ -1,7 +1,5 @@
'use client'
import type { ReactNode } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { memo, type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { ChevronLeft, ChevronRight } from 'lucide-react'
import { ArrowDown, ArrowUp, Button, Checkbox, Loader, Plus, Skeleton } from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
@@ -49,7 +47,6 @@ interface ResourceProps {
create?: CreateAction
search?: SearchConfig
defaultSort?: string
disableHeaderSort?: boolean
sort?: SortConfig
headerActions?: HeaderAction[]
columns: ResourceColumn[]
@@ -60,7 +57,6 @@ interface ResourceProps {
onRowHover?: (rowId: string) => void
onRowContextMenu?: (e: React.MouseEvent, rowId: string) => void
isLoading?: boolean
loadingRows?: number
onContextMenu?: (e: React.MouseEvent) => void
filter?: ReactNode
filterTags?: FilterTag[]
@@ -75,6 +71,7 @@ interface ResourceProps {
}
const EMPTY_CELL_PLACEHOLDER = '- - -'
const SKELETON_ROW_COUNT = 5
/**
* Shared page shell for resource list pages (tables, files, knowledge, schedules, logs).
@@ -87,7 +84,6 @@ export function Resource({
create,
search,
defaultSort,
disableHeaderSort,
sort: sortOverride,
headerActions,
columns,
@@ -98,7 +94,6 @@ export function Resource({
onRowHover,
onRowContextMenu,
isLoading,
loadingRows = 5,
onContextMenu,
filter,
filterTags,
@@ -111,10 +106,102 @@ export function Resource({
contentOverride,
overlay,
}: ResourceProps) {
return (
<div
className='flex h-full flex-1 flex-col overflow-hidden bg-[var(--bg)]'
onContextMenu={onContextMenu}
>
<ResourceHeader
icon={icon}
title={title}
breadcrumbs={breadcrumbs}
create={create}
actions={headerActions}
/>
<ResourceOptionsBar
search={search}
sort={sortOverride ?? undefined}
filter={filter}
filterTags={filterTags}
extras={extras}
/>
{contentOverride ? (
<div className='relative flex min-h-0 flex-1 flex-col overflow-auto'>
{contentOverride}
{overlay}
</div>
) : (
<ResourceTable
columns={columns}
rows={rows}
defaultSort={defaultSort}
sort={sortOverride}
selectedRowId={selectedRowId}
selectable={selectable}
onRowClick={onRowClick}
onRowHover={onRowHover}
onRowContextMenu={onRowContextMenu}
isLoading={isLoading}
create={create}
onLoadMore={onLoadMore}
hasMore={hasMore}
isLoadingMore={isLoadingMore}
pagination={pagination}
emptyMessage={emptyMessage}
overlay={overlay}
/>
)}
</div>
)
}
export interface ResourceTableProps {
columns: ResourceColumn[]
rows: ResourceRow[]
defaultSort?: string
sort?: SortConfig
selectedRowId?: string | null
selectable?: SelectableConfig
onRowClick?: (rowId: string) => void
onRowHover?: (rowId: string) => void
onRowContextMenu?: (e: React.MouseEvent, rowId: string) => void
isLoading?: boolean
create?: CreateAction
onLoadMore?: () => void
hasMore?: boolean
isLoadingMore?: boolean
pagination?: PaginationConfig
emptyMessage?: string
overlay?: ReactNode
}
/**
* Data table body extracted from Resource for independent composition.
* Use directly when rendering a table without the Resource header/toolbar.
*/
export const ResourceTable = memo(function ResourceTable({
columns,
rows,
defaultSort,
sort: externalSort,
selectedRowId,
selectable,
onRowClick,
onRowHover,
onRowContextMenu,
isLoading,
create,
onLoadMore,
hasMore,
isLoadingMore,
pagination,
emptyMessage,
overlay,
}: ResourceTableProps) {
const headerRef = useRef<HTMLDivElement>(null)
const loadMoreRef = useRef<HTMLDivElement>(null)
const sortEnabled = defaultSort != null && !disableHeaderSort
const [sort, setSort] = useState<{ column: string; direction: 'asc' | 'desc' }>({
const sortEnabled = defaultSort != null
const [internalSort, setInternalSort] = useState<{ column: string; direction: 'asc' | 'desc' }>({
column: defaultSort ?? '',
direction: 'desc',
})
@@ -126,31 +213,22 @@ export function Resource({
}, [])
const handleSort = useCallback((column: string, direction: 'asc' | 'desc') => {
setSort({ column, direction })
setInternalSort({ column, direction })
}, [])
const sortConfig = useMemo<SortConfig | undefined>(() => {
if (!sortEnabled) return undefined
return {
options: columns.map((col) => ({ id: col.id, label: col.header })),
active: sort,
onSort: handleSort,
}
}, [sortEnabled, columns, sort, handleSort])
const displayRows = useMemo(() => {
if (!sortEnabled || sortOverride) return rows
if (!sortEnabled || externalSort) return rows
return [...rows].sort((a, b) => {
const col = sort.column
const col = internalSort.column
const aVal = a.sortValues?.[col] ?? a.cells[col]?.label ?? ''
const bVal = b.sortValues?.[col] ?? b.cells[col]?.label ?? ''
const cmp =
typeof aVal === 'number' && typeof bVal === 'number'
? aVal - bVal
: String(aVal).localeCompare(String(bVal))
return sort.direction === 'asc' ? -cmp : cmp
return internalSort.direction === 'asc' ? -cmp : cmp
})
}, [rows, sort, sortEnabled, sortOverride])
}, [rows, internalSort, sortEnabled, externalSort])
useEffect(() => {
if (!onLoadMore || !hasMore) return
@@ -169,178 +247,166 @@ export function Resource({
const hasCheckbox = selectable != null
const totalColSpan = columns.length + (hasCheckbox ? 1 : 0)
return (
<div
className='flex h-full flex-1 flex-col overflow-hidden bg-[var(--bg)]'
onContextMenu={onContextMenu}
>
<ResourceHeader
icon={icon}
title={title}
breadcrumbs={breadcrumbs}
create={create}
actions={headerActions}
/>
<ResourceOptionsBar
search={search}
sort={sortOverride ?? sortConfig}
filter={filter}
filterTags={filterTags}
extras={extras}
if (isLoading) {
return (
<DataTableSkeleton
columns={columns}
rowCount={SKELETON_ROW_COUNT}
hasCheckbox={hasCheckbox}
/>
)
}
{contentOverride ? (
<div className='min-h-0 flex-1 overflow-auto'>{contentOverride}</div>
) : isLoading ? (
<DataTableSkeleton columns={columns} rowCount={loadingRows} hasCheckbox={hasCheckbox} />
) : rows.length === 0 && emptyMessage ? (
<div className='flex min-h-0 flex-1 items-center justify-center'>
<span className='text-[13px] text-[var(--text-secondary)]'>{emptyMessage}</span>
</div>
) : (
<div className='relative flex min-h-0 flex-1 flex-col overflow-hidden'>
<div ref={headerRef} className='overflow-hidden'>
<table className='w-full table-fixed text-[13px]'>
<ResourceColGroup columns={columns} hasCheckbox={hasCheckbox} />
<thead className='shadow-[inset_0_-1px_0_var(--border)]'>
<tr>
if (rows.length === 0 && emptyMessage) {
return (
<div className='flex min-h-0 flex-1 items-center justify-center'>
<span className='text-[13px] text-[var(--text-secondary)]'>{emptyMessage}</span>
</div>
)
}
return (
<div className='relative flex min-h-0 flex-1 flex-col overflow-hidden'>
<div ref={headerRef} className='overflow-hidden'>
<table className='w-full table-fixed text-[13px]'>
<ResourceColGroup columns={columns} hasCheckbox={hasCheckbox} />
<thead className='shadow-[inset_0_-1px_0_var(--border)]'>
<tr>
{hasCheckbox && (
<th className='h-10 w-[52px] py-[6px] pr-0 pl-[20px] text-left align-middle'>
<Checkbox
size='sm'
checked={selectable.isAllSelected}
onCheckedChange={(checked) => selectable.onSelectAll(checked as boolean)}
disabled={selectable.disabled}
aria-label='Select all'
/>
</th>
)}
{columns.map((col) => {
if (!sortEnabled) {
return (
<th
key={col.id}
className='h-10 px-[24px] py-[6px] text-left align-middle font-base text-[12px] text-[var(--text-muted)]'
>
{col.header}
</th>
)
}
const isActive = internalSort.column === col.id
const SortIcon = internalSort.direction === 'asc' ? ArrowUp : ArrowDown
return (
<th key={col.id} className='h-10 px-[16px] py-[6px] text-left align-middle'>
<Button
variant='subtle'
className='px-[8px] py-[4px] font-base text-[var(--text-muted)] hover:text-[var(--text-muted)]'
onClick={() =>
handleSort(
col.id,
isActive ? (internalSort.direction === 'desc' ? 'asc' : 'desc') : 'desc'
)
}
>
{col.header}
{isActive && (
<SortIcon className='ml-[4px] h-[12px] w-[12px] text-[var(--text-icon)]' />
)}
</Button>
</th>
)
})}
</tr>
</thead>
</table>
</div>
<div className='min-h-0 flex-1 overflow-auto' onScroll={handleBodyScroll}>
<table className='w-full table-fixed text-[13px]'>
<ResourceColGroup columns={columns} hasCheckbox={hasCheckbox} />
<tbody>
{displayRows.map((row) => {
const isSelected = selectable?.selectedIds.has(row.id) ?? false
return (
<tr
key={row.id}
data-resource-row
data-row-id={row.id}
className={cn(
'transition-colors hover:bg-[var(--surface-3)]',
onRowClick && 'cursor-pointer',
(selectedRowId === row.id || isSelected) && 'bg-[var(--surface-3)]'
)}
onClick={() => onRowClick?.(row.id)}
onMouseEnter={onRowHover ? () => onRowHover(row.id) : undefined}
onContextMenu={(e) => onRowContextMenu?.(e, row.id)}
>
{hasCheckbox && (
<th className='h-10 w-[52px] py-[6px] pr-0 pl-[20px] text-left align-middle'>
<td className='w-[52px] py-[10px] pr-0 pl-[20px] align-middle'>
<Checkbox
size='sm'
checked={selectable.isAllSelected}
onCheckedChange={(checked) => selectable.onSelectAll(checked as boolean)}
checked={isSelected}
onCheckedChange={(checked) =>
selectable.onSelectRow(row.id, checked as boolean)
}
disabled={selectable.disabled}
aria-label='Select all'
aria-label='Select row'
onClick={(e) => e.stopPropagation()}
/>
</th>
</td>
)}
{columns.map((col) => {
if (disableHeaderSort || !sortEnabled) {
return (
<th
key={col.id}
className='h-10 px-[24px] py-[6px] text-left align-middle font-base text-[12px] text-[var(--text-muted)]'
>
{col.header}
</th>
)
}
const isActive = sort.column === col.id
const SortIcon = sort.direction === 'asc' ? ArrowUp : ArrowDown
{columns.map((col, colIdx) => {
const cell = row.cells[col.id]
return (
<th key={col.id} className='h-10 px-[16px] py-[6px] text-left align-middle'>
<Button
variant='subtle'
className='px-[8px] py-[4px] font-base text-[var(--text-muted)] hover:text-[var(--text-muted)]'
onClick={() =>
handleSort(
col.id,
isActive ? (sort.direction === 'desc' ? 'asc' : 'desc') : 'desc'
)
}
>
{col.header}
{isActive && (
<SortIcon className='ml-[4px] h-[12px] w-[12px] text-[var(--text-icon)]' />
)}
</Button>
</th>
<td key={col.id} className='px-[24px] py-[10px] align-middle'>
<CellContent
cell={{ ...cell, label: cell?.label || EMPTY_CELL_PLACEHOLDER }}
primary={colIdx === 0}
/>
</td>
)
})}
</tr>
</thead>
</table>
</div>
<div className='min-h-0 flex-1 overflow-auto' onScroll={handleBodyScroll}>
<table className='w-full table-fixed text-[13px]'>
<ResourceColGroup columns={columns} hasCheckbox={hasCheckbox} />
<tbody>
{displayRows.map((row) => {
const isSelected = selectable?.selectedIds.has(row.id) ?? false
return (
<tr
key={row.id}
data-resource-row
data-row-id={row.id}
className={cn(
'transition-colors hover:bg-[var(--surface-3)]',
onRowClick && 'cursor-pointer',
(selectedRowId === row.id || isSelected) && 'bg-[var(--surface-3)]'
)}
onClick={() => onRowClick?.(row.id)}
onMouseEnter={onRowHover ? () => onRowHover(row.id) : undefined}
onContextMenu={(e) => onRowContextMenu?.(e, row.id)}
>
{hasCheckbox && (
<td className='w-[52px] py-[10px] pr-0 pl-[20px] align-middle'>
<Checkbox
size='sm'
checked={isSelected}
onCheckedChange={(checked) =>
selectable.onSelectRow(row.id, checked as boolean)
}
disabled={selectable.disabled}
aria-label='Select row'
onClick={(e) => e.stopPropagation()}
/>
</td>
)}
{columns.map((col, colIdx) => {
const cell = row.cells[col.id]
return (
<td key={col.id} className='px-[24px] py-[10px] align-middle'>
<CellContent
cell={{ ...cell, label: cell?.label || EMPTY_CELL_PLACEHOLDER }}
primary={colIdx === 0}
/>
</td>
)
})}
</tr>
)
})}
{create && (
<tr
className={cn(
'transition-colors',
create.disabled
? 'cursor-not-allowed'
: 'cursor-pointer hover:bg-[var(--surface-3)]'
)}
onClick={create.disabled ? undefined : create.onClick}
>
<td colSpan={totalColSpan} className='px-[24px] py-[10px] align-middle'>
<span className='flex items-center gap-[12px] font-medium text-[14px] text-[var(--text-secondary)]'>
<Plus className='h-[14px] w-[14px] text-[var(--text-subtle)]' />
{create.label}
</span>
</td>
</tr>
)
})}
{create && (
<tr
className={cn(
'transition-colors',
create.disabled
? 'cursor-not-allowed'
: 'cursor-pointer hover:bg-[var(--surface-3)]'
)}
</tbody>
</table>
{hasMore && (
<div ref={loadMoreRef} className='flex items-center justify-center py-[12px]'>
{isLoadingMore && (
<Loader className='h-[16px] w-[16px] text-[var(--text-secondary)]' animate />
)}
</div>
onClick={create.disabled ? undefined : create.onClick}
>
<td colSpan={totalColSpan} className='px-[24px] py-[10px] align-middle'>
<span className='flex items-center gap-[12px] font-medium text-[14px] text-[var(--text-secondary)]'>
<Plus className='h-[14px] w-[14px] text-[var(--text-subtle)]' />
{create.label}
</span>
</td>
</tr>
)}
</tbody>
</table>
{hasMore && (
<div ref={loadMoreRef} className='flex items-center justify-center py-[12px]'>
{isLoadingMore && (
<Loader className='h-[16px] w-[16px] text-[var(--text-secondary)]' animate />
)}
</div>
{overlay}
{pagination && pagination.totalPages > 1 && (
<Pagination
currentPage={pagination.currentPage}
totalPages={pagination.totalPages}
onPageChange={pagination.onPageChange}
/>
)}
</div>
)}
</div>
{overlay}
{pagination && pagination.totalPages > 1 && (
<Pagination
currentPage={pagination.currentPage}
totalPages={pagination.totalPages}
onPageChange={pagination.onPageChange}
/>
)}
</div>
)
}
})
function Pagination({
currentPage,

View File

@@ -49,6 +49,7 @@ const TOOL_ICONS: Record<MothershipToolName | SubagentName | 'mothership', IconC
deploy: Rocket,
auth: Integration,
knowledge: Database,
knowledge_base: Database,
table: TableIcon,
job: Calendar,
agent: BubbleChatPreview,

View File

@@ -57,6 +57,7 @@ export type MothershipToolName =
| 'deploy'
| 'auth'
| 'knowledge'
| 'knowledge_base'
| 'table'
| 'job'
| 'agent'
@@ -184,6 +185,7 @@ export const TOOL_UI_METADATA: Partial<Record<MothershipToolName, ToolUIMetadata
deploy: { title: 'Deploying', phaseLabel: 'Deploy', phase: 'subagent' },
auth: { title: 'Connecting credentials', phaseLabel: 'Auth', phase: 'subagent' },
knowledge: { title: 'Managing knowledge', phaseLabel: 'Knowledge', phase: 'subagent' },
knowledge_base: { title: 'Managing knowledge base', phaseLabel: 'Resource', phase: 'resource' },
table: { title: 'Managing tables', phaseLabel: 'Table', phase: 'subagent' },
job: { title: 'Managing jobs', phaseLabel: 'Job', phase: 'subagent' },
agent: { title: 'Agent action', phaseLabel: 'Agent', phase: 'subagent' },

View File

@@ -8,6 +8,7 @@ export const RESOURCE_TOOL_NAMES = new Set([
'edit_workflow',
'function_execute',
'read',
'knowledge_base',
'knowledge',
])
@@ -134,6 +135,30 @@ export function extractKnowledgeBaseResource(
return null
}
/**
* Extracts knowledge base resources from a `knowledge` subagent respond result.
* The Go `knowledge_respond` tool returns a `knowledge_bases` array with `{id, name}` entries.
*/
export function extractKnowledgeRespondResources(parsed: SSEPayload): MothershipResource[] {
const topResult = getTopResult(parsed)
const data = topResult?.data as Record<string, unknown> | undefined
const kbArray = data?.knowledge_bases as Array<Record<string, unknown>> | undefined
if (!Array.isArray(kbArray)) return []
const resources: MothershipResource[] = []
for (const kb of kbArray) {
const id = kb.id as string | undefined
if (id) {
resources.push({
type: 'knowledgebase',
id,
title: (kb.name as string) || 'Knowledge Base',
})
}
}
return resources
}
export const GENERIC_TITLES = new Set(['Table', 'File', 'Workflow', 'Knowledge Base'])
/**
@@ -181,8 +206,17 @@ export function extractResourcesFromHistory(messages: TaskStoredMessage[]): Moth
} else if (tc.name === 'create_workflow' || tc.name === 'edit_workflow') {
resource = extractWorkflowResource(payload, lastWorkflowId)
if (resource) lastWorkflowId = resource.id
} else if (tc.name === 'knowledge') {
} else if (tc.name === 'knowledge_base') {
resource = extractKnowledgeBaseResource(payload, args)
} else if (tc.name === 'knowledge') {
const kbResources = extractKnowledgeRespondResources(payload)
for (const r of kbResources) {
const key = `${r.type}:${r.id}`
const existing = resourceMap.get(key)
if (!existing || (GENERIC_TITLES.has(existing.title) && !GENERIC_TITLES.has(r.title))) {
resourceMap.set(key, r)
}
}
}
if (resource) {

View File

@@ -300,13 +300,11 @@ export function Document({
const isInEditorView = selectedChunkId !== null || isCreatingNewChunk
// Derive selected chunk from displayChunks (memoized)
const selectedChunk = useMemo(
() => (selectedChunkId ? (displayChunks.find((c) => c.id === selectedChunkId) ?? null) : null),
[selectedChunkId, displayChunks]
)
// Chunk navigation helpers (memoized)
const currentChunkIndex = useMemo(
() => (selectedChunk ? displayChunks.findIndex((c) => c.id === selectedChunk.id) : -1),
[selectedChunk, displayChunks]
@@ -365,7 +363,6 @@ export function Document({
}
}, [pendingAction, closeEditor])
// Cmd+S keyboard shortcut
useEffect(() => {
if (!isInEditorView) return
const handleKeyDown = (e: KeyboardEvent) => {
@@ -378,7 +375,6 @@ export function Document({
return () => window.removeEventListener('keydown', handleKeyDown)
}, [isInEditorView, handleSave])
// beforeunload guard
useEffect(() => {
if (!isDirty) return
const handler = (e: BeforeUnloadEvent) => {
@@ -908,9 +904,8 @@ export function Document({
}))
}, [isCompleted, documentData?.processingStatus, displayChunks, searchQuery])
const emptyMessage = isCompleted ? (searchQuery ? 'No chunks found' : 'No chunks yet') : undefined
const emptyMessage = combinedError ? 'Error loading document' : undefined
// Save button label
const saveLabel =
saveStatus === 'saving'
? isCreatingNewChunk
@@ -1066,7 +1061,6 @@ export function Document({
)
}
// Inline edit chunk view
if (selectedChunkId) {
if (!selectedChunk || !documentData) {
return (
@@ -1113,7 +1107,6 @@ export function Document({
)
}
// Default table view
return (
<>
<Resource
@@ -1122,7 +1115,6 @@ export function Document({
breadcrumbs={breadcrumbs}
create={createAction}
search={combinedError ? undefined : searchConfig}
disableHeaderSort
columns={CHUNK_COLUMNS}
rows={combinedError ? [] : chunkRows}
selectable={combinedError ? undefined : selectableConfig}
@@ -1131,7 +1123,7 @@ export function Document({
onContextMenu={handleEmptyContextMenu}
isLoading={isLoadingDocument || isFetchingNewDoc}
pagination={paginationConfig}
emptyMessage={combinedError ? 'Error loading document' : emptyMessage}
emptyMessage={emptyMessage}
filter={combinedError ? undefined : filterContent}
filterTags={combinedError ? undefined : filterTags}
/>

View File

@@ -1055,7 +1055,6 @@ export function KnowledgeBase({
},
{ label: knowledgeBaseName },
]}
disableHeaderSort
columns={DOCUMENT_COLUMNS}
rows={[]}
emptyMessage='Error loading knowledge base'
@@ -1070,7 +1069,6 @@ export function KnowledgeBase({
title='Knowledge Base'
breadcrumbs={breadcrumbs}
headerActions={headerActions}
disableHeaderSort
sort={sortConfig}
search={{
value: searchQuery,

View File

@@ -1054,7 +1054,6 @@ export default function Logs() {
<Resource
icon={Library}
title='Logs'
disableHeaderSort
headerActions={headerActions}
search={searchConfig}
filter={<LogsFilterPanel searchQuery={searchQuery} onSearchQueryChange={setSearchQuery} />}