chat metadata

This commit is contained in:
Emir Karabeg
2026-03-10 17:27:48 -07:00
parent 18dacc53bf
commit 2587406b2a
10 changed files with 119 additions and 50 deletions

View File

@@ -1,6 +1,6 @@
import { cn } from '@/lib/core/utils/cn'
import type { MothershipToolName, ToolCallStatus, ToolPhase } from '../../../../types'
import { TOOL_UI_METADATA } from '../../../../types'
import type { MothershipToolName, SubagentName, ToolCallStatus, ToolPhase } from '../../../../types'
import { SUBAGENT_LABELS, TOOL_UI_METADATA } from '../../../../types'
import { getToolIcon } from '../../utils'
const STATUS_STYLES: Record<ToolCallStatus, string> = {
@@ -24,14 +24,18 @@ interface ToolCallProps {
displayTitle?: string
status: ToolCallStatus
phaseLabel?: string
calledBy?: string
}
export function ToolCall({ toolName, displayTitle, status, phaseLabel }: ToolCallProps) {
export function ToolCall({ toolName, displayTitle, status, phaseLabel, calledBy }: ToolCallProps) {
const metadata = TOOL_UI_METADATA[toolName as MothershipToolName]
const resolvedTitle = displayTitle || metadata?.title || toolName
const resolvedPhase = phaseLabel || metadata?.phaseLabel
const resolvedPhaseType = metadata?.phase
const Icon = getToolIcon(toolName)
const callerLabel = calledBy
? (SUBAGENT_LABELS[calledBy as SubagentName] ?? calledBy)
: 'Mothership'
return (
<div className='flex items-center gap-[6px]'>
@@ -48,6 +52,7 @@ export function ToolCall({ toolName, displayTitle, status, phaseLabel }: ToolCal
{resolvedPhase}
</span>
)}
<span className='text-[11px] text-[var(--text-quaternary)]'>via {callerLabel}</span>
</div>
)
}

View File

@@ -16,6 +16,7 @@ interface ToolCallSegment {
displayTitle?: string
status: ToolCallStatus
phaseLabel?: string
calledBy?: string
}
interface SubagentSegment {
@@ -92,6 +93,7 @@ function parseBlocks(blocks: ContentBlock[], isStreaming: boolean): MessageSegme
displayTitle: block.toolCall.displayTitle || formatToolName(block.toolCall.name),
status: block.toolCall.status,
phaseLabel: block.toolCall.phaseLabel,
calledBy: block.toolCall.calledBy,
})
}
break
@@ -147,6 +149,7 @@ export function MessageContent({
displayTitle={segment.displayTitle}
status={segment.status}
phaseLabel={segment.phaseLabel}
calledBy={segment.calledBy}
/>
)
case 'subagent':

View File

@@ -97,6 +97,10 @@ export function UserInput({
onSubmit(fileAttachmentsForApi.length > 0 ? fileAttachmentsForApi : undefined)
files.clearAttachedFiles()
if (textareaRef.current) {
textareaRef.current.style.height = 'auto'
}
}, [onSubmit, files])
const handleKeyDown = useCallback(

View File

@@ -200,8 +200,30 @@ export function useChat(workspaceId: string, initialChatId?: string): UseChatRet
if (restored.length > 0) {
setResources(restored)
setActiveResourceId(restored[restored.length - 1].id)
for (const resource of restored) {
if (resource.type !== 'workflow') continue
const registry = useWorkflowRegistry.getState()
if (!registry.workflows[resource.id]) {
useWorkflowRegistry.setState((state) => ({
workflows: {
...state.workflows,
[resource.id]: {
id: resource.id,
name: resource.title,
lastModified: new Date(),
createdAt: new Date(),
color: '#7F2FFF',
workspaceId,
folderId: null,
sortOrder: 0,
},
},
}))
}
}
}
}, [chatHistory])
}, [chatHistory, workspaceId])
const processSSEStream = useCallback(
async (reader: ReadableStreamDefaultReader<Uint8Array>, assistantId: string) => {
@@ -209,6 +231,7 @@ export function useChat(workspaceId: string, initialChatId?: string): UseChatRet
let buffer = ''
const blocks: ContentBlock[] = []
const toolMap = new Map<string, number>()
let activeSubagent: string | undefined
let lastTableId: string | null = null
let lastWorkflowId: string | null = null
let runningText = ''
@@ -304,6 +327,7 @@ export function useChat(workspaceId: string, initialChatId?: string): UseChatRet
}
}
if (name.endsWith('_respond')) break
const ui = parsed.ui || data?.ui
if (ui?.hidden) break
const displayTitle = ui?.title || ui?.phaseLabel
@@ -312,7 +336,14 @@ export function useChat(workspaceId: string, initialChatId?: string): UseChatRet
toolMap.set(id, blocks.length)
blocks.push({
type: 'tool_call',
toolCall: { id, name, status: 'executing', displayTitle, phaseLabel },
toolCall: {
id,
name,
status: 'executing',
displayTitle,
phaseLabel,
calledBy: activeSubagent,
},
})
} else {
const idx = toolMap.get(id)!
@@ -424,12 +455,14 @@ export function useChat(workspaceId: string, initialChatId?: string): UseChatRet
case 'subagent_start': {
const name = parsed.subagent || getPayloadData(parsed)?.agent
if (name) {
activeSubagent = name
blocks.push({ type: 'subagent', content: name })
flush()
}
break
}
case 'subagent_end': {
activeSubagent = undefined
flush()
break
}

View File

@@ -64,6 +64,7 @@ export type MothershipToolName =
| 'research'
| 'plan'
| 'debug'
| 'edit'
/**
* Subagent identifiers dispatched via `subagent_start` SSE events.
@@ -102,6 +103,7 @@ export interface ToolCallInfo {
status: ToolCallStatus
displayTitle?: string
phaseLabel?: string
calledBy?: string
result?: { success: boolean; output?: unknown; error?: string }
}
@@ -180,6 +182,7 @@ export const TOOL_UI_METADATA: Partial<Record<MothershipToolName, ToolUIMetadata
research: { title: 'Researching', phaseLabel: 'Research', phase: 'subagent' },
plan: { title: 'Planning', phaseLabel: 'Plan', phase: 'subagent' },
debug: { title: 'Debugging', phaseLabel: 'Debug', phase: 'subagent' },
edit: { title: 'Editing workflow', phaseLabel: 'Edit', phase: 'subagent' },
}
export interface SSEPayloadUI {

View File

@@ -12,6 +12,8 @@ export interface WorkflowBlockProps {
isPreview?: boolean
/** Whether this block is selected in preview mode */
isPreviewSelected?: boolean
/** Whether this block is rendered inside an embedded (read-only) workflow view */
isEmbedded?: boolean
subBlockValues?: Record<string, any>
blockState?: any
}

