feat(mothership): inline rename for resource tabs + workspace_file rename tool

- Add double-click inline rename on file and table resource tabs
- Wire useInlineRename + useRenameWorkspaceFile/useRenameTable mutations
- Add rename operation to workspace_file copilot tool (schema, server, router)
- Add knowledge base resource support (type, extraction, rendering, actions)
- Accept optional className on InlineRenameInput for context-specific sizing

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
waleed
2026-03-11 16:55:02 -07:00
parent 511e3a9011
commit 28c8afcb96
14 changed files with 257 additions and 29 deletions

View File

@@ -1,15 +1,23 @@
'use client'
import { useEffect, useRef } from 'react'
import { cn } from '@/lib/core/utils/cn'
interface InlineRenameInputProps {
value: string
onChange: (value: string) => void
onSubmit: () => void
onCancel: () => void
className?: string
}
export function InlineRenameInput({ value, onChange, onSubmit, onCancel }: InlineRenameInputProps) {
export function InlineRenameInput({
value,
onChange,
onSubmit,
onCancel,
className,
}: InlineRenameInputProps) {
const inputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
@@ -32,7 +40,10 @@ export function InlineRenameInput({ value, onChange, onSubmit, onCancel }: Inlin
}}
onBlur={onSubmit}
onClick={(e) => e.stopPropagation()}
className='min-w-0 flex-1 truncate border-0 bg-transparent p-0 font-medium text-[14px] text-[var(--text-body)] outline-none focus:outline-none focus:ring-0'
className={cn(
'min-w-0 flex-1 truncate border-0 bg-transparent p-0 font-medium text-[14px] text-[var(--text-body)] outline-none focus:outline-none focus:ring-0',
className
)}
/>
)
}

View File

@@ -1,2 +1,6 @@
export { EmbeddedWorkflowActions, ResourceContent } from './resource-content'
export {
EmbeddedKnowledgeBaseActions,
EmbeddedWorkflowActions,
ResourceContent,
} from './resource-content'
export { ResourceTabs } from './resource-tabs'

View File

@@ -1 +1,5 @@
export { EmbeddedWorkflowActions, ResourceContent } from './resource-content'
export {
EmbeddedKnowledgeBaseActions,
EmbeddedWorkflowActions,
ResourceContent,
} from './resource-content'

View File