View File

@@ -1248,7 +1248,7 @@ export const WorkflowBlock = memo(function WorkflowBlock({
</div>
)}
{!data.isPreview && (
{!data.isPreview && !data.isEmbedded && (
<ActionBar blockId={id} blockType={type} disabled={!userPermissions.canEdit} />
)}

View File

@@ -37,6 +37,7 @@ export function useBlockVisual({
isSelected = false,
}: UseBlockVisualProps) {
const isPreview = data.isPreview ?? false
const isEmbedded = data.isEmbedded ?? false
const isPreviewSelected = data.isPreviewSelected ?? false
const currentWorkflow = useCurrentWorkflow()
@@ -56,13 +57,13 @@ export function useBlockVisual({
const activeTabIsEditor = usePanelStore(
useCallback(
(state) => {
if (isPreview || !isThisBlockInEditor) return false
if (isPreview || isEmbedded || !isThisBlockInEditor) return false
return state.activeTab === 'editor'
},
[isPreview, isThisBlockInEditor]
[isPreview, isEmbedded, isThisBlockInEditor]
)
)
const isEditorOpen = !isPreview && isThisBlockInEditor && activeTabIsEditor
const isEditorOpen = !isPreview && !isEmbedded && isThisBlockInEditor && activeTabIsEditor
const lastRunPath = useLastRunPath()
const runPathStatus = isPreview ? undefined : lastRunPath.get(blockId)
@@ -70,10 +71,10 @@ export function useBlockVisual({
const setCurrentBlockId = usePanelEditorStore((state) => state.setCurrentBlockId)
const handleClick = useCallback(() => {
if (!isPreview) {
if (!isPreview && !isEmbedded) {
setCurrentBlockId(blockId)
}
}, [blockId, setCurrentBlockId, isPreview])
}, [blockId, setCurrentBlockId, isPreview, isEmbedded])
const { hasRing, ringClassName: ringStyles } = useMemo(
() =>
@@ -85,7 +86,7 @@ export function useBlockVisual({
diffStatus: isPreview ? undefined : diffStatus,
runPathStatus,
isPreviewSelection: isPreview && isPreviewSelected,
isSelected: isPreview ? false : isSelected,
isSelected: isPreview || isEmbedded ? false : isSelected,
}),
[
isExecuting,
@@ -95,6 +96,7 @@ export function useBlockVisual({
diffStatus,
runPathStatus,
isPreview,
isEmbedded,
isPreviewSelected,
isSelected,
]

View File

@@ -243,7 +243,7 @@ const WorkflowContent = React.memo(
const [isErrorConnectionDrag, setIsErrorConnectionDrag] = useState(false)
const selectedIdsRef = useRef<string[] | null>(null)
const canvasMode = useCanvasModeStore((state) => state.mode)
const isHandMode = embedded ? false : canvasMode === 'hand'
const isHandMode = embedded ? true : canvasMode === 'hand'
const { handleCanvasMouseDown, selectionProps } = useShiftSelectionLock({ isHandMode })
const [oauthModal, setOauthModal] = useState<{
provider: OAuthProvider
@@ -2299,6 +2299,8 @@ const WorkflowContent = React.memo(
/** Handles navigation validation and redirects for invalid workflow IDs. */
useEffect(() => {
if (embedded) return
// Wait for metadata to finish loading before making navigation decisions
if (hydration.phase === 'metadata-loading' || hydration.phase === 'idle') {
return
@@ -2341,6 +2343,7 @@ const WorkflowContent = React.memo(
router.replace(`/workspace/${workflowData.workspaceId}/w/${workflowIdParam}`)
}
}, [
embedded,
workflowIdParam,
currentWorkflowExists,
workflowCount,
@@ -2480,6 +2483,7 @@ const WorkflowContent = React.memo(
name: block.name,
isActive,
isPending,
...(embedded && { isEmbedded: true }),
},
// Include dynamic dimensions for container resizing calculations (must match rendered size)
// Both note and workflow blocks calculate dimensions deterministically via useBlockDimensions
@@ -3862,14 +3866,14 @@ const WorkflowContent = React.memo(
onSelectionContextMenu={handleSelectionContextMenu}
onPointerMove={handleCanvasPointerMove}
onPointerLeave={handleCanvasPointerLeave}
elementsSelectable={true}
selectionOnDrag={selectionProps.selectionOnDrag}
elementsSelectable={!embedded}
selectionOnDrag={embedded ? false : selectionProps.selectionOnDrag}
selectionMode={SelectionMode.Partial}
panOnDrag={selectionProps.panOnDrag}
selectionKeyCode={selectionProps.selectionKeyCode}
multiSelectionKeyCode={['Meta', 'Control', 'Shift']}
nodesConnectable={effectivePermissions.canEdit}
nodesDraggable={effectivePermissions.canEdit}
panOnDrag={embedded ? true : selectionProps.panOnDrag}
selectionKeyCode={embedded ? null : selectionProps.selectionKeyCode}
multiSelectionKeyCode={embedded ? null : ['Meta', 'Control', 'Shift']}
nodesConnectable={!embedded && effectivePermissions.canEdit}
nodesDraggable={!embedded && effectivePermissions.canEdit}
draggable={false}
noWheelClassName='allow-scroll'
edgesFocusable={true}

View File

@@ -381,6 +381,7 @@ export const Sidebar = memo(function Sidebar() {
[fetchedTasks, workspaceId]
)
const [visibleTaskCount, setVisibleTaskCount] = useState(5)
const [renamingTaskId, setRenamingTaskId] = useState<string | null>(null)
const [renameValue, setRenameValue] = useState('')
const renameInputRef = useRef<HTMLInputElement>(null)
@@ -803,43 +804,55 @@ export const Sidebar = memo(function Sidebar() {
{tasksLoading ? (
<SidebarItemSkeleton />
) : (
tasks.map((task) => {
const active = task.id !== 'new' && pathname === task.href
const isRenaming = renamingTaskId === task.id
<>
{tasks.slice(0, visibleTaskCount).map((task) => {
const active = task.id !== 'new' && pathname === task.href
const isRenaming = renamingTaskId === task.id
if (isRenaming) {
return (
<div
key={task.id}
className='mx-[2px] flex h-[30px] items-center gap-[8px] rounded-[8px] bg-[var(--surface-active)] px-[8px] text-[14px]'
>
<Blimp className='h-[16px] w-[16px] flex-shrink-0 text-[var(--text-icon)]' />
<input
ref={renameInputRef}
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
onKeyDown={handleRenameKeyDown}
onBlur={handleSaveTaskRename}
className='min-w-0 flex-1 border-none bg-transparent font-base text-[14px] text-[var(--text-body)] outline-none'
/>
</div>
)
}
if (isRenaming) {
return (
<div
<Link
key={task.id}
className='mx-[2px] flex h-[30px] items-center gap-[8px] rounded-[8px] bg-[var(--surface-active)] px-[8px] text-[14px]'
href={task.href}
className={`mx-[2px] flex h-[30px] items-center gap-[8px] rounded-[8px] px-[8px] text-[14px] hover:bg-[var(--surface-active)] ${active ? 'bg-[var(--surface-active)]' : ''}`}
onContextMenu={(e) => handleTaskContextMenu(e, task.href, task.id)}
>
<Blimp className='h-[16px] w-[16px] flex-shrink-0 text-[var(--text-icon)]' />
<input
ref={renameInputRef}
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
onKeyDown={handleRenameKeyDown}
onBlur={handleSaveTaskRename}
className='min-w-0 flex-1 border-none bg-transparent font-base text-[14px] text-[var(--text-body)] outline-none'
/>
</div>
<div className='min-w-0 truncate font-[var(--sidebar-font-weight)] text-[var(--text-body)]'>
{task.name}
</div>
</Link>
)
}
return (
<Link
key={task.id}
href={task.href}
className={`mx-[2px] flex h-[30px] items-center gap-[8px] rounded-[8px] px-[8px] text-[14px] hover:bg-[var(--surface-active)] ${active ? 'bg-[var(--surface-active)]' : ''}`}
onContextMenu={(e) => handleTaskContextMenu(e, task.href, task.id)}
})}
{tasks.length > visibleTaskCount && (
<button
type='button'
onClick={() => setVisibleTaskCount((prev) => prev + 5)}
className='mx-[2px] flex h-[30px] items-center gap-[8px] rounded-[8px] px-[8px] text-[14px] text-[var(--text-icon)] hover:bg-[var(--surface-active)]'
>
<Blimp className='h-[16px] w-[16px] flex-shrink-0 text-[var(--text-icon)]' />
<div className='min-w-0 truncate font-[var(--sidebar-font-weight)] text-[var(--text-body)]'>
{task.name}
</div>
</Link>
)
})
<MoreHorizontal className='h-[16px] w-[16px] flex-shrink-0' />
<span className='font-[var(--sidebar-font-weight)]'>See more</span>
</button>
)}
</>
)}
</div>
</div>