@@ -4,12 +4,14 @@ import { lazy, Suspense, useCallback, useEffect, useMemo } from 'react'
import { Square } from 'lucide-react'
import { useRouter } from 'next/navigation'
import { Button, PlayOutline, Skeleton, Tooltip } from '@/components/emcn'
import { BookOpen } from '@/components/emcn/icons'
import { WorkflowIcon } from '@/components/icons'
import {
FileViewer,
type PreviewMode,
} from '@/app/workspace/[workspaceId]/files/components/file-viewer'
import type { MothershipResource } from '@/app/workspace/[workspaceId]/home/types'
import { KnowledgeBase } from '@/app/workspace/[workspaceId]/knowledge/[id]/base'
import { useWorkspacePermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { Table } from '@/app/workspace/[workspaceId]/tables/[tableId]/components'
import { useUsageLimits } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/hooks'
@@ -62,6 +64,16 @@ export function ResourceContent({ workspaceId, resource, previewMode }: Resource
</Suspense>
)
case 'knowledgebase':
return (
<KnowledgeBase
key={resource.id}
id={resource.id}
knowledgeBaseName={resource.title}
workspaceId={workspaceId}
/>
)
default:
return null
}
@@ -147,6 +159,40 @@ export function EmbeddedWorkflowActions({ workspaceId, workflowId }: EmbeddedWor
)
}
interface EmbeddedKnowledgeBaseActionsProps {
workspaceId: string
knowledgeBaseId: string
}
export function EmbeddedKnowledgeBaseActions({
workspaceId,
knowledgeBaseId,
}: EmbeddedKnowledgeBaseActionsProps) {
const router = useRouter()
const handleOpenKnowledgeBase = useCallback(() => {
router.push(`/workspace/${workspaceId}/knowledge/${knowledgeBaseId}`)
}, [router, workspaceId, knowledgeBaseId])
return (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='subtle'
onClick={handleOpenKnowledgeBase}
className='shrink-0 bg-transparent px-[8px] py-[5px] text-[12px]'
aria-label='Open knowledge base'
>
<BookOpen className='h-[16px] w-[16px] text-[var(--text-icon)]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='bottom'>
<p>Open Knowledge Base</p>
</Tooltip.Content>
</Tooltip.Root>
)
}
interface EmbeddedFileProps {
workspaceId: string
fileId: string

View File

@@ -8,10 +8,11 @@ import {
useCallback,
} from 'react'
import { Button, Tooltip } from '@/components/emcn'
import { PanelLeft, Table as TableIcon } from '@/components/emcn/icons'
import { BookOpen, PanelLeft, Table as TableIcon } from '@/components/emcn/icons'
import { WorkflowIcon } from '@/components/icons'
import { getDocumentIcon } from '@/components/icons/document-icons'
import { cn } from '@/lib/core/utils/cn'
import { InlineRenameInput } from '@/app/workspace/[workspaceId]/components/inline-rename-input'
import type { PreviewMode } from '@/app/workspace/[workspaceId]/files/components/file-viewer'
import type {
MothershipResource,
@@ -47,6 +48,8 @@ function PreviewModeIcon({ mode, ...props }: { mode: PreviewMode } & SVGProps<SV
)
}
const RENAMABLE_TYPES = new Set(['file', 'table'])
interface ResourceTabsProps {
resources: MothershipResource[]
activeId: string | null
@@ -55,11 +58,18 @@ interface ResourceTabsProps {
previewMode?: PreviewMode
onCyclePreviewMode?: () => void
actions?: ReactNode
editingId?: string | null
editValue?: string
onEditValueChange?: (value: string) => void
onStartRename?: (id: string, currentName: string) => void
onSubmitRename?: () => void
onCancelRename?: () => void
}
const RESOURCE_ICONS: Record<Exclude<MothershipResourceType, 'file'>, ElementType> = {
table: TableIcon,
workflow: WorkflowIcon,
knowledgebase: BookOpen,
}
function getResourceIcon(resource: MothershipResource): ElementType {
@@ -81,6 +91,12 @@ export function ResourceTabs({
previewMode,
onCyclePreviewMode,
actions,
editingId,
editValue,
onEditValueChange,
onStartRename,
onSubmitRename,
onCancelRename,
}: ResourceTabsProps) {
const scrollRef = useCallback<RefCallback<HTMLDivElement>>((node) => {
if (!node) return
@@ -118,20 +134,37 @@ export function ResourceTabs({
{resources.map((resource) => {
const Icon = getResourceIcon(resource)
const isActive = activeId === resource.id
const isEditing = editingId === resource.id
const isRenamable = RENAMABLE_TYPES.has(resource.type)
return (
<Tooltip.Root key={resource.id}>
<Tooltip.Root key={resource.id} open={isEditing ? false : undefined}>
<Tooltip.Trigger asChild>
<Button
variant='subtle'
onClick={() => onSelect(resource.id)}
onDoubleClick={
isRenamable && onStartRename
? () => onStartRename(resource.id, resource.title)
: undefined
}
className={cn(
'shrink-0 bg-transparent px-[8px] py-[4px] text-[12px]',
isActive && 'bg-[var(--surface-4)]'
)}
>
<Icon className={cn('mr-[6px] h-[14px] w-[14px] text-[var(--text-icon)]')} />
{resource.title}
{isEditing && onEditValueChange && onSubmitRename && onCancelRename ? (
<InlineRenameInput
value={editValue ?? ''}
onChange={onEditValueChange}
onSubmit={onSubmitRename}
onCancel={onCancelRename}
className='min-w-[60px] max-w-[160px] text-[12px]'
/>
) : (
resource.title
)}
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='bottom'>

View File

@@ -1,11 +1,19 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { cn } from '@/lib/core/utils/cn'
import { getFileExtension } from '@/lib/uploads/utils/file-utils'
import type { PreviewMode } from '@/app/workspace/[workspaceId]/files/components/file-viewer'
import type { MothershipResource } from '@/app/workspace/[workspaceId]/home/types'
import { EmbeddedWorkflowActions, ResourceContent, ResourceTabs } from './components'
import { useRenameTable } from '@/hooks/queries/tables'
import { useRenameWorkspaceFile } from '@/hooks/queries/workspace-files'
import { useInlineRename } from '@/hooks/use-inline-rename'
import {
EmbeddedKnowledgeBaseActions,
EmbeddedWorkflowActions,
ResourceContent,
ResourceTabs,
} from './components'
const PREVIEWABLE_EXTENSIONS = new Set(['md', 'html', 'htm', 'csv'])
const PREVIEW_ONLY_EXTENSIONS = new Set(['html', 'htm'])
@@ -21,13 +29,14 @@ interface MothershipViewProps {
resources: MothershipResource[]
activeResourceId: string | null
onSelectResource: (id: string) => void
onRenameResource: (id: string, newTitle: string) => void
onCollapse: () => void
isCollapsed: boolean
className?: string
}
/**
* Split-pane view that renders embedded resources (tables, files, workflows)
* Split-pane view that renders embedded resources (tables, files, workflows, knowledge bases)
* alongside the chat conversation. Composes ResourceTabs for navigation
* and ResourceContent for rendering the active resource.
*/
@@ -36,12 +45,40 @@ export function MothershipView({
resources,
activeResourceId,
onSelectResource,
onRenameResource,
onCollapse,
isCollapsed,
className,
}: MothershipViewProps) {
const active = resources.find((r) => r.id === activeResourceId) ?? resources[0] ?? null
const { mutate: renameFile } = useRenameWorkspaceFile()
const { mutate: renameTable } = useRenameTable(workspaceId)
const resourcesRef = useRef(resources)
useEffect(() => {
resourcesRef.current = resources
}, [resources])
const handleRenameSave = useCallback(
(id: string, newName: string) => {
const resource = resourcesRef.current.find((r) => r.id === id)
if (!resource) return
onRenameResource(id, newName)
if (resource.type === 'file') {
renameFile({ workspaceId, fileId: id, name: newName })
} else if (resource.type === 'table') {
renameTable({ tableId: id, name: newName })
}
},
[onRenameResource, renameFile, renameTable, workspaceId]
)
const { editingId, editValue, setEditValue, startRename, submitRename, cancelRename } =
useInlineRename({ onSave: handleRenameSave })
const [previewMode, setPreviewMode] = useState<PreviewMode>('split')
const handleCyclePreview = useCallback(() => setPreviewMode((m) => PREVIEW_CYCLE[m]), [])
@@ -56,6 +93,8 @@ export function MothershipView({
const headerActions =
active?.type === 'workflow' ? (
<EmbeddedWorkflowActions workspaceId={workspaceId} workflowId={active.id} />
) : active?.type === 'knowledgebase' ? (
<EmbeddedKnowledgeBaseActions workspaceId={workspaceId} knowledgeBaseId={active.id} />
) : null
return (
@@ -75,6 +114,12 @@ export function MothershipView({
actions={headerActions}
previewMode={isActivePreviewable ? previewMode : undefined}
onCyclePreviewMode={isActivePreviewable ? handleCyclePreview : undefined}
editingId={editingId}
editValue={editValue}
onEditValueChange={setEditValue}
onStartRename={startRename}
onSubmitRename={submitRename}
onCancelRename={cancelRename}
/>
<div className='min-h-0 flex-1 overflow-hidden'>
{active && (

View File

@@ -169,6 +169,7 @@ export function Home({ chatId }: HomeProps = {}) {
resources,
activeResourceId,
setActiveResourceId,
renameResource,
} = useChat(workspaceId, chatId)
const [isResourceCollapsed, setIsResourceCollapsed] = useState(false)
@@ -346,6 +347,7 @@ export function Home({ chatId }: HomeProps = {}) {
resources={resources}
activeResourceId={activeResourceId}
onSelectResource={setActiveResourceId}
onRenameResource={renameResource}
onCollapse={collapseResource}
isCollapsed={isResourceCollapsed}
className={animateResourcePanel ? 'animate-slide-in-right' : undefined}

View File

@@ -3,6 +3,7 @@ import { createLogger } from '@sim/logger'
import { useQueryClient } from '@tanstack/react-query'
import { usePathname } from 'next/navigation'
import { MOTHERSHIP_CHAT_API_PATH } from '@/lib/copilot/constants'
import { knowledgeKeys } from '@/hooks/queries/kb/knowledge'
import { tableKeys } from '@/hooks/queries/tables'
import {
type TaskChatHistory,
@@ -29,9 +30,11 @@ import type {
import {
extractFileResource,
extractFunctionExecuteResource,
extractKnowledgeBaseResource,
extractResourcesFromHistory,
extractTableResource,
extractWorkflowResource,
GENERIC_TITLES,
RESOURCE_TOOL_NAMES,
} from '../utils'
@@ -44,6 +47,7 @@ export interface UseChatReturn {
resources: MothershipResource[]
activeResourceId: string | null
setActiveResourceId: (id: string | null) => void
renameResource: (id: string, newTitle: string) => void
}
const STATE_TO_STATUS: Record<string, ToolCallStatus> = {
@@ -160,11 +164,15 @@ export function useChat(workspaceId: string, initialChatId?: string): UseChatRet
const { data: chatHistory } = useChatHistory(initialChatId)
const renameResource = useCallback((id: string, newTitle: string) => {
setResources((prev) => prev.map((r) => (r.id === id ? { ...r, title: newTitle } : r)))
}, [])
const addResource = useCallback((resource: MothershipResource) => {
setResources((prev) => {
const existing = prev.find((r) => r.type === resource.type && r.id === resource.id)
if (existing) {
const keepOldTitle = existing.title !== 'Table' && existing.title !== 'File'
const keepOldTitle = !GENERIC_TITLES.has(existing.title)
const title = keepOldTitle ? existing.title : resource.title
if (title === existing.title) return prev
return prev.map((r) =>
@@ -468,6 +476,16 @@ export function useChat(workspaceId: string, initialChatId?: string): UseChatRet
registry.loadWorkflowState(resource.id)
}
}
} else if (toolName === 'knowledge') {
resource = extractKnowledgeBaseResource(parsed, storedArgs)
if (resource) {
queryClient.invalidateQueries({
queryKey: knowledgeKeys.detail(resource.id),
})
queryClient.invalidateQueries({
queryKey: knowledgeKeys.list(workspaceId),
})
}
}
if (resource) addResource(resource)
@@ -723,5 +741,6 @@ export function useChat(workspaceId: string, initialChatId?: string): UseChatRet
resources,
activeResourceId,
setActiveResourceId,
renameResource,
}
}

View File

@@ -227,7 +227,7 @@ export interface SSEPayload {
subagent?: string
}
export type MothershipResourceType = 'table' | 'file' | 'workflow'
export type MothershipResourceType = 'table' | 'file' | 'workflow' | 'knowledgebase'
export interface MothershipResource {
type: MothershipResourceType

View File

@@ -8,13 +8,21 @@ export const RESOURCE_TOOL_NAMES = new Set([
'edit_workflow',
'function_execute',
'read',
'knowledge',
])
/**
* Resolves the top-level result object from an SSE payload.
* The result may arrive at `parsed.result` or nested under `parsed.data.result`.
*/
function getTopResult(parsed: SSEPayload): Record<string, unknown> | undefined {
return (parsed.result ?? (typeof parsed.data === 'object' ? parsed.data?.result : undefined)) as
| Record<string, unknown>
| undefined
}
function getResultData(parsed: SSEPayload): Record<string, unknown> | undefined {
const topResult = parsed.result as Record<string, unknown> | undefined
const nestedResult =
typeof parsed.data === 'object' ? (parsed.data?.result as Record<string, unknown>) : undefined
const result = topResult ?? nestedResult
const result = getTopResult(parsed)
return result?.data as Record<string, unknown> | undefined
}
@@ -66,10 +74,7 @@ export function extractFunctionExecuteResource(
parsed: SSEPayload,
storedArgs: Record<string, unknown> | undefined
): MothershipResource | null {
const topResult = (parsed.result ??
(typeof parsed.data === 'object' ? parsed.data?.result : undefined)) as
| Record<string, unknown>
| undefined
const topResult = getTopResult(parsed)
if (topResult?.tableId) {
return {
@@ -94,10 +99,7 @@ export function extractWorkflowResource(
parsed: SSEPayload,
fallbackWorkflowId: string | null
): MothershipResource | null {
const topResult = (parsed.result ??
(typeof parsed.data === 'object' ? parsed.data?.result : undefined)) as
| Record<string, unknown>
| undefined
const topResult = getTopResult(parsed)
const data = topResult?.data as Record<string, unknown> | undefined
const workflowId =
@@ -110,7 +112,29 @@ export function extractWorkflowResource(
return null
}
const GENERIC_TITLES = new Set(['Table', 'File', 'Workflow'])
export function extractKnowledgeBaseResource(
parsed: SSEPayload,
storedArgs: Record<string, unknown> | undefined
): MothershipResource | null {
const topResult = getTopResult(parsed)
const data = topResult?.data as Record<string, unknown> | undefined
const knowledgeBaseId =
(data?.id as string) ??
(topResult?.knowledgeBaseId as string) ??
(data?.knowledgeBaseId as string) ??
(storedArgs?.knowledgeBaseId as string)
const knowledgeBaseName =
(data?.name as string) ?? (topResult?.knowledgeBaseName as string) ?? 'Knowledge Base'
if (knowledgeBaseId) {
return { type: 'knowledgebase', id: knowledgeBaseId, title: knowledgeBaseName }
}
return null
}
export const GENERIC_TITLES = new Set(['Table', 'File', 'Workflow', 'Knowledge Base'])
/**
* Reconstructs the MothershipResource list from persisted tool calls.
@@ -157,6 +181,8 @@ 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') {
resource = extractKnowledgeBaseResource(payload, args)
}
if (resource) {

View File

@@ -91,6 +91,7 @@ const DOCUMENT_COLUMNS: ResourceColumn[] = [
interface KnowledgeBaseProps {
id: string
knowledgeBaseName?: string
workspaceId?: string
}
const AnimatedLoader = ({ className }: { className?: string }) => (
@@ -185,9 +186,10 @@ function getDocumentTags(doc: DocumentData, definitions: TagDefinition[]): TagVa
export function KnowledgeBase({
id,
knowledgeBaseName: passedKnowledgeBaseName,
workspaceId: propWorkspaceId,
}: KnowledgeBaseProps) {
const params = useParams()
const workspaceId = params.workspaceId as string
const workspaceId = propWorkspaceId || (params.workspaceId as string)
const { removeKnowledgeBase } = useKnowledgeBasesList(workspaceId, { enabled: false })
const userPermissions = useUserPermissionsContext()

View File

@@ -4,6 +4,7 @@ import type { WorkspaceFileArgs, WorkspaceFileResult } from '@/lib/copilot/tools
import {
deleteWorkspaceFile,
getWorkspaceFile,
renameWorkspaceFile,
updateWorkspaceFileContent,
uploadWorkspaceFile,
} from '@/lib/uploads/contexts/workspace/workspace-file-manager'
@@ -125,6 +126,39 @@ export const workspaceFileServerTool: BaseServerTool<WorkspaceFileArgs, Workspac
}
}
case 'rename': {
const fileId = (args as Record<string, unknown>).fileId as string | undefined
const newName = (args as Record<string, unknown>).newName as string | undefined
if (!fileId) {
return { success: false, message: 'fileId is required for rename operation' }
}
if (!newName) {
return { success: false, message: 'newName is required for rename operation' }
}
const fileRecord = await getWorkspaceFile(workspaceId, fileId)
if (!fileRecord) {
return { success: false, message: `File with ID "${fileId}" not found` }
}
const oldName = fileRecord.name
await renameWorkspaceFile(workspaceId, fileId, newName)
logger.info('Workspace file renamed via copilot', {
fileId,
oldName,
newName,
userId: context.userId,
})
return {
success: true,
message: `File renamed from "${oldName}" to "${newName}"`,
data: { id: fileId, name: newName },
}
}
case 'delete': {
const fileId = (args as Record<string, unknown>).fileId as string | undefined
if (!fileId) {
@@ -154,7 +188,7 @@ export const workspaceFileServerTool: BaseServerTool<WorkspaceFileArgs, Workspac
default:
return {
success: false,
message: `Unknown operation: ${operation}. Supported: write, delete. Use the filesystem to list/read files.`,
message: `Unknown operation: ${operation}. Supported: write, update, rename, delete. Use the filesystem to list/read files.`,
}
}
} catch (error) {

View File

@@ -57,7 +57,7 @@ const WRITE_ACTIONS: Record<string, string[]> = {
manage_mcp_tool: ['add', 'edit', 'delete'],
manage_skill: ['add', 'edit', 'delete'],
manage_credential: ['rename', 'delete'],
workspace_file: ['write', 'update', 'delete'],
workspace_file: ['write', 'update', 'delete', 'rename'],
}
function isActionAllowed(toolName: string, action: string, userPermission: string): boolean {

View File

@@ -172,7 +172,7 @@ export type UserTableResult = z.infer<typeof UserTableResultSchema>
// workspace_file - shared schema used by server tool and Go catalog
export const WorkspaceFileArgsSchema = z.object({
operation: z.enum(['write', 'update', 'delete']),
operation: z.enum(['write', 'update', 'delete', 'rename']),
args: z
.object({
fileId: z.string().optional(),
@@ -180,6 +180,8 @@ export const WorkspaceFileArgsSchema = z.object({
content: z.string().optional(),
contentType: z.string().optional(),
workspaceId: z.string().optional(),
/** New name for the file (required for rename operation) */
newName: z.string().optional(),
})
.optional(),
})