chore: import upstream snapshot with attribution
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:20:55 +08:00
commit d25d482dc2
13754 changed files with 4996608 additions and 0 deletions
@@ -0,0 +1,102 @@
import type { ReactNode } from 'react'
import {
Calendar,
Database,
Folder as FolderIcon,
Library,
Table as TableIcon,
Task,
Workflow,
} from '@sim/emcn/icons'
import { AgentSkillsIcon } from '@/components/icons'
import { getDocumentIcon } from '@/components/icons/document-icons'
import type { ChatContextKind, ChatMessageContext } from '@/app/workspace/[workspaceId]/home/types'
import { getBareIconStyle } from '@/blocks/icon-color'
import { registry as blockRegistry } from '@/blocks/registry'
interface RenderIconArgs {
context: ChatMessageContext
className: string
}
interface ChatContextKindConfig {
/** Human label for the kind (used in tooltips / accessible names). */
label: string
/** Renders the chip icon. Returns null when no icon should be shown for this kind. */
renderIcon: (args: RenderIconArgs) => ReactNode | null
}
function renderWorkflowIcon({ className }: RenderIconArgs): ReactNode | null {
return <Workflow className={className} />
}
/**
* Renders the integration chip glyph: just the block's brand SVG icon, no
* background tile — sized and positioned by the caller-supplied className
* (same slot the `@` character normally occupies). The block is resolved
* by `context.blockType` so the chip stays in sync with the registry.
*/
function renderIntegrationTile({ context, className }: RenderIconArgs): ReactNode | null {
if (context.kind !== 'integration') return null
if (!context.blockType) return null
const block = blockRegistry[context.blockType]
if (!block) return null
const Icon = block.icon
return <Icon className={className} style={getBareIconStyle(Icon)} />
}
/**
* Single source of truth for the icon and label associated with each
* {@link ChatContextKind}. The `Record<ChatContextKind, …>` typing forces a
* compile error whenever a new kind is added to the union without a
* corresponding entry here, preventing the chip from silently rendering
* without an icon.
*/
export const CHAT_CONTEXT_KIND_REGISTRY: Record<ChatContextKind, ChatContextKindConfig> = {
workflow: { label: 'Workflow', renderIcon: renderWorkflowIcon },
current_workflow: { label: 'Current workflow', renderIcon: renderWorkflowIcon },
workflow_block: { label: 'Block', renderIcon: renderWorkflowIcon },
blocks: { label: 'Blocks', renderIcon: () => null },
knowledge: {
label: 'Knowledge base',
renderIcon: ({ className }) => <Database className={className} />,
},
table: {
label: 'Table',
renderIcon: ({ className }) => <TableIcon className={className} />,
},
file: {
label: 'File',
renderIcon: ({ context, className }) => {
const FileDocIcon = getDocumentIcon('', context.label)
return <FileDocIcon className={className} />
},
},
folder: {
label: 'Folder',
renderIcon: ({ className }) => <FolderIcon className={className} />,
},
filefolder: {
label: 'File folder',
renderIcon: ({ className }) => <FolderIcon className={className} />,
},
scheduledtask: {
label: 'Scheduled task',
renderIcon: ({ className }) => <Calendar className={className} />,
},
past_chat: {
label: 'Past chat',
renderIcon: ({ className }) => <Task className={className} />,
},
logs: {
label: 'Logs',
renderIcon: ({ className }) => <Library className={className} />,
},
docs: { label: 'Docs', renderIcon: () => null },
slash_command: { label: 'Command', renderIcon: () => null },
integration: { label: 'Integration', renderIcon: renderIntegrationTile },
skill: {
label: 'Skill',
renderIcon: ({ className }) => <AgentSkillsIcon className={className} />,
},
}
@@ -0,0 +1 @@
export { CHAT_CONTEXT_KIND_REGISTRY } from './chat-context-kind-registry'
@@ -0,0 +1,67 @@
import { cn } from '@sim/emcn'
import { getDocumentIcon } from '@/components/icons/document-icons'
import type { ChatMessageAttachment } from '@/app/workspace/[workspaceId]/home/types'
function FileAttachmentPill(props: { mediaType: string; filename: string }) {
const Icon = getDocumentIcon(props.mediaType, props.filename)
return (
<div className='flex max-w-[140px] items-center gap-[5px] rounded-[10px] bg-[var(--surface-5)] px-[6px] py-[3px]'>
<Icon className='size-[14px] flex-shrink-0 text-[var(--text-icon)]' />
<span className='truncate text-[11px] text-[var(--text-body)]'>{props.filename}</span>
</div>
)
}
export function ChatMessageAttachments(props: {
attachments: ChatMessageAttachment[]
align?: 'start' | 'end'
className?: string
}) {
const { attachments, align = 'end', className } = props
if (!attachments.length) return null
return (
<div
className={cn(
'flex flex-wrap gap-[6px]',
align === 'end' ? 'justify-end' : 'justify-start',
className
)}
>
{attachments.map((att) => {
if (!att.previewUrl) {
return (
<FileAttachmentPill key={att.id} mediaType={att.media_type} filename={att.filename} />
)
}
const isVideo = att.media_type.startsWith('video/')
if (isVideo) {
const Icon = getDocumentIcon(att.media_type, att.filename)
return (
<div
key={att.id}
className='relative size-[56px] overflow-hidden rounded-[8px] bg-[var(--surface-5)]'
>
<div className='absolute inset-0 flex items-center justify-center text-[var(--text-icon)]'>
<Icon className='size-[18px]' />
</div>
<video
src={att.previewUrl}
muted
playsInline
preload='metadata'
className='relative h-full w-full object-cover'
/>
</div>
)
}
return (
<div key={att.id} className='size-[56px] overflow-hidden rounded-[8px]'>
<img src={att.previewUrl} alt={att.filename} className='h-full w-full object-cover' />
</div>
)
})}
</div>
)
}
@@ -0,0 +1 @@
export { ChatMessageAttachments } from './chat-message-attachments'
@@ -0,0 +1,105 @@
'use client'
import {
createContext,
type ReactNode,
useCallback,
useContext,
useLayoutEffect,
useMemo,
useRef,
} from 'react'
import { noop } from '@sim/utils/helpers'
import type { MothershipResource } from '@/app/workspace/[workspaceId]/home/types'
import type { ChatContext } from '@/stores/panel'
/**
* Identity and interaction callbacks shared across a Mothership chat surface
* (home conversation view, home initial view, copilot panel). Carried via
* context so leaf components (UserInput, MessageContent, MessageActions) can
* consume them without relaying through every intermediate component.
*/
interface ChatSurfaceContextValue {
/** Resolved id of the chat backing this surface, if one exists yet. */
chatId?: string
/** Id of the user interacting with this surface. */
userId?: string
/** Notifies the surface owner that a context chip was added to the input. */
onContextAdd: (context: ChatContext) => void
/** Notifies the surface owner that a context chip was removed from the input. */
onContextRemove: (context: ChatContext) => void
/** Opens a workspace resource referenced from rendered message content. */
onWorkspaceResourceSelect: (resource: MothershipResource) => void
}
const ChatSurfaceContext = createContext<ChatSurfaceContextValue>({
onContextAdd: noop,
onContextRemove: noop,
onWorkspaceResourceSelect: noop,
})
interface ChatSurfaceProviderProps {
chatId?: string
userId?: string
onContextAdd?: (context: ChatContext) => void
onContextRemove?: (context: ChatContext) => void
onWorkspaceResourceSelect?: (resource: MothershipResource) => void
children: ReactNode
}
/**
* Provides the chat-surface identity and interaction callbacks to descendants.
* Callbacks are latched in refs and exposed as stable wrappers so the memoized
* context value only changes when `chatId` or `userId` change — consumers do
* not re-render when a parent re-creates a handler.
*/
export function ChatSurfaceProvider({
chatId,
userId,
onContextAdd,
onContextRemove,
onWorkspaceResourceSelect,
children,
}: ChatSurfaceProviderProps) {
const onContextAddRef = useRef(onContextAdd)
const onContextRemoveRef = useRef(onContextRemove)
const onWorkspaceResourceSelectRef = useRef(onWorkspaceResourceSelect)
useLayoutEffect(() => {
onContextAddRef.current = onContextAdd
onContextRemoveRef.current = onContextRemove
onWorkspaceResourceSelectRef.current = onWorkspaceResourceSelect
})
const stableOnContextAdd = useCallback((context: ChatContext) => {
onContextAddRef.current?.(context)
}, [])
const stableOnContextRemove = useCallback((context: ChatContext) => {
onContextRemoveRef.current?.(context)
}, [])
const stableOnWorkspaceResourceSelect = useCallback((resource: MothershipResource) => {
onWorkspaceResourceSelectRef.current?.(resource)
}, [])
const value = useMemo<ChatSurfaceContextValue>(
() => ({
chatId,
userId,
onContextAdd: stableOnContextAdd,
onContextRemove: stableOnContextRemove,
onWorkspaceResourceSelect: stableOnWorkspaceResourceSelect,
}),
[chatId, userId, stableOnContextAdd, stableOnContextRemove, stableOnWorkspaceResourceSelect]
)
return <ChatSurfaceContext.Provider value={value}>{children}</ChatSurfaceContext.Provider>
}
/**
* Reads the surrounding chat surface. Outside a provider this returns no-op
* callbacks and undefined identity, matching the previous optional-prop
* behavior.
*/
export function useChatSurface(): ChatSurfaceContextValue {
return useContext(ChatSurfaceContext)
}
@@ -0,0 +1 @@
export { ChatSurfaceProvider, useChatSurface } from './chat-surface-context'
@@ -0,0 +1,18 @@
import { CHAT_CONTEXT_KIND_REGISTRY } from '@/app/workspace/[workspaceId]/home/components/chat-context-kind-registry'
import type { ChatMessageContext } from '@/app/workspace/[workspaceId]/home/types'
interface ContextMentionIconProps {
context: ChatMessageContext
/** Applied to every icon element. Include sizing and positional classes (e.g. h-[12px] w-[12px]). */
className: string
}
/** Renders the icon for a context mention chip. Returns null when no icon applies. */
export function ContextMentionIcon({ context, className }: ContextMentionIconProps) {
return (
CHAT_CONTEXT_KIND_REGISTRY[context.kind].renderIcon({
context,
className,
}) ?? null
)
}
@@ -0,0 +1 @@
export { ContextMentionIcon } from './context-mention-icon'
@@ -0,0 +1,98 @@
'use client'
import { useCallback } from 'react'
import { Chip } from '@sim/emcn'
import { Credit } from '@sim/emcn/icons'
import { useQueryClient } from '@tanstack/react-query'
import { useParams, useRouter } from 'next/navigation'
import { formatCredits } from '@/lib/billing/credits/conversion'
import { getPooledCreditsRemaining } from '@/lib/billing/on-demand'
import { buildUpgradeHref } from '@/lib/billing/upgrade-reasons'
import { isBillingEnabled } from '@/app/workspace/[workspaceId]/settings/navigation'
import { useMyMemberCredits } from '@/hooks/queries/organization'
import { usePlanView } from '@/hooks/queries/plan-view'
import { prefetchUpgradeBillingData, useSubscriptionData } from '@/hooks/queries/subscription'
import { prefetchWorkspaceSettings } from '@/hooks/queries/workspace'
export function CreditsChip() {
if (!isBillingEnabled) return null
return <CreditsChipInner />
}
function CreditsChipInner() {
const { planView, isLoading, hasData } = usePlanView()
/**
* `usePlanView` is built on top of `useSubscriptionData`, so the second call
* dedups against the same React Query cache entry. We read the raw usage
* fields here because `planView` intentionally only exposes plan-derived
* decisions, not display math.
*/
const { data } = useSubscriptionData()
const router = useRouter()
const queryClient = useQueryClient()
const { workspaceId } = useParams<{ workspaceId: string }>()
const { data: memberCredits, isLoading: memberLoading } = useMyMemberCredits(workspaceId)
const upgradeHref = buildUpgradeHref(workspaceId, 'credits')
/**
* Warm the route bundle and the exact queries the Upgrade page gates on, so
* the click navigates into already-cached data instead of a blank, loading page.
*/
const prefetchUpgrade = useCallback(() => {
router.prefetch(upgradeHref)
prefetchUpgradeBillingData(queryClient)
prefetchWorkspaceSettings(queryClient, workspaceId)
}, [router, queryClient, upgradeHref, workspaceId])
const renderChip = (dollars: number) => (
<Chip
aria-label='Credits remaining — upgrade plan'
onClick={() => router.push(upgradeHref)}
onMouseEnter={prefetchUpgrade}
onFocus={prefetchUpgrade}
leftIcon={Credit}
>
{formatCredits(dollars)}
</Chip>
)
// Wait for the per-member cap result before rendering: until it resolves,
// `limitDollars` is null and a capped member would briefly see the larger
// pooled number. Disabled (no workspace) → not loading, so non-org users are
// unaffected; cached after the first load (30s staleTime), so it's a one-time
// wait, not a per-navigation one.
if (memberLoading) return null
/**
* Pooled/plan remaining (dollars): unused plan allowance, matching enforcement
* (`currentUsage >= limit` blocks, so remaining is `limit - currentUsage`).
* Granted credits are already folded into `usageLimit`, so they are not added
* again here. Null when the plan-based chip wouldn't show on its own (data not
* ready, or the plan isn't credit-metered). The unlimited sentinel renders as ∞.
*/
const pooledData = !isLoading && hasData && planView.showCredits ? (data?.data ?? null) : null
const pooledRemaining =
pooledData === null
? null
: getPooledCreditsRemaining(pooledData.usageLimit, pooledData.currentUsage)
/**
* A per-member cap is the authoritative personal remaining, but the actor gate
* blocks on the pooled cap first — so show the tighter of the two, or a member
* could see credits left while every action 402s on org/plan usage. Clamp at 0.
* Fall back to personal alone when pooled isn't available/shown, so a capped
* member still sees a balance even where the plan chip would be hidden.
*/
const limitDollars = memberCredits?.limitDollars ?? null
if (limitDollars !== null) {
const personalRemaining = Math.max(0, limitDollars - (memberCredits?.usedDollars ?? 0))
return renderChip(
pooledRemaining === null ? personalRemaining : Math.min(personalRemaining, pooledRemaining)
)
}
if (pooledRemaining === null) return null
return renderChip(pooledRemaining)
}
@@ -0,0 +1 @@
export { CreditsChip } from './credits-chip'
@@ -0,0 +1,16 @@
export { ChatMessageAttachments } from './chat-message-attachments'
export { ChatSurfaceProvider, useChatSurface } from './chat-surface-context'
export { ContextMentionIcon } from './context-mention-icon'
export { CreditsChip } from './credits-chip'
export {
assistantMessageHasRenderableContent,
MessageContent,
} from './message-content'
export { MothershipChat } from './mothership-chat'
export {
MothershipResourcesProvider,
useMothershipResources,
} from './mothership-resources-context'
export { QueuedMessages } from './queued-messages'
export { SuggestedActions } from './suggested-actions'
export { UserInput, type UserInputHandle } from './user-input'
@@ -0,0 +1,71 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import type { ToolCallData, ToolCallStatus } from '../../../../types'
import type { AgentGroupItem } from './agent-group'
import { isAgentGroupResolved } from './agent-group'
let toolSeq = 0
function tool(status: ToolCallStatus): AgentGroupItem {
toolSeq += 1
const data: ToolCallData = {
id: `tool-${toolSeq}`,
toolName: 'grep',
displayTitle: 'Searching',
status,
}
return { type: 'tool', data }
}
function text(content: string): AgentGroupItem {
return { type: 'text', content }
}
function group(items: AgentGroupItem[], isDelegating = false): AgentGroupItem {
return {
type: 'agent_group',
group: {
id: `group-${toolSeq}`,
agentName: 'deploy',
agentLabel: 'Deploy',
items,
isDelegating,
isOpen: true,
},
}
}
describe('isAgentGroupResolved', () => {
it('is unresolved when there is no work yet', () => {
expect(isAgentGroupResolved([])).toBe(false)
expect(isAgentGroupResolved([text('thinking...')])).toBe(false)
})
it('resolves once every own tool is terminal', () => {
expect(isAgentGroupResolved([tool('success')])).toBe(true)
expect(isAgentGroupResolved([tool('success'), tool('error')])).toBe(true)
})
it('stays unresolved while any own tool is still executing', () => {
expect(isAgentGroupResolved([tool('success'), tool('executing')])).toBe(false)
})
it('resolves a parent whose only work is a finished child group', () => {
expect(isAgentGroupResolved([group([tool('success')])])).toBe(true)
})
it('stays unresolved while a nested child is still delegating', () => {
expect(isAgentGroupResolved([group([], true)])).toBe(false)
})
it('stays unresolved while a nested child has an executing tool', () => {
expect(isAgentGroupResolved([group([tool('executing')])])).toBe(false)
})
it('resolves deep nesting only when every descendant is terminal', () => {
expect(isAgentGroupResolved([group([group([tool('success')])])])).toBe(true)
expect(isAgentGroupResolved([group([group([tool('executing')])])])).toBe(false)
})
})
@@ -0,0 +1,249 @@
'use client'
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
import { ChevronDown, cn, Expandable, ExpandableContent } from '@sim/emcn'
import { ShimmerText } from '@/components/ui'
import type { ToolCallData } from '../../../../types'
import { getAgentIcon, isToolDone } from '../../utils'
import { ToolCallItem } from './tool-call-item'
/**
* A subagent group nested inside another agent's output. Carries the same shape
* as a top-level group so {@link AgentGroup} can render it recursively, which is
* how deterministic parent/child nesting (e.g. Deploy inside Workflow) is drawn.
*/
export interface NestedAgentGroup {
id: string
agentName: string
agentLabel: string
items: AgentGroupItem[]
isDelegating: boolean
isOpen: boolean
}
export type AgentGroupItem =
| { type: 'text'; content: string }
| { type: 'tool'; data: ToolCallData }
| { type: 'agent_group'; group: NestedAgentGroup }
interface AgentGroupProps {
agentName: string
agentLabel: string
items: AgentGroupItem[]
isDelegating?: boolean
isStreaming?: boolean
/** This group is the latest section in its parent sequence (drives collapse). */
isCurrentSection?: boolean
/** The subagent lane is still open (no subagent_end yet) — i.e. actively running. */
isLaneOpen?: boolean
}
export function isAgentGroupResolved(items: AgentGroupItem[]): boolean {
let hasWork = false
for (const item of items) {
if (item.type === 'tool') {
hasWork = true
if (!isToolDone(item.data.status)) return false
} else if (item.type === 'agent_group') {
hasWork = true
if (item.group.isDelegating || !isAgentGroupResolved(item.group.items)) return false
}
}
return hasWork
}
export function AgentGroup({
agentName,
agentLabel,
items,
isDelegating = false,
isStreaming = false,
isCurrentSection = false,
isLaneOpen = false,
}: AgentGroupProps) {
const AgentIcon = getAgentIcon(agentName)
const hasItems = items.length > 0
const resolved = isAgentGroupResolved(items)
const isWorking = (isDelegating && !resolved) || (isStreaming && isLaneOpen)
// Expand while the turn is live and any of: the lane is open (the subagent is
// actively running), this is the current/latest section, or there is unresolved
// work. A finished group stays open until the NEXT section starts (it is no
// longer the latest), instead of collapsing the instant its own work resolves.
// Keying "still running" off the lane-open signal (not `resolved` alone) avoids
// a collapse/reopen flicker on parallel siblings: a subagent's tools all
// momentarily read "done" in the gap between its last search and its `respond`
// ("Gathering thoughts") tool, transiently flipping `resolved` true; the open
// lane bridges that gap so the row never collapses mid-run. The turn ending
// (isStreaming false) collapses everything; a manual toggle pins the choice.
const autoExpanded = isStreaming && (isCurrentSection || isLaneOpen || !resolved)
const [manualExpanded, setManualExpanded] = useState<boolean | null>(null)
const expanded = manualExpanded ?? autoExpanded
return (
<div className='flex flex-col gap-1.5'>
{hasItems ? (
<button
type='button'
onClick={() => setManualExpanded(!expanded)}
className='group/agent flex cursor-pointer items-center gap-2'
>
<div className='flex size-[16px] flex-shrink-0 items-center justify-center'>
<AgentIcon className='size-[16px] text-[var(--text-icon)]' />
</div>
{isWorking ? (
<ShimmerText className='text-sm'>{agentLabel}</ShimmerText>
) : (
<span className='text-[var(--text-body)] text-sm'>{agentLabel}</span>
)}
<ChevronDown
className={cn(
'h-[7px] w-[9px] text-[var(--text-icon)] opacity-0 transition-[transform,opacity] duration-150 group-hover/agent:opacity-100 group-focus-visible/agent:opacity-100',
!expanded && '-rotate-90'
)}
/>
</button>
) : (
<div className='flex items-center gap-2'>
<div className='flex size-[16px] flex-shrink-0 items-center justify-center'>
<AgentIcon className='size-[16px] text-[var(--text-icon)]' />
</div>
{isWorking ? (
<ShimmerText className='text-sm'>{agentLabel}</ShimmerText>
) : (
<span className='text-[var(--text-body)] text-sm'>{agentLabel}</span>
)}
</div>
)}
{hasItems && (
<Expandable expanded={expanded}>
<ExpandableContent>
<BoundedViewport isStreaming={isStreaming}>
<div className='flex flex-col gap-1.5 py-0.5'>
{items.map((item, idx) => {
if (item.type === 'tool') {
return (
<ToolCallItem
key={item.data.id}
toolName={item.data.toolName}
displayTitle={item.data.displayTitle}
status={item.data.status}
streamingArgs={item.data.streamingArgs}
/>
)
}
if (item.type === 'agent_group') {
return (
<div key={item.group.id} className='pl-6'>
<AgentGroup
agentName={item.group.agentName}
agentLabel={item.group.agentLabel}
items={item.group.items}
isDelegating={item.group.isDelegating}
isStreaming={isStreaming}
isCurrentSection={idx === items.length - 1}
isLaneOpen={item.group.isOpen}
/>
</div>
)
}
return (
<span
key={`text-${idx}`}
className='pl-6 text-[13px] text-[var(--text-secondary)] leading-[18px] opacity-60'
>
{item.content.trim()}
</span>
)
})}
</div>
</BoundedViewport>
</ExpandableContent>
</Expandable>
)}
</div>
)
}
interface BoundedViewportProps {
children: React.ReactNode
isStreaming: boolean
}
const BOTTOM_STICK_THRESHOLD_PX = 8
function BoundedViewport({ children, isStreaming }: BoundedViewportProps) {
const ref = useRef<HTMLDivElement>(null)
const rafRef = useRef<number | null>(null)
const stickToBottomRef = useRef(true)
const [hasOverflow, setHasOverflow] = useState(false)
useEffect(() => {
const el = ref.current
if (!el) return
// Any upward user input detaches auto-stick. A subsequent scroll-to-bottom
// (wheel back down or dragging scrollbar) re-attaches it.
const handleWheel = (e: WheelEvent) => {
if (e.deltaY < 0) stickToBottomRef.current = false
}
const handleScroll = () => {
const distance = el.scrollHeight - el.scrollTop - el.clientHeight
if (distance < BOTTOM_STICK_THRESHOLD_PX) stickToBottomRef.current = true
}
el.addEventListener('wheel', handleWheel, { passive: true })
el.addEventListener('scroll', handleScroll, { passive: true })
return () => {
el.removeEventListener('wheel', handleWheel)
el.removeEventListener('scroll', handleScroll)
}
}, [])
useLayoutEffect(() => {
const el = ref.current
if (el) {
const next = el.scrollHeight > el.clientHeight
setHasOverflow((prev) => (prev === next ? prev : next))
}
if (rafRef.current !== null) {
window.cancelAnimationFrame(rafRef.current)
rafRef.current = null
}
if (!isStreaming) return
const tick = () => {
const node = ref.current
if (!node || !stickToBottomRef.current) {
rafRef.current = null
return
}
const target = node.scrollHeight - node.clientHeight
const gap = target - node.scrollTop
if (gap < 1) {
rafRef.current = null
return
}
node.scrollTop = node.scrollTop + Math.max(1, gap * 0.18)
rafRef.current = window.requestAnimationFrame(tick)
}
rafRef.current = window.requestAnimationFrame(tick)
return () => {
if (rafRef.current !== null) {
window.cancelAnimationFrame(rafRef.current)
rafRef.current = null
}
}
})
return (
<div className='relative'>
<div ref={ref} className={cn('max-h-[110px] overflow-y-auto pr-2', hasOverflow && 'py-1')}>
{children}
</div>
{hasOverflow && (
<>
<div className='pointer-events-none absolute top-0 right-2 left-0 h-3 bg-gradient-to-b from-[var(--bg)] to-transparent' />
<div className='pointer-events-none absolute right-2 bottom-0 left-0 h-3 bg-gradient-to-t from-[var(--bg)] to-transparent' />
</>
)}
</div>
)
}
@@ -0,0 +1,3 @@
export type { AgentGroupItem, NestedAgentGroup } from './agent-group'
export { AgentGroup, isAgentGroupResolved } from './agent-group'
export { CircleStop } from './tool-call-item'
@@ -0,0 +1,151 @@
import { useMemo } from 'react'
import { ShimmerText } from '@/components/ui'
import { WorkspaceFile } from '@/lib/copilot/generated/tool-catalog-v1'
import type { ToolCallStatus } from '../../../../types'
import { getToolIcon, resolveToolDisplayState } from '../../utils'
function CircleCheck({ className }: { className?: string }) {
return (
<svg
width='16'
height='16'
viewBox='0 0 16 16'
fill='none'
xmlns='http://www.w3.org/2000/svg'
className={className}
>
<circle cx='8' cy='8' r='6.5' stroke='currentColor' strokeWidth='1.25' />
<path
d='M5.5 8.5L7 10L10.5 6.5'
stroke='currentColor'
strokeWidth='1.25'
strokeLinecap='round'
strokeLinejoin='round'
/>
</svg>
)
}
export function CircleStop({ className }: { className?: string }) {
return (
<svg
width='16'
height='16'
viewBox='0 0 16 16'
fill='none'
xmlns='http://www.w3.org/2000/svg'
className={className}
>
<circle cx='8' cy='8' r='6.5' stroke='currentColor' strokeWidth='1.25' />
<rect x='6' y='6' width='4' height='4' rx='0.5' fill='currentColor' />
</svg>
)
}
function Hyphen({ className }: { className?: string }) {
return (
<svg
width='16'
height='16'
viewBox='0 0 16 16'
fill='none'
xmlns='http://www.w3.org/2000/svg'
className={className}
>
<path d='M4 8H12' stroke='currentColor' strokeWidth='1.25' strokeLinecap='round' />
</svg>
)
}
function CircleOutline({ className }: { className?: string }) {
return (
<svg
width='16'
height='16'
viewBox='0 0 16 16'
fill='none'
xmlns='http://www.w3.org/2000/svg'
className={className}
>
<circle cx='8' cy='8' r='6.5' stroke='currentColor' strokeWidth='1.25' />
</svg>
)
}
function StatusIcon({ status, toolName }: { status: ToolCallStatus; toolName: string }) {
const display = resolveToolDisplayState(status)
if (display === 'spinner') {
const Icon = getToolIcon(toolName)
if (Icon) {
return <Icon className='size-[15px] text-[var(--text-tertiary)]' />
}
return <CircleOutline className='size-[15px] text-[var(--text-tertiary)]' />
}
if (display === 'cancelled') {
return <CircleStop className='size-[15px] text-[var(--text-tertiary)]' />
}
if (display === 'interrupted') {
return <Hyphen className='size-[15px] text-[var(--text-tertiary)]' />
}
const Icon = getToolIcon(toolName)
if (Icon) {
return <Icon className='size-[15px] text-[var(--text-tertiary)]' />
}
return <CircleCheck className='size-[15px] text-[var(--text-tertiary)]' />
}
interface ToolCallItemProps {
toolName: string
displayTitle: string
status: ToolCallStatus
streamingArgs?: string
}
export function ToolCallItem({ toolName, displayTitle, status, streamingArgs }: ToolCallItemProps) {
const liveWorkspaceFileTitle = useMemo(() => {
if (toolName !== WorkspaceFile.id || !streamingArgs) return null
const titleMatch = streamingArgs.match(/"title"\s*:\s*"([^"]+)"/)
if (!titleMatch?.[1]) return null
const opMatch = streamingArgs.match(/"operation"\s*:\s*"(\w+)"/)
const op = opMatch?.[1] ?? ''
const verb =
op === 'create'
? 'Creating'
: op === 'append'
? 'Adding'
: op === 'patch'
? 'Editing'
: op === 'update'
? 'Writing'
: op === 'rename'
? 'Renaming'
: op === 'delete'
? 'Deleting'
: 'Writing'
const unescaped = titleMatch[1]
.replace(/\\u([0-9a-fA-F]{4})/g, (_, hex: string) =>
String.fromCharCode(Number.parseInt(hex, 16))
)
.replace(/\\"/g, '"')
.replace(/\\\\/g, '\\')
return `${verb} ${unescaped}`
}, [toolName, streamingArgs])
const isExecuting = resolveToolDisplayState(status) === 'spinner'
const title = liveWorkspaceFileTitle || displayTitle
return (
<div className='flex items-center gap-[8px] pl-[24px]'>
<div className='flex size-[16px] flex-shrink-0 items-center justify-center'>
<StatusIcon status={status} toolName={toolName} />
</div>
{isExecuting ? (
<ShimmerText className='text-[13px] [--shimmer-rest:var(--text-secondary)]'>
{title}
</ShimmerText>
) : (
<span className='text-[13px] text-[var(--text-secondary)]'>{title}</span>
)}
</div>
)
}
@@ -0,0 +1,22 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import { sanitizeChatDisplayContent } from './chat-sanitize'
describe('sanitizeChatDisplayContent', () => {
it('unwraps workspace resource tags from inline code spans', () => {
const content =
'`I updated <workspace_resource>{"type":"workflow","id":"wf-1","title":"Workflow"}</workspace_resource>.`'
expect(sanitizeChatDisplayContent(content)).toBe(
'I updated <workspace_resource>{"type":"workflow","id":"wf-1","title":"Workflow"}</workspace_resource>.'
)
})
it('removes hidden internal references wrapped in inline code', () => {
const content = 'Read `internal/tool-results/read-1.md` and found the issue.'
expect(sanitizeChatDisplayContent(content)).toBe('Read and found the issue.')
})
})
@@ -0,0 +1,525 @@
'use client'
import { type ComponentPropsWithoutRef, memo, useEffect, useMemo, useRef, useState } from 'react'
import { Streamdown } from 'streamdown'
import 'streamdown/styles.css'
// prismjs core must load before its language components — they register on the
// global `Prism` it installs (on `window`/`global`); fixes SSR + client order.
import 'prismjs'
import 'prismjs/components/prism-typescript'
import 'prismjs/components/prism-bash'
import 'prismjs/components/prism-css'
import 'prismjs/components/prism-markup'
import '@sim/emcn/components/code/code.css'
import { Checkbox, CopyCodeButton, cn, highlight, languages } from '@sim/emcn'
import { extractTextContent } from '@/lib/core/utils/react-node-text'
import {
type ContentSegment,
PendingTagIndicator,
parseSpecialTags,
SpecialTags,
} from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags'
import type { MothershipResource } from '@/app/workspace/[workspaceId]/home/types'
import { useSmoothText } from '@/hooks/use-smooth-text'
import { sanitizeChatDisplayContent } from './chat-sanitize'
const LANG_ALIASES: Record<string, string> = {
js: 'javascript',
ts: 'typescript',
tsx: 'typescript',
jsx: 'javascript',
sh: 'bash',
shell: 'bash',
html: 'markup',
xml: 'markup',
yml: 'yaml',
py: 'python',
}
const PROSE_CLASSES = cn(
'prose prose-base dark:prose-invert max-w-none',
'font-[family-name:var(--font-inter)] antialiased break-words font-[430] tracking-[0]',
'prose-headings:font-[600] prose-headings:tracking-[0] prose-headings:text-[var(--text-primary)]',
'prose-headings:mb-3 prose-headings:mt-6 first:prose-headings:mt-0',
'prose-p:text-base prose-p:leading-[25px] prose-p:text-[var(--text-primary)]',
'prose-li:text-base prose-li:leading-[25px] prose-li:text-[var(--text-primary)]',
'prose-li:my-1',
'prose-ul:my-4 prose-ol:my-4',
'prose-strong:font-[600] prose-strong:text-[var(--text-primary)]',
'prose-a:text-[var(--text-primary)] prose-a:underline prose-a:decoration-dashed prose-a:underline-offset-4',
'prose-hr:border-[var(--divider)] prose-hr:my-6',
'prose-table:my-0'
)
/**
* Soft fade for newly revealed text. Paired with {@link useSmoothText}, which
* paces the reveal; `stagger: 0` keeps the cadence driven by the pacer rather
* than an overlapping per-token delay ramp — every span revealed in one tick
* fades as a unit, so `sep: 'word'` looks identical to `sep: 'char'` while
* creating ~5x fewer spans. That span count is the dominant mid-stream cost:
* the animate plugin rebuilds a span per token for the WHOLE trailing block on
* every reveal tick, so per-char wrapping of a long paragraph meant thousands
* of hast nodes + React elements reconciled ~40x/sec. Streamdown's
* prev-content tracking keeps a word that grows across two ticks from
* re-fading (its continuation renders unfaded), and the pacer's word-boundary
* snapping makes such splits rare to begin with.
*/
const STREAM_ANIMATION = {
animation: 'fadeIn',
duration: 220,
stagger: 0,
sep: 'word',
} as const
/**
* How long after the reveal fully settles before the animated tree is dropped.
* Must exceed {@link STREAM_ANIMATION}'s 220ms duration so the last characters
* finish fading at full opacity before their spans are swapped for plain text.
*/
const ANIMATION_DRAIN_MS = 300
/**
* Once a segment has revealed this many characters, new text stops fading in;
* the word-paced reveal itself is unchanged. Fade cost scales with segment
* length — every reveal tick rebuilds a span per word for the WHOLE trailing
* markdown block — so on an unbroken wall of text it eventually swamps the
* frame budget (measured: ~9k-char single paragraphs spent ~30% of main-thread
* time in long tasks) while the fade itself is imperceptible detail that deep
* into a reply.
*/
const FADE_MAX_REVEALED_CHARS = 6000
function startsInlineWord(value: string): boolean {
return /^[A-Za-z0-9_(]/.test(value)
}
function endsInlineWord(value: string): boolean {
return /[A-Za-z0-9_)]$/.test(value)
}
function nextInlineSegmentLabel(segment?: ContentSegment): string {
if (!segment) return ''
if (segment.type === 'text' || segment.type === 'thinking') return segment.content
if (segment.type === 'workspace_resource') return segment.data.title || segment.data.id || ''
return ''
}
function appendInlineReferenceMarkdown(
currentMarkdown: string,
referenceMarkdown: string,
nextSegment?: ContentSegment
): string {
let nextMarkdown = currentMarkdown
if (currentMarkdown && endsInlineWord(currentMarkdown) && !/\s$/.test(currentMarkdown)) {
nextMarkdown += ' '
}
nextMarkdown += referenceMarkdown
const followingText = nextInlineSegmentLabel(nextSegment)
if (
followingText &&
startsInlineWord(followingText) &&
!/^\s/.test(followingText) &&
!/\s$/.test(nextMarkdown)
) {
nextMarkdown += ' '
}
return nextMarkdown
}
type TdProps = ComponentPropsWithoutRef<'td'>
type ThProps = ComponentPropsWithoutRef<'th'>
const MARKDOWN_COMPONENTS = {
table({ children }: { children?: React.ReactNode }) {
return (
<div className='not-prose my-4 w-full overflow-x-auto [&_strong]:font-[600]'>
<table className='min-w-full border-collapse [&_tbody_tr:last-child_td]:border-b-0'>
{children}
</table>
</div>
)
},
thead({ children }: { children?: React.ReactNode }) {
return <thead>{children}</thead>
},
th({ children, style }: ThProps) {
return (
<th
style={style}
className='whitespace-nowrap border-[var(--divider)] border-b px-3 py-2 text-left font-[600] text-[var(--text-primary)] text-sm leading-6'
>
{children}
</th>
)
},
td({ children, style }: TdProps) {
return (
<td
style={style}
className='whitespace-nowrap border-[var(--divider)] border-b px-3 py-2 text-[var(--text-primary)] text-sm leading-6'
>
{children}
</td>
)
},
code({ children, className }: { children?: React.ReactNode; className?: string }) {
const langMatch = className?.match(/language-(\w+)/)
const language = langMatch ? langMatch[1] : ''
const codeString = extractTextContent(children)
if (!codeString) {
return (
<pre className='not-prose my-6 overflow-x-auto rounded-lg bg-[var(--surface-5)] p-4 font-[430] font-mono text-[var(--text-primary)] text-small leading-[21px] dark:bg-[var(--code-bg)]'>
<code>{children}</code>
</pre>
)
}
const resolved = LANG_ALIASES[language] || language || 'javascript'
const grammar = languages[resolved] || languages.javascript
const html = highlight(codeString.trimEnd(), grammar, resolved)
return (
<div className='not-prose my-6 overflow-hidden rounded-lg border border-[var(--divider)]'>
<div className='flex items-center justify-between border-[var(--divider)] border-b bg-[var(--surface-4)] px-4 py-2 dark:bg-[var(--surface-4)]'>
<span className='text-[var(--text-tertiary)] text-xs'>{language || 'code'}</span>
<CopyCodeButton
code={codeString}
className='-mr-2 text-[var(--text-tertiary)] hover-hover:bg-[var(--surface-5)] hover-hover:text-[var(--text-secondary)]'
/>
</div>
<div className='code-editor-theme bg-[var(--surface-5)] dark:bg-[var(--code-bg)]'>
<pre
className='m-0 overflow-x-auto whitespace-pre p-4 font-[430] font-mono text-[var(--text-primary)] text-small leading-[21px]'
dangerouslySetInnerHTML={{ __html: html }}
/>
</div>
</div>
)
},
a({ children, href }: { children?: React.ReactNode; href?: string }) {
if (href?.startsWith('#wsres-')) {
return (
<a
href={href}
className='text-[var(--text-primary)] underline decoration-dashed underline-offset-4'
onClick={(e) => {
e.preventDefault()
const match = href.match(/^#wsres-(\w+)-(.+)$/)
if (match) {
const type = match[1]
const ref = match[2]
const linkText = e.currentTarget.textContent || ref
window.dispatchEvent(
new CustomEvent('wsres-click', {
detail:
type === 'file'
? { type, path: ref, title: linkText }
: { type, id: ref, title: linkText },
})
)
}
}}
>
{children}
</a>
)
}
return (
<a
href={href}
className='text-[var(--text-primary)] underline decoration-dashed underline-offset-4'
target='_blank'
rel='noopener noreferrer'
>
{children}
</a>
)
},
ul({ children, className }: { children?: React.ReactNode; className?: string }) {
if (className?.includes('contains-task-list')) {
return <ul className='my-4 list-none space-y-2 pl-0'>{children}</ul>
}
return <ul className='my-4 list-disc pl-5 marker:text-[var(--text-primary)]'>{children}</ul>
},
ol({ children }: { children?: React.ReactNode }) {
return <ol className='my-4 list-decimal pl-5 marker:text-[var(--text-primary)]'>{children}</ol>
},
li({ children, className }: { children?: React.ReactNode; className?: string }) {
if (className?.includes('task-list-item')) {
return (
<li className='flex list-none items-start gap-2 text-[var(--text-primary)] text-base leading-[25px] [&>p:only-child]:inline [&>p]:my-0'>
{children}
</li>
)
}
return (
<li className='my-1 text-[var(--text-primary)] text-base leading-[25px] marker:text-[var(--text-primary)] [&>p:only-child]:inline [&>p]:my-0'>
{children}
</li>
)
},
inlineCode({ children }: { children?: React.ReactNode }) {
return (
<code className='whitespace-normal rounded bg-[var(--surface-5)] px-1.5 py-0.5 font-[400] font-mono text-[var(--text-primary)] not-italic before:content-none after:content-none'>
{children}
</code>
)
},
blockquote({ children }: { children?: React.ReactNode }) {
return (
<blockquote className='my-4 break-words border-[var(--divider)] border-l-2 pl-4 text-[var(--text-primary)] italic [&>p:first-child]:mt-0 [&>p:last-child]:mb-0 [&>p]:my-2'>
{children}
</blockquote>
)
},
input({ type, checked }: { type?: string; checked?: boolean }) {
if (type === 'checkbox') {
return <Checkbox checked={checked || false} disabled size='sm' className='mt-1.5 shrink-0' />
}
return <input type={type} checked={checked} readOnly />
},
em({ children }: { children?: React.ReactNode }) {
return <em className='text-[var(--text-primary)] italic'>{children}</em>
},
del({ children }: { children?: React.ReactNode }) {
return <del className='text-[var(--text-tertiary)] line-through'>{children}</del>
},
img({ src, alt }: ComponentPropsWithoutRef<'img'>) {
if (typeof src !== 'string' || !src) return null
return (
<img
src={src}
alt={alt ?? ''}
loading='lazy'
className='my-4 h-auto max-w-full rounded-lg border border-[var(--divider)]'
/>
)
},
}
interface ChatContentProps {
content: string
isStreaming?: boolean
onOptionSelect?: (id: string) => void
onWorkspaceResourceSelect?: (resource: MothershipResource) => void
onRevealStateChange?: (isRevealing: boolean) => void
}
function ChatContentInner({
content,
isStreaming = false,
onOptionSelect,
onWorkspaceResourceSelect,
onRevealStateChange,
}: ChatContentProps) {
const onWorkspaceResourceSelectRef = useRef(onWorkspaceResourceSelect)
onWorkspaceResourceSelectRef.current = onWorkspaceResourceSelect
const onRevealStateChangeRef = useRef(onRevealStateChange)
onRevealStateChangeRef.current = onRevealStateChange
const displayContent = useMemo(() => sanitizeChatDisplayContent(content), [content])
const streamedContent = useSmoothText(displayContent, isStreaming)
const isRevealing = isStreaming || streamedContent.length < displayContent.length
useEffect(() => {
onRevealStateChangeRef.current?.(isRevealing)
}, [isRevealing])
/**
* Streaming-tree lifecycle. While a message streams (and until its reveal
* drains), it renders through Streamdown's streaming/animated pipeline, whose
* animate plugin wraps every character in its own `<span data-sd-animate>` —
* thousands of DOM nodes per streamed message. Holding that tree forever made
* long sessions progressively laggier until a refresh (which renders the same
* transcript static). `animationDrained` flips one-way
* {@link ANIMATION_DRAIN_MS} after the reveal settles and swaps to the static
* pipeline; the drain window lets the last 220ms fades finish so the swap
* trades identical pixels, unlike flipping at `isRevealing`'s edge, which cut
* running fades short (the old completion flash).
*
* The swap must REMOUNT Streamdown (via `key`), not just flip its props:
* Streamdown's default element components are memoized on className + source
* position (`E`/`qe` in streamdown 2.5), so a re-parse of unchanged content
* without the animate plugin bails at every unoverridden element (`p`,
* `strong`, `tr`, headings, …) and leaves the stale per-char span DOM in
* place. The settled instance keeps the streaming parser (`parserTree`
* below) so the remount only sheds the spans, never re-interprets the
* markdown.
*
* The drain is deliberately one-way: a stream that resumes afterwards
* (reconnect/continuation) reveals paced but unfaded, because re-arming
* mounts a fresh animate plugin with no prev-content tracking, which would
* re-fade the entire already-visible message.
*/
const [streamedThisSession, setStreamedThisSession] = useState(false)
const [animationDrained, setAnimationDrained] = useState(false)
const [fadeCutoff, setFadeCutoff] = useState(false)
/**
* The per-session latches above outlive the content when React reuses this
* instance for a different logical message — parent rows key by turn
* position and text segments by run ordinal (both deliberately stable across
* the live→persisted id swap), so an ordinal shift or regeneration can hand
* a settled instance brand-new content whose stale `animationDrained` would
* silently render the new stream static. Reset the latches when the content
* is REPLACED (not an append of the previous string) after the instance has
* settled. A resumed turn only ever appends, so this never undoes the
* one-way drain; mid-stream sanitize rewrites are excluded by the
* `animationDrained` gate (the drain only fires after settle). All latches
* are render-phase `useState` adjustments (prev-tracker idiom), not refs —
* they are read during render, and state is concurrent-safe where a
* render-phase ref mutation is not.
*/
const [prevDisplayContent, setPrevDisplayContent] = useState(displayContent)
if (prevDisplayContent !== displayContent) {
setPrevDisplayContent(displayContent)
if (!displayContent.startsWith(prevDisplayContent) && animationDrained) {
setStreamedThisSession(false)
setFadeCutoff(false)
setAnimationDrained(false)
}
}
if (isStreaming && !streamedThisSession) setStreamedThisSession(true)
useEffect(() => {
if (isRevealing || animationDrained || !streamedThisSession) return
const timeout = setTimeout(() => setAnimationDrained(true), ANIMATION_DRAIN_MS)
return () => clearTimeout(timeout)
}, [isRevealing, animationDrained, streamedThisSession])
/**
* `parserTree` (drives `mode`) stays latched for the mount's life: streaming
* mode is the only one that applies remend/incomplete-markdown repair and
* block-split parsing, so a settled message must KEEP the streaming parser —
* swapping to `mode='static'` at drain re-parses the same source through a
* different pipeline (no remend, whole-doc parse) and visibly flashes on any
* reply with unbalanced markdown. `streamingTree` (drives the remount key
* and animation props) additionally drops at drain, so the settled instance
* re-renders through the SAME parser minus the per-word animation spans —
* byte-identical pixels. Only never-streamed mounts (reloaded history)
* render static.
*/
const parserTree = isRevealing || streamedThisSession
const streamingTree = parserTree && !animationDrained
/**
* One-way fade cutoff (see {@link FADE_MAX_REVEALED_CHARS}). Latched so a
* sanitize-induced content shrink back across the boundary cannot re-arm
* `animated` — a fresh animate plugin has no prev-content tracking and would
* re-fade the entire visible segment.
*/
if (!fadeCutoff && streamedContent.length > FADE_MAX_REVEALED_CHARS) setFadeCutoff(true)
const fadeActive = streamingTree && !fadeCutoff
useEffect(() => {
const handler = (e: Event) => {
const { type, id, path, title } = (e as CustomEvent).detail
onWorkspaceResourceSelectRef.current?.({
type,
id: id ?? '',
path,
title: title || id || path || '',
})
}
window.addEventListener('wsres-click', handler)
return () => window.removeEventListener('wsres-click', handler)
}, [])
const parsed = useMemo(
() => parseSpecialTags(streamedContent, isRevealing),
[streamedContent, isRevealing]
)
type BlockSegment = Exclude<
ContentSegment,
{ type: 'text' } | { type: 'thinking' } | { type: 'workspace_resource' }
>
type RenderGroup =
| { kind: 'inline'; markdown: string }
| { kind: 'block'; segment: BlockSegment; index: number }
const groups: RenderGroup[] = []
let pendingMarkdown = ''
const flushMarkdown = () => {
if (pendingMarkdown.trim()) {
groups.push({ kind: 'inline', markdown: pendingMarkdown })
}
pendingMarkdown = ''
}
for (let i = 0; i < parsed.segments.length; i++) {
const s = parsed.segments[i]
const nextSegment = parsed.segments[i + 1]
if (s.type === 'workspace_resource') {
// Files are addressed by their encoded VFS path (copied verbatim from the tag);
// workflows/tables/KBs by id. The angle-bracket link destination keeps the path
// intact through markdown parsing (tolerates parens) without re-encoding it.
const ref = s.data.type === 'file' ? (s.data.path ?? s.data.id ?? '') : (s.data.id ?? '')
const label = s.data.title || ref
pendingMarkdown = appendInlineReferenceMarkdown(
pendingMarkdown,
`[${label}](<#wsres-${s.data.type}-${ref}>)`,
nextSegment
)
} else if (s.type === 'text' || s.type === 'thinking') {
pendingMarkdown += s.content
} else {
flushMarkdown()
groups.push({ kind: 'block', segment: s, index: i })
}
}
flushMarkdown()
/**
* Plain text and special-tag content share ONE render structure. A message
* with no special tags is simply a single inline group — it must NOT get a
* dedicated JSX branch, because most replies gain a trailing `<options>` tag
* (suggested follow-ups) at the very end, and switching branches at that
* moment re-parents the Streamdown to a different tree position. React then
* remounts it with a fresh animate plugin and the ENTIRE message re-fades
* from transparent — the "flash at the conclusion". With the unified
* structure the leading text group keeps its position (`inline-0`) and only
* the new special block mounts.
*/
return (
<div className='space-y-3'>
{groups.map((group, i) => {
if (group.kind === 'inline') {
return (
<div
key={`inline-${i}`}
className={cn(PROSE_CLASSES, '[&>:first-child]:mt-0 [&>:last-child]:mb-0')}
>
<Streamdown
key={streamingTree ? 'stream' : 'settled'}
mode={parserTree ? undefined : 'static'}
animated={fadeActive ? STREAM_ANIMATION : false}
isAnimating={streamingTree}
components={MARKDOWN_COMPONENTS}
>
{group.markdown}
</Streamdown>
</div>
)
}
return (
<SpecialTags
key={`special-${group.index}`}
segment={group.segment}
onOptionSelect={onOptionSelect}
/>
)
})}
{parsed.hasPendingTag && isRevealing && <PendingTagIndicator />}
</div>
)
}
export const ChatContent = memo(ChatContentInner)
@@ -0,0 +1,12 @@
const HIDDEN_INLINE_REFERENCE_PATTERN =
/`[^`\n]*(?:internal\/tool-results\/|internal\/blocktips\/|components\/integrations\/[^`\n]*README)[^`\n]*`/g
const WORKSPACE_RESOURCE_CODE_SPAN_PATTERN =
/`([^`\n]*<workspace_resource>[\s\S]*?<\/workspace_resource>[^`\n]*)`/g
export function sanitizeChatDisplayContent(content: string): string {
return content
.replace(WORKSPACE_RESOURCE_CODE_SPAN_PATTERN, '$1')
.replace(HIDDEN_INLINE_REFERENCE_PATTERN, '')
.replace(/`(\s*<workspace_resource>)/g, '$1')
.replace(/(<\/workspace_resource>\s*)`/g, '$1')
}
@@ -0,0 +1 @@
export { ChatContent } from './chat-content'
@@ -0,0 +1,5 @@
export type { AgentGroupItem, NestedAgentGroup } from './agent-group'
export { AgentGroup, CircleStop, isAgentGroupResolved } from './agent-group'
export { ChatContent } from './chat-content'
export { Options } from './options'
export { PendingTagIndicator, parseSpecialTags, SpecialTags } from './special-tags'
@@ -0,0 +1 @@
export { Options } from './options'
@@ -0,0 +1,25 @@
import type { OptionItem } from '../../../../types'
interface OptionsProps {
items: OptionItem[]
onSelect?: (id: string) => void
}
export function Options({ items, onSelect }: OptionsProps) {
if (items.length === 0) return null
return (
<div className='flex flex-wrap gap-2'>
{items.map((item) => (
<button
key={item.id}
type='button'
onClick={() => onSelect?.(item.id)}
className='rounded-full border border-[var(--divider)] bg-[var(--bg)] px-3.5 py-1.5 font-[430] font-[family-name:var(--font-inter)] text-[var(--text-primary)] text-sm leading-5 transition-colors hover-hover:bg-[var(--surface-5)]'
>
{item.label}
</button>
))}
</div>
)
}
@@ -0,0 +1,27 @@
export type {
ContentSegment,
CredentialTagData,
CredentialTagType,
FileTagData,
MothershipErrorTagData,
OptionsTagData,
ParsedSpecialContent,
RuntimeSpecialTagName,
UsageUpgradeAction,
UsageUpgradeTagData,
WorkspaceResourceTagData,
WorkspaceResourceTagType,
} from './special-tags'
export {
CREDENTIAL_TAG_TYPES,
PendingTagIndicator,
parseFileTag,
parseJsonTagBody,
parseSpecialTags,
parseTagAttributes,
parseTextTagBody,
SpecialTags,
USAGE_UPGRADE_ACTIONS,
WORKSPACE_RESOURCE_TAG_TYPES,
WorkspaceResourceDisplay,
} from './special-tags'
@@ -0,0 +1,91 @@
/**
* @vitest-environment jsdom
*/
import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockUseUserPermissionsContext } = vi.hoisted(() => ({
mockUseUserPermissionsContext: vi.fn(),
}))
vi.mock('@/app/workspace/[workspaceId]/providers/workspace-permissions-provider', () => ({
useUserPermissionsContext: mockUseUserPermissionsContext,
}))
vi.mock('next/navigation', () => ({
useParams: () => ({ workspaceId: 'workspace-1' }),
}))
import type { CredentialTagData } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags'
import { SpecialTags } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags'
/**
* Minimal dependency-free render harness (the repo has no `@testing-library/react`). Mounts the
* component in a real React 19 root under jsdom, matching the pattern in `use-autosave.test.tsx`.
*/
function renderCredentialLink(data: CredentialTagData): { container: HTMLDivElement; root: Root } {
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
const container = document.createElement('div')
const root: Root = createRoot(container)
act(() => {
root.render(<SpecialTags segment={{ type: 'credential', data }} />)
})
return { container, root }
}
describe('CredentialDisplay link tag', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseUserPermissionsContext.mockReturnValue({ canEdit: true })
})
it('does not render an anchor for a javascript: scheme value', () => {
const { container, root } = renderCredentialLink({
type: 'link',
provider: 'github',
value: 'javascript:alert(1)',
})
expect(container.querySelector('a')).toBeNull()
act(() => root.unmount())
})
it('does not render an anchor for a data: scheme value', () => {
const { container, root } = renderCredentialLink({
type: 'link',
provider: 'github',
value: 'data:text/html,<script>alert(1)</script>',
})
expect(container.querySelector('a')).toBeNull()
act(() => root.unmount())
})
it('renders a working link for a real http(s) connect URL', () => {
const url = 'https://github.com/login/oauth/authorize?client_id=abc&scope=repo'
const { container, root } = renderCredentialLink({
type: 'link',
provider: 'github',
value: url,
})
const link = container.querySelector('a')
expect(link).not.toBeNull()
expect(link?.getAttribute('href')).toBe(url)
expect(container.textContent).toContain('Connect github')
act(() => root.unmount())
})
it('renders nothing when the user cannot edit, regardless of URL safety', () => {
mockUseUserPermissionsContext.mockReturnValue({ canEdit: false })
const { container, root } = renderCredentialLink({
type: 'link',
provider: 'github',
value: 'https://github.com/login/oauth/authorize',
})
expect(container.querySelector('a')).toBeNull()
act(() => root.unmount())
})
})
@@ -0,0 +1,813 @@
'use client'
import { createElement, useMemo, useState } from 'react'
import {
ArrowRight,
Button,
ChevronDown,
cn,
Expandable,
ExpandableContent,
SecretInput,
SecretReveal,
Tooltip,
toast,
} from '@sim/emcn'
import { useParams } from 'next/navigation'
import { ThinkingLoader } from '@/components/ui'
import { canonicalWorkspaceFilePath } from '@/lib/copilot/vfs/path-utils'
import { isSafeHttpUrl } from '@/lib/core/utils/urls'
import { OAUTH_PROVIDERS } from '@/lib/oauth/oauth'
import { ContextMentionIcon } from '@/app/workspace/[workspaceId]/home/components/context-mention-icon'
import type {
ChatMessageContext,
MothershipResource,
} from '@/app/workspace/[workspaceId]/home/types'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import {
usePersonalEnvironment,
useSavePersonalEnvironment,
useUpsertWorkspaceEnvironment,
} from '@/hooks/queries/environment'
import { useKnowledgeBasesQuery } from '@/hooks/queries/kb/knowledge'
import { useTablesList } from '@/hooks/queries/tables'
import { useWorkflows } from '@/hooks/queries/workflows'
import { useWorkspaceFiles } from '@/hooks/queries/workspace-files'
export interface OptionsItemData {
title: string
description: string
}
export type OptionsTagData = Record<string, OptionsItemData>
export const USAGE_UPGRADE_ACTIONS = ['upgrade_plan', 'increase_limit'] as const
export type UsageUpgradeAction = (typeof USAGE_UPGRADE_ACTIONS)[number]
/**
* Synthetic inline tag payload derived from request-layer HTTP upgrade/quota
* failures and rendered through the same special-tag abstraction as streamed tags.
*/
export interface UsageUpgradeTagData {
reason: string
action: UsageUpgradeAction
message: string
}
export const CREDENTIAL_TAG_TYPES = [
'env_key',
'oauth_key',
'sim_key',
'credential_id',
'link',
'secret_input',
] as const
export type CredentialTagType = (typeof CREDENTIAL_TAG_TYPES)[number]
export const SECRET_INPUT_SCOPES = ['personal', 'workspace'] as const
export type SecretInputScope = (typeof SECRET_INPUT_SCOPES)[number]
export interface CredentialTagData {
value?: string
type: CredentialTagType
provider?: string
redacted?: boolean
/**
* Env-var key name to save the pasted secret under (secret_input only),
* e.g. "OPENAI_API_KEY".
*/
name?: string
/** Where a secret_input value is persisted. Defaults to "workspace". */
scope?: SecretInputScope
}
export interface MothershipErrorTagData {
message: string
code?: string
provider?: string
}
export interface FileTagData {
name: string
type: string
content: string
}
export const WORKSPACE_RESOURCE_TAG_TYPES = ['workflow', 'table', 'file'] as const
export type WorkspaceResourceTagType = (typeof WORKSPACE_RESOURCE_TAG_TYPES)[number]
export interface WorkspaceResourceTagData {
type: WorkspaceResourceTagType
id?: string
path?: string
title?: string
}
export type ContentSegment =
| { type: 'text'; content: string }
| { type: 'thinking'; content: string }
| { type: 'options'; data: OptionsTagData }
| { type: 'usage_upgrade'; data: UsageUpgradeTagData }
| { type: 'credential'; data: CredentialTagData }
| { type: 'mothership-error'; data: MothershipErrorTagData }
| { type: 'workspace_resource'; data: WorkspaceResourceTagData }
export type RuntimeSpecialTagName =
| 'thinking'
| 'options'
| 'credential'
| 'mothership-error'
| 'file'
| 'workspace_resource'
export interface ParsedSpecialContent {
segments: ContentSegment[]
hasPendingTag: boolean
}
const RUNTIME_SPECIAL_TAG_NAMES = [
'thinking',
'options',
'credential',
'mothership-error',
'file',
'workspace_resource',
] as const
const SPECIAL_TAG_NAMES = [
'thinking',
'options',
'usage_upgrade',
'credential',
'mothership-error',
'workspace_resource',
] as const
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null
}
function isOptionsItemData(value: unknown): value is OptionsItemData {
if (!isRecord(value)) return false
return typeof value.title === 'string' && typeof value.description === 'string'
}
function isOptionsTagData(value: unknown): value is OptionsTagData {
if (!isRecord(value)) return false
return Object.values(value).every(isOptionsItemData)
}
function isUsageUpgradeTagData(value: unknown): value is UsageUpgradeTagData {
if (!isRecord(value)) return false
return (
typeof value.reason === 'string' &&
typeof value.message === 'string' &&
typeof value.action === 'string' &&
(USAGE_UPGRADE_ACTIONS as readonly string[]).includes(value.action)
)
}
function isCredentialTagData(value: unknown): value is CredentialTagData {
if (!isRecord(value)) return false
if (
typeof value.type !== 'string' ||
!(CREDENTIAL_TAG_TYPES as readonly string[]).includes(value.type)
) {
return false
}
if (value.provider !== undefined && typeof value.provider !== 'string') return false
// secret_input is an empty input the user fills in — it carries a key name to
// save under, not a value.
if (value.type === 'secret_input') {
if (
value.scope !== undefined &&
!(SECRET_INPUT_SCOPES as readonly string[]).includes(value.scope as string)
) {
return false
}
return typeof value.name === 'string' && value.name.trim().length > 0
}
if (value.redacted === true) return value.value === undefined || typeof value.value === 'string'
return typeof value.value === 'string'
}
function isMothershipErrorTagData(value: unknown): value is MothershipErrorTagData {
if (!isRecord(value)) return false
return (
typeof value.message === 'string' &&
(value.code === undefined || typeof value.code === 'string') &&
(value.provider === undefined || typeof value.provider === 'string')
)
}
function isWorkspaceResourceTagData(value: unknown): value is WorkspaceResourceTagData {
if (!isRecord(value)) return false
if (
typeof value.type !== 'string' ||
!(WORKSPACE_RESOURCE_TAG_TYPES as readonly string[]).includes(value.type)
) {
return false
}
if (value.title !== undefined && typeof value.title !== 'string') return false
if (value.path !== undefined && typeof value.path !== 'string') return false
if (value.id !== undefined && typeof value.id !== 'string') return false
const id = typeof value.id === 'string' ? value.id.trim() : ''
const path = typeof value.path === 'string' ? value.path.trim() : ''
if (value.type === 'file') return id.length > 0 || path.length > 0
return id.length > 0
}
export function parseJsonTagBody<T>(
body: string,
isExpectedShape: (value: unknown) => value is T
): T | null {
try {
const parsed = JSON.parse(body) as unknown
return isExpectedShape(parsed) ? parsed : null
} catch {
return null
}
}
export function parseTextTagBody(body: string): string | null {
return body.trim() ? body : null
}
export function parseTagAttributes(openTag: string): Record<string, string> {
const attributes: Record<string, string> = {}
const attributePattern = /([A-Za-z_:][A-Za-z0-9_:-]*)="([^"]*)"/g
let match: RegExpExecArray | null = null
while ((match = attributePattern.exec(openTag)) !== null) {
attributes[match[1]] = match[2]
}
return attributes
}
export function parseFileTag(openTag: string, body: string): FileTagData | null {
const attributes = parseTagAttributes(openTag)
if (!attributes.name || !attributes.type) return null
return {
name: attributes.name,
type: attributes.type,
content: body,
}
}
function parseSpecialTagData(
tagName: (typeof SPECIAL_TAG_NAMES)[number],
body: string
):
| { type: 'thinking'; content: string }
| { type: 'options'; data: OptionsTagData }
| { type: 'usage_upgrade'; data: UsageUpgradeTagData }
| { type: 'credential'; data: CredentialTagData }
| { type: 'mothership-error'; data: MothershipErrorTagData }
| { type: 'workspace_resource'; data: WorkspaceResourceTagData }
| null {
if (tagName === 'thinking') {
const content = parseTextTagBody(body)
return content ? { type: 'thinking', content } : null
}
if (tagName === 'options') {
const data = parseJsonTagBody(body, isOptionsTagData)
return data ? { type: 'options', data } : null
}
if (tagName === 'usage_upgrade') {
const data = parseJsonTagBody(body, isUsageUpgradeTagData)
return data ? { type: 'usage_upgrade', data } : null
}
if (tagName === 'credential') {
const data = parseJsonTagBody(body, isCredentialTagData)
return data ? { type: 'credential', data } : null
}
if (tagName === 'mothership-error') {
const data = parseJsonTagBody(body, isMothershipErrorTagData)
return data ? { type: 'mothership-error', data } : null
}
if (tagName === 'workspace_resource') {
const data = parseJsonTagBody(body, isWorkspaceResourceTagData)
return data ? { type: 'workspace_resource', data } : null
}
return null
}
/**
* Parses inline special tags (`<options>`, `<usage_upgrade>`, `<workspace_resource>`) from streamed
* text content. Complete tags are extracted into typed segments; incomplete
* tags (still streaming) are suppressed from display and flagged via
* `hasPendingTag` so the caller can show a loading indicator.
*
* Trailing partial opening tags (e.g. `<opt`, `<usage_`) are also stripped
* during streaming to prevent flashing raw markup.
*/
export function parseSpecialTags(content: string, isStreaming: boolean): ParsedSpecialContent {
const segments: ContentSegment[] = []
let hasPendingTag = false
let cursor = 0
while (cursor < content.length) {
let nearestStart = -1
let nearestTagName: (typeof SPECIAL_TAG_NAMES)[number] | '' = ''
for (const name of SPECIAL_TAG_NAMES) {
const idx = content.indexOf(`<${name}>`, cursor)
if (idx !== -1 && (nearestStart === -1 || idx < nearestStart)) {
nearestStart = idx
nearestTagName = name
}
}
if (nearestStart === -1) {
let remaining = content.slice(cursor)
if (isStreaming) {
const partial = remaining.match(/<[a-z_-]*$/i)
if (partial) {
const fragment = partial[0].slice(1)
if (
fragment.length > 0 &&
[...SPECIAL_TAG_NAMES, ...RUNTIME_SPECIAL_TAG_NAMES].some((t) => t.startsWith(fragment))
) {
remaining = remaining.slice(0, -partial[0].length)
hasPendingTag = true
}
}
}
if (remaining.trim()) {
segments.push({ type: 'text', content: remaining })
}
break
}
if (nearestStart > cursor) {
const text = content.slice(cursor, nearestStart)
if (text.trim()) {
segments.push({ type: 'text', content: text })
}
}
const openTag = `<${nearestTagName}>`
const closeTag = `</${nearestTagName}>`
const bodyStart = nearestStart + openTag.length
const closeIdx = content.indexOf(closeTag, bodyStart)
if (closeIdx === -1) {
hasPendingTag = true
cursor = content.length
break
}
const body = content.slice(bodyStart, closeIdx)
if (!nearestTagName) {
cursor = closeIdx + closeTag.length
continue
}
const parsedTag = parseSpecialTagData(nearestTagName, body)
if (parsedTag) {
segments.push(parsedTag)
}
cursor = closeIdx + closeTag.length
}
if (segments.length === 0 && !hasPendingTag) {
segments.push({ type: 'text', content })
}
return { segments, hasPendingTag }
}
interface SpecialTagsProps {
segment: Exclude<ContentSegment, { type: 'text' }>
onOptionSelect?: (id: string) => void
onWorkspaceResourceSelect?: (resource: MothershipResource) => void
}
/**
* Unified renderer for inline special tags: `<options>`, `<usage_upgrade>`, `<credential>`,
* and `<workspace_resource>`.
*/
export function SpecialTags({
segment,
onOptionSelect,
onWorkspaceResourceSelect,
}: SpecialTagsProps) {
switch (segment.type) {
case 'thinking':
return null
case 'options':
return <OptionsDisplay data={segment.data} onSelect={onOptionSelect} />
case 'usage_upgrade':
return <UsageUpgradeDisplay data={segment.data} />
case 'credential':
return <CredentialDisplay data={segment.data} />
case 'mothership-error':
return <MothershipErrorDisplay data={segment.data} />
case 'workspace_resource':
return <WorkspaceResourceDisplay data={segment.data} onSelect={onWorkspaceResourceSelect} />
default:
return null
}
}
/**
* Renders a "Thinking" shimmer while a special tag is still streaming in.
*/
export function PendingTagIndicator() {
return (
<div className='animate-stream-fade-in py-2'>
<ThinkingLoader size={20} startVariant='corners' label='Thinking…' labelRatio={0.7} />
</div>
)
}
interface OptionsDisplayProps {
data: OptionsTagData
onSelect?: (id: string) => void
}
function OptionsDisplay({ data, onSelect }: OptionsDisplayProps) {
const disabled = !onSelect
const [collapsedByUser, setCollapsedByUser] = useState(false)
// When interactive (not disabled), always expanded. When disabled, the user can toggle.
const expanded = !disabled || !collapsedByUser
const entries = Object.entries(data)
if (entries.length === 0) return null
return (
<div>
{disabled ? (
<button
type='button'
onClick={() => setCollapsedByUser((prev) => !prev)}
aria-expanded={expanded}
className='flex items-center gap-2'
>
<span className='text-[var(--text-body)] text-sm'>Suggested follow-ups</span>
<ChevronDown
className={cn(
'h-[7px] w-[9px] text-[var(--text-icon)] transition-transform duration-150',
!expanded && '-rotate-90'
)}
/>
</button>
) : (
<span className='text-[var(--text-body)] text-sm'>Suggested follow-ups</span>
)}
<Expandable expanded={expanded}>
<ExpandableContent className='mt-1.5'>
<div className='flex flex-col'>
{entries.map(([key, value], i) => {
const title = value.title
return (
<button
key={key}
type='button'
disabled={disabled}
onClick={() => onSelect?.(title)}
className={cn(
'flex items-center gap-2 border-[var(--divider)] px-2 py-2 text-left transition-colors',
disabled ? 'cursor-not-allowed' : 'hover-hover:bg-[var(--surface-5)]',
i > 0 && 'border-t'
)}
>
<div className='flex size-[16px] flex-shrink-0 items-center justify-center'>
<span className='text-[var(--text-icon)] text-sm'>{i + 1}</span>
</div>
<span className='flex-1 text-[var(--text-body)] text-sm'>{title}</span>
<ArrowRight className='size-[16px] shrink-0 text-[var(--text-icon)]' />
</button>
)
})}
</div>
</ExpandableContent>
</Expandable>
</div>
)
}
function fallbackWorkspaceResourceTitle(type: WorkspaceResourceTagType): string {
switch (type) {
case 'workflow':
return 'Workflow'
case 'table':
return 'Table'
case 'file':
return 'File'
}
}
function toMothershipResourceType(type: WorkspaceResourceTagType): MothershipResource['type'] {
return type
}
function toChatMessageContext(data: WorkspaceResourceTagData, label: string): ChatMessageContext {
switch (data.type) {
case 'workflow':
return { kind: 'workflow', label, workflowId: data.id ?? '' }
case 'table':
return { kind: 'table', label, tableId: data.id ?? '' }
case 'file':
return { kind: 'file', label, fileId: data.id ?? data.path ?? '' }
}
}
export function WorkspaceResourceDisplay({
data,
onSelect,
}: {
data: WorkspaceResourceTagData
onSelect?: (resource: MothershipResource) => void
}) {
const { workspaceId } = useParams<{ workspaceId: string }>()
const { data: workflows = [] } = useWorkflows(workspaceId)
const { data: tables = [] } = useTablesList(workspaceId)
const { data: files = [] } = useWorkspaceFiles(workspaceId)
const { data: knowledgeBases = [] } = useKnowledgeBasesQuery(workspaceId)
const resource = useMemo<MothershipResource>(() => {
const fileFromPath =
data.type === 'file' && data.path
? files.find(
(file) =>
canonicalWorkspaceFilePath({ folderPath: file.folderPath, name: file.name }) ===
data.path
)
: undefined
const title =
data.type === 'workflow'
? (workflows.find((workflow) => workflow.id === data.id)?.name ??
fallbackWorkspaceResourceTitle(data.type))
: data.type === 'table'
? (tables.find((table) => table.id === data.id)?.name ??
fallbackWorkspaceResourceTitle(data.type))
: data.type === 'file'
? (files.find((file) => file.id === data.id)?.name ??
fileFromPath?.name ??
data.title ??
fallbackWorkspaceResourceTitle(data.type))
: (knowledgeBases.find((knowledgeBase) => knowledgeBase.id === data.id)?.name ??
fallbackWorkspaceResourceTitle(data.type))
return {
type: toMothershipResourceType(data.type),
id: data.id ?? fileFromPath?.id ?? data.path ?? '',
title,
...(data.type === 'file' && data.path ? { path: data.path } : {}),
}
}, [data.id, data.path, data.title, data.type, files, knowledgeBases, tables, workflows])
const context = toChatMessageContext(data, resource.title)
const mentionContent = (
<>
<ContextMentionIcon
context={context}
className='relative top-0.5 size-[12px] flex-shrink-0 text-[var(--text-icon)]'
/>
{resource.title}
</>
)
const classes =
'inline-flex items-baseline gap-1 rounded-[5px] bg-[var(--surface-5)] px-[5px] align-baseline font-[inherit] text-[inherit] leading-[inherit]'
if (!onSelect) {
return <span className={classes}>{mentionContent}</span>
}
return (
<button
type='button'
onClick={() => onSelect(resource)}
className={cn(classes, 'cursor-pointer transition-colors hover-hover:bg-[var(--surface-6)]')}
>
{mentionContent}
</button>
)
}
function getCredentialIcon(provider: string): React.ComponentType<{ className?: string }> | null {
const lower = provider.toLowerCase()
const directMatch = OAUTH_PROVIDERS[lower]
if (directMatch) return directMatch.icon
for (const config of Object.values(OAUTH_PROVIDERS)) {
if (config.name.toLowerCase() === lower) return config.icon
for (const service of Object.values(config.services)) {
if (service.name.toLowerCase() === lower) return service.icon
if (service.providerId.toLowerCase() === lower) return service.icon
}
}
return null
}
const LockIcon = (props: { className?: string }) => (
<svg
className={props.className}
viewBox='0 0 16 16'
fill='none'
xmlns='http://www.w3.org/2000/svg'
>
<rect x='2' y='5' width='12' height='8' rx='1.5' stroke='currentColor' strokeWidth='1.3' />
<path
d='M5 5V3.5a3 3 0 1 1 6 0V5'
stroke='currentColor'
strokeWidth='1.3'
strokeLinecap='round'
/>
<circle cx='8' cy='9.5' r='1.25' fill='currentColor' />
</svg>
)
/**
* Inline "paste a secret" widget rendered for
* `<credential>{"type":"secret_input","name":"OPENAI_API_KEY"}</credential>`.
* Reuses the shared emcn SecretInput; the pasted value is saved straight to
* workspace (default) or personal environment variables under `name` and never
* flows back through the chat transcript.
*/
function SecretInputDisplay({ data }: { data: CredentialTagData }) {
const { workspaceId } = useParams<{ workspaceId: string }>()
const secretName = (data.name ?? '').trim()
const scope: SecretInputScope = data.scope === 'personal' ? 'personal' : 'workspace'
const [value, setValue] = useState('')
const [saved, setSaved] = useState(false)
const upsertWorkspace = useUpsertWorkspaceEnvironment()
const savePersonal = useSavePersonalEnvironment()
const personalQuery = usePersonalEnvironment()
const personalEnv = personalQuery.data
const { canEdit } = useUserPermissionsContext()
// Setting a workspace var needs write/admin (same gate as the secrets manager);
// personal vars are the user's own, so any member may set them.
const canManage = scope === 'personal' || canEdit
const isSaving = upsertWorkspace.isPending || savePersonal.isPending
// Personal saves replace the whole map, so block until existing vars are loaded.
const personalReady = scope !== 'personal' || personalEnv !== undefined
const canSave =
canManage && secretName.length > 0 && value.trim().length > 0 && !isSaving && personalReady
const handleSave = async () => {
if (!canSave) return
try {
if (scope === 'personal') {
// The personal POST replaces the whole map, so re-read the latest vars
// right before merging — a stale snapshot would drop keys saved elsewhere.
const { data: latest } = await personalQuery.refetch()
const merged: Record<string, string> = {}
for (const [key, entry] of Object.entries(latest ?? personalEnv ?? {}))
merged[key] = entry.value
merged[secretName] = value
await savePersonal.mutateAsync({ variables: merged })
} else {
await upsertWorkspace.mutateAsync({ workspaceId, variables: { [secretName]: value } })
}
setValue('')
setSaved(true)
toast.success(`Saved ${secretName}`)
} catch {
toast.error(`Couldn't save ${secretName}. Please try again.`)
}
}
if (!secretName) return null
// Only confirm after the user saves via THIS widget. A fresh prompt always shows
// the input so the user can set or override the key, even if it already exists.
if (saved) return <SecretReveal redacted />
if (!canManage) return null
return (
<SecretInput
value={value}
onChange={setValue}
placeholder={`Paste ${secretName}`}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
void handleSave()
}
}}
endAdornment={
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
type='button'
variant='quiet'
className='size-[18px] rounded-sm p-0'
onClick={() => void handleSave()}
disabled={!canSave}
aria-label='Save'
>
<ArrowRight className='size-[13px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content>{isSaving ? 'Saving…' : 'Save'}</Tooltip.Content>
</Tooltip.Root>
}
/>
)
}
function CredentialDisplay({ data }: { data: CredentialTagData }) {
const { canEdit } = useUserPermissionsContext()
if (data.type === 'secret_input') {
return <SecretInputDisplay data={data} />
}
if (data.type === 'link') {
// Connecting a credential mutates the workspace — hide it from read-only members.
if (!data.provider || !canEdit) return null
// The connect link value comes from the streamed model output, so only
// render it as a clickable link when it resolves to a real http(s) URL.
if (!data.value || !isSafeHttpUrl(data.value)) return null
const Icon = getCredentialIcon(data.provider) ?? LockIcon
return (
<a
href={data.value}
target='_blank'
rel='noopener noreferrer'
className='flex items-center gap-2 rounded-lg border border-[var(--divider)] px-3 py-2.5 transition-colors hover-hover:bg-[var(--surface-5)]'
>
{createElement(Icon, { className: 'size-[16px] shrink-0' })}
<span className='flex-1 text-[var(--text-body)] text-sm'>Connect {data.provider}</span>
<ArrowRight className='size-[16px] shrink-0 text-[var(--text-icon)]' />
</a>
)
}
if (data.type === 'sim_key') {
return <SecretReveal value={data.value} redacted={data.redacted || !data.value} />
}
return null
}
function MothershipErrorDisplay({ data }: { data: MothershipErrorTagData }) {
const detail = data.code ? `${data.message} (${data.code})` : data.message
return <p className='text-[13px] text-[var(--text-secondary)] italic leading-[20px]'>{detail}</p>
}
function UsageUpgradeDisplay({ data }: { data: UsageUpgradeTagData }) {
const { workspaceId } = useParams<{ workspaceId: string }>()
const settingsPath = `/workspace/${workspaceId}/settings/billing`
const buttonLabel = data.action === 'upgrade_plan' ? 'Upgrade Plan' : 'Increase Limit'
return (
<div className='rounded-xl border border-amber-300/40 bg-amber-50/50 px-4 py-3 dark:border-amber-500/20 dark:bg-amber-950/20'>
<div className='flex items-center gap-2'>
<svg
className='size-4 shrink-0 text-amber-600 dark:text-amber-400'
viewBox='0 0 16 16'
fill='none'
xmlns='http://www.w3.org/2000/svg'
>
<path
d='M8 1.5L1 14h14L8 1.5z'
stroke='currentColor'
strokeWidth='1.3'
strokeLinejoin='round'
/>
<path d='M8 6.5v3' stroke='currentColor' strokeWidth='1.3' strokeLinecap='round' />
<circle cx='8' cy='11.5' r='0.75' fill='currentColor' />
</svg>
<span className='font-[500] text-amber-800 text-sm leading-5 dark:text-amber-300'>
Usage Limit Reached
</span>
</div>
<p className='mt-1.5 text-amber-700/90 text-small leading-[20px] dark:text-amber-400/80'>
{data.message}
</p>
<a
href={settingsPath}
className='mt-2 inline-flex items-center gap-1 font-[500] text-amber-700 text-small underline decoration-dashed underline-offset-2 transition-colors hover-hover:text-amber-900 dark:text-amber-300 dark:hover-hover:text-amber-200'
>
{buttonLabel}
<ArrowRight className='size-3' />
</a>
</div>
)
}
@@ -0,0 +1,5 @@
export {
assistantMessageHasRenderableContent,
MessageContent,
} from './message-content'
export type { MessagePhase } from './utils'
@@ -0,0 +1,268 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import type { ContentBlock } from '../../types'
import { parseBlocks, shouldSmoothTextSegment } from './message-content'
function subagentStart(name: string, spanId: string, parentSpanId: string): ContentBlock {
return { type: 'subagent', content: name, spanId, parentSpanId, timestamp: 1 }
}
function subagentToolCall(
id: string,
name: string,
spanId: string,
calledBy: string
): ContentBlock {
return {
type: 'tool_call',
toolCall: { id, name, status: 'success', calledBy },
spanId,
timestamp: 1,
}
}
function mainText(content: string): ContentBlock {
return { type: 'text', content, timestamp: 1 }
}
function mainToolCall(id: string, name: string): ContentBlock {
return { type: 'tool_call', toolCall: { id, name, status: 'success' }, timestamp: 1 }
}
describe('parseBlocks span-identity tree', () => {
it('nests a deploy subagent inside the workflow subagent that spawned it', () => {
const blocks: ContentBlock[] = [
subagentStart('workflow', 'S1', 'main'),
subagentToolCall('t1', 'create_workflow', 'S1', 'workflow'),
subagentStart('deploy', 'S2', 'S1'),
subagentToolCall('t2', 'check_deployment_status', 'S2', 'deploy'),
]
const segments = parseBlocks(blocks)
expect(segments).toHaveLength(1)
const workflow = segments[0]
expect(workflow.type).toBe('agent_group')
if (workflow.type !== 'agent_group') throw new Error('expected workflow group')
expect(workflow.agentName).toBe('workflow')
const nested = workflow.items.find((item) => item.type === 'agent_group')
expect(nested).toBeDefined()
if (!nested || nested.type !== 'agent_group') throw new Error('expected nested deploy group')
expect(nested.group.agentName).toBe('deploy')
// Deploy's own tool nests under deploy, not under workflow.
expect(nested.group.items.some((item) => item.type === 'tool')).toBe(true)
})
it('clears the parent delegating flag once it has spawned a child, leaving only the child active', () => {
const blocks: ContentBlock[] = [
subagentStart('workflow', 'S1', 'main'),
subagentStart('deploy', 'S2', 'S1'),
]
const segments = parseBlocks(blocks)
expect(segments).toHaveLength(1)
const workflow = segments[0]
if (workflow.type !== 'agent_group') throw new Error('expected workflow group')
expect(workflow.isDelegating).toBe(false)
const nested = workflow.items.find((item) => item.type === 'agent_group')
if (!nested || nested.type !== 'agent_group') throw new Error('expected nested deploy group')
expect(nested.group.isDelegating).toBe(true)
})
it('keeps two top-level subagents as siblings', () => {
const blocks: ContentBlock[] = [
subagentStart('workflow', 'S1', 'main'),
subagentStart('research', 'S3', 'main'),
]
const segments = parseBlocks(blocks)
const groups = segments.filter((s) => s.type === 'agent_group')
expect(groups).toHaveLength(2)
})
it('creates distinct groups for repeated deploy invocations (no collision)', () => {
const blocks: ContentBlock[] = [
subagentStart('deploy', 'S2', 'main'),
subagentToolCall('t1', 'deploy_api', 'S2', 'deploy'),
subagentStart('deploy', 'S4', 'main'),
subagentToolCall('t2', 'deploy_api', 'S4', 'deploy'),
]
const segments = parseBlocks(blocks)
const groups = segments.filter((s) => s.type === 'agent_group')
expect(groups).toHaveLength(2)
})
it('shows the delegating spinner while a span subagent is open with no output, and clears it once content arrives', () => {
const openOnly = parseBlocks([subagentStart('deploy', 'S2', 'main')])
expect(openOnly).toHaveLength(1)
if (openOnly[0].type !== 'agent_group') throw new Error('expected group')
expect(openOnly[0].isDelegating).toBe(true)
const withContent = parseBlocks([
subagentStart('deploy', 'S2', 'main'),
{ type: 'subagent_text', content: 'working on it', spanId: 'S2', timestamp: 2 },
])
if (withContent[0].type !== 'agent_group') throw new Error('expected group')
expect(withContent[0].isDelegating).toBe(false)
})
it('keeps two concurrently-open subagent lanes separate with interleaved text', () => {
const blocks: ContentBlock[] = [
subagentStart('research', 'A', 'main'),
subagentStart('research', 'B', 'main'),
{ type: 'subagent_text', content: 'A1 ', spanId: 'A', subagent: 'research', timestamp: 2 },
{ type: 'subagent_text', content: 'B1 ', spanId: 'B', subagent: 'research', timestamp: 2 },
{ type: 'subagent_text', content: 'A2', spanId: 'A', subagent: 'research', timestamp: 3 },
]
const segments = parseBlocks(blocks)
const groups = segments.filter((s) => s.type === 'agent_group')
expect(groups).toHaveLength(2)
const textOf = (g: (typeof groups)[number]): string => {
if (g.type !== 'agent_group') return ''
return g.items
.filter((i) => i.type === 'text')
.map((i) => (i.type === 'text' ? i.content : ''))
.join('')
}
// Group A (spanId A) created first, group B second. Interleaved chunks stay
// in their own lane and in order — no cross-contamination.
expect(textOf(groups[0])).toBe('A1 A2')
expect(textOf(groups[1])).toBe('B1 ')
})
it('renders a persisted subagent lane as closed when only endedAt is set (no subagent_end)', () => {
// The Sim backend stamps endedAt on the subagent block but does not emit a
// separate subagent_end block; a reloaded transcript must still show the
// lane closed (no stuck delegating spinner).
const blocks: ContentBlock[] = [
{
type: 'subagent',
content: 'research',
spanId: 'S1',
parentSpanId: 'main',
timestamp: 1,
endedAt: 5,
},
{ type: 'subagent_text', content: 'done', spanId: 'S1', subagent: 'research', timestamp: 2 },
]
const segments = parseBlocks(blocks)
const group = segments.find((s) => s.type === 'agent_group')
expect(group).toBeDefined()
if (!group || group.type !== 'agent_group') throw new Error('expected research group')
expect(group.isOpen).toBe(false)
expect(group.isDelegating).toBe(false)
})
it('prunes an empty nested subagent that started and ended without output', () => {
const blocks: ContentBlock[] = [
subagentStart('workflow', 'S1', 'main'),
subagentToolCall('t1', 'create_workflow', 'S1', 'workflow'),
subagentStart('deploy', 'S2', 'S1'),
{ type: 'subagent_end', spanId: 'S2', parentSpanId: 'S1', timestamp: 3 },
]
const segments = parseBlocks(blocks)
expect(segments).toHaveLength(1)
if (segments[0].type !== 'agent_group') throw new Error('expected workflow group')
// The empty, ended deploy group is pruned; only the workflow tool remains.
expect(segments[0].items.some((item) => item.type === 'agent_group')).toBe(false)
expect(segments[0].items.some((item) => item.type === 'tool')).toBe(true)
})
it('interleaves mothership tools with main text instead of clustering them at the top', () => {
const blocks: ContentBlock[] = [
mainText('Let me search.'),
mainToolCall('t1', 'grep'),
subagentStart('research', 'S1', 'main'),
{ type: 'subagent_text', content: 'looking', spanId: 'S1', timestamp: 2 },
{ type: 'subagent_end', spanId: 'S1', parentSpanId: 'main', timestamp: 3 },
mainText('Found it, now finding files.'),
mainToolCall('t2', 'glob'),
]
const segments = parseBlocks(blocks)
// Order is preserved chronologically: the second mothership tool stays below
// the research subagent and the trailing text rather than jumping back up
// into the first group.
const shape = segments.map((s) => (s.type === 'agent_group' ? s.agentName : s.type))
expect(shape).toEqual(['text', 'mothership', 'research', 'text', 'mothership'])
// The two mothership tools land in two distinct groups, one each.
const mothershipGroups = segments.filter(
(s) => s.type === 'agent_group' && s.agentName === 'mothership'
)
expect(mothershipGroups).toHaveLength(2)
const [first, second] = mothershipGroups
if (first.type !== 'agent_group' || second.type !== 'agent_group') {
throw new Error('expected mothership groups')
}
expect(first.items).toHaveLength(1)
expect(second.items).toHaveLength(1)
expect(first.items[0].type === 'tool' && first.items[0].data.toolName).toBe('grep')
expect(second.items[0].type === 'tool' && second.items[0].data.toolName).toBe('glob')
})
it('absorbs the dispatch tool of a nested file subagent from its parent span group', () => {
const blocks: ContentBlock[] = [
subagentStart('workflow', 'S1', 'main'),
subagentToolCall('t1', 'workspace_file', 'S1', 'workflow'),
{ type: 'subagent', content: 'file', spanId: 'S2', parentSpanId: 'S1', timestamp: 2 },
{ type: 'subagent_text', content: 'writing', spanId: 'S2', timestamp: 3 },
]
const segments = parseBlocks(blocks)
expect(segments).toHaveLength(1)
const workflow = segments[0]
if (workflow.type !== 'agent_group') throw new Error('expected workflow group')
// The workspace_file dispatch tool is absorbed (not shown as a sibling tool);
// only the nested file subagent remains under workflow.
expect(workflow.items.some((item) => item.type === 'tool')).toBe(false)
const nested = workflow.items.find((item) => item.type === 'agent_group')
if (!nested || nested.type !== 'agent_group') throw new Error('expected nested file group')
expect(nested.group.agentName).toBe('file')
})
it('falls back to legacy flat grouping when blocks have no span identity', () => {
const blocks: ContentBlock[] = [
{ type: 'subagent', content: 'workflow', parentToolCallId: 'tc-1', timestamp: 1 },
{
type: 'tool_call',
toolCall: { id: 't1', name: 'create_workflow', status: 'success', calledBy: 'workflow' },
parentToolCallId: 'tc-1',
timestamp: 1,
},
]
const segments = parseBlocks(blocks)
const groups = segments.filter((s) => s.type === 'agent_group')
expect(groups).toHaveLength(1)
if (groups[0].type !== 'agent_group') throw new Error('expected group')
expect(groups[0].agentName).toBe('workflow')
})
})
describe('shouldSmoothTextSegment', () => {
it('only smooths the trailing text segment of a live stream', () => {
expect(shouldSmoothTextSegment({ isStreaming: true, segmentIndex: 0, segmentCount: 2 })).toBe(
false
)
expect(shouldSmoothTextSegment({ isStreaming: true, segmentIndex: 1, segmentCount: 2 })).toBe(
true
)
})
it('never smooths completed messages', () => {
expect(shouldSmoothTextSegment({ isStreaming: false, segmentIndex: 0, segmentCount: 1 })).toBe(
false
)
})
})
@@ -0,0 +1,777 @@
'use client'
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Read as ReadTool, WorkspaceFile } from '@/lib/copilot/generated/tool-catalog-v1'
import { isToolHiddenInUi } from '@/lib/copilot/tools/client/hidden-tools'
import { resolveToolDisplay } from '@/lib/copilot/tools/client/store-utils'
import { ClientToolCallState } from '@/lib/copilot/tools/client/tool-call-state'
import { getToolDisplayTitle, humanizeToolName } from '@/lib/copilot/tools/tool-display'
import { useChatSurface } from '@/app/workspace/[workspaceId]/home/components/chat-surface-context'
import type { ContentBlock, OptionItem, ToolCallData } from '../../types'
import { SUBAGENT_LABELS } from '../../types'
import type { AgentGroupItem } from './components'
import { AgentGroup, ChatContent, CircleStop, Options, PendingTagIndicator } from './components'
import { deriveMessagePhase, isToolDone, type MessagePhase } from './utils'
const FILE_SUBAGENT_ID = 'file'
interface TextSegment {
type: 'text'
/** Stable per-run React key (see the counters in parseBlocksWithSpanTree). */
id: string
content: string
}
interface AgentGroupSegment {
type: 'agent_group'
id: string
agentName: string
agentLabel: string
items: AgentGroupItem[]
isDelegating: boolean
isOpen: boolean
}
interface OptionsSegment {
type: 'options'
items: OptionItem[]
}
interface StoppedSegment {
type: 'stopped'
}
type MessageSegment = TextSegment | AgentGroupSegment | OptionsSegment | StoppedSegment
const SUBAGENT_KEYS = new Set(Object.keys(SUBAGENT_LABELS))
/**
* Maps subagent names to the Mothership tool that dispatches them when the
* tool name differs from the subagent name (e.g. `workspace_file` → `file`).
* When a `subagent` block arrives, any trailing dispatch tool in the previous
* group is absorbed so it doesn't render as a separate Mothership entry.
*/
const SUBAGENT_DISPATCH_TOOLS: Record<string, string> = {
[FILE_SUBAGENT_ID]: WorkspaceFile.id,
}
function isToolResultRead(params?: Record<string, unknown>): boolean {
const path = params?.path
return typeof path === 'string' && path.startsWith('internal/tool-results/')
}
function isHiddenToolCall(toolName: string | undefined): boolean {
return isToolHiddenInUi(toolName)
}
function resolveAgentLabel(key: string): string {
if (key === 'mothership') return 'Sim'
return SUBAGENT_LABELS[key] ?? humanizeToolName(key)
}
function isDelegatingTool(tc: NonNullable<ContentBlock['toolCall']>): boolean {
return tc.status === 'executing'
}
function mapToolStatusToClientState(
status: ContentBlock['toolCall'] extends { status: infer T } ? T : string
) {
switch (status) {
case 'success':
return ClientToolCallState.success
case 'error':
return ClientToolCallState.error
case 'cancelled':
return ClientToolCallState.cancelled
case 'skipped':
return ClientToolCallState.aborted
case 'rejected':
return ClientToolCallState.rejected
default:
return ClientToolCallState.executing
}
}
function getOverrideDisplayTitle(tc: NonNullable<ContentBlock['toolCall']>): string | undefined {
if (tc.name === ReadTool.id || tc.name === 'respond' || tc.name.endsWith('_respond')) {
return resolveToolDisplay(tc.name, mapToolStatusToClientState(tc.status), tc.params)?.text
}
return undefined
}
function toToolData(tc: NonNullable<ContentBlock['toolCall']>): ToolCallData {
const overrideDisplayTitle = getOverrideDisplayTitle(tc)
const displayTitle =
overrideDisplayTitle || tc.displayTitle || getToolDisplayTitle(tc.name, tc.params)
return {
id: tc.id,
toolName: tc.name,
displayTitle,
status: tc.status,
params: tc.params,
result: tc.result,
streamingArgs: tc.streamingArgs,
}
}
const SPAN_ROOT = 'main'
function createAgentGroupSegment(name: string, id: string): AgentGroupSegment {
return {
type: 'agent_group',
id,
agentName: name,
agentLabel: resolveAgentLabel(name),
items: [],
isDelegating: false,
isOpen: false,
}
}
function appendTextItem(group: AgentGroupSegment, content: string): void {
const lastItem = group.items[group.items.length - 1]
if (lastItem?.type === 'text') {
lastItem.content += content
} else {
group.items.push({ type: 'text', content })
}
}
/**
* Deterministic span-identity grouping. Every subagent-scoped block carries the
* stable `spanId` of the run that produced it and a `parentSpanId` linking it to
* its caller. Groups are keyed by `spanId` and nested under their parent's group
* via `parentSpanId`, producing a real tree (e.g. Deploy inside Workflow) with
* no name/tool-call reverse lookups. Delegation tool_calls are absorbed — the
* subagent span is the canonical representation of the nested agent.
*/
function parseBlocksWithSpanTree(blocks: ContentBlock[]): MessageSegment[] {
const segments: MessageSegment[] = []
const groupsBySpanId = new Map<string, AgentGroupSegment>()
// Stable per-run counters for React keys. The Nth top-level text run / Nth
// mothership group keeps the same key across re-parses (text runs and groups
// are append-only at the top level), so React never remounts the streaming
// ChatContent / AgentGroup when later segments shift array position. Keying by
// array index or block index is unstable (subagent_end interleaves, parallel
// spans reorder), which caused the disappear/re-animate + parallel-subagent flash.
let textRun = 0
let mothershipRun = 0
// Canonical subagent identity: the dispatch tool call id. It is stable across
// the no-spanId (legacy parser) -> spanId (span-tree parser) transition and
// across DB-load vs live, so the group's React key never changes when the
// underlying span id is stamped — eliminating the remount/flash and keeping a
// refreshed transcript byte-identical to the live stream.
const spanAnchor = new Map<string, string>()
for (const b of blocks) {
if (b.type === 'subagent' && b.spanId && b.parentToolCallId) {
spanAnchor.set(b.spanId, b.parentToolCallId)
}
}
const spanGroupKey = (spanId: string): string => `agent-${spanAnchor.get(spanId) ?? spanId}`
const tailMothershipGroup = (): AgentGroupSegment | null => {
const last = segments[segments.length - 1]
return last?.type === 'agent_group' && last.agentName === 'mothership' ? last : null
}
// Top-level (mothership) tool calls render in a collapsible group. Reuse that
// group only while it is still the most recent segment so consecutive tools
// stay together; once any other segment (main text, a spawned subagent,
// thinking, etc.) breaks the run, the next tool opens a fresh group below it
// instead of jumping back up into the original one. This keeps the mothership's
// tools and prose interleaved in the order they actually happened.
const ensureMothership = (): AgentGroupSegment => {
const existing = tailMothershipGroup()
if (existing) return existing
const group = createAgentGroupSegment('mothership', `agent-mothership-${mothershipRun++}`)
segments.push(group)
return group
}
// When a subagent spawns, drop the dispatch tool that triggered it (e.g.
// workspace_file -> file) from whichever container it landed in so it does not
// render as a separate entry beside the agent group.
const absorbDispatchTool = (toolName: string, parentSpanId: string | undefined): void => {
const container =
parentSpanId && parentSpanId !== SPAN_ROOT
? groupsBySpanId.get(parentSpanId)
: tailMothershipGroup()
if (!container) return
const last = container.items[container.items.length - 1]
if (last?.type === 'tool' && last.data.toolName === toolName) {
container.items.pop()
}
}
const attachSpanGroup = (group: AgentGroupSegment, parentSpanId: string | undefined): void => {
if (parentSpanId && parentSpanId !== SPAN_ROOT) {
const parent = groupsBySpanId.get(parentSpanId)
if (parent) {
parent.isDelegating = false
parent.items.push({ type: 'agent_group', group })
return
}
}
segments.push(group)
}
const ensureSpanGroup = (
name: string,
spanId: string,
parentSpanId: string | undefined
): AgentGroupSegment => {
const existing = groupsBySpanId.get(spanId)
if (existing) return existing
// Key by the dispatch tool call id (canonical, parser-stable) when known,
// falling back to the spanId for spans with no dispatch tool (legacy/orphan).
const group = createAgentGroupSegment(name, spanGroupKey(spanId))
groupsBySpanId.set(spanId, group)
attachSpanGroup(group, parentSpanId)
return group
}
const flushMainText = (content: string) => {
const last = segments[segments.length - 1]
if (last?.type === 'text') {
last.content += content
} else {
segments.push({ type: 'text', id: `text-${textRun++}`, content })
}
}
for (let i = 0; i < blocks.length; i++) {
const block = blocks[i]
if (block.type === 'subagent_text' || block.type === 'subagent_thinking') {
if (!block.content || !block.spanId) continue
let g = groupsBySpanId.get(block.spanId)
// Out-of-order safety: content can arrive before its subagent-start block
// (live streaming across resume legs). Create the span group on demand,
// nested via parentSpanId, instead of dropping the content.
if (!g && block.subagent) {
g = ensureSpanGroup(block.subagent, block.spanId, block.parentSpanId)
}
if (!g) continue
g.isDelegating = false
appendTextItem(g, block.content)
continue
}
// Main-agent thinking is intentionally not rendered. The reasoning is still
// reduced and persisted upstream — this is a display-only omission.
if (block.type === 'thinking') continue
if (block.type === 'text') {
if (!block.content) continue
if (block.subagent && block.spanId) {
let g = groupsBySpanId.get(block.spanId)
// Out-of-order safety: see subagent_text branch above.
if (!g) g = ensureSpanGroup(block.subagent, block.spanId, block.parentSpanId)
if (g) {
g.isDelegating = false
appendTextItem(g, block.content)
continue
}
}
flushMainText(block.content)
continue
}
if (block.type === 'subagent') {
if (!block.content || !block.spanId) continue
// Absorb a trailing dispatch tool (e.g. workspace_file -> file) so it does
// not render as a separate entry alongside the agent group.
const dispatchToolName = SUBAGENT_DISPATCH_TOOLS[block.content]
if (dispatchToolName) absorbDispatchTool(dispatchToolName, block.parentSpanId)
const g = ensureSpanGroup(block.content, block.spanId, block.parentSpanId)
if (block.endedAt !== undefined) {
// Persisted backend path: the lane was stamped closed (endedAt) without
// a separate subagent_end block (the Sim backend stamps endedAt only;
// only the live browser path pushes subagent_end). Honor endedAt so a
// reloaded transcript shows the subagent closed instead of a stuck
// delegating spinner.
g.isOpen = false
g.isDelegating = false
continue
}
// Show the working/delegating spinner from span open until the agent
// emits its first content or tool (or ends). The legacy path derived this
// from the dispatch tool_call, which the span path absorbs, so we set it
// here. It is cleared in the subagent_text/subagent_thinking, scoped text,
// tool_call, and subagent_end branches.
g.isDelegating = true
g.isOpen = true
continue
}
if (block.type === 'tool_call') {
if (!block.toolCall) continue
const tc = block.toolCall
if (isHiddenToolCall(tc.name)) continue
if (tc.name === ReadTool.id && isToolResultRead(tc.params)) continue
// Delegation tools are represented by their subagent span group; absorb.
if (SUBAGENT_KEYS.has(tc.name)) continue
const tool = toToolData(tc)
if (block.spanId) {
let g = groupsBySpanId.get(block.spanId)
// Out-of-order safety: a subagent's tool can stream before its
// subagent-start block (live streaming across resume legs). Create the
// span group on demand (nested via parentSpanId) so the tool nests
// under its agent instead of leaking to the top-level mothership flow.
if (!g && tc.calledBy) {
g = ensureSpanGroup(tc.calledBy, block.spanId, block.parentSpanId)
}
if (g) {
g.isDelegating = false
g.items.push({ type: 'tool', data: tool })
continue
}
}
ensureMothership().items.push({ type: 'tool', data: tool })
continue
}
if (block.type === 'options') {
if (!block.options?.length) continue
segments.push({ type: 'options', items: block.options })
continue
}
if (block.type === 'subagent_end') {
if (block.spanId) {
const g = groupsBySpanId.get(block.spanId)
if (g) {
g.isOpen = false
g.isDelegating = false
}
}
continue
}
if (block.type === 'stopped') {
segments.push({ type: 'stopped' })
}
}
// Recursively drop empty, closed, non-delegating nested groups so a subagent
// that started and ended without emitting anything does not leave a stray
// header row. The top-level filter below covers top-level groups.
const pruneEmptyNested = (items: AgentGroupItem[]): AgentGroupItem[] =>
items.filter((item) => {
if (item.type !== 'agent_group') return true
item.group.items = pruneEmptyNested(item.group.items)
return item.group.items.length > 0 || item.group.isOpen || item.group.isDelegating
})
for (const segment of segments) {
if (segment.type === 'agent_group') {
segment.items = pruneEmptyNested(segment.items)
}
}
return segments.filter(
(segment) =>
segment.type !== 'agent_group' ||
segment.items.length > 0 ||
segment.isDelegating ||
segment.isOpen
)
}
/**
* Groups content blocks into agent-scoped segments.
* Dispatch tool_calls (name matches a subagent key, no calledBy) are absorbed
* into the agent header. Inner tool_calls are nested underneath their agent.
* Orphan tool_calls (no calledBy, not a dispatch) group under "Sim".
*
* New backends stamp every subagent block with deterministic span identity; in
* that case {@link parseBlocksWithSpanTree} builds a real nested tree. The
* legacy flat heuristics below are retained for transcripts persisted before
* span identity existed.
*/
export function parseBlocks(blocks: ContentBlock[]): MessageSegment[] {
if (blocks.some((block) => Boolean(block.spanId))) {
return parseBlocksWithSpanTree(blocks)
}
return parseBlocksLegacy(blocks)
}
function parseBlocksLegacy(blocks: ContentBlock[]): MessageSegment[] {
const segments: MessageSegment[] = []
const groupsByKey = new Map<string, AgentGroupSegment>()
let activeGroupKey: string | null = null
const groupKey = (name: string, parentToolCallId: string | undefined) =>
parentToolCallId ? `${name}:${parentToolCallId}` : `${name}:legacy`
const resolveGroupKey = (name: string, parentToolCallId: string | undefined) => {
if (parentToolCallId) return groupKey(name, parentToolCallId)
if (activeGroupKey && groupsByKey.get(activeGroupKey)?.agentName === name) {
return activeGroupKey
}
for (const [key, g] of groupsByKey) {
if (g.agentName === name && g.isOpen) return key
}
return groupKey(name, undefined)
}
const ensureGroup = (
name: string,
parentToolCallId: string | undefined
): { group: AgentGroupSegment; created: boolean } => {
const key = resolveGroupKey(name, parentToolCallId)
const existing = groupsByKey.get(key)
if (existing) return { group: existing, created: false }
const group: AgentGroupSegment = {
type: 'agent_group',
// Canonical key = the dispatch tool call id, identical to the span-tree
// parser, so a transcript that gains span ids (or a DB reload) keeps the
// same React key and never remounts. Orphans (no dispatch tool) keep the
// position-based legacy id.
id: parentToolCallId ? `agent-${parentToolCallId}` : `agent-${key}-${segments.length}`,
agentName: name,
agentLabel: resolveAgentLabel(name),
items: [],
isDelegating: false,
isOpen: false,
}
segments.push(group)
groupsByKey.set(key, group)
return { group, created: true }
}
const findGroupForSubagentChunk = (
parentToolCallId: string | undefined
): AgentGroupSegment | undefined => {
if (parentToolCallId) {
for (const [key, g] of groupsByKey) {
if (key.endsWith(`:${parentToolCallId}`)) return g
}
return undefined
}
if (activeGroupKey) return groupsByKey.get(activeGroupKey)
return undefined
}
const flushLanes = () => {
for (const g of groupsByKey.values()) {
g.isOpen = false
g.isDelegating = false
}
groupsByKey.clear()
activeGroupKey = null
}
for (let i = 0; i < blocks.length; i++) {
const block = blocks[i]
if (block.type === 'subagent_text' || block.type === 'subagent_thinking') {
if (!block.content) continue
const g = findGroupForSubagentChunk(block.parentToolCallId)
if (!g) continue
g.isDelegating = false
const lastItem = g.items[g.items.length - 1]
if (lastItem?.type === 'text') {
lastItem.content += block.content
} else {
g.items.push({ type: 'text', content: block.content })
}
continue
}
if (block.type === 'thinking') {
// Main-agent thinking is not rendered, but it still breaks open subagent
// lanes so later chunks don't merge across it (display-only omission).
if (!block.content?.trim()) continue
flushLanes()
continue
}
if (block.type === 'text') {
if (!block.content) continue
if (block.subagent) {
const g = groupsByKey.get(resolveGroupKey(block.subagent, block.parentToolCallId))
if (g) {
g.isDelegating = false
const lastItem = g.items[g.items.length - 1]
if (lastItem?.type === 'text') {
lastItem.content += block.content
} else {
g.items.push({ type: 'text', content: block.content })
}
continue
}
}
flushLanes()
const last = segments[segments.length - 1]
if (last?.type === 'text') {
last.content += block.content
} else {
segments.push({ type: 'text', id: `text-${i}`, content: block.content })
}
continue
}
if (block.type === 'subagent') {
if (!block.content) continue
const key = block.content
let inheritedDelegation = false
const dispatchToolName = SUBAGENT_DISPATCH_TOOLS[key]
if (dispatchToolName) {
const mship = groupsByKey.get(groupKey('mothership', undefined))
if (mship) {
const last = mship.items[mship.items.length - 1]
if (last?.type === 'tool' && last.data.toolName === dispatchToolName) {
inheritedDelegation = !isToolDone(last.data.status) && Boolean(last.data.streamingArgs)
mship.items.pop()
}
}
}
groupsByKey.delete(groupKey('mothership', undefined))
const { group: g } = ensureGroup(key, block.parentToolCallId)
if (inheritedDelegation) g.isDelegating = true
g.isOpen = true
activeGroupKey = resolveGroupKey(key, block.parentToolCallId)
continue
}
if (block.type === 'tool_call') {
if (!block.toolCall) continue
const tc = block.toolCall
if (isToolHiddenInUi(tc.name)) continue
if (tc.name === ReadTool.id && isToolResultRead(tc.params)) continue
const isDispatch = SUBAGENT_KEYS.has(tc.name) && !tc.calledBy
if (isDispatch) {
groupsByKey.delete(groupKey('mothership', undefined))
const { group: g } = ensureGroup(tc.name, tc.id)
g.isDelegating = isDelegatingTool(tc)
g.isOpen = g.isDelegating
continue
}
const tool = toToolData(tc)
if (tc.calledBy) {
const { group: g, created } = ensureGroup(tc.calledBy, block.parentToolCallId)
g.isDelegating = false
if (created && block.parentToolCallId) g.isOpen = true
g.items.push({ type: 'tool', data: tool })
activeGroupKey = resolveGroupKey(tc.calledBy, block.parentToolCallId)
} else {
const { group: g } = ensureGroup('mothership', undefined)
g.items.push({ type: 'tool', data: tool })
}
continue
}
if (block.type === 'options') {
if (!block.options?.length) continue
flushLanes()
segments.push({ type: 'options', items: block.options })
continue
}
if (block.type === 'subagent_end') {
if (block.parentToolCallId) {
for (const [key, g] of groupsByKey) {
if (key.endsWith(`:${block.parentToolCallId}`)) {
g.isOpen = false
g.isDelegating = false
}
}
if (activeGroupKey?.endsWith(`:${block.parentToolCallId}`)) {
activeGroupKey = null
}
} else {
for (const [key, g] of groupsByKey) {
if (key.endsWith(':legacy') && g.agentName !== 'mothership') {
g.isOpen = false
g.isDelegating = false
}
}
if (activeGroupKey?.endsWith(':legacy')) {
activeGroupKey = null
}
}
continue
}
if (block.type === 'stopped') {
flushLanes()
segments.push({ type: 'stopped' })
}
}
const visibleSegments = segments.filter(
(segment) =>
segment.type !== 'agent_group' ||
segment.items.length > 0 ||
segment.isDelegating ||
segment.isOpen
)
return visibleSegments
}
/**
* Mirrors the segment resolution inside {@link MessageContent} so list renderers
* can tell whether an assistant message has anything visible yet. Avoids treating
* `contentBlocks: [{ type: 'text', content: '' }]` as "has content" — that briefly
* made MessageContent return null while streaming and caused a double Thinking flash.
*/
export function assistantMessageHasRenderableContent(
blocks: ContentBlock[],
fallbackContent: string
): boolean {
const parsed = blocks.length > 0 ? parseBlocks(blocks) : []
const segments: MessageSegment[] =
parsed.length > 0
? parsed
: fallbackContent.trim()
? [{ type: 'text' as const, id: 'text-fallback', content: fallbackContent }]
: []
return segments.length > 0
}
export function shouldSmoothTextSegment({
isStreaming,
segmentIndex,
segmentCount,
}: {
isStreaming: boolean
segmentIndex: number
segmentCount: number
}): boolean {
return isStreaming && segmentIndex === segmentCount - 1
}
interface MessageContentProps {
blocks: ContentBlock[]
fallbackContent: string
isStreaming: boolean
onOptionSelect?: (id: string) => void
onPhaseChange?: (phase: MessagePhase) => void
}
function MessageContentInner({
blocks,
fallbackContent,
isStreaming = false,
onOptionSelect,
onPhaseChange,
}: MessageContentProps) {
const { onWorkspaceResourceSelect } = useChatSurface()
const parsed = useMemo(() => (blocks.length > 0 ? parseBlocks(blocks) : []), [blocks])
const [trailingRevealing, setTrailingRevealing] = useState(false)
const handleTrailingRevealChange = useCallback((revealing: boolean) => {
setTrailingRevealing(revealing)
}, [])
const segments: MessageSegment[] =
parsed.length > 0
? parsed
: fallbackContent?.trim()
? [{ type: 'text' as const, id: 'text-fallback', content: fallbackContent }]
: []
const lastSegment = segments[segments.length - 1]
const hasTrailingTextSegment = lastSegment?.type === 'text'
const isRevealing = hasTrailingTextSegment && trailingRevealing
const phase = deriveMessagePhase({ isStreaming, isRevealing })
const onPhaseChangeRef = useRef(onPhaseChange)
onPhaseChangeRef.current = onPhaseChange
useEffect(() => {
onPhaseChangeRef.current?.(phase)
}, [phase])
if (segments.length === 0) {
if (isStreaming) {
return (
<div className='space-y-[10px]'>
<PendingTagIndicator />
</div>
)
}
return null
}
const hasTrailingContent = lastSegment.type === 'text' || lastSegment.type === 'stopped'
// Deterministic "between steps" signal: the turn is still streaming, nothing
// is actively running (a running tool/subagent renders its own spinner), and
// no trailing text is being revealed. Derived from explicit node state rather
// than guessing from the shape of the last segment.
const hasRunningWork = blocks.some(
(b) => b.toolCall?.status === 'executing' || (b.type === 'subagent' && b.endedAt === undefined)
)
const showTrailingThinking = phase === 'streaming' && !hasTrailingContent && !hasRunningWork
return (
<div className='space-y-[10px]'>
{segments.map((segment, i) => {
switch (segment.type) {
case 'text':
return (
<ChatContent
key={segment.id}
content={segment.content}
isStreaming={shouldSmoothTextSegment({
isStreaming,
segmentIndex: i,
segmentCount: segments.length,
})}
onOptionSelect={onOptionSelect}
onWorkspaceResourceSelect={onWorkspaceResourceSelect}
onRevealStateChange={
i === segments.length - 1 ? handleTrailingRevealChange : undefined
}
/>
)
case 'agent_group': {
return (
<div key={segment.id} className={isStreaming ? 'animate-stream-fade-in' : undefined}>
<AgentGroup
key={segment.id}
agentName={segment.agentName}
agentLabel={segment.agentLabel}
items={segment.items}
isDelegating={segment.isDelegating}
isStreaming={isStreaming}
isCurrentSection={i === segments.length - 1}
isLaneOpen={segment.isOpen}
/>
</div>
)
}
case 'options':
return (
<div
key={`options-${i}`}
className={isStreaming ? 'animate-stream-fade-in' : undefined}
>
<Options items={segment.items} onSelect={onOptionSelect} />
</div>
)
case 'stopped':
return (
<div key={`stopped-${i}`} className='flex items-center gap-[8px]'>
<CircleStop className='size-[16px] flex-shrink-0 text-[var(--text-icon)]' />
<span className='text-[14px] text-[var(--text-body)]'>Stopped by user</span>
</div>
)
}
})}
{showTrailingThinking && (
<div className='animate-stream-fade-in-delayed opacity-0'>
<PendingTagIndicator />
</div>
)}
</div>
)
}
export const MessageContent = memo(MessageContentInner)
@@ -0,0 +1,38 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import { deriveMessagePhase, resolveToolDisplayState } from './utils'
describe('deriveMessagePhase', () => {
it('is streaming whenever the transport is live', () => {
expect(deriveMessagePhase({ isStreaming: true, isRevealing: false })).toBe('streaming')
expect(deriveMessagePhase({ isStreaming: true, isRevealing: true })).toBe('streaming')
})
it('is revealing when the transport stopped but text is still draining', () => {
expect(deriveMessagePhase({ isStreaming: false, isRevealing: true })).toBe('revealing')
})
it('is settled once neither the transport nor the reveal is active', () => {
expect(deriveMessagePhase({ isStreaming: false, isRevealing: false })).toBe('settled')
})
})
describe('resolveToolDisplayState', () => {
it('spins iff the tool is executing — a pure projection of its own status', () => {
expect(resolveToolDisplayState('executing')).toBe('spinner')
})
it('maps cancelled and interrupted to their own glyphs', () => {
expect(resolveToolDisplayState('cancelled')).toBe('cancelled')
expect(resolveToolDisplayState('interrupted')).toBe('interrupted')
})
it('renders terminal successes and errors as the tool icon', () => {
expect(resolveToolDisplayState('success')).toBe('icon')
expect(resolveToolDisplayState('error')).toBe('icon')
expect(resolveToolDisplayState('skipped')).toBe('icon')
expect(resolveToolDisplayState('rejected')).toBe('icon')
})
})
@@ -0,0 +1,117 @@
import type { ComponentType, SVGProps } from 'react'
import {
Asterisk,
Blimp,
Bug,
Database,
Eye,
File,
FolderCode,
Hammer,
Integration,
Layout,
Library,
Pencil,
PlayOutline,
Rocket,
Search,
Settings,
TerminalWindow,
Wrench,
} from '@sim/emcn'
import { Calendar, Table as TableIcon } from '@sim/emcn/icons'
import { AgentIcon, ImageIcon, TTSIcon, VideoIcon } from '@/components/icons'
import type { ToolCallStatus } from '@/app/workspace/[workspaceId]/home/types'
export type IconComponent = ComponentType<SVGProps<SVGSVGElement>>
const TOOL_ICONS: Record<string, IconComponent> = {
mothership: Blimp,
glob: FolderCode,
grep: Search,
read: File,
search_online: Search,
scrape_page: Search,
get_page_contents: Search,
search_library_docs: Library,
manage_mcp_tool: Settings,
manage_skill: Asterisk,
user_memory: Database,
function_execute: TerminalWindow,
superagent: Blimp,
user_table: TableIcon,
workspace_file: File,
edit_content: File,
create_workflow: Layout,
edit_workflow: Pencil,
workflow: Hammer,
debug: Bug,
run: PlayOutline,
deploy: Rocket,
auth: Integration,
knowledge: Database,
knowledge_base: Database,
table: TableIcon,
scheduled_task: Calendar,
job: Calendar,
agent: AgentIcon,
custom_tool: Wrench,
research: Search,
context_compaction: Asterisk,
open_resource: Eye,
file: File,
media: VideoIcon,
generate_image: ImageIcon,
generate_video: VideoIcon,
generate_audio: TTSIcon,
ffmpeg: Wrench,
}
export function getAgentIcon(name: string): IconComponent {
return TOOL_ICONS[name as keyof typeof TOOL_ICONS] ?? Blimp
}
export function getToolIcon(name: string): IconComponent | undefined {
const icon = TOOL_ICONS[name as keyof typeof TOOL_ICONS]
return icon === Blimp ? undefined : icon
}
export type MessagePhase = 'streaming' | 'revealing' | 'settled'
interface DeriveMessagePhaseArgs {
isStreaming: boolean
isRevealing: boolean
}
export function deriveMessagePhase({
isStreaming,
isRevealing,
}: DeriveMessagePhaseArgs): MessagePhase {
if (isStreaming) return 'streaming'
if (isRevealing) return 'revealing'
return 'settled'
}
type ToolDisplayState = 'spinner' | 'cancelled' | 'interrupted' | 'icon'
export function resolveToolDisplayState(status: ToolCallStatus): ToolDisplayState {
// Pure projection of the tool's own status. A row spins iff it is genuinely
// executing; every terminal status maps to a glyph. No transport/turn-live
// gating — deterministic terminals (tool `result`, turn propagation) guarantee
// a row never lingers `executing` after its work is done.
if (status === 'executing') return 'spinner'
if (status === 'cancelled') return 'cancelled'
if (status === 'interrupted') return 'interrupted'
return 'icon'
}
export function isToolDone(status: ToolCallStatus): boolean {
return (
status === 'success' ||
status === 'error' ||
status === 'cancelled' ||
status === 'skipped' ||
status === 'rejected' ||
status === 'interrupted'
)
}
@@ -0,0 +1 @@
export { MothershipChatSkeleton } from './mothership-chat-skeleton'
@@ -0,0 +1 @@
export { MothershipChatSkeleton } from './mothership-chat-skeleton'
@@ -0,0 +1,51 @@
import { Skeleton } from '@sim/emcn'
const LAYOUT_SKELETON_STYLES = {
'mothership-view': {
content: 'mx-auto max-w-[48rem] space-y-6',
userRow: 'flex flex-col items-end gap-[6px] pt-3',
},
'copilot-view': {
content: 'space-y-4',
userRow: 'flex flex-col items-end gap-[6px] pt-2',
},
} as const
interface MothershipChatSkeletonProps {
layout?: 'mothership-view' | 'copilot-view'
}
/**
* Skeleton content rendered inside MothershipChat's scroll area
* while chat history is being fetched.
*/
export function MothershipChatSkeleton({
layout = 'mothership-view',
}: MothershipChatSkeletonProps) {
const styles = LAYOUT_SKELETON_STYLES[layout]
return (
<div className={styles.content}>
<div className={styles.userRow}>
<Skeleton className='h-[40px] w-[55%] rounded-[16px]' />
</div>
<div className='space-y-3'>
<Skeleton className='h-[14px] w-[90%] rounded-[4px]' />
<Skeleton className='h-[14px] w-[75%] rounded-[4px]' />
<Skeleton className='h-[14px] w-[82%] rounded-[4px]' />
<Skeleton className='h-[14px] w-[40%] rounded-[4px]' />
</div>
<div className={styles.userRow}>
<Skeleton className='h-[32px] w-[40%] rounded-[16px]' />
</div>
<div className='space-y-3'>
<Skeleton className='h-[14px] w-[85%] rounded-[4px]' />
<Skeleton className='h-[14px] w-[70%] rounded-[4px]' />
<Skeleton className='h-[14px] w-[60%] rounded-[4px]' />
</div>
</div>
)
}
@@ -0,0 +1,2 @@
export { MothershipChatSkeleton } from './components'
export { MothershipChat } from './mothership-chat'
@@ -0,0 +1,481 @@
'use client'
import {
memo,
useCallback,
useDeferredValue,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from 'react'
import { cn } from '@sim/emcn'
import { defaultRangeExtractor, type Range, useVirtualizer } from '@tanstack/react-virtual'
import { MessageActions } from '@/app/workspace/[workspaceId]/components'
import { ChatMessageAttachments } from '@/app/workspace/[workspaceId]/home/components/chat-message-attachments'
import { ChatSurfaceProvider } from '@/app/workspace/[workspaceId]/home/components/chat-surface-context'
import {
assistantMessageHasRenderableContent,
MessageContent,
type MessagePhase,
} from '@/app/workspace/[workspaceId]/home/components/message-content'
import { PendingTagIndicator } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags'
import { QueuedMessages } from '@/app/workspace/[workspaceId]/home/components/queued-messages'
import {
UserInput,
type UserInputHandle,
} from '@/app/workspace/[workspaceId]/home/components/user-input'
import { UserMessageContent } from '@/app/workspace/[workspaceId]/home/components/user-message-content'
import type {
ChatMessage,
ChatMessageAttachment,
ChatMessageContext,
ContentBlock,
FileAttachmentForApi,
MothershipResource,
QueuedMessage,
} from '@/app/workspace/[workspaceId]/home/types'
import { useAutoScroll } from '@/hooks/use-auto-scroll'
import type { ChatContext } from '@/stores/panel'
import { MothershipChatSkeleton } from './components/mothership-chat-skeleton'
interface MothershipChatProps {
messages: ChatMessage[]
isSending: boolean
isReconnecting?: boolean
isLoading?: boolean
onSubmit: (
text: string,
fileAttachments?: FileAttachmentForApi[],
contexts?: ChatContext[]
) => void
onStopGeneration: () => void
messageQueue: QueuedMessage[]
editingQueuedId: string | null
dispatchingHeadId: string | null
onRemoveQueuedMessage: (id: string) => void
onSendQueuedMessage: (id: string) => Promise<void>
onEditQueuedMessage: (id: string) => QueuedMessage | undefined
onCancelQueueEdit: () => void
userId?: string
chatId?: string
onContextAdd?: (context: ChatContext) => void
onContextRemove?: (context: ChatContext) => void
onWorkspaceResourceSelect?: (resource: MothershipResource) => void
draftScopeKey?: string
layout?: 'mothership-view' | 'copilot-view'
initialScrollBlocked?: boolean
animateInput?: boolean
onInputAnimationEnd?: () => void
className?: string
}
/**
* Per-role row-height estimates seed the virtualizer before each row is measured.
* They only size the scrollbar for not-yet-rendered rows — every visible row is
* measured precisely via `measureElement` — so approximate values suffice. Split
* by role because user bubbles are short and assistant turns are tall; a single
* blended number would over/under-shoot both and drift the scrollbar more.
*/
const ROW_HEIGHT_ESTIMATE = {
'mothership-view': { user: 64, assistant: 280 },
'copilot-view': { user: 48, assistant: 180 },
} as const
/**
* Rows render farther beyond the viewport edges than the default so fast scroll
* and the streaming tail stay painted without a blank flash before measurement.
*/
const OVERSCAN = 6
/**
* Initial-scroll sentinel. Distinct from every real `chatId` value — including
* `undefined` (a not-yet-persisted chat) — so the first scroll-to-bottom fires
* even before a chat has an id, instead of treating `undefined` as "already
* scrolled this chat".
*/
const UNSCROLLED = Symbol('unscrolled')
const LAYOUT_STYLES = {
'mothership-view': {
scrollContainer:
'min-h-0 flex-1 overflow-y-auto overflow-x-hidden px-6 pt-4 pb-8 [scrollbar-gutter:stable_both-edges]',
sizer: 'relative mx-auto w-full max-w-[48rem]',
rowGap: 'pb-6',
userRow: 'flex flex-col items-end gap-[6px] pt-3',
attachmentWidth: 'max-w-[70%]',
userBubble: 'max-w-[70%] overflow-hidden rounded-[16px] bg-[var(--surface-5)] px-3.5 py-2',
assistantRow: 'group/msg',
footer: 'flex-shrink-0 px-[24px] pb-[16px]',
footerInner: 'mx-auto max-w-[48rem]',
},
'copilot-view': {
scrollContainer: 'min-h-0 flex-1 overflow-y-auto overflow-x-hidden px-3 pt-2 pb-4',
sizer: 'relative w-full',
rowGap: 'pb-4',
userRow: 'flex flex-col items-end gap-[6px] pt-2',
attachmentWidth: 'max-w-[85%]',
userBubble: 'max-w-[85%] overflow-hidden rounded-[16px] bg-[var(--surface-5)] px-3 py-2',
assistantRow: 'group/msg',
footer: 'flex-shrink-0 px-3 pb-3',
footerInner: '',
},
} as const
const EMPTY_BLOCKS: ContentBlock[] = []
interface UserMessageRowProps {
content: string
contexts?: ChatMessageContext[]
attachments?: ChatMessageAttachment[]
rowClassName: string
bubbleClassName: string
attachmentWidthClassName: string
}
const UserMessageRow = memo(function UserMessageRow({
content,
contexts,
attachments,
rowClassName,
bubbleClassName,
attachmentWidthClassName,
}: UserMessageRowProps) {
const hasAttachments = Boolean(attachments?.length)
return (
<div className={rowClassName}>
{hasAttachments && (
<ChatMessageAttachments
attachments={attachments ?? []}
align='end'
className={attachmentWidthClassName}
/>
)}
<div className={bubbleClassName}>
<UserMessageContent content={content} contexts={contexts} />
</div>
</div>
)
})
interface AssistantMessageRowProps {
message: ChatMessage
isStreaming: boolean
precedingUserContent?: string
rowClassName: string
onOptionSelect?: (id: string) => void
onAnimatingChange?: (animating: boolean) => void
}
const AssistantMessageRow = memo(function AssistantMessageRow({
message,
isStreaming,
precedingUserContent,
rowClassName,
onOptionSelect,
onAnimatingChange,
}: AssistantMessageRowProps) {
const blocks = message.contentBlocks ?? EMPTY_BLOCKS
const hasAnyBlocks = blocks.length > 0
const trimmedContent = message.content?.trim() ?? ''
const [phase, setPhase] = useState<MessagePhase>(isStreaming ? 'streaming' : 'settled')
const onAnimatingChangeRef = useRef(onAnimatingChange)
onAnimatingChangeRef.current = onAnimatingChange
useEffect(() => {
onAnimatingChangeRef.current?.(phase !== 'settled')
}, [phase])
if (!hasAnyBlocks && !trimmedContent && isStreaming) {
return <PendingTagIndicator />
}
const hasRenderableAssistant = assistantMessageHasRenderableContent(blocks, message.content ?? '')
if (!hasRenderableAssistant && !trimmedContent && !isStreaming) {
return null
}
const showActions = phase === 'settled' && (message.content || hasAnyBlocks)
return (
<div className={rowClassName}>
<MessageContent
blocks={blocks}
fallbackContent={message.content}
isStreaming={isStreaming}
onOptionSelect={onOptionSelect}
onPhaseChange={setPhase}
/>
{showActions && (
<div className='mt-2.5'>
<MessageActions
content={message.content}
userQuery={precedingUserContent}
requestId={message.requestId}
messageId={message.id}
/>
</div>
)}
</div>
)
})
export function MothershipChat({
messages: messagesProp,
isSending,
isReconnecting = false,
isLoading = false,
onSubmit,
onStopGeneration,
messageQueue,
editingQueuedId,
dispatchingHeadId,
onRemoveQueuedMessage,
onSendQueuedMessage,
onEditQueuedMessage,
onCancelQueueEdit,
userId,
chatId,
onContextAdd,
onContextRemove,
onWorkspaceResourceSelect,
draftScopeKey,
layout = 'mothership-view',
initialScrollBlocked = false,
animateInput = false,
onInputAnimationEnd,
className,
}: MothershipChatProps) {
const styles = LAYOUT_STYLES[layout]
const isStreamActive = isSending || isReconnecting
/**
* Defer the streamed message list so its re-render (virtualizer + rows) is
* low-priority: React yields it to urgent interactions (dragging/panning the
* side-panel canvas, scrolling, typing), keeping those at 60fps instead of
* starving the main thread on every streaming token.
*/
const messages = useDeferredValue(messagesProp)
const [lastRowAnimating, setLastRowAnimating] = useState(false)
const scrollElementRef = useRef<HTMLDivElement | null>(null)
const { ref: autoScrollRef } = useAutoScroll(isStreamActive || lastRowAnimating)
const setScrollElement = useCallback(
(el: HTMLDivElement | null) => {
scrollElementRef.current = el
autoScrollRef(el)
},
[autoScrollRef]
)
const hasMessages = messages.length > 0
/**
* Stable per-row identity for virtualizer measurement caching and React
* reconciliation. User rows key on their message id; assistant rows key on
* their turn position (`assistant:<userId>:<ordinal>`) so a streaming
* placeholder keeps the same element — and its smooth-text state — when the
* persisted message arrives with a new id.
*/
const rowKeyByIndex = useMemo(() => {
const out: string[] = []
let lastUserId: string | undefined
let ordinal = 0
for (const [index, message] of messages.entries()) {
if (message.role === 'user') {
lastUserId = message.id
ordinal = 0
out[index] = message.id
} else {
out[index] = lastUserId ? `assistant:${lastUserId}:${ordinal++}` : message.id
}
}
return out
}, [messages])
const precedingUserContentByIndex = useMemo(() => {
const out: Array<string | undefined> = []
let lastUserContent: string | undefined
for (const [index, message] of messages.entries()) {
out[index] = lastUserContent
if (message.role === 'user') lastUserContent = message.content
}
return out
}, [messages])
/**
* Always keep the last row in the rendered window. It is the live/streaming
* row; unmounting it (by scrolling far enough up that it leaves the overscan
* window) and remounting it mid-stream would reset its smooth-text reveal
* state and re-fire the fade-in animation — a visible flash. Pinning it costs
* one extra always-mounted row.
*/
const lastIndex = messages.length - 1
const lastRowKey = lastIndex >= 0 ? rowKeyByIndex[lastIndex] : undefined
useEffect(() => {
setLastRowAnimating(false)
}, [lastRowKey])
const rangeExtractor = useCallback(
(range: Range) => {
const indexes = defaultRangeExtractor(range)
if (lastIndex >= 0 && !indexes.includes(lastIndex)) {
indexes.push(lastIndex)
}
return indexes
},
[lastIndex]
)
const virtualizer = useVirtualizer({
count: messages.length,
getScrollElement: () => scrollElementRef.current,
estimateSize: (index) => {
const estimate = ROW_HEIGHT_ESTIMATE[layout]
return messages[index]?.role === 'user' ? estimate.user : estimate.assistant
},
overscan: OVERSCAN,
getItemKey: (index) => rowKeyByIndex[index] ?? index,
rangeExtractor,
})
const scrolledChatRef = useRef<string | undefined | typeof UNSCROLLED>(UNSCROLLED)
const userInputRef = useRef<UserInputHandle>(null)
const messageQueueRef = useRef(messageQueue)
useEffect(() => {
messageQueueRef.current = messageQueue
}, [messageQueue])
const onSubmitRef = useRef(onSubmit)
useEffect(() => {
onSubmitRef.current = onSubmit
}, [onSubmit])
const stableOnOptionSelect = useCallback((id: string) => {
onSubmitRef.current(id)
}, [])
const handleSendQueuedHead = useCallback(() => {
const topMessage = messageQueueRef.current[0]
if (!topMessage) return
void onSendQueuedMessage(topMessage.id)
}, [onSendQueuedMessage])
const handleEditQueued = useCallback(
(id: string) => {
const msg = onEditQueuedMessage(id)
if (msg) userInputRef.current?.loadQueuedMessage(msg)
},
[onEditQueuedMessage]
)
const handleEditQueuedTail = useCallback(() => {
const tail = messageQueueRef.current[messageQueueRef.current.length - 1]
if (!tail) return
handleEditQueued(tail.id)
}, [handleEditQueued])
/**
* Land at the most recent message once per chat — on open and when switching
* chats. The ref tracks which `chatId` we last scrolled for (seeded with
* {@link UNSCROLLED} so a pending, id-less chat still scrolls on first mount),
* so it re-fires on a genuine chat switch, including between chats of equal
* length. A pending chat persisting its id (`undefined` → string) is the SAME
* conversation, so adopt the id without re-scrolling — otherwise the viewport
* would snap back to the bottom after the user scrolled up mid-stream. Runs
* before paint so a long transcript never flashes at the top. Subsequent
* growth within the same chat is handled by {@link useAutoScroll}'s streaming
* sticky-scroll, not here.
*/
useLayoutEffect(() => {
const scrolledFor = scrolledChatRef.current
if (!hasMessages || initialScrollBlocked || scrolledFor === chatId) return
const isPendingPersist = scrolledFor === undefined && chatId !== undefined
scrolledChatRef.current = chatId
if (isPendingPersist) return
virtualizer.scrollToIndex(lastIndex, { align: 'end' })
}, [chatId, hasMessages, initialScrollBlocked, lastIndex, virtualizer])
const virtualItems = virtualizer.getVirtualItems()
return (
<ChatSurfaceProvider
chatId={chatId}
userId={userId}
onContextAdd={onContextAdd}
onContextRemove={onContextRemove}
onWorkspaceResourceSelect={onWorkspaceResourceSelect}
>
<div className={cn('flex h-full min-h-0 flex-col', className)}>
<div ref={setScrollElement} className={styles.scrollContainer}>
{isLoading && !hasMessages ? (
<MothershipChatSkeleton layout={layout} />
) : (
<div className={styles.sizer} style={{ height: virtualizer.getTotalSize() }}>
{virtualItems.map((virtualItem) => {
const index = virtualItem.index
const msg = messages[index]
const isLast = index === lastIndex
return (
<div
key={virtualItem.key}
data-index={index}
ref={virtualizer.measureElement}
className='absolute top-0 left-0 w-full'
style={{ transform: `translateY(${virtualItem.start}px)` }}
>
{msg.role === 'user' ? (
<UserMessageRow
content={msg.content}
contexts={msg.contexts}
attachments={msg.attachments}
rowClassName={cn(styles.userRow, styles.rowGap)}
bubbleClassName={styles.userBubble}
attachmentWidthClassName={styles.attachmentWidth}
/>
) : (
<AssistantMessageRow
message={msg}
isStreaming={isStreamActive && isLast}
precedingUserContent={precedingUserContentByIndex[index]}
rowClassName={cn(styles.assistantRow, styles.rowGap)}
onOptionSelect={isLast ? stableOnOptionSelect : undefined}
onAnimatingChange={isLast ? setLastRowAnimating : undefined}
/>
)}
</div>
)
})}
</div>
)}
</div>
<div
className={cn(styles.footer, animateInput && 'animate-slide-in-bottom')}
onAnimationEnd={animateInput ? onInputAnimationEnd : undefined}
>
<div className={styles.footerInner}>
<QueuedMessages
messageQueue={messageQueue}
editingQueuedId={editingQueuedId}
dispatchingHeadId={dispatchingHeadId}
onRemove={onRemoveQueuedMessage}
onSendNow={onSendQueuedMessage}
onEdit={handleEditQueued}
onCancelEdit={onCancelQueueEdit}
/>
<UserInput
ref={userInputRef}
onSubmit={onSubmit}
isSending={isStreamActive}
onStopGeneration={onStopGeneration}
isInitialView={false}
onSendQueuedHead={handleSendQueuedHead}
onEditQueuedTail={handleEditQueuedTail}
draftScopeKey={draftScopeKey}
/>
</div>
</div>
</div>
</ChatSurfaceProvider>
)
}
@@ -0,0 +1,4 @@
export {
MothershipResourcesProvider,
useMothershipResources,
} from './mothership-resources-context'
@@ -0,0 +1,69 @@
'use client'
import { createContext, type ReactNode, useContext, useMemo } from 'react'
import type {
MothershipResource,
MothershipResourceType,
} from '@/app/workspace/[workspaceId]/home/types'
/**
* Resource-management operations for the Mothership resource panel. Provided
* by the home surface (which owns the resource state via `useChat`) and
* consumed at the leaves (`ResourceTabs`, `MothershipView`) so the operations
* do not have to be relayed through intermediate components.
*/
interface MothershipResourcesContextValue {
/** Makes the given resource the active tab. */
selectResource: (id: string) => void
/** Adds a resource to the panel and activates it. */
addResource: (resource: MothershipResource) => void
/** Removes a resource from the panel. */
removeResource: (resourceType: MothershipResourceType, resourceId: string) => void
/** Replaces the resource list with a new ordering. */
reorderResources: (resources: MothershipResource[]) => void
/** Collapses the resource panel. */
collapseResource: () => void
}
const MothershipResourcesContext = createContext<MothershipResourcesContextValue | null>(null)
interface MothershipResourcesProviderProps extends MothershipResourcesContextValue {
children: ReactNode
}
/**
* Provides resource-management operations to the resource panel subtree. All
* operations are expected to be referentially stable; the context value is
* memoized on their identities.
*/
export function MothershipResourcesProvider({
selectResource,
addResource,
removeResource,
reorderResources,
collapseResource,
children,
}: MothershipResourcesProviderProps) {
const value = useMemo<MothershipResourcesContextValue>(
() => ({ selectResource, addResource, removeResource, reorderResources, collapseResource }),
[selectResource, addResource, removeResource, reorderResources, collapseResource]
)
return (
<MothershipResourcesContext.Provider value={value}>
{children}
</MothershipResourcesContext.Provider>
)
}
/**
* Reads the resource-management operations for the surrounding resource panel.
* Must be called under a {@link MothershipResourcesProvider}.
*/
export function useMothershipResources(): MothershipResourcesContextValue {
const value = useContext(MothershipResourcesContext)
if (!value) {
throw new Error('useMothershipResources must be used within a MothershipResourcesProvider')
}
return value
}
@@ -0,0 +1,573 @@
'use client'
import { useMemo, useState } from 'react'
import {
Button,
cn,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSearchInput,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
Tooltip,
} from '@sim/emcn'
import { Folder, Plus, Workflow } from '@sim/emcn/icons'
import { truncate } from '@sim/utils/string'
import { getResourceConfig } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry'
import {
RESOURCE_TAB_ICON_BUTTON_CLASS,
RESOURCE_TAB_ICON_CLASS,
} from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tab-controls'
import type {
MothershipResource,
MothershipResourceType,
} from '@/app/workspace/[workspaceId]/home/types'
import { formatDate } from '@/app/workspace/[workspaceId]/logs/utils'
import { listIntegrations } from '@/blocks/integration-matcher'
import { useFolders } from '@/hooks/queries/folders'
import { useKnowledgeBasesQuery } from '@/hooks/queries/kb/knowledge'
import { useLogsList } from '@/hooks/queries/logs'
import { useMothershipChats } from '@/hooks/queries/mothership-chats'
import { useWorkspaceSchedules } from '@/hooks/queries/schedules'
import { useTablesList } from '@/hooks/queries/tables'
import { useWorkflows } from '@/hooks/queries/workflows'
import { useWorkspaceFileFolders } from '@/hooks/queries/workspace-file-folders'
import { useWorkspaceFiles } from '@/hooks/queries/workspace-files'
export interface AddResourceDropdownProps {
workspaceId: string
existingKeys: Set<string>
onAdd: (resource: MothershipResource) => void
onSwitch?: (resourceId: string) => void
/** Resource types to hide from the dropdown (e.g. `['folder', 'task']`). */
excludeTypes?: readonly MothershipResourceType[]
}
export type AvailableItem = { id: string; name: string; isOpen?: boolean; [key: string]: unknown }
interface AvailableItemsByType {
type: MothershipResourceType
items: AvailableItem[]
}
const LOG_DROPDOWN_LIMIT = 50
const LOG_DROPDOWN_FILTERS = {
timeRange: 'All time' as const,
level: 'all',
workflowIds: [] as string[],
folderIds: [] as string[],
triggers: [] as string[],
searchQuery: '',
limit: LOG_DROPDOWN_LIMIT,
sortBy: 'date' as const,
sortOrder: 'desc' as const,
}
export function useAvailableResources(
workspaceId: string,
existingKeys: Set<string>,
excludeTypes?: readonly MothershipResourceType[]
): AvailableItemsByType[] {
const { data: workflows = [] } = useWorkflows(workspaceId)
const { data: tables = [] } = useTablesList(workspaceId)
const { data: files = [] } = useWorkspaceFiles(workspaceId)
const { data: knowledgeBases } = useKnowledgeBasesQuery(workspaceId)
const { data: folders = [] } = useFolders(workspaceId)
const { data: fileFolders = [] } = useWorkspaceFileFolders(workspaceId)
const { data: tasks = [] } = useMothershipChats(workspaceId)
const { data: schedules = [] } = useWorkspaceSchedules(workspaceId)
const { data: logsData } = useLogsList(workspaceId, LOG_DROPDOWN_FILTERS)
const logs = useMemo(() => (logsData?.pages ?? []).flatMap((page) => page.logs), [logsData])
return useMemo(() => {
const excluded = new Set<MothershipResourceType>(excludeTypes ?? [])
const groups: AvailableItemsByType[] = [
{
type: 'workflow' as const,
items: workflows.map((w) => ({
id: w.id,
name: w.name,
folderId: w.folderId ?? null,
sortOrder: w.sortOrder,
isOpen: existingKeys.has(`workflow:${w.id}`),
})),
},
{
type: 'folder' as const,
items: folders.map((f) => ({
id: f.id,
name: f.name,
parentId: f.parentId ?? null,
sortOrder: f.sortOrder,
isOpen: existingKeys.has(`folder:${f.id}`),
})),
},
{
type: 'table' as const,
items: tables.map((t) => ({
id: t.id,
name: t.name,
isOpen: existingKeys.has(`table:${t.id}`),
})),
},
{
type: 'file' as const,
items: files.map((f) => ({
id: f.id,
name: f.name,
folderId: f.folderId ?? null,
isOpen: existingKeys.has(`file:${f.id}`),
})),
},
{
type: 'filefolder' as const,
items: fileFolders.map((f) => ({
id: f.id,
name: f.name,
parentId: f.parentId ?? null,
isOpen: existingKeys.has(`filefolder:${f.id}`),
})),
},
{
type: 'knowledgebase' as const,
items: (knowledgeBases ?? []).map((kb) => ({
id: kb.id,
name: kb.name,
isOpen: existingKeys.has(`knowledgebase:${kb.id}`),
})),
},
{
type: 'integration' as const,
items: listIntegrations().map((integration) => ({
id: integration.blockType,
name: integration.name,
iconComponent: integration.icon,
bgColor: integration.bgColor,
isOpen: existingKeys.has(`integration:${integration.blockType}`),
})),
},
{
type: 'task' as const,
items: tasks.map((t) => ({
id: t.id,
name: t.name,
isOpen: existingKeys.has(`task:${t.id}`),
})),
},
{
type: 'scheduledtask' as const,
items: schedules
.filter((s) => s.sourceType === 'job')
.map((s) => ({
id: s.id,
name: s.jobTitle || truncate(s.prompt ?? '', 40) || 'Scheduled Task',
isOpen: existingKeys.has(`scheduledtask:${s.id}`),
})),
},
{
type: 'log' as const,
items: logs.map((log) => {
const workflowName = log.workflow?.name ?? log.workflowId ?? 'Unknown'
const time = formatDate(log.createdAt).compact
return {
id: log.id,
name: `${workflowName} · ${time}`,
workflowName,
time,
isOpen: existingKeys.has(`log:${log.id}`),
}
}),
},
]
return groups.filter((g) => !excluded.has(g.type))
}, [
workflows,
folders,
fileFolders,
tables,
files,
knowledgeBases,
tasks,
schedules,
logs,
existingKeys,
excludeTypes,
])
}
export type WorkflowTreeNode =
| { kind: 'workflow'; id: string; name: string; isOpen?: boolean }
| { kind: 'folder'; id: string; name: string; children: WorkflowTreeNode[] }
export function buildWorkflowFolderTree(
workflowItems: AvailableItem[],
folderItems: AvailableItem[]
): WorkflowTreeNode[] {
const knownFolderIds = new Set(folderItems.map((f) => f.id))
const byFolder = new Map<string | null, AvailableItem[]>()
for (const w of workflowItems) {
const fid = (w.folderId as string | null | undefined) ?? null
const key = fid && knownFolderIds.has(fid) ? fid : null
const bucket = byFolder.get(key) ?? []
bucket.push(w)
byFolder.set(key, bucket)
}
const toWorkflowNode = (w: AvailableItem): WorkflowTreeNode => ({
kind: 'workflow',
id: w.id,
name: w.name,
isOpen: w.isOpen,
})
const buildLevel = (parentId: string | null): WorkflowTreeNode[] => {
const childFolders = folderItems.filter(
(f) => ((f.parentId as string | null | undefined) ?? null) === parentId
)
const childWorkflows = byFolder.get(parentId) ?? []
const mixed: Array<{ sortOrder: number; id: string; node: WorkflowTreeNode }> = []
for (const f of childFolders) {
const children = buildLevel(f.id)
if (children.length === 0) continue
mixed.push({
sortOrder: (f.sortOrder as number) ?? 0,
id: f.id,
node: { kind: 'folder', id: f.id, name: f.name, children },
})
}
for (const w of childWorkflows) {
mixed.push({
sortOrder: (w.sortOrder as number) ?? 0,
id: w.id,
node: toWorkflowNode(w),
})
}
mixed.sort((a, b) =>
a.sortOrder !== b.sortOrder ? a.sortOrder - b.sortOrder : a.id.localeCompare(b.id)
)
return mixed.map((m) => m.node)
}
return buildLevel(null)
}
interface WorkflowFolderTreeItemsProps {
nodes: WorkflowTreeNode[]
onSelect: (resource: MothershipResource, isOpen?: boolean) => void
}
export function WorkflowFolderTreeItems({ nodes, onSelect }: WorkflowFolderTreeItemsProps) {
return (
<>
{nodes.map((node) =>
node.kind === 'workflow' ? (
<DropdownMenuItem
key={node.id}
onClick={() =>
onSelect({ type: 'workflow', id: node.id, title: node.name }, node.isOpen)
}
>
{getResourceConfig('workflow').renderDropdownItem({
item: { id: node.id, name: node.name },
})}
</DropdownMenuItem>
) : (
<DropdownMenuSub key={node.id}>
<DropdownMenuSubTrigger>
<Folder className='size-[14px]' />
<span>{node.name}</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<WorkflowFolderTreeItems nodes={node.children} onSelect={onSelect} />
</DropdownMenuSubContent>
</DropdownMenuSub>
)
)}
</>
)
}
export type FileFolderTreeNode =
| { kind: 'file'; id: string; name: string; isOpen?: boolean }
| { kind: 'folder'; id: string; name: string; isOpen?: boolean; children: FileFolderTreeNode[] }
export function buildFileFolderTree(
fileItems: AvailableItem[],
folderItems: AvailableItem[]
): FileFolderTreeNode[] {
const byFolder = new Map<string | null, AvailableItem[]>()
for (const f of fileItems) {
const key = (f.folderId as string | null | undefined) ?? null
const bucket = byFolder.get(key) ?? []
bucket.push(f)
byFolder.set(key, bucket)
}
const buildLevel = (parentId: string | null): FileFolderTreeNode[] => {
const childFolders = folderItems.filter(
(f) => ((f.parentId as string | null | undefined) ?? null) === parentId
)
const childFiles = byFolder.get(parentId) ?? []
const nodes: FileFolderTreeNode[] = []
for (const folder of childFolders) {
const children = buildLevel(folder.id)
nodes.push({
kind: 'folder',
id: folder.id,
name: folder.name,
isOpen: folder.isOpen,
children,
})
}
for (const file of childFiles) {
nodes.push({ kind: 'file', id: file.id, name: file.name, isOpen: file.isOpen })
}
return nodes
}
return buildLevel(null)
}
interface FileFolderTreeItemsProps {
nodes: FileFolderTreeNode[]
onSelect: (resource: MothershipResource, isOpen?: boolean) => void
}
export function FileFolderTreeItems({ nodes, onSelect }: FileFolderTreeItemsProps) {
return (
<>
{nodes.map((node) =>
node.kind === 'file' ? (
<DropdownMenuItem
key={node.id}
onClick={() => onSelect({ type: 'file', id: node.id, title: node.name }, node.isOpen)}
>
{getResourceConfig('file').renderDropdownItem({
item: { id: node.id, name: node.name },
})}
</DropdownMenuItem>
) : (
<DropdownMenuSub key={node.id}>
<DropdownMenuSubTrigger>
<Folder className='size-[14px]' />
<span>{node.name}</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuItem
onClick={() =>
onSelect({ type: 'filefolder', id: node.id, title: node.name }, node.isOpen)
}
>
<Folder className='size-[14px]' />
<span>{node.name}</span>
</DropdownMenuItem>
{node.children.length > 0 && (
<FileFolderTreeItems nodes={node.children} onSelect={onSelect} />
)}
</DropdownMenuSubContent>
</DropdownMenuSub>
)
)}
</>
)
}
export function AddResourceDropdown({
workspaceId,
existingKeys,
onAdd,
onSwitch,
excludeTypes,
}: AddResourceDropdownProps) {
const [open, setOpen] = useState(false)
const [search, setSearch] = useState('')
const [activeIndex, setActiveIndex] = useState(0)
const available = useAvailableResources(workspaceId, existingKeys, [
...(excludeTypes ?? []),
'integration',
])
const handleOpenChange = (next: boolean) => {
setOpen(next)
if (!next) {
setSearch('')
setActiveIndex(0)
}
}
const select = (resource: MothershipResource, isOpen?: boolean) => {
if (isOpen && onSwitch) {
onSwitch(resource.id)
} else {
onAdd(resource)
}
setOpen(false)
setSearch('')
setActiveIndex(0)
}
const workflowTree = useMemo(() => {
const workflowGroup = available.find((g) => g.type === 'workflow')
const folderGroup = available.find((g) => g.type === 'folder')
return buildWorkflowFolderTree(workflowGroup?.items ?? [], folderGroup?.items ?? [])
}, [available])
const fileFolderTree = useMemo(() => {
const fileGroup = available.find((g) => g.type === 'file')
const fileFolderGroup = available.find((g) => g.type === 'filefolder')
return buildFileFolderTree(fileGroup?.items ?? [], fileFolderGroup?.items ?? [])
}, [available])
const filtered = useMemo(() => {
const q = search.toLowerCase().trim()
if (!q) return null
return available.flatMap(({ type, items }) =>
items.filter((item) => item.name.toLowerCase().includes(q)).map((item) => ({ type, item }))
)
}, [search, available])
const handleSearchKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (!filtered) return
if (e.key === 'ArrowDown') {
e.preventDefault()
setActiveIndex((prev) => Math.min(prev + 1, filtered.length - 1))
} else if (e.key === 'ArrowUp') {
e.preventDefault()
setActiveIndex((prev) => Math.max(prev - 1, 0))
} else if (e.key === 'Enter' || (e.key === 'Tab' && !e.shiftKey)) {
if (filtered.length > 0 && filtered[activeIndex]) {
e.preventDefault()
const { type, item } = filtered[activeIndex]
select({ type, id: item.id, title: item.name }, item.isOpen)
}
}
}
return (
<DropdownMenu open={open} onOpenChange={handleOpenChange}>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<DropdownMenuTrigger asChild>
<Button
variant='subtle'
className={RESOURCE_TAB_ICON_BUTTON_CLASS}
aria-label='Add resource tab'
>
<Plus className={RESOURCE_TAB_ICON_CLASS} />
</Button>
</DropdownMenuTrigger>
</Tooltip.Trigger>
<Tooltip.Content side='bottom'>
<p>Add resource</p>
</Tooltip.Content>
</Tooltip.Root>
<DropdownMenuContent
align='start'
sideOffset={8}
className='flex w-[320px] flex-col overflow-hidden'
onCloseAutoFocus={(e) => e.preventDefault()}
>
<DropdownMenuSearchInput
placeholder='Search resources...'
value={search}
onChange={(e) => {
setSearch(e.target.value)
setActiveIndex(0)
}}
onKeyDown={handleSearchKeyDown}
/>
<div className='min-h-0 flex-1 overflow-y-auto'>
{filtered ? (
filtered.length > 0 ? (
filtered.map(({ type, item }, index) => {
const config = getResourceConfig(type)
return (
<DropdownMenuItem
key={`${type}:${item.id}`}
className={cn(index === activeIndex && 'bg-[var(--surface-active)]')}
onMouseEnter={() => setActiveIndex(index)}
onClick={() => select({ type, id: item.id, title: item.name }, item.isOpen)}
>
{config.renderDropdownItem({ item })}
</DropdownMenuItem>
)
})
) : (
<div className='px-2 py-1.5 text-center font-medium text-[var(--text-tertiary)] text-caption'>
No results
</div>
)
) : (
<>
{workflowTree.length > 0 && (
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<Workflow className='size-[14px]' />
<span>Workflows</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<WorkflowFolderTreeItems nodes={workflowTree} onSelect={select} />
</DropdownMenuSubContent>
</DropdownMenuSub>
)}
{fileFolderTree.length > 0 && (
<DropdownMenuSub>
<DropdownMenuSubTrigger>
{(() => {
const Icon = getResourceConfig('file').icon
return <Icon className='size-[14px]' />
})()}
<span>Files</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<FileFolderTreeItems nodes={fileFolderTree} onSelect={select} />
</DropdownMenuSubContent>
</DropdownMenuSub>
)}
{available.map(({ type, items }) => {
if (
type === 'workflow' ||
type === 'folder' ||
type === 'file' ||
type === 'filefolder'
)
return null
if (items.length === 0) return null
const config = getResourceConfig(type)
const Icon = config.icon
return (
<DropdownMenuSub key={type}>
<DropdownMenuSubTrigger>
<Icon className='size-[14px]' />
<span>{config.label}</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
{items.map((item) => (
<DropdownMenuItem
key={item.id}
onClick={() =>
select({ type, id: item.id, title: item.name }, item.isOpen)
}
>
{config.renderDropdownItem({ item })}
</DropdownMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
)
})}
</>
)}
</div>
</DropdownMenuContent>
</DropdownMenu>
)
}
@@ -0,0 +1,14 @@
export type {
AddResourceDropdownProps,
AvailableItem,
FileFolderTreeNode,
WorkflowTreeNode,
} from './add-resource-dropdown'
export {
AddResourceDropdown,
buildFileFolderTree,
buildWorkflowFolderTree,
FileFolderTreeItems,
useAvailableResources,
WorkflowFolderTreeItems,
} from './add-resource-dropdown'
@@ -0,0 +1,11 @@
export type { AddResourceDropdownProps, AvailableItem } from './add-resource-dropdown'
export { AddResourceDropdown, useAvailableResources } from './add-resource-dropdown'
export { ResourceActions, ResourceContent } from './resource-content'
export type { ResourceTypeConfig } from './resource-registry'
export {
getResourceConfig,
invalidateResourceQueries,
RESOURCE_REGISTRY,
RESOURCE_TYPES,
} from './resource-registry'
export { ResourceTabs } from './resource-tabs'
@@ -0,0 +1,75 @@
'use client'
import { useEffect, useRef } from 'react'
import { PillsRing } from '@sim/emcn'
import type { GenericResourceData } from '@/app/workspace/[workspaceId]/home/types'
interface GenericResourceContentProps {
data: GenericResourceData
}
// TODO: Emir — replace with rich UI (status icons, collapsible result cards, copy-to-clipboard, etc.)
export function GenericResourceContent({ data }: GenericResourceContentProps) {
const bottomRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const el = bottomRef.current
const container = el?.parentElement
if (container) {
container.scrollTop = container.scrollHeight
}
}, [data.entries.length])
if (data.entries.length === 0) {
return (
<div className='flex h-full items-center justify-center'>
<p className='text-[13px] text-[var(--text-muted)]'>No results yet</p>
</div>
)
}
return (
<div className='flex h-full flex-col divide-y divide-[var(--border)] overflow-y-auto [scrollbar-gutter:stable]'>
{data.entries.map((entry) => (
<div key={entry.toolCallId} className='flex flex-col gap-2 px-4 py-3'>
<div className='flex items-center gap-2'>
{entry.status === 'executing' && (
<PillsRing
className='size-[14px] flex-shrink-0 text-[var(--text-tertiary)]'
animate
/>
)}
<span className='font-medium text-[13px] text-[var(--text-primary)]'>
{entry.displayTitle}
</span>
{entry.status === 'error' && (
<span className='ml-auto text-[12px] text-[var(--text-error)]'>Error</span>
)}
{entry.status === 'skipped' && (
<span className='ml-auto text-[12px] text-[var(--text-muted)]'>Skipped</span>
)}
{entry.status === 'rejected' && (
<span className='ml-auto text-[12px] text-[var(--text-muted)]'>Rejected</span>
)}
</div>
{entry.streamingArgs && (
<pre className='overflow-x-auto whitespace-pre-wrap break-words font-mono text-[12px] text-[var(--text-body)]'>
{entry.streamingArgs}
</pre>
)}
{!entry.streamingArgs && entry.result?.output != null && (
<pre className='overflow-x-auto whitespace-pre-wrap break-words font-mono text-[12px] text-[var(--text-body)]'>
{typeof entry.result.output === 'string'
? entry.result.output
: JSON.stringify(entry.result.output, null, 2)}
</pre>
)}
{entry.result?.error && (
<p className='text-[12px] text-[var(--text-error)]'>{entry.result.error}</p>
)}
</div>
))}
<div ref={bottomRef} />
</div>
)
}
@@ -0,0 +1 @@
export { GenericResourceContent } from './generic-resource-content'
@@ -0,0 +1 @@
export { GenericResourceContent } from './generic-resource-content'
@@ -0,0 +1 @@
export { ResourceActions, ResourceContent } from './resource-content'
@@ -0,0 +1,932 @@
'use client'
import { lazy, memo, Suspense, useEffect, useMemo, useRef, useState } from 'react'
import { Button, PlayOutline, Skeleton, Tooltip } from '@sim/emcn'
import {
Calendar,
Download,
FileX,
Folder as FolderIcon,
Library,
Square,
SquareArrowUpRight,
Workflow as WorkflowIcon,
WorkflowX,
} from '@sim/emcn/icons'
import { createLogger } from '@sim/logger'
import { format } from 'date-fns'
import { useRouter } from 'next/navigation'
import { isApiClientError } from '@/lib/api/client/errors'
import type { FilePreviewSession } from '@/lib/copilot/request/session'
import {
cancelRunToolExecution,
markRunToolManuallyStopped,
reportManualRunToolStop,
} from '@/lib/copilot/tools/client/run-tool-execution'
import { canonicalWorkspaceFilePath } from '@/lib/copilot/vfs/path-utils'
import { triggerFileDownload } from '@/lib/uploads/client/download'
import { getFileExtension, getMimeTypeFromExtension } from '@/lib/uploads/utils/file-utils'
import { parseCronToHumanReadable } from '@/lib/workflows/schedules/utils'
import {
FileViewer,
type PreviewMode,
resolveFileCategory,
} from '@/app/workspace/[workspaceId]/files/components/file-viewer'
import { GenericResourceContent } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/generic-resource-content'
import {
RESOURCE_TAB_ICON_BUTTON_CLASS,
RESOURCE_TAB_ICON_CLASS,
} from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tab-controls'
import { hasRenderableFilePreviewContent } from '@/app/workspace/[workspaceId]/home/hooks/preview'
import type {
GenericResourceData,
MothershipResource,
} from '@/app/workspace/[workspaceId]/home/types'
import { KnowledgeBase } from '@/app/workspace/[workspaceId]/knowledge/[id]/base'
import { LogDetailsContent } from '@/app/workspace/[workspaceId]/logs/components'
import {
useUserPermissionsContext,
useWorkspacePermissionsContext,
} from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { Table } from '@/app/workspace/[workspaceId]/tables/[tableId]/table'
import { useUsageLimits } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/hooks'
import { useWorkflowExecution } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution'
import { useFolders } from '@/hooks/queries/folders'
import { useLogDetail } from '@/hooks/queries/logs'
import { useScheduleById } from '@/hooks/queries/schedules'
import { downloadTableExport } from '@/hooks/queries/tables'
import { useWorkflows } from '@/hooks/queries/workflows'
import { useWorkspaceFiles } from '@/hooks/queries/workspace-files'
import { useSettingsNavigation } from '@/hooks/use-settings-navigation'
import { useExecutionStore } from '@/stores/execution/store'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
const Workflow = lazy(() => import('@/app/workspace/[workspaceId]/w/[workflowId]/workflow'))
const LOADING_SKELETON = (
<div className='flex h-full flex-col gap-2 p-6'>
<Skeleton className='h-[16px] w-[60%]' />
<Skeleton className='h-[16px] w-[80%]' />
<Skeleton className='h-[16px] w-[40%]' />
</div>
)
interface ResourceContentProps {
workspaceId: string
resource: MothershipResource
previewMode?: PreviewMode
previewSession?: FilePreviewSession | null
isAgentResponding?: boolean
genericResourceData?: GenericResourceData
previewContextKey?: string
onNotFound?: (resourceId: string) => void
}
/**
* Renders the content for the currently active mothership resource.
* Handles table, file, and workflow resource types with appropriate
* embedded rendering for each.
*/
const STREAMING_EPOCH = new Date(0)
/**
* Grace window kept locked after the agent stops streaming into the file, so the lock bridges the
* gaps between the file subagent's sequential edit sections instead of flickering open between them.
*/
const AGENT_EDIT_LOCK_GRACE_MS = 1500
/**
* Holds the editor read-only while the agent is actively writing to the file, plus a short grace so
* brief gaps between edit sections don't unlock it. Releases as soon as the turn ends
* (`isAgentResponding` false) so the file becomes editable the moment the agent is done, even when
* the surrounding turn keeps running — the completed preview session otherwise lingers all turn.
*/
function useAgentFileEditLock(isStreamingToFile: boolean, isAgentResponding: boolean): boolean {
const [locked, setLocked] = useState(isStreamingToFile)
const graceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
useEffect(() => {
if (graceTimerRef.current !== null) {
clearTimeout(graceTimerRef.current)
graceTimerRef.current = null
}
if (isStreamingToFile) {
setLocked(true)
return
}
if (!isAgentResponding) {
setLocked(false)
return
}
graceTimerRef.current = setTimeout(() => {
graceTimerRef.current = null
setLocked(false)
}, AGENT_EDIT_LOCK_GRACE_MS)
return () => {
if (graceTimerRef.current !== null) {
clearTimeout(graceTimerRef.current)
graceTimerRef.current = null
}
}
}, [isStreamingToFile, isAgentResponding])
return locked
}
export const ResourceContent = memo(function ResourceContent({
workspaceId,
resource,
previewMode,
previewSession,
isAgentResponding,
genericResourceData,
previewContextKey,
onNotFound,
}: ResourceContentProps) {
const streamFileName = previewSession?.fileName || 'file.md'
const syntheticFile = useMemo(() => {
const ext = getFileExtension(streamFileName)
const SOURCE_MIME_MAP: Record<string, string> = {
pptx: 'text/x-pptxgenjs',
docx: 'text/x-docxjs',
pdf: 'text/x-pdflibjs',
}
const type = SOURCE_MIME_MAP[ext] ?? getMimeTypeFromExtension(ext)
return {
id: 'streaming-file',
workspaceId,
name: streamFileName,
key: '',
path: '',
size: 0,
type,
uploadedBy: '',
uploadedAt: STREAMING_EPOCH,
updatedAt: STREAMING_EPOCH,
}
}, [workspaceId, streamFileName])
const disableStreamingAutoScroll = previewSession?.operation === 'patch'
// `append`/`patch` stream complete full-file snapshots (built on the existing file), so the editor
// applies each live. `create`/`update` are streamed from scratch and would collapse an open doc, so
// the editor holds until settle. See the rich-markdown streaming tick.
const streamIsIncremental =
previewSession?.operation === 'append' || previewSession?.operation === 'patch'
const isTextPreview =
!!previewSession && resolveFileCategory(null, previewSession.fileName) === 'text-editable'
// Feed streamed content only while actively streaming. On completion the session keeps
// `previewText` for history, but clearing it here lets the editor reconcile to the agent's
// server-side write and hand off to the editable surface (the agent persists, not the editor).
const textStreamingContent =
isTextPreview &&
previewSession?.status === 'streaming' &&
typeof previewSession?.previewText === 'string' &&
hasRenderableFilePreviewContent(previewSession)
? previewSession.previewText
: undefined
const isAgentEditing = useAgentFileEditLock(
previewSession?.status === 'streaming',
Boolean(isAgentResponding)
)
if (resource.id === 'streaming-file') {
return (
<div className='flex h-full flex-col overflow-hidden'>
<FileViewer
file={syntheticFile}
workspaceId={workspaceId}
canEdit={false}
previewMode={previewMode ?? 'preview'}
streamingContent={textStreamingContent}
isAgentEditing={isAgentEditing}
streamIsIncremental={streamIsIncremental}
disableStreamingAutoScroll={disableStreamingAutoScroll}
previewContextKey={previewContextKey}
/>
</div>
)
}
switch (resource.type) {
case 'table':
return <Table key={resource.id} workspaceId={workspaceId} tableId={resource.id} embedded />
case 'file':
return (
<EmbeddedFile
key={resource.id}
workspaceId={workspaceId}
fileId={resource.id}
filePath={resource.path}
previewMode={previewMode}
streamingContent={
previewSession?.fileId === resource.id ? textStreamingContent : undefined
}
isAgentEditing={isAgentEditing}
streamIsIncremental={streamIsIncremental}
disableStreamingAutoScroll={disableStreamingAutoScroll}
previewContextKey={previewContextKey}
/>
)
case 'workflow':
return (
<EmbeddedWorkflow key={resource.id} workspaceId={workspaceId} workflowId={resource.id} />
)
case 'knowledgebase':
return (
<KnowledgeBase
key={resource.id}
id={resource.id}
knowledgeBaseName={resource.title}
workspaceId={workspaceId}
/>
)
case 'folder':
return <EmbeddedFolder key={resource.id} workspaceId={workspaceId} folderId={resource.id} />
case 'scheduledtask':
return (
<EmbeddedScheduledTask
key={resource.id}
workspaceId={workspaceId}
scheduleId={resource.id}
/>
)
case 'log':
return (
<EmbeddedLog
key={resource.id}
workspaceId={workspaceId}
logId={resource.id}
onNotFound={onNotFound ? () => onNotFound(resource.id) : undefined}
/>
)
case 'generic':
return (
<GenericResourceContent key={resource.id} data={genericResourceData ?? { entries: [] }} />
)
default:
return null
}
})
interface ResourceActionsProps {
workspaceId: string
resource: MothershipResource
}
export function ResourceActions({ workspaceId, resource }: ResourceActionsProps) {
switch (resource.type) {
case 'workflow':
return <EmbeddedWorkflowActions workspaceId={workspaceId} workflowId={resource.id} />
case 'file':
return (
<EmbeddedFileActions
workspaceId={workspaceId}
fileId={resource.id}
filePath={resource.path}
/>
)
case 'knowledgebase':
return (
<EmbeddedKnowledgeBaseActions workspaceId={workspaceId} knowledgeBaseId={resource.id} />
)
case 'table':
return (
<EmbeddedTableActions
workspaceId={workspaceId}
tableId={resource.id}
tableName={resource.title}
/>
)
case 'log':
return <EmbeddedLogActions workspaceId={workspaceId} logId={resource.id} />
case 'scheduledtask':
return <EmbeddedScheduledTaskActions workspaceId={workspaceId} />
case 'folder':
case 'generic':
return null
default:
return null
}
}
interface EmbeddedWorkflowActionsProps {
workspaceId: string
workflowId: string
}
export function EmbeddedWorkflowActions({ workspaceId, workflowId }: EmbeddedWorkflowActionsProps) {
const { navigateToSettings } = useSettingsNavigation()
const { userPermissions: effectivePermissions } = useWorkspacePermissionsContext()
const setActiveWorkflow = useWorkflowRegistry((state) => state.setActiveWorkflow)
const { handleRunWorkflow, handleCancelExecution } = useWorkflowExecution()
const isExecuting = useExecutionStore(
(state) => state.workflowExecutions.get(workflowId)?.isExecuting ?? false
)
const { usageExceeded } = useUsageLimits()
useEffect(() => {
void setActiveWorkflow(workflowId)
}, [workflowId, setActiveWorkflow])
const isRunButtonDisabled =
!isExecuting && !effectivePermissions.canRead && !effectivePermissions.isLoading
const handleRun = async () => {
setActiveWorkflow(workflowId)
if (isExecuting) {
const toolCallId = markRunToolManuallyStopped(workflowId)
cancelRunToolExecution(workflowId)
await handleCancelExecution()
await reportManualRunToolStop(workflowId, toolCallId)
return
}
if (usageExceeded) {
navigateToSettings({ section: 'billing' })
return
}
await handleRunWorkflow()
}
const handleOpenWorkflow = () => {
window.open(`/workspace/${workspaceId}/w/${workflowId}`, '_blank')
}
return (
<>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='subtle'
onClick={handleOpenWorkflow}
className={RESOURCE_TAB_ICON_BUTTON_CLASS}
aria-label='Open workflow'
>
<SquareArrowUpRight className={RESOURCE_TAB_ICON_CLASS} />
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='bottom'>
<p>Open workflow</p>
</Tooltip.Content>
</Tooltip.Root>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='subtle'
onClick={() => void handleRun()}
disabled={isRunButtonDisabled}
className={RESOURCE_TAB_ICON_BUTTON_CLASS}
aria-label={isExecuting ? 'Stop workflow' : 'Run workflow'}
>
{isExecuting ? (
<Square className={RESOURCE_TAB_ICON_CLASS} />
) : (
<PlayOutline className={RESOURCE_TAB_ICON_CLASS} />
)}
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='bottom'>
<p>{isExecuting ? 'Stop' : 'Run workflow'}</p>
</Tooltip.Content>
</Tooltip.Root>
</>
)
}
interface EmbeddedKnowledgeBaseActionsProps {
workspaceId: string
knowledgeBaseId: string
}
export function EmbeddedKnowledgeBaseActions({
workspaceId,
knowledgeBaseId,
}: EmbeddedKnowledgeBaseActionsProps) {
const router = useRouter()
const handleOpenKnowledgeBase = () => {
router.push(`/workspace/${workspaceId}/knowledge/${knowledgeBaseId}`)
}
return (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='subtle'
onClick={handleOpenKnowledgeBase}
className={RESOURCE_TAB_ICON_BUTTON_CLASS}
aria-label='Open knowledge base'
>
<SquareArrowUpRight className={RESOURCE_TAB_ICON_CLASS} />
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='bottom'>
<p>Open knowledge base</p>
</Tooltip.Content>
</Tooltip.Root>
)
}
const tableLogger = createLogger('EmbeddedTableActions')
interface EmbeddedTableActionsProps {
workspaceId: string
tableId: string
tableName: string
}
function EmbeddedTableActions({ workspaceId, tableId, tableName }: EmbeddedTableActionsProps) {
const router = useRouter()
const handleOpenTable = () => {
router.push(`/workspace/${workspaceId}/tables/${tableId}`)
}
const handleExport = async () => {
try {
await downloadTableExport(tableId, tableName)
} catch (err) {
tableLogger.error('Failed to export table:', err)
}
}
return (
<>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='subtle'
onClick={handleOpenTable}
className={RESOURCE_TAB_ICON_BUTTON_CLASS}
aria-label='Open table'
>
<SquareArrowUpRight className={RESOURCE_TAB_ICON_CLASS} />
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='bottom'>
<p>Open table</p>
</Tooltip.Content>
</Tooltip.Root>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='subtle'
onClick={() => void handleExport()}
className={RESOURCE_TAB_ICON_BUTTON_CLASS}
aria-label='Export table as CSV'
>
<Download className={RESOURCE_TAB_ICON_CLASS} />
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='bottom'>
<p>Export CSV</p>
</Tooltip.Content>
</Tooltip.Root>
</>
)
}
const fileLogger = createLogger('EmbeddedFileActions')
interface EmbeddedFileActionsProps {
workspaceId: string
fileId: string
filePath?: string
}
function EmbeddedFileActions({ workspaceId, fileId, filePath }: EmbeddedFileActionsProps) {
const router = useRouter()
const { data: files = [] } = useWorkspaceFiles(workspaceId)
const file = useMemo(
() =>
files.find(
(f) =>
f.id === fileId ||
(filePath &&
canonicalWorkspaceFilePath({ folderPath: f.folderPath, name: f.name }) === filePath)
),
[files, fileId, filePath]
)
const handleDownload = async () => {
if (!file) return
try {
await triggerFileDownload(file)
} catch (err) {
fileLogger.error('Failed to download file:', err)
}
}
const handleOpenInFiles = () => {
router.push(`/workspace/${workspaceId}/files/${encodeURIComponent(file?.id ?? fileId)}`)
}
return (
<>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='subtle'
onClick={handleOpenInFiles}
className={RESOURCE_TAB_ICON_BUTTON_CLASS}
aria-label='Open in files'
>
<SquareArrowUpRight className={RESOURCE_TAB_ICON_CLASS} />
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='bottom'>
<p>Open in files</p>
</Tooltip.Content>
</Tooltip.Root>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='subtle'
onClick={() => void handleDownload()}
disabled={!file}
className={RESOURCE_TAB_ICON_BUTTON_CLASS}
aria-label='Download file'
>
<Download className={RESOURCE_TAB_ICON_CLASS} />
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='bottom'>
<p>Download</p>
</Tooltip.Content>
</Tooltip.Root>
</>
)
}
interface EmbeddedWorkflowProps {
workspaceId: string
workflowId: string
}
function EmbeddedWorkflow({ workspaceId, workflowId }: EmbeddedWorkflowProps) {
const { data: workflowList, isPending: isWorkflowsPending } = useWorkflows(workspaceId)
const workflowExists = (workflowList ?? []).some((w) => w.id === workflowId)
const hasLoadError = useWorkflowRegistry(
(state) => state.hydration.phase === 'error' && state.hydration.workflowId === workflowId
)
if (isWorkflowsPending) return LOADING_SKELETON
if (!workflowExists || hasLoadError) {
return (
<div className='flex h-full flex-col items-center justify-center gap-3'>
<WorkflowX className='size-[32px] text-[var(--text-icon)]' />
<div className='flex flex-col items-center gap-1'>
<h2 className='font-medium text-[20px] text-[var(--text-primary)]'>Workflow not found</h2>
<p className='text-[var(--text-body)] text-small'>
This workflow may have been deleted or moved
</p>
</div>
</div>
)
}
return (
<Suspense fallback={LOADING_SKELETON}>
<Workflow workspaceId={workspaceId} workflowId={workflowId} embedded />
</Suspense>
)
}
interface EmbeddedFileProps {
workspaceId: string
fileId: string
filePath?: string
previewMode?: PreviewMode
streamingContent?: string
isAgentEditing?: boolean
streamIsIncremental?: boolean
disableStreamingAutoScroll?: boolean
previewContextKey?: string
}
function EmbeddedFile({
workspaceId,
fileId,
filePath,
previewMode,
streamingContent,
isAgentEditing,
streamIsIncremental,
disableStreamingAutoScroll = false,
previewContextKey,
}: EmbeddedFileProps) {
const { canEdit } = useUserPermissionsContext()
const { data: files = [], isLoading, isFetching } = useWorkspaceFiles(workspaceId)
const file = useMemo(
() =>
files.find(
(f) =>
f.id === fileId ||
(filePath &&
canonicalWorkspaceFilePath({ folderPath: f.folderPath, name: f.name }) === filePath)
),
[files, fileId, filePath]
)
if (isLoading || (isFetching && !file)) return LOADING_SKELETON
if (!file) {
return (
<div className='flex h-full flex-col items-center justify-center gap-3'>
<FileX className='size-[32px] text-[var(--text-icon)]' />
<div className='flex flex-col items-center gap-1'>
<h2 className='font-medium text-[20px] text-[var(--text-primary)]'>File not found</h2>
<p className='text-[var(--text-body)] text-small'>
This file may have been deleted or moved
</p>
</div>
</div>
)
}
return (
<div className='flex h-full flex-col overflow-hidden'>
<FileViewer
key={file.id}
file={file}
workspaceId={workspaceId}
canEdit={canEdit}
previewMode={previewMode}
streamingContent={streamingContent}
isAgentEditing={isAgentEditing}
streamIsIncremental={streamIsIncremental}
disableStreamingAutoScroll={disableStreamingAutoScroll}
previewContextKey={previewContextKey}
/>
</div>
)
}
interface EmbeddedFolderProps {
workspaceId: string
folderId: string
}
function EmbeddedFolder({ workspaceId, folderId }: EmbeddedFolderProps) {
const { data: folderList, isPending: isFoldersPending } = useFolders(workspaceId)
const { data: workflowList = [] } = useWorkflows(workspaceId)
const folder = (folderList ?? []).find((f) => f.id === folderId)
const folderWorkflows = workflowList.filter((w) => w.folderId === folderId)
if (isFoldersPending) return LOADING_SKELETON
if (!folder) {
return (
<div className='flex h-full flex-col items-center justify-center gap-3'>
<FolderIcon className='size-[32px] text-[var(--text-icon)]' />
<div className='flex flex-col items-center gap-1'>
<h2 className='font-medium text-[20px] text-[var(--text-primary)]'>Folder not found</h2>
<p className='text-[var(--text-body)] text-small'>
This folder may have been deleted or moved
</p>
</div>
</div>
)
}
return (
<div className='flex h-full flex-col overflow-y-auto p-6'>
<h2 className='mb-4 font-medium text-[16px] text-[var(--text-primary)]'>{folder.name}</h2>
{folderWorkflows.length === 0 ? (
<p className='text-[13px] text-[var(--text-muted)]'>No workflows in this folder</p>
) : (
<div className='flex flex-col gap-1'>
{folderWorkflows.map((w) => (
<button
key={w.id}
type='button'
onClick={() => window.open(`/workspace/${workspaceId}/w/${w.id}`, '_blank')}
className='flex items-center gap-2 rounded-[6px] px-3 py-2 text-left transition-colors hover:bg-[var(--surface-4)]'
>
<WorkflowIcon className='size-[14px] flex-shrink-0 text-[var(--text-icon)]' />
<span className='truncate text-[13px] text-[var(--text-primary)]'>{w.name}</span>
</button>
))}
</div>
)}
</div>
)
}
const SCHEDULE_STATUS_LABEL: Record<string, string> = {
active: 'Active',
disabled: 'Paused',
completed: 'Completed',
}
function formatScheduleInstant(iso: string | null): string {
if (!iso) return '—'
const date = new Date(iso)
return Number.isNaN(date.getTime()) ? '—' : format(date, "EEE, MMM d 'at' h:mm a")
}
interface ScheduledTaskFieldProps {
title: string
value: string
}
function ScheduledTaskField({ title, value }: ScheduledTaskFieldProps) {
return (
<div className='flex flex-col gap-1'>
<span className='text-[var(--text-muted)] text-caption'>{title}</span>
<span className='text-[var(--text-body)] text-small'>{value}</span>
</div>
)
}
interface EmbeddedScheduledTaskProps {
workspaceId: string
scheduleId: string
}
function EmbeddedScheduledTask({ scheduleId }: EmbeddedScheduledTaskProps) {
const { data: schedule, isLoading, isError } = useScheduleById(scheduleId)
if (isLoading && !schedule) return LOADING_SKELETON
if (!schedule) {
const heading = isError ? "Couldn't load scheduled task" : 'Scheduled task not found'
const detail = isError
? 'Something went wrong loading this scheduled task. Try again.'
: 'This scheduled task may have been deleted'
return (
<div className='flex h-full flex-col items-center justify-center gap-3'>
<Calendar className='size-[32px] text-[var(--text-icon)]' />
<div className='flex flex-col items-center gap-1'>
<h2 className='font-medium text-[20px] text-[var(--text-primary)]'>{heading}</h2>
<p className='text-[var(--text-body)] text-small'>{detail}</p>
</div>
</div>
)
}
const title = schedule.jobTitle || schedule.prompt || 'Scheduled task'
const timing = schedule.cronExpression
? parseCronToHumanReadable(schedule.cronExpression, schedule.timezone)
: 'Runs once'
const status = SCHEDULE_STATUS_LABEL[schedule.status] ?? schedule.status
return (
<div className='flex h-full flex-col gap-6 overflow-y-auto p-6'>
<div className='flex items-center gap-2'>
<Calendar className='size-[16px] flex-shrink-0 text-[var(--text-icon)]' />
<h2 className='truncate font-medium text-[16px] text-[var(--text-primary)]'>{title}</h2>
</div>
<div className='grid grid-cols-2 gap-4'>
<ScheduledTaskField title='Status' value={status} />
<ScheduledTaskField title='Schedule' value={timing} />
<ScheduledTaskField title='Next run' value={formatScheduleInstant(schedule.nextRunAt)} />
<ScheduledTaskField title='Last run' value={formatScheduleInstant(schedule.lastRanAt)} />
</div>
<div className='flex flex-col gap-1'>
<span className='text-[var(--text-muted)] text-caption'>Prompt</span>
<p className='whitespace-pre-wrap text-[var(--text-body)] text-small'>
{schedule.prompt || '—'}
</p>
</div>
{schedule.jobHistory && schedule.jobHistory.length > 0 && (
<div className='flex flex-col gap-2'>
<span className='text-[var(--text-muted)] text-caption'>Recent runs</span>
<div className='flex flex-col gap-2'>
{schedule.jobHistory.slice(0, 5).map((run, index) => (
<div
key={`${run.timestamp}-${index}`}
className='flex flex-col gap-1 rounded-[6px] bg-[var(--surface-4)] px-3 py-2'
>
<span className='text-[var(--text-tertiary)] text-caption'>
{formatScheduleInstant(run.timestamp)}
</span>
<span className='text-[var(--text-body)] text-small'>{run.summary}</span>
</div>
))}
</div>
</div>
)}
</div>
)
}
interface EmbeddedScheduledTaskActionsProps {
workspaceId: string
}
function EmbeddedScheduledTaskActions({ workspaceId }: EmbeddedScheduledTaskActionsProps) {
const router = useRouter()
const handleOpenScheduledTasks = () => {
router.push(`/workspace/${workspaceId}/scheduled-tasks`)
}
return (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='subtle'
onClick={handleOpenScheduledTasks}
className={RESOURCE_TAB_ICON_BUTTON_CLASS}
aria-label='Open in scheduled tasks'
>
<SquareArrowUpRight className={RESOURCE_TAB_ICON_CLASS} />
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='bottom'>
<p>Open in scheduled tasks</p>
</Tooltip.Content>
</Tooltip.Root>
)
}
interface EmbeddedLogProps {
workspaceId: string
logId: string
onNotFound?: () => void
}
function EmbeddedLog({ workspaceId, logId, onNotFound }: EmbeddedLogProps) {
const { data: log, isLoading, error } = useLogDetail(logId, workspaceId)
const onNotFoundRef = useRef(onNotFound)
onNotFoundRef.current = onNotFound
useEffect(() => {
if (isApiClientError(error) && error.status === 404) {
onNotFoundRef.current?.()
}
}, [error])
if (isLoading) return LOADING_SKELETON
if (!log) {
return (
<div className='flex h-full flex-col items-center justify-center gap-3'>
<Library className='size-[32px] text-[var(--text-icon)]' />
<div className='flex flex-col items-center gap-1'>
<h2 className='font-medium text-[20px] text-[var(--text-primary)]'>Log not found</h2>
<p className='text-[var(--text-body)] text-small'>
This log may have been deleted or is no longer available
</p>
</div>
</div>
)
}
return (
<div className='flex h-full flex-col overflow-hidden px-3.5 pt-3'>
<LogDetailsContent log={log} />
</div>
)
}
interface EmbeddedLogActionsProps {
workspaceId: string
logId: string
}
export function EmbeddedLogActions({ workspaceId, logId }: EmbeddedLogActionsProps) {
const router = useRouter()
const { data: log } = useLogDetail(logId, workspaceId)
const handleOpenInLogs = () => {
const param = log?.executionId ? `?executionId=${log.executionId}` : ''
router.push(`/workspace/${workspaceId}/logs${param}`)
}
return (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='subtle'
onClick={handleOpenInLogs}
className={RESOURCE_TAB_ICON_BUTTON_CLASS}
aria-label='Open in logs'
>
<SquareArrowUpRight className={RESOURCE_TAB_ICON_CLASS} />
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='bottom'>
<p>Open in logs</p>
</Tooltip.Content>
</Tooltip.Root>
)
}
@@ -0,0 +1,7 @@
export type { ResourceTypeConfig } from './resource-registry'
export {
getResourceConfig,
invalidateResourceQueries,
RESOURCE_REGISTRY,
RESOURCE_TYPES,
} from './resource-registry'
@@ -0,0 +1,283 @@
'use client'
import type { ElementType, ReactNode } from 'react'
import { cn } from '@sim/emcn'
import {
Calendar,
Connections,
Database,
File as FileIcon,
Folder as FolderIcon,
Library,
Table as TableIcon,
Task,
TerminalWindow,
Workflow,
} from '@sim/emcn/icons'
import type { QueryClient } from '@tanstack/react-query'
import { getDocumentIcon } from '@/components/icons/document-icons'
import type {
MothershipResource,
MothershipResourceType,
} from '@/app/workspace/[workspaceId]/home/types'
import { getBareIconStyle, type StyleableIcon } from '@/blocks/icon-color'
import { knowledgeKeys } from '@/hooks/queries/kb/knowledge'
import { logKeys } from '@/hooks/queries/logs'
import { mothershipChatKeys } from '@/hooks/queries/mothership-chats'
import { scheduleKeys } from '@/hooks/queries/schedules'
import { folderKeys } from '@/hooks/queries/utils/folder-keys'
import { invalidateWorkflowLists } from '@/hooks/queries/utils/invalidate-workflow-lists'
import { tableKeys } from '@/hooks/queries/utils/table-keys'
import { workspaceFileFolderKeys } from '@/hooks/queries/workspace-file-folders'
import { workspaceFilesKeys } from '@/hooks/queries/workspace-files'
interface DropdownItemRenderProps {
item: { id: string; name: string; [key: string]: unknown }
}
export interface ResourceTypeConfig {
type: MothershipResourceType
label: string
icon: ElementType
renderTabIcon: (resource: MothershipResource, className: string) => ReactNode
renderDropdownItem: (props: DropdownItemRenderProps) => ReactNode
}
function WorkflowDropdownItem({ item }: DropdownItemRenderProps) {
return (
<>
<Workflow className='size-[14px] flex-shrink-0 text-[var(--text-icon)]' />
<span className='truncate'>{item.name}</span>
</>
)
}
function DefaultDropdownItem({ item }: DropdownItemRenderProps) {
return <span className='truncate'>{item.name}</span>
}
function FileDropdownItem({ item }: DropdownItemRenderProps) {
const DocIcon = getDocumentIcon('', item.name)
return (
<>
<DocIcon className='size-[14px] flex-shrink-0 text-[var(--text-icon)]' />
<span className='truncate'>{item.name}</span>
</>
)
}
function IconDropdownItem({ item, icon: Icon }: DropdownItemRenderProps & { icon: ElementType }) {
return (
<>
<Icon className='size-[14px] flex-shrink-0 text-[var(--text-icon)]' />
<span className='truncate'>{item.name}</span>
</>
)
}
/**
* Renders an integration mention candidate using the block's own brand icon at
* the standard 14px dropdown size. Single-fill icons drawn with
* `fill='currentColor'` (e.g. HubSpot) are tinted with the block's brand
* {@link BlockConfig.iconColor}; multi-color brand icons keep their own SVG fills.
*/
function IntegrationDropdownItem({ item }: DropdownItemRenderProps) {
const Icon = item.iconComponent as StyleableIcon | undefined
if (!Icon) return <span className='truncate'>{item.name}</span>
return (
<>
<Icon
className='size-[14px] flex-shrink-0 text-[var(--text-icon)]'
style={getBareIconStyle(Icon)}
/>
<span className='truncate'>{item.name}</span>
</>
)
}
function LogDropdownItem({ item }: DropdownItemRenderProps) {
const workflowName = (item.workflowName as string) ?? item.name
const time = (item.time as string) ?? ''
return (
<>
<Workflow className='size-[14px] flex-shrink-0 text-[var(--text-icon)]' />
<span className='truncate'>{workflowName}</span>
{time && (
<span className='ml-auto flex-shrink-0 text-[var(--text-tertiary)] text-caption'>
{time}
</span>
)}
</>
)
}
export const RESOURCE_REGISTRY: Record<MothershipResourceType, ResourceTypeConfig> = {
generic: {
type: 'generic',
label: 'Results',
icon: TerminalWindow,
renderTabIcon: (_resource, className) => (
<TerminalWindow className={cn(className, 'text-[var(--text-icon)]')} />
),
renderDropdownItem: (props) => <DefaultDropdownItem {...props} />,
},
workflow: {
type: 'workflow',
label: 'Workflows',
icon: Workflow,
renderTabIcon: (_resource, className) => (
<Workflow className={cn(className, 'text-[var(--text-icon)]')} />
),
renderDropdownItem: (props) => <WorkflowDropdownItem {...props} />,
},
table: {
type: 'table',
label: 'Tables',
icon: TableIcon,
renderTabIcon: (_resource, className) => (
<TableIcon className={cn(className, 'text-[var(--text-icon)]')} />
),
renderDropdownItem: (props) => <IconDropdownItem {...props} icon={TableIcon} />,
},
file: {
type: 'file',
label: 'Files',
icon: FileIcon,
renderTabIcon: (resource, className) => {
const DocIcon = getDocumentIcon('', resource.title)
return <DocIcon className={cn(className, 'text-[var(--text-icon)]')} />
},
renderDropdownItem: (props) => <FileDropdownItem {...props} />,
},
knowledgebase: {
type: 'knowledgebase',
label: 'Knowledge Bases',
icon: Database,
renderTabIcon: (_resource, className) => (
<Database className={cn(className, 'text-[var(--text-icon)]')} />
),
renderDropdownItem: (props) => <IconDropdownItem {...props} icon={Database} />,
},
folder: {
type: 'folder',
label: 'Folders',
icon: FolderIcon,
renderTabIcon: (_resource, className) => (
<FolderIcon className={cn(className, 'text-[var(--text-icon)]')} />
),
renderDropdownItem: (props) => <IconDropdownItem {...props} icon={FolderIcon} />,
},
filefolder: {
type: 'filefolder',
label: 'File Folders',
icon: FolderIcon,
renderTabIcon: (_resource, className) => (
<FolderIcon className={cn(className, 'text-[var(--text-icon)]')} />
),
renderDropdownItem: (props) => <IconDropdownItem {...props} icon={FolderIcon} />,
},
task: {
type: 'task',
label: 'Chats',
icon: Task,
renderTabIcon: (_resource, className) => (
<Task className={cn(className, 'text-[var(--text-icon)]')} />
),
renderDropdownItem: (props) => <DefaultDropdownItem {...props} />,
},
scheduledtask: {
type: 'scheduledtask',
label: 'Scheduled Tasks',
icon: Calendar,
renderTabIcon: (_resource, className) => (
<Calendar className={cn(className, 'text-[var(--text-icon)]')} />
),
renderDropdownItem: (props) => <IconDropdownItem {...props} icon={Calendar} />,
},
log: {
type: 'log',
label: 'Logs',
icon: Library,
renderTabIcon: (_resource, className) => (
<Library className={cn(className, 'text-[var(--text-icon)]')} />
),
renderDropdownItem: (props) => <LogDropdownItem {...props} />,
},
integration: {
type: 'integration',
label: 'Integrations',
icon: Connections,
renderTabIcon: (_resource, className) => (
<Connections className={cn(className, 'text-[var(--text-icon)]')} />
),
renderDropdownItem: (props) => <IntegrationDropdownItem {...props} />,
},
} as const
export const RESOURCE_TYPES = Object.values(RESOURCE_REGISTRY)
export function getResourceConfig(type: MothershipResourceType): ResourceTypeConfig {
return RESOURCE_REGISTRY[type]
}
type CacheableResourceType = Exclude<MothershipResourceType, 'generic'>
const RESOURCE_INVALIDATORS: Record<
CacheableResourceType,
(qc: QueryClient, workspaceId: string, resourceId: string) => void
> = {
table: (qc, _wId, id) => {
qc.invalidateQueries({ queryKey: tableKeys.lists() })
qc.invalidateQueries({ queryKey: tableKeys.detail(id) })
},
file: (qc, wId, id) => {
qc.invalidateQueries({ queryKey: workspaceFilesKeys.lists() })
qc.invalidateQueries({ queryKey: workspaceFilesKeys.contentFile(wId, id) })
qc.invalidateQueries({ queryKey: workspaceFilesKeys.storageInfo() })
},
workflow: (qc, wId) => {
void invalidateWorkflowLists(qc, wId)
},
knowledgebase: (qc, _wId, id) => {
qc.invalidateQueries({ queryKey: knowledgeKeys.lists() })
qc.invalidateQueries({ queryKey: knowledgeKeys.detail(id) })
qc.invalidateQueries({ queryKey: knowledgeKeys.tagDefinitions(id) })
},
folder: (qc) => {
qc.invalidateQueries({ queryKey: folderKeys.lists() })
},
filefolder: (qc, wId) => {
qc.invalidateQueries({ queryKey: workspaceFileFolderKeys.workspaceLists(wId) })
},
task: (qc, wId) => {
qc.invalidateQueries({ queryKey: mothershipChatKeys.list(wId) })
},
scheduledtask: (qc, wId) => {
qc.invalidateQueries({ queryKey: scheduleKeys.list(wId) })
},
log: (qc, wId, id) => {
qc.invalidateQueries({ queryKey: logKeys.details() })
qc.invalidateQueries({ queryKey: logKeys.detail(wId, id) })
},
/**
* Integrations are sourced from the static integration catalog
* (`listIntegrations()`), not a server-backed query, so there is nothing to
* invalidate when one is added.
*/
integration: () => {},
}
/**
* Invalidate list and detail queries for a specific resource.
* Called when a `resource_added` event arrives so the embedded view refreshes
* and the add-resource dropdown stays up to date.
*/
export function invalidateResourceQueries(
queryClient: QueryClient,
workspaceId: string,
resourceType: MothershipResourceType,
resourceId: string
): void {
if (resourceType === 'generic') return
RESOURCE_INVALIDATORS[resourceType](queryClient, workspaceId, resourceId)
}
@@ -0,0 +1 @@
export { ResourceTabs } from './resource-tabs'
@@ -0,0 +1,5 @@
export const RESOURCE_TAB_GAP_CLASS = 'gap-1.5'
export const RESOURCE_TAB_ICON_BUTTON_CLASS = 'shrink-0 bg-transparent px-2 py-[5px] text-caption'
export const RESOURCE_TAB_ICON_CLASS = 'size-[16px] text-[var(--text-icon)]'
@@ -0,0 +1,652 @@
import {
type ComponentProps,
type Dispatch,
memo,
type ReactNode,
type SetStateAction,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react'
import { Button, cn, Tooltip } from '@sim/emcn'
import { Columns3, Eye, PanelLeft, Pencil } from '@sim/emcn/icons'
import { SIM_RESOURCE_DRAG_TYPE, SIM_RESOURCES_DRAG_TYPE } from '@/lib/copilot/resource-types'
import { isEphemeralResource } from '@/lib/copilot/resources/types'
import type { PreviewMode } from '@/app/workspace/[workspaceId]/files/components/file-viewer'
import { useMothershipResources } from '@/app/workspace/[workspaceId]/home/components/mothership-resources-context'
import { AddResourceDropdown } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown'
import { getResourceConfig } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry'
import {
RESOURCE_TAB_GAP_CLASS,
RESOURCE_TAB_ICON_BUTTON_CLASS,
RESOURCE_TAB_ICON_CLASS,
} from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tab-controls'
import type {
MothershipResource,
MothershipResourceType,
} from '@/app/workspace/[workspaceId]/home/types'
import { useFolders } from '@/hooks/queries/folders'
import { useKnowledgeBasesQuery } from '@/hooks/queries/kb/knowledge'
import {
useAddChatResource,
useRemoveChatResource,
useReorderChatResources,
} from '@/hooks/queries/mothership-chats'
import { useTablesList } from '@/hooks/queries/tables'
import { useWorkflows } from '@/hooks/queries/workflows'
import { useWorkspaceFiles } from '@/hooks/queries/workspace-files'
const EDGE_ZONE = 40
const SCROLL_SPEED = 8
const ADD_RESOURCE_EXCLUDED_TYPES: readonly MothershipResourceType[] = ['folder', 'task'] as const
/**
* Returns the id of the nearest resource to `idx` that is in `filter`
* (or any resource if `filter` is null). Returns undefined if nothing qualifies.
*/
function findNearestId(
resources: MothershipResource[],
idx: number,
filter: Set<string> | null
): string | undefined {
for (let offset = 1; offset < resources.length; offset++) {
for (const candidate of [idx + offset, idx - offset]) {
const r = resources[candidate]
if (r && (!filter || filter.has(r.id))) return r.id
}
}
return undefined
}
/**
* Builds an offscreen drag image showing all selected tabs side-by-side, so the
* cursor visibly carries every tab in the multi-selection. The element is
* appended to the document and removed on the next tick after the browser has
* snapshotted it.
*/
function buildMultiDragImage(
scrollNode: HTMLElement | null,
selected: MothershipResource[]
): HTMLElement | null {
if (!scrollNode || selected.length === 0) return null
const container = document.createElement('div')
Object.assign(container.style, {
position: 'fixed',
top: '-10000px',
left: '-10000px',
display: 'flex',
alignItems: 'center',
gap: '6px',
padding: '4px',
pointerEvents: 'none',
} satisfies Partial<CSSStyleDeclaration>)
let appendedAny = false
for (const r of selected) {
const original = scrollNode.querySelector<HTMLElement>(
`[data-resource-tab-id="${CSS.escape(r.id)}"]`
)
if (!original) continue
const clone = original.cloneNode(true) as HTMLElement
clone.style.opacity = '0.95'
container.appendChild(clone)
appendedAny = true
}
if (!appendedAny) return null
document.body.appendChild(container)
return container
}
const PREVIEW_MODE_ICONS = {
editor: Columns3,
split: Eye,
preview: Pencil,
} satisfies Record<PreviewMode, (props: ComponentProps<typeof Eye>) => ReactNode>
const PREVIEW_MODE_LABELS: Record<PreviewMode, string> = {
editor: 'Split Mode',
split: 'Preview Mode',
preview: 'Edit Mode',
}
/**
* Builds a `type:id` -> current name lookup from live query data so resource
* tabs always reflect the latest name even after a rename.
*/
function useResourceNameLookup(workspaceId: string): Map<string, string> {
const { data: workflows = [] } = useWorkflows(workspaceId)
const { data: tables = [] } = useTablesList(workspaceId)
const { data: files = [] } = useWorkspaceFiles(workspaceId)
const { data: knowledgeBases } = useKnowledgeBasesQuery(workspaceId)
const { data: folders = [] } = useFolders(workspaceId)
return useMemo(() => {
const map = new Map<string, string>()
for (const w of workflows) map.set(`workflow:${w.id}`, w.name)
for (const t of tables) map.set(`table:${t.id}`, t.name)
for (const f of files) map.set(`file:${f.id}`, f.name)
for (const kb of knowledgeBases ?? []) map.set(`knowledgebase:${kb.id}`, kb.name)
for (const folder of folders) map.set(`folder:${folder.id}`, folder.name)
return map
}, [workflows, tables, files, knowledgeBases, folders])
}
interface ResourceTabItemProps {
resource: MothershipResource
idx: number
isActive: boolean
isHovered: boolean
isDragging: boolean
isSelected: boolean
showGapBefore: boolean
showGapAfter: boolean
displayName: string
chatId?: string
onDragStart: (e: React.DragEvent, idx: number) => void
onDragOver: (e: React.DragEvent, idx: number) => void
onDragLeave: () => void
onDragEnd: () => void
onTabClick: (e: React.MouseEvent, idx: number) => void
setHoveredTabId: Dispatch<SetStateAction<string | null>>
onRemove: (e: React.SyntheticEvent, resource: MothershipResource) => void
}
const ResourceTabItem = memo(function ResourceTabItem({
resource,
idx,
isActive,
isHovered,
isDragging,
isSelected,
showGapBefore,
showGapAfter,
displayName,
chatId,
onDragStart,
onDragOver,
onDragLeave,
onDragEnd,
onTabClick,
setHoveredTabId,
onRemove,
}: ResourceTabItemProps) {
const config = getResourceConfig(resource.type)
return (
<div className='relative flex shrink-0 items-center'>
{showGapBefore && (
<div className='-translate-x-1/2 -translate-y-1/2 pointer-events-none absolute top-1/2 left-0 z-10 h-[16px] w-[2px] rounded-full bg-[var(--text-subtle)]' />
)}
<Button
variant='subtle'
draggable
data-resource-tab-id={resource.id}
onDragStart={(e) => onDragStart(e, idx)}
onDragOver={(e) => onDragOver(e, idx)}
onDragLeave={onDragLeave}
onDragEnd={onDragEnd}
onMouseDown={(e) => {
if (e.button === 1) {
e.preventDefault()
if (chatId) onRemove(e, resource)
}
}}
onClick={(e) => onTabClick(e, idx)}
onMouseEnter={() => setHoveredTabId(resource.id)}
onMouseLeave={() => setHoveredTabId(null)}
className={cn(
'group relative shrink-0 bg-transparent px-2 py-[3px] pr-[22px] text-caption transition-colors duration-150',
isActive && 'bg-[var(--surface-4)]',
isSelected && !isActive && 'bg-[var(--surface-3)]',
isDragging && 'opacity-30'
)}
>
{config.renderTabIcon(resource, 'mr-1.5 size-[14px]')}
{displayName}
{(isHovered || isActive) && chatId && (
<span
role='button'
tabIndex={-1}
onClick={(e) => onRemove(e, resource)}
onKeyDown={(e) => {
if (e.key === 'Enter') onRemove(e, resource)
}}
className='-translate-y-1/2 absolute top-1/2 right-[4px] flex items-center justify-center rounded-sm p-[1px] hover-hover:bg-[var(--surface-5)]'
aria-label={`Close ${displayName}`}
>
<svg
className='size-[10px] text-[var(--text-icon)]'
viewBox='0 0 24 24'
fill='none'
stroke='currentColor'
strokeWidth='2.5'
strokeLinecap='round'
strokeLinejoin='round'
>
<path d='M18 6 6 18M6 6l12 12' />
</svg>
</span>
)}
</Button>
{showGapAfter && (
<div className='-translate-y-1/2 pointer-events-none absolute top-1/2 right-0 z-10 h-[16px] w-[2px] translate-x-1/2 rounded-full bg-[var(--text-subtle)]' />
)}
</div>
)
})
interface ResourceTabsProps {
workspaceId: string
chatId?: string
resources: MothershipResource[]
activeId: string | null
previewMode?: PreviewMode
onCyclePreviewMode?: () => void
actions?: ReactNode
}
export function ResourceTabs({
workspaceId,
chatId,
resources,
activeId,
previewMode,
onCyclePreviewMode,
actions,
}: ResourceTabsProps) {
const PreviewModeIcon = PREVIEW_MODE_ICONS[previewMode ?? 'split']
const nameLookup = useResourceNameLookup(workspaceId)
const {
selectResource,
addResource: onAddResource,
removeResource: onRemoveResource,
reorderResources: onReorderResources,
collapseResource,
} = useMothershipResources()
const scrollNodeRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const node = scrollNodeRef.current
if (!node) return
const handler = (e: WheelEvent) => {
if (e.deltaY !== 0) {
node.scrollLeft += e.deltaY
e.preventDefault()
}
}
node.addEventListener('wheel', handler, { passive: false })
return () => node.removeEventListener('wheel', handler)
}, [])
useEffect(() => {
const node = scrollNodeRef.current
if (!node || !activeId) return
const tab = node.querySelector<HTMLElement>(`[data-resource-tab-id="${CSS.escape(activeId)}"]`)
if (!tab) return
// Use bounding rects because the tab's offsetParent is a `position: relative`
// wrapper, so `offsetLeft` is relative to that wrapper rather than `node`.
const tabRect = tab.getBoundingClientRect()
const nodeRect = node.getBoundingClientRect()
const tabLeft = tabRect.left - nodeRect.left + node.scrollLeft
const tabRight = tabLeft + tabRect.width
const viewLeft = node.scrollLeft
const viewRight = viewLeft + node.clientWidth
if (tabLeft < viewLeft) {
node.scrollTo({ left: tabLeft, behavior: 'smooth' })
} else if (tabRight > viewRight) {
node.scrollTo({ left: tabRight - node.clientWidth, behavior: 'smooth' })
}
}, [activeId])
const addResource = useAddChatResource(chatId)
const removeResource = useRemoveChatResource(chatId)
const reorderResources = useReorderChatResources(chatId)
const [hoveredTabId, setHoveredTabId] = useState<string | null>(null)
const [draggedIdx, setDraggedIdx] = useState<number | null>(null)
const [dropGapIdx, setDropGapIdx] = useState<number | null>(null)
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
const dragStartIdx = useRef<number | null>(null)
const autoScrollRaf = useRef<number | null>(null)
const anchorIdRef = useRef<string | null>(null)
const prevChatIdRef = useRef(chatId)
// Reset selection when switching chats — component instance persists across
// chat switches so stale IDs would otherwise carry over.
if (prevChatIdRef.current !== chatId) {
prevChatIdRef.current = chatId
setSelectedIds(new Set())
anchorIdRef.current = null
}
const existingKeys = useMemo(
() => new Set(resources.map((r) => `${r.type}:${r.id}`)),
[resources]
)
const handleAdd = useCallback(
(resource: MothershipResource) => {
if (!chatId) return
addResource.mutate({ chatId, resource })
onAddResource(resource)
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[chatId, onAddResource]
)
const handleTabClick = useCallback(
(e: React.MouseEvent, idx: number) => {
const resource = resources[idx]
if (!resource) return
// Shift+click: contiguous range from anchor
if (e.shiftKey) {
// Fall back to activeId when no explicit anchor exists (e.g. tab opened via sidebar)
const anchorId = anchorIdRef.current ?? activeId
const anchorIdx = anchorId ? resources.findIndex((r) => r.id === anchorId) : -1
if (anchorIdx !== -1) {
const start = Math.min(anchorIdx, idx)
const end = Math.max(anchorIdx, idx)
const next = new Set<string>()
for (let i = start; i <= end; i++) next.add(resources[i].id)
setSelectedIds(next)
selectResource(resource.id)
return
}
}
// Cmd/Ctrl+click: toggle individual tab in/out of selection
if (e.metaKey || e.ctrlKey) {
const wasSelected = selectedIds.has(resource.id)
if (wasSelected) {
const next = new Set(selectedIds)
next.delete(resource.id)
setSelectedIds(next)
// Only switch active if we just deselected the currently-active tab
if (activeId === resource.id) {
const fallback =
findNearestId(resources, idx, next) ?? findNearestId(resources, idx, null)
if (fallback) selectResource(fallback)
}
} else {
setSelectedIds((prev) => new Set(prev).add(resource.id))
selectResource(resource.id)
}
if (!anchorIdRef.current) anchorIdRef.current = resource.id
return
}
// Plain click: single-select
anchorIdRef.current = resource.id
setSelectedIds(new Set([resource.id]))
selectResource(resource.id)
},
[resources, selectResource, selectedIds, activeId]
)
const handleRemove = useCallback(
(e: React.SyntheticEvent, resource: MothershipResource) => {
e.stopPropagation()
if (!chatId) return
const isMulti = selectedIds.has(resource.id) && selectedIds.size > 1
const targets = isMulti ? resources.filter((r) => selectedIds.has(r.id)) : [resource]
// Update parent state immediately for all targets
for (const r of targets) {
onRemoveResource(r.type, r.id)
}
// Clear stale selection and anchor for all removed targets
const removedIds = new Set(targets.map((r) => r.id))
setSelectedIds((prev) => {
const next = new Set(prev)
for (const id of removedIds) next.delete(id)
return next
})
if (anchorIdRef.current && removedIds.has(anchorIdRef.current)) {
anchorIdRef.current = null
}
for (const r of targets) {
if (isEphemeralResource(r)) continue
removeResource.mutate({ chatId, resourceType: r.type, resourceId: r.id })
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[chatId, onRemoveResource, resources, selectedIds]
)
const handleDragStart = useCallback(
(e: React.DragEvent, idx: number) => {
const resource = resources[idx]
if (!resource) return
const selected = resources.filter((r) => selectedIds.has(r.id))
const isMultiDrag = selected.length > 1 && selectedIds.has(resource.id)
if (isMultiDrag) {
e.dataTransfer.effectAllowed = 'copy'
e.dataTransfer.setData(SIM_RESOURCES_DRAG_TYPE, JSON.stringify(selected))
const dragImage = buildMultiDragImage(scrollNodeRef.current, selected)
if (dragImage) {
e.dataTransfer.setDragImage(dragImage, 16, 16)
setTimeout(() => dragImage.remove(), 0)
}
// Skip dragStartIdx so internal reorder is disabled for multi-select drags
dragStartIdx.current = null
setDraggedIdx(null)
return
}
dragStartIdx.current = idx
setDraggedIdx(idx)
e.dataTransfer.effectAllowed = 'copyMove'
e.dataTransfer.setData('text/plain', String(idx))
e.dataTransfer.setData(
SIM_RESOURCE_DRAG_TYPE,
JSON.stringify({ type: resource.type, id: resource.id, title: resource.title })
)
},
[resources, selectedIds]
)
const stopAutoScroll = useCallback(() => {
if (autoScrollRaf.current) {
cancelAnimationFrame(autoScrollRaf.current)
autoScrollRaf.current = null
}
}, [])
const startEdgeScroll = useCallback(
(clientX: number) => {
const container = scrollNodeRef.current
if (!container) return
const cRect = container.getBoundingClientRect()
if (autoScrollRaf.current) cancelAnimationFrame(autoScrollRaf.current)
if (clientX < cRect.left + EDGE_ZONE) {
const tick = () => {
container.scrollLeft -= SCROLL_SPEED
autoScrollRaf.current = requestAnimationFrame(tick)
}
autoScrollRaf.current = requestAnimationFrame(tick)
} else if (clientX > cRect.right - EDGE_ZONE) {
const tick = () => {
container.scrollLeft += SCROLL_SPEED
autoScrollRaf.current = requestAnimationFrame(tick)
}
autoScrollRaf.current = requestAnimationFrame(tick)
} else {
stopAutoScroll()
}
},
[stopAutoScroll]
)
const handleDragOver = useCallback(
(e: React.DragEvent, idx: number) => {
e.preventDefault()
e.dataTransfer.dropEffect = 'move'
const rect = e.currentTarget.getBoundingClientRect()
const midpoint = rect.left + rect.width / 2
const gap = e.clientX < midpoint ? idx : idx + 1
setDropGapIdx(gap)
startEdgeScroll(e.clientX)
},
[startEdgeScroll]
)
const handleDragLeave = useCallback(() => {
setDropGapIdx(null)
stopAutoScroll()
}, [stopAutoScroll])
const handleDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault()
stopAutoScroll()
const fromIdx = dragStartIdx.current
const gapIdx = dropGapIdx
if (fromIdx === null || gapIdx === null) {
setDraggedIdx(null)
setDropGapIdx(null)
dragStartIdx.current = null
return
}
const insertAt = gapIdx > fromIdx ? gapIdx - 1 : gapIdx
if (insertAt === fromIdx) {
setDraggedIdx(null)
setDropGapIdx(null)
dragStartIdx.current = null
return
}
const reordered = [...resources]
const [moved] = reordered.splice(fromIdx, 1)
reordered.splice(insertAt, 0, moved)
onReorderResources(reordered)
if (chatId) {
const persistable = reordered.filter((r) => !isEphemeralResource(r))
if (persistable.length > 0) {
reorderResources.mutate({ chatId, resources: persistable })
}
}
setDraggedIdx(null)
setDropGapIdx(null)
dragStartIdx.current = null
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[chatId, resources, onReorderResources, dropGapIdx, stopAutoScroll]
)
const handleDragEnd = useCallback(() => {
stopAutoScroll()
setDraggedIdx(null)
setDropGapIdx(null)
dragStartIdx.current = null
}, [stopAutoScroll])
return (
<div
className={cn(
'flex shrink-0 items-center border-[var(--border)] border-b px-4 py-[8.5px]',
RESOURCE_TAB_GAP_CLASS
)}
>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='subtle'
onClick={collapseResource}
className={RESOURCE_TAB_ICON_BUTTON_CLASS}
aria-label='Collapse resource view'
>
<PanelLeft className={cn(RESOURCE_TAB_ICON_CLASS, '-scale-x-100')} />
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='bottom'>
<p>Collapse</p>
</Tooltip.Content>
</Tooltip.Root>
<div className={cn('flex min-w-0 flex-1 items-center', RESOURCE_TAB_GAP_CLASS)}>
<div
ref={scrollNodeRef}
className={cn(
'flex min-w-0 items-center overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden',
RESOURCE_TAB_GAP_CLASS
)}
onDragOver={(e) => {
e.preventDefault()
startEdgeScroll(e.clientX)
}}
onDrop={handleDrop}
>
{resources.map((resource, idx) => {
const displayName = nameLookup.get(`${resource.type}:${resource.id}`) ?? resource.title
const isActive = activeId === resource.id
const isHovered = hoveredTabId === resource.id
const isDragging = draggedIdx === idx
const isSelected = selectedIds.has(resource.id) && selectedIds.size > 1
const showGapBefore =
dropGapIdx === idx &&
draggedIdx !== null &&
draggedIdx !== idx &&
draggedIdx !== idx - 1
const showGapAfter =
idx === resources.length - 1 &&
dropGapIdx === resources.length &&
draggedIdx !== null &&
draggedIdx !== idx
return (
<ResourceTabItem
key={resource.id}
resource={resource}
idx={idx}
isActive={isActive}
isHovered={isHovered}
isDragging={isDragging}
isSelected={isSelected}
showGapBefore={showGapBefore}
showGapAfter={showGapAfter}
displayName={displayName}
chatId={chatId}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDragEnd={handleDragEnd}
onTabClick={handleTabClick}
setHoveredTabId={setHoveredTabId}
onRemove={handleRemove}
/>
)
})}
</div>
{chatId && (
<AddResourceDropdown
workspaceId={workspaceId}
existingKeys={existingKeys}
onAdd={handleAdd}
onSwitch={selectResource}
excludeTypes={ADD_RESOURCE_EXCLUDED_TYPES}
/>
)}
</div>
{(actions || (previewMode && onCyclePreviewMode)) && (
<div className={cn('ml-auto flex shrink-0 items-center', RESOURCE_TAB_GAP_CLASS)}>
{actions}
{previewMode && onCyclePreviewMode && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='subtle'
onClick={onCyclePreviewMode}
className={RESOURCE_TAB_ICON_BUTTON_CLASS}
aria-label='Cycle preview mode'
>
<PreviewModeIcon mode={previewMode} className={RESOURCE_TAB_ICON_CLASS} />
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='bottom'>
<p>{PREVIEW_MODE_LABELS[previewMode]}</p>
</Tooltip.Content>
</Tooltip.Root>
)}
</div>
)}
</div>
)
}
@@ -0,0 +1 @@
export { MothershipView } from './mothership-view'
@@ -0,0 +1,156 @@
'use client'
import { forwardRef, memo, useState } from 'react'
import { cn } from '@sim/emcn'
import type { FilePreviewSession } from '@/lib/copilot/request/session'
import { getFileExtension } from '@/lib/uploads/utils/file-utils'
import type { PreviewMode } from '@/app/workspace/[workspaceId]/files/components/file-viewer'
import {
isCsvStreamOnly,
isMarkdownFile,
RICH_PREVIEWABLE_EXTENSIONS,
} from '@/app/workspace/[workspaceId]/files/components/file-viewer'
import { useMothershipResources } from '@/app/workspace/[workspaceId]/home/components/mothership-resources-context'
import { hasRenderableFilePreviewContent } from '@/app/workspace/[workspaceId]/home/hooks/preview'
import type {
GenericResourceData,
MothershipResource,
} from '@/app/workspace/[workspaceId]/home/types'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { useWorkspaceFiles } from '@/hooks/queries/workspace-files'
import { ResourceActions, ResourceContent, ResourceTabs } from './components'
const PREVIEW_CYCLE: Record<PreviewMode, PreviewMode> = {
editor: 'split',
split: 'preview',
preview: 'editor',
} as const
/**
* Whether the active resource should show the in-progress file stream.
* The synthetic `streaming-file` tab always shows it; a real file tab only shows it
* after a preview content event has arrived for that exact resource.
*/
function shouldShowStreamingFilePanel(
previewSession: FilePreviewSession | null | undefined,
active: MothershipResource | null
): boolean {
if (!previewSession || !hasRenderableFilePreviewContent(previewSession) || !active) return false
if (active.id === 'streaming-file') return true
if (active.type !== 'file') return false
if (active.id && previewSession.fileId === active.id) {
return true
}
return false
}
interface MothershipViewProps {
workspaceId: string
chatId?: string
resources: MothershipResource[]
activeResourceId: string | null
isCollapsed: boolean
className?: string
previewSession?: FilePreviewSession | null
isAgentResponding?: boolean
genericResourceData?: GenericResourceData
}
export const MothershipView = memo(
forwardRef<HTMLDivElement, MothershipViewProps>(function MothershipView(
{
workspaceId,
chatId,
resources,
activeResourceId,
isCollapsed,
className,
previewSession,
isAgentResponding,
genericResourceData,
}: MothershipViewProps,
ref
) {
const active = resources.find((r) => r.id === activeResourceId) ?? resources[0] ?? null
const { canEdit } = useUserPermissionsContext()
const { removeResource } = useMothershipResources()
const previewForActive =
previewSession && active && shouldShowStreamingFilePanel(previewSession, active)
? previewSession
: undefined
const [previewMode, setPreviewMode] = useState<PreviewMode>('preview')
const handleCyclePreview = () => setPreviewMode((m) => PREVIEW_CYCLE[m])
const [prevActiveId, setPrevActiveId] = useState(active?.id)
if (prevActiveId !== active?.id) {
setPrevActiveId(active?.id)
setPreviewMode('preview')
}
// A large CSV renders read-only (streamed) with no editor, so it must not offer the
// edit/split/preview toggle. Its size lives on the file record, not the resource tab.
const { data: files, isLoading: filesLoading } = useWorkspaceFiles(workspaceId, 'active', {
enabled: active?.type === 'file',
})
const activeFile = active?.type === 'file' ? files?.find((f) => f.id === active.id) : undefined
const isActiveCsv = active?.type === 'file' && getFileExtension(active.title) === 'csv'
const isActivePreviewable =
canEdit &&
active?.type === 'file' &&
RICH_PREVIEWABLE_EXTENSIONS.has(getFileExtension(active.title)) &&
// Markdown renders in the single-surface inline editor (streamed preview → editable in place),
// so it has no raw/split/preview toggle to offer.
!isMarkdownFile({ type: '', name: active.title }) &&
// Only a CSV's previewability depends on its size (large = read-only, no editor). Wait for
// the record before deciding so the toggle doesn't flash on for a large CSV — but don't gate
// other rich types (html, svg, …) on the file list loading.
!(isActiveCsv && filesLoading) &&
!(activeFile && isCsvStreamOnly(activeFile))
return (
<div
ref={ref}
className={cn(
'relative z-10 flex h-full flex-col overflow-hidden border-[var(--border)] bg-[var(--bg)] transition-[width,min-width,border-width] duration-200 ease-[cubic-bezier(0.25,0.1,0.25,1)]',
isCollapsed ? 'w-0 min-w-0 border-l-0' : 'w-1/2 border-l',
className
)}
>
<div className='flex min-h-0 flex-1 flex-col'>
<ResourceTabs
workspaceId={workspaceId}
chatId={chatId}
resources={resources}
activeId={active?.id ?? null}
actions={
active ? <ResourceActions workspaceId={workspaceId} resource={active} /> : null
}
previewMode={isActivePreviewable ? previewMode : undefined}
onCyclePreviewMode={isActivePreviewable ? handleCyclePreview : undefined}
/>
<div className='min-h-0 flex-1 overflow-hidden'>
{active ? (
<ResourceContent
workspaceId={workspaceId}
resource={active}
previewMode={isActivePreviewable ? previewMode : undefined}
previewSession={previewForActive}
isAgentResponding={isAgentResponding}
genericResourceData={active.type === 'generic' ? genericResourceData : undefined}
previewContextKey={chatId}
onNotFound={(resourceId) => removeResource('log', resourceId)}
/>
) : (
<div className='flex h-full items-center justify-center text-[var(--text-muted)] text-sm'>
Click "+" above to add a resource
</div>
)}
</div>
</div>
</div>
)
})
)
@@ -0,0 +1 @@
export { QueuedMessages } from './queued-messages'
@@ -0,0 +1,202 @@
'use client'
import { useCallback, useRef, useState } from 'react'
import { cn, Tooltip } from '@sim/emcn'
import { ArrowUp, ChevronDown, ChevronRight, Paperclip, Pencil, Trash2, X } from 'lucide-react'
import { UserMessageContent } from '@/app/workspace/[workspaceId]/home/components/user-message-content'
import type { QueuedMessage } from '@/app/workspace/[workspaceId]/home/types'
const NARROW_WIDTH_PX = 320
interface QueuedMessagesProps {
messageQueue: QueuedMessage[]
editingQueuedId: string | null
dispatchingHeadId: string | null
onRemove: (id: string) => void
onSendNow: (id: string) => Promise<void>
onEdit: (id: string) => void
onCancelEdit: () => void
}
export function QueuedMessages({
messageQueue,
editingQueuedId,
dispatchingHeadId,
onRemove,
onSendNow,
onEdit,
onCancelEdit,
}: QueuedMessagesProps) {
const [isExpanded, setIsExpanded] = useState(true)
const [isNarrow, setIsNarrow] = useState(false)
const roRef = useRef<ResizeObserver | null>(null)
const containerRef = useCallback((el: HTMLDivElement | null) => {
if (roRef.current) {
roRef.current.disconnect()
roRef.current = null
}
if (!el) return
const ro = new ResizeObserver((entries) => {
setIsNarrow(entries[0].contentRect.width < NARROW_WIDTH_PX)
})
ro.observe(el)
roRef.current = ro
}, [])
if (messageQueue.length === 0) return null
return (
<div
ref={containerRef}
className='-mb-3 mx-3.5 overflow-hidden rounded-t-[16px] border border-[var(--border-1)] border-b-0 bg-[var(--surface-3)] pb-3'
>
<button
type='button'
onClick={() => setIsExpanded(!isExpanded)}
className='flex w-full items-center gap-1.5 px-3.5 py-2 transition-colors hover-hover:bg-[var(--surface-active)]'
>
{isExpanded ? (
<ChevronDown className='size-[14px] text-[var(--text-icon)]' />
) : (
<ChevronRight className='size-[14px] text-[var(--text-icon)]' />
)}
<span className='font-medium text-[var(--text-secondary)] text-small'>
{messageQueue.length} Queued
</span>
</button>
{isExpanded && (
<div>
{messageQueue.map((msg) => {
const isEditing = msg.id === editingQueuedId
const isDispatching = msg.id === dispatchingHeadId
return (
<div
key={msg.id}
className={cn(
'flex items-center gap-2 py-1.5 pr-2 pl-3.5 transition-colors hover-hover:bg-[var(--surface-active)]',
isEditing && 'bg-[var(--surface-active)]'
)}
>
<div className='flex size-[16px] shrink-0 items-center justify-center'>
<div
className={cn(
'size-[10px] rounded-full border-[1.5px] border-[color-mix(in_srgb,var(--text-tertiary)_40%,transparent)]',
isEditing &&
'border-[color-mix(in_srgb,var(--text-secondary)_60%,transparent)] border-dashed'
)}
/>
</div>
<div className='min-w-0 flex-1 overflow-hidden'>
<UserMessageContent content={msg.content} contexts={msg.contexts} compact />
</div>
{msg.fileAttachments && msg.fileAttachments.length > 0 && (
<span className='inline-flex min-w-0 max-w-[40%] shrink items-center gap-1 rounded-[5px] bg-[var(--surface-5)] px-[5px] py-0.5 text-[var(--text-primary)] text-small'>
<Paperclip className='size-[12px] shrink-0 text-[var(--text-icon)]' />
{isNarrow ? (
<span className='shrink-0 text-[var(--text-secondary)]'>
{msg.fileAttachments.length}
</span>
) : (
<>
<span className='truncate'>{msg.fileAttachments[0].filename}</span>
{msg.fileAttachments.length > 1 && (
<span className='shrink-0 text-[var(--text-secondary)]'>
+{msg.fileAttachments.length - 1}
</span>
)}
</>
)}
</span>
)}
<div className='flex shrink-0 items-center gap-0.5'>
{isEditing ? (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<button
type='button'
onClick={(e) => {
e.stopPropagation()
onCancelEdit()
}}
className='rounded-md p-[5px] text-[var(--text-icon)] transition-colors hover-hover:bg-[var(--surface-active)] hover-hover:text-[var(--text-primary)]'
>
<X className='size-[13px]' />
</button>
</Tooltip.Trigger>
<Tooltip.Content side='top' sideOffset={4}>
Cancel edit
</Tooltip.Content>
</Tooltip.Root>
) : (
<>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<button
type='button'
disabled={isDispatching}
onClick={(e) => {
e.stopPropagation()
onEdit(msg.id)
}}
className='rounded-md p-[5px] text-[var(--text-icon)] transition-colors hover-hover:bg-[var(--surface-active)] hover-hover:text-[var(--text-primary)] disabled:cursor-not-allowed disabled:opacity-40 disabled:hover-hover:bg-transparent disabled:hover-hover:text-[var(--text-icon)]'
>
<Pencil className='size-[13px]' />
</button>
</Tooltip.Trigger>
<Tooltip.Content side='top' sideOffset={4}>
{isDispatching ? 'Sending now' : 'Edit queued message'}
</Tooltip.Content>
</Tooltip.Root>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<button
type='button'
disabled={isDispatching}
onClick={(e) => {
e.stopPropagation()
void onSendNow(msg.id)
}}
className='rounded-md p-[5px] text-[var(--text-icon)] transition-colors hover-hover:bg-[var(--surface-active)] hover-hover:text-[var(--text-primary)] disabled:cursor-not-allowed disabled:opacity-40 disabled:hover-hover:bg-transparent disabled:hover-hover:text-[var(--text-icon)]'
>
<ArrowUp className='size-[13px]' />
</button>
</Tooltip.Trigger>
<Tooltip.Content side='top' sideOffset={4}>
Send now
</Tooltip.Content>
</Tooltip.Root>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<button
type='button'
onClick={(e) => {
e.stopPropagation()
onRemove(msg.id)
}}
className='rounded-md p-[5px] text-[var(--text-icon)] transition-colors hover-hover:bg-[var(--surface-active)] hover-hover:text-[var(--text-primary)]'
>
<Trash2 className='size-[13px]' />
</button>
</Tooltip.Trigger>
<Tooltip.Content side='top' sideOffset={4}>
Remove from queue
</Tooltip.Content>
</Tooltip.Root>
</>
)}
</div>
</div>
)
})}
</div>
)}
</div>
)
}
@@ -0,0 +1 @@
export { SuggestedActions } from './suggested-actions'
@@ -0,0 +1,434 @@
'use client'
import { type ComponentType, type CSSProperties, useMemo, useState } from 'react'
import { ArrowRight, ChevronDown, chipVariants, cn, Expandable, ExpandableContent } from '@sim/emcn'
import { Shuffle, Table } from '@sim/emcn/icons'
import { randomFloat } from '@sim/utils/random'
import { stripVersionSuffix } from '@sim/utils/string'
import { useParams } from 'next/navigation'
import { usePostHog } from 'posthog-js/react'
import { GmailIcon, SlackIcon } from '@/components/icons'
import {
getAllBlockMeta,
INTEGRATIONS,
type OAuthServiceMatch,
resolveOAuthServiceForIntegration,
resolveOAuthServiceForSlug,
} from '@/lib/integrations'
import { captureEvent } from '@/lib/posthog/client'
import { ConnectOAuthModal } from '@/app/workspace/[workspaceId]/components/connect-oauth-modal'
import { getBareIconStyle } from '@/blocks/icon-color'
import type { ModuleTag } from '@/blocks/types'
import { useWorkspaceCredentials } from '@/hooks/queries/credentials'
import { useKnowledgeBasesQuery } from '@/hooks/queries/kb/knowledge'
import { useOAuthConnections } from '@/hooks/queries/oauth/oauth-connections'
import { useTablesList } from '@/hooks/queries/tables'
type Icon = ComponentType<{ className?: string; style?: CSSProperties }>
type Action =
| { kind: 'prompt'; id: string; label: string; prompt: string; icon: Icon }
| { kind: 'integration'; id: string; label: string; icon: Icon; slug: string }
/** Lookup integration slug by OAuth service display name (case-insensitive). */
const SLUG_BY_LOWER_NAME: ReadonlyMap<string, string> = new Map(
INTEGRATIONS.map((i) => [i.name.toLowerCase(), i.slug])
)
/** Lookup base block type by catalog slug, for the connect-row popularity weight. */
const TYPE_BY_SLUG: ReadonlyMap<string, string> = new Map(
INTEGRATIONS.map((i) => [i.slug, stripVersionSuffix(i.type)])
)
/**
* A scored suggestion candidate derived from the block template catalog (plus
* a few generic table starters). `providerId` is set when the owning block is
* an OAuth integration, enabling connectivity-aware scoring.
*/
interface Candidate {
id: string
/** Diversity key — at most one suggestion per block is ever shown. */
blockType: string
label: string
prompt: string
icon: Icon
modules: readonly ModuleTag[]
featured: boolean
popular: boolean
providerId: string | null
}
/** Generic table starters for workspaces without integration context. */
const TABLE_STARTERS: readonly Candidate[] = [
{ label: 'Create a CRM with sample data', prompt: 'Create a CRM with sample data.' },
{ label: 'Build a project tracker', prompt: 'Build a project tracker table.' },
{ label: 'Create a content calendar', prompt: 'Create a content calendar table.' },
{ label: 'Build an expense tracker', prompt: 'Build an expense tracker table.' },
{ label: 'Create a bug tracker', prompt: 'Create a bug tracker table.' },
].map(({ label, prompt }, i) => ({
id: `table-starter-${i}`,
blockType: `table-starter-${i}`,
label,
prompt,
icon: Table,
modules: ['tables'] as const,
featured: false,
popular: true,
providerId: null,
}))
/**
* The full suggestion pool, built once at module load from the curated block
* template catalog (`getAllBlockMeta`). Each block's templates are hand-written
* catalog prompts; the owning block links a template to its integration so
* connectivity can inform scoring. Blocks without a catalog entry (internal
* blocks) are skipped. Catalog types may carry version suffixes (`gmail_v2`)
* while meta-registry keys are base types (`gmail`), so the integration map
* is keyed by both forms.
*/
const CANDIDATES: readonly Candidate[] = (() => {
const integrationByType = new Map(
INTEGRATIONS.flatMap((i) => [[i.type, i] as const, [stripVersionSuffix(i.type), i] as const])
)
const out: Candidate[] = [...TABLE_STARTERS]
for (const [blockType, meta] of Object.entries(getAllBlockMeta())) {
const integration = integrationByType.get(blockType)
if (!integration) continue
const providerId = resolveOAuthServiceForIntegration(integration)?.providerId ?? null
for (const [i, template] of (meta.templates ?? []).entries()) {
out.push({
id: `${blockType}-${i}`,
blockType,
label: template.title,
prompt: template.prompt,
icon: template.icon as Icon,
modules: template.modules,
featured: template.featured ?? false,
popular: template.category === 'popular',
providerId,
})
}
}
return out
})()
/** Template count per block type — a data-driven popularity proxy for connect rows. */
const TEMPLATE_COUNT_BY_TYPE: ReadonlyMap<string, number> = (() => {
const counts = new Map<string, number>()
for (const c of CANDIDATES) {
if (c.providerId) counts.set(c.blockType, (counts.get(c.blockType) ?? 0) + 1)
}
return counts
})()
interface Signals {
connectedProviders: ReadonlySet<string>
hasTables: boolean
hasKnowledgeBases: boolean
}
/**
* Scores a candidate against workspace signals. Connected-provider prompts get
* the largest boost — they are runnable immediately, with no OAuth detour —
* while unconnected OAuth prompts are discounted (but kept, since they still
* teach capability). Resource gaps nudge the mix: workspaces without tables
* see more table starters; workspaces that already run knowledge bases see
* fewer "create a knowledge base" prompts.
*/
function scoreCandidate(c: Candidate, signals: Signals): number {
let weight = 1
if (c.featured) weight *= 3
if (c.popular) weight *= 1.5
if (c.providerId) {
weight *= signals.connectedProviders.has(c.providerId) ? 4 : 0.4
}
if (c.modules.includes('tables') && !signals.hasTables) weight *= 1.5
if (c.modules.includes('knowledge-base') && signals.hasKnowledgeBases) weight *= 0.6
return weight
}
/**
* Weighted sampling without replacement. Each pick's probability is
* proportional to its weight, so shuffles stay fresh while staying relevant.
*/
function weightedSample<T>(pool: readonly T[], n: number, weightOf: (item: T) => number): T[] {
const remaining = pool.map((item) => ({ item, weight: Math.max(weightOf(item), 0) }))
const out: T[] = []
while (out.length < n && remaining.length > 0) {
const total = remaining.reduce((sum, entry) => sum + entry.weight, 0)
if (total <= 0) break
let roll = randomFloat() * total
const index = remaining.findIndex((entry) => {
roll -= entry.weight
return roll <= 0
})
const [picked] = remaining.splice(index === -1 ? remaining.length - 1 : index, 1)
out.push(picked.item)
}
return out
}
const EMPTY_CREDENTIALS: NonNullable<ReturnType<typeof useWorkspaceCredentials>['data']> = []
const EMPTY_SERVICES: NonNullable<ReturnType<typeof useOAuthConnections>['data']> = []
type ServiceInfo = NonNullable<ReturnType<typeof useOAuthConnections>['data']>[number]
function toPromptAction(c: Candidate): Action {
return { kind: 'prompt', id: c.id, label: c.label, prompt: c.prompt, icon: c.icon }
}
function toIntegrationAction(service: ServiceInfo, slug: string): Action {
return {
kind: 'integration',
id: `integrate-${service.providerId}`,
label: `Integrate with ${service.name}`,
icon: service.icon,
slug,
}
}
/**
* Builds a fresh set of four suggested actions: "Integrate with X" rows for
* unconnected services (weighted by how many catalog templates the service
* has — a data-driven popularity proxy), then prompt rows weighted by
* {@link scoreCandidate}. At most one prompt per block keeps the set diverse.
* Workspaces with at least one connection get a single connect row and three
* prompts; fresh workspaces get two of each.
*/
function computeActions(services: readonly ServiceInfo[], signals: Signals): Action[] {
const connectCandidates = services.flatMap((s) => {
if (signals.connectedProviders.has(s.providerId)) return []
const slug = SLUG_BY_LOWER_NAME.get(s.name.toLowerCase())
return slug ? [{ service: s, slug }] : []
})
const connectCount = signals.connectedProviders.size === 0 ? 2 : 1
const integrations = weightedSample(
connectCandidates,
connectCount,
({ slug }) => (TEMPLATE_COUNT_BY_TYPE.get(TYPE_BY_SLUG.get(slug) ?? '') ?? 0) + 1
).map(({ service, slug }) => toIntegrationAction(service, slug))
const scored = CANDIDATES.map((c) => ({ c, weight: scoreCandidate(c, signals) })).filter(
(entry) => entry.weight > 0
)
const prompts: Action[] = []
const usedBlockTypes = new Set<string>()
while (prompts.length < 4 - integrations.length) {
const available = scored.filter((entry) => !usedBlockTypes.has(entry.c.blockType))
const [pick] = weightedSample(available, 1, (entry) => entry.weight)
if (!pick) break
usedBlockTypes.add(pick.c.blockType)
prompts.push(toPromptAction(pick.c))
}
return [...integrations, ...prompts]
}
/**
* Initial actions rendered on first paint, before OAuth/credentials queries
* resolve. For users with no connections this is also the final result, so the
* section never flashes. Users with existing connections briefly see this
* before the personalized recompute replaces it.
*/
const INITIAL_ACTIONS: Action[] = [
{
kind: 'integration',
id: 'integrate-slack',
label: 'Integrate with Slack',
icon: SlackIcon,
slug: 'slack',
},
{
kind: 'integration',
id: 'integrate-gmail',
label: 'Integrate with Gmail',
icon: GmailIcon,
slug: 'gmail',
},
toPromptAction(TABLE_STARTERS[0]),
...CANDIDATES.filter((c) => c.blockType === 'github' && c.featured)
.slice(0, 1)
.map(toPromptAction),
]
interface SuggestedActionsProps {
onSelectPrompt: (prompt: string) => void
}
export function SuggestedActions({ onSelectPrompt }: SuggestedActionsProps) {
const { workspaceId } = useParams<{ workspaceId: string }>()
const posthog = usePostHog()
const { data: credentials = EMPTY_CREDENTIALS } = useWorkspaceCredentials({
workspaceId,
enabled: Boolean(workspaceId),
})
const { data: services = EMPTY_SERVICES } = useOAuthConnections()
const { data: tables = [] } = useTablesList(workspaceId)
const { data: knowledgeBases = [] } = useKnowledgeBasesQuery(workspaceId, {
enabled: Boolean(workspaceId),
})
const [expanded, setExpanded] = useState(true)
/**
* Collapsible animations are enabled only after the first user toggle, so
* the initially-open, server-rendered panel appears at full height on first
* paint instead of replaying the open animation and shifting the input
* above it.
*/
const [animationsEnabled, setAnimationsEnabled] = useState(false)
/** Incremented by the shuffle control to re-roll the weighted sample. */
const [shuffleNonce, setShuffleNonce] = useState(0)
/**
* OAuth connect modal target. Setting this opens the modal; setting it back
* to `null` (via `onOpenChange(false)`) closes it. Mirrors the local-state
* pattern used by the integrations detail page.
*/
const [oauthTarget, setOAuthTarget] = useState<OAuthServiceMatch | null>(null)
const connectedProviders = useMemo(
() =>
new Set(
credentials
.filter((c) => c.type === 'oauth' || c.type === 'service_account')
.map((c) => c.providerId)
.filter((id): id is string => Boolean(id))
),
[credentials]
)
const signals = useMemo<Signals>(
() => ({
connectedProviders,
hasTables: tables.length > 0,
hasKnowledgeBases: knowledgeBases.length > 0,
}),
[connectedProviders, tables.length, knowledgeBases.length]
)
/**
* Personalized suggestions, re-sampled whenever signals resolve or the user
* shuffles. Falls back to {@link INITIAL_ACTIONS} until the credential and
* service queries have loaded (and stays there for users with no
* connections, unless they shuffle), so first paint never flashes.
*/
const actions = useMemo(() => {
const personalized = services.length > 0 && connectedProviders.size > 0
if (!personalized && shuffleNonce === 0) return INITIAL_ACTIONS
return computeActions(services, signals)
}, [connectedProviders, services, signals, shuffleNonce])
const handleSelect = (action: Action, position: number) => {
captureEvent(posthog, 'suggested_action_clicked', {
workspace_id: workspaceId,
kind: action.kind,
action_id: action.id,
label: action.label,
position,
connected_provider_count: connectedProviders.size,
})
if (action.kind === 'prompt') {
onSelectPrompt(action.prompt)
return
}
const match = resolveOAuthServiceForSlug(action.slug)
if (match) setOAuthTarget(match)
}
const handleShuffle = () => {
captureEvent(posthog, 'suggested_actions_shuffled', {
workspace_id: workspaceId,
connected_provider_count: connectedProviders.size,
})
setShuffleNonce((n) => n + 1)
}
const handleToggleExpanded = () => {
captureEvent(posthog, 'suggested_actions_toggled', {
workspace_id: workspaceId,
expanded: !expanded,
})
setAnimationsEnabled(true)
setExpanded((prev) => !prev)
}
return (
<div className='mx-auto mt-7 w-full max-w-[48rem]'>
<div className='flex items-center justify-between'>
<button
type='button'
onClick={handleToggleExpanded}
aria-expanded={expanded}
className='flex items-center gap-2'
>
<span className='text-[var(--text-muted)] text-small'>Suggested actions</span>
<ChevronDown
className={cn(
'h-[7px] w-[9px] text-[var(--text-icon)] transition-transform duration-150',
!expanded && '-rotate-90'
)}
/>
</button>
<button
type='button'
onClick={handleShuffle}
aria-label='Shuffle suggested actions'
aria-hidden={!expanded}
tabIndex={expanded ? undefined : -1}
className={cn(
chipVariants({ flush: true }),
'-mr-2 gap-1.5 transition-opacity duration-150 ease-out motion-reduce:transition-none',
expanded ? 'opacity-100' : 'pointer-events-none opacity-0'
)}
>
<span className='-mt-px text-[var(--text-muted)] text-small'>Shuffle</span>
<Shuffle className='size-[16px] flex-shrink-0 text-[var(--text-icon)]' />
</button>
</div>
<Expandable expanded={expanded}>
<ExpandableContent className={cn('mt-2', !animationsEnabled && '!animate-none')}>
<div className='flex flex-col'>
{actions.map((action, i) => {
const Icon = action.icon
return (
<button
key={action.id}
type='button'
onClick={() => handleSelect(action, i)}
className={cn(
'flex items-center gap-2 border-[var(--divider)] px-2 py-2 text-left transition-colors hover-hover:bg-[var(--surface-5)]',
i > 0 && 'border-t'
)}
>
<Icon
className='size-[16px] flex-shrink-0 text-[var(--text-icon)]'
style={getBareIconStyle(Icon)}
/>
<span className='flex-1 truncate text-[var(--text-body)] text-sm'>
{action.label}
</span>
<ArrowRight className='size-[16px] shrink-0 text-[var(--text-icon)]' />
</button>
)
})}
</div>
</ExpandableContent>
</Expandable>
{oauthTarget && workspaceId && (
<ConnectOAuthModal
mode='connect'
origin='integrations'
open
onOpenChange={(open) => {
if (!open) setOAuthTarget(null)
}}
workspaceId={workspaceId}
providerId={oauthTarget.providerId}
requiredScopes={oauthTarget.requiredScopes}
serviceName={oauthTarget.serviceName}
serviceIcon={oauthTarget.serviceIcon}
/>
)}
</div>
)
}
@@ -0,0 +1,113 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import {
type ChipBound,
type Selection,
snapSelectionToChips,
} from '@/app/workspace/[workspaceId]/home/components/user-input/chip-selection'
// A chip occupying value indices [5, 12): e.g. " @Gmail " sitting at offset 5.
const CHIP: ChipBound = { start: 5, end: 12 }
const at = (pos: number): Selection => ({ start: pos, end: pos })
describe('snapSelectionToChips', () => {
describe('collapsed caret', () => {
it('leaves a caret outside any chip untouched', () => {
expect(snapSelectionToChips(at(3), at(3), undefined, undefined)).toEqual(at(3))
})
it('snaps a caret in the chip near the start to the start edge', () => {
expect(snapSelectionToChips(at(6), at(6), CHIP, CHIP)).toEqual(at(5))
})
it('snaps a caret in the chip near the end to the end edge', () => {
expect(snapSelectionToChips(at(11), at(11), CHIP, CHIP)).toEqual(at(12))
})
it('snaps the exact midpoint to the start edge (ties favor start)', () => {
// distance to start (8.5-5=3.5) equals distance to end (12-8.5=3.5) only at
// 8.5; integer midpoint 8 is closer to start.
expect(snapSelectionToChips(at(8), at(8), CHIP, CHIP)).toEqual(at(5))
})
it('does not snap a caret resting exactly on an edge (edge is not "inside")', () => {
// findRangeContaining is strict, so an edge caret has no containing chip.
expect(snapSelectionToChips(at(5), at(5), undefined, undefined)).toEqual(at(5))
expect(snapSelectionToChips(at(12), at(12), undefined, undefined)).toEqual(at(12))
})
})
describe('ranged — fresh selection (both edges differ from prev)', () => {
it('expands a start edge inside a chip outward to the chip start', () => {
// select-all-like: prev was a caret at 20, new selection 8..30 grew both edges.
const out = snapSelectionToChips({ start: 8, end: 30 }, at(20), CHIP, undefined)
expect(out).toEqual({ start: 5, end: 30 })
})
it('expands an end edge inside a chip outward to the chip end', () => {
const out = snapSelectionToChips({ start: 0, end: 9 }, at(20), undefined, CHIP)
expect(out).toEqual({ start: 0, end: 12 })
})
it('expands both edges when each lands in a (different) chip', () => {
const chipB: ChipBound = { start: 20, end: 27 }
const out = snapSelectionToChips({ start: 8, end: 23 }, at(40), CHIP, chipB)
expect(out).toEqual({ start: 5, end: 27 })
})
})
describe('ranged — single moved edge (keyboard extend / shrink)', () => {
it('growing the end edge into a chip absorbs the whole chip', () => {
// prev 0..6, end moved 6 -> 9 (grew); start unchanged.
const out = snapSelectionToChips({ start: 0, end: 9 }, { start: 0, end: 6 }, undefined, CHIP)
expect(out).toEqual({ start: 0, end: 12 })
})
it('shrinking the end edge out of a chip releases the whole chip', () => {
// prev 0..14, end moved 14 -> 9 (shrank) into the chip; release to chip start.
const out = snapSelectionToChips({ start: 0, end: 9 }, { start: 0, end: 14 }, undefined, CHIP)
expect(out).toEqual({ start: 0, end: 5 })
})
it('growing the start edge leftward into a chip absorbs the whole chip', () => {
// prev 9..20, start moved 9 -> 6 (grew leftward, start < prev.start).
const out = snapSelectionToChips(
{ start: 6, end: 20 },
{ start: 9, end: 20 },
CHIP,
undefined
)
expect(out).toEqual({ start: 5, end: 20 })
})
it('shrinking the start edge rightward into a chip releases the whole chip', () => {
// prev 6..20, start moved 6 -> 9 (shrank rightward, start > prev.start) → chip end.
const out = snapSelectionToChips(
{ start: 9, end: 20 },
{ start: 6, end: 20 },
CHIP,
undefined
)
expect(out).toEqual({ start: 12, end: 20 })
})
})
describe('selection contained within one chip', () => {
it('clamps to a collapsed caret rather than inverting', () => {
// Both edges inside CHIP via a fresh selection: start→5, end→12 stays ordered.
// Construct an inverting case: a shrink where start snaps to 12 and end to 5.
const out = snapSelectionToChips({ start: 7, end: 9 }, { start: 5, end: 9 }, CHIP, CHIP)
expect(out.start).toBeLessThanOrEqual(out.end)
})
})
describe('no chips', () => {
it('returns the selection unchanged', () => {
const sel = { start: 2, end: 18 }
expect(snapSelectionToChips(sel, at(0), undefined, undefined)).toEqual(sel)
})
})
})
@@ -0,0 +1,69 @@
/**
* Pure selection-snapping math for the mention-chip textarea, extracted from the
* `onSelect` handler so it can be unit-tested in isolation from DOM I/O.
*
* A mention chip occupies a contiguous `[start, end)` span of the textarea
* value. The UI treats each chip as atomic: a selection edge may never land
* strictly inside a chip. This function maps an observed selection to the
* nearest valid one.
*/
/** A half-open chip span `[start, end)` in textarea-value coordinates. */
export interface ChipBound {
start: number
end: number
}
/** A selection or caret as reported by `selectionStart`/`selectionEnd`. */
export interface Selection {
start: number
end: number
}
/**
* Snaps a selection so no edge sits inside a chip.
*
* - **Collapsed caret inside a chip** → nearest chip edge.
* - **Ranged selection** → each edge inside a chip snaps to a boundary without
* collapsing the range. A lone moved edge (keyboard extend/shrink, drag) snaps
* in its direction of travel — growing absorbs the chip, shrinking releases
* it; a fresh selection (double-click, select-all) expands outward. The two
* paths differ only for a shrinking edge, so the gesture inference is safe
* even when a fresh selection happens to share an edge with `prev`.
*
* @param sel - The observed selection.
* @param prev - The previously observed selection, used to infer which edge moved.
* @param startChip - The chip containing `sel.start`, if any.
* @param endChip - The chip containing `sel.end`, if any.
* @returns The snapped selection (equal to `sel` when no edge is inside a chip).
*/
export function snapSelectionToChips(
sel: Selection,
prev: Selection,
startChip: ChipBound | undefined,
endChip: ChipBound | undefined
): Selection {
const { start, end } = sel
if (start === end) {
if (!startChip) return sel
const nearest =
start - startChip.start < startChip.end - start ? startChip.start : startChip.end
return { start: nearest, end: nearest }
}
const singleEdgeMoved = (start !== prev.start) !== (end !== prev.end)
let newStart = startChip
? singleEdgeMoved && start > prev.start
? startChip.end
: startChip.start
: start
const newEnd = endChip ? (singleEdgeMoved && end < prev.end ? endChip.start : endChip.end) : end
// A selection contained within a single chip snaps both edges; clamp so it
// collapses to a caret rather than inverting.
if (newStart > newEnd) newStart = newEnd
return { start: newStart, end: newEnd }
}
@@ -0,0 +1,25 @@
'use client'
import { useEffect } from 'react'
import { useAnimatedPlaceholder } from '@/hooks/use-animated-placeholder'
interface AnimatedPlaceholderEffectProps {
textareaRef: React.RefObject<HTMLTextAreaElement | null>
isInitialView: boolean
}
export function AnimatedPlaceholderEffect({
textareaRef,
isInitialView,
}: AnimatedPlaceholderEffectProps) {
const animatedPlaceholder = useAnimatedPlaceholder(isInitialView)
const placeholder = isInitialView ? animatedPlaceholder : 'Send message to Sim'
useEffect(() => {
if (textareaRef.current) {
textareaRef.current.placeholder = placeholder
}
}, [placeholder, textareaRef])
return null
}
@@ -0,0 +1 @@
export { AnimatedPlaceholderEffect } from './animated-placeholder-effect'
@@ -0,0 +1,97 @@
'use client'
import React from 'react'
import { Loader, Tooltip } from '@sim/emcn'
import { X } from 'lucide-react'
import { getDocumentIcon } from '@/components/icons/document-icons'
import type { AttachedFile } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-file-attachments'
interface AttachedFilesListProps {
attachedFiles: AttachedFile[]
onFileClick: (file: AttachedFile) => void
onRemoveFile: (id: string) => void
}
export const AttachedFilesList = React.memo(function AttachedFilesList({
attachedFiles,
onFileClick,
onRemoveFile,
}: AttachedFilesListProps) {
if (attachedFiles.length === 0) return null
return (
<div className='mb-1.5 flex flex-wrap gap-1.5'>
{attachedFiles.map((file) => {
const isVideo = file.type.startsWith('video/')
const hasPreview = Boolean(file.previewUrl)
return (
<Tooltip.Root key={file.id}>
<div className='group relative size-[56px] flex-shrink-0'>
<Tooltip.Trigger asChild>
<button
type='button'
className='relative h-full w-full cursor-pointer overflow-hidden rounded-[8px] border border-[var(--border-1)] bg-[var(--surface-5)] p-0 hover:bg-[var(--surface-4)]'
onClick={() => onFileClick(file)}
>
{hasPreview && isVideo ? (
<>
<div className='absolute inset-0 flex items-center justify-center text-[var(--text-icon)]'>
{(() => {
const Icon = getDocumentIcon(file.type, file.name)
return <Icon className='size-[18px]' />
})()}
</div>
<video
src={file.previewUrl}
muted
playsInline
preload='metadata'
className='relative h-full w-full object-cover'
/>
</>
) : hasPreview ? (
<img
src={file.previewUrl}
alt={file.name}
className='h-full w-full object-cover'
/>
) : (
<div className='flex h-full w-full flex-col items-center justify-center gap-0.5 text-[var(--text-icon)]'>
{(() => {
const Icon = getDocumentIcon(file.type, file.name)
return <Icon className='size-[18px]' />
})()}
<span className='max-w-[48px] truncate px-[2px] text-[9px] text-[var(--text-muted)]'>
{file.name.split('.').pop()}
</span>
</div>
)}
{file.uploading && (
<div className='absolute inset-0 flex items-center justify-center bg-black/50'>
<Loader className='size-[14px] text-white' animate />
</div>
)}
</button>
</Tooltip.Trigger>
{!file.uploading && (
<button
type='button'
onClick={(e) => {
e.stopPropagation()
onRemoveFile(file.id)
}}
className='absolute top-[2px] right-[2px] flex size-[16px] items-center justify-center rounded-full bg-black/60 opacity-0 group-hover:opacity-100'
>
<X className='size-[10px] text-white' />
</button>
)}
</div>
<Tooltip.Content side='top'>
<p className='max-w-[200px] truncate'>{file.name}</p>
</Tooltip.Content>
</Tooltip.Root>
)
})}
</div>
)
})
@@ -0,0 +1 @@
export { AttachedFilesList } from './attached-files-list'
@@ -0,0 +1,228 @@
import {
computeMentionHighlightRanges,
extractContextTokens,
restoreSkillTriggerText,
stripMentionTrigger,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/utils'
import type { ChatContext } from '@/stores/panel'
/** URI scheme for portable chip links (`[label](sim:kind/id)`). Custom so only
* our own links — never generic markdown — are parsed back into chips. */
const CHIP_LINK_SCHEME = 'sim'
/**
* Every chip kind that carries a single stable identifier → the
* {@link ChatContext} id field encoded in `sim:<kind>/<id>`. This is the one map
* that drives BOTH serialization and parsing, so every chip — resource, skill,
* integration, slash command — round-trips through the exact same mechanism by
* its true id (not by name). `satisfies Partial<Record<ChatContext['kind'],
* string>>` keeps it union-synced: rename a kind's id field and this stops
* type-checking.
*
* Excluded kinds (`current_workflow`, `blocks`, `workflow_block`, `docs`) carry
* no single portable id (an array / two ids / none) and degrade to plain text.
*/
const PORTABLE_KIND_TO_ID_FIELD = {
table: 'tableId',
file: 'fileId',
folder: 'folderId',
filefolder: 'fileFolderId',
scheduledtask: 'scheduleId',
knowledge: 'knowledgeId',
past_chat: 'chatId',
workflow: 'workflowId',
logs: 'executionId',
skill: 'skillId',
integration: 'blockType',
slash_command: 'command',
} as const satisfies Partial<Record<ChatContext['kind'], string>>
/**
* The subset of {@link ChatContext} kinds that serialize to a portable
* `sim:<kind>/<id>` markdown link.
*/
export type PortableKind = keyof typeof PORTABLE_KIND_TO_ID_FIELD
/**
* Matches a portable chip markdown link: `[label](sim:kind/id)`.
* - group 1: label (any non-`]` chars)
* - group 2: kind (lowercase letters / underscores, e.g. `past_chat`)
* - group 3: id (any non-`)` / non-whitespace chars)
*/
const CHIP_LINK_PATTERN = new RegExp(
`\\[([^\\]]+)\\]\\(${CHIP_LINK_SCHEME}:([a-z_]+)\\/([^)\\s]+)\\)`,
'g'
)
/**
* Parsed result of a single portable chip markdown link, including the source
* span so callers can rewrite the surrounding text.
*/
export interface ParsedChipLink {
kind: PortableKind
id: string
label: string
start: number
end: number
}
/**
* Type guard narrowing an arbitrary kind string to a {@link PortableKind}.
*/
function isPortableKind(kind: string): kind is PortableKind {
return Object.hasOwn(PORTABLE_KIND_TO_ID_FIELD, kind)
}
/**
* Reads the portable id off a context for its kind, or `undefined` when the
* context isn't a portable kind. Centralizes the id-field lookup so the
* `PORTABLE_KIND_TO_ID_FIELD` map stays the single source of truth.
*/
function getPortableId(context: ChatContext): string | undefined {
if (!isPortableKind(context.kind)) return undefined
const field = PORTABLE_KIND_TO_ID_FIELD[context.kind]
const value = (context as Record<string, unknown>)[field]
return typeof value === 'string' && value.length > 0 ? value : undefined
}
/**
* Serializes a context to a portable `[label](sim:kind/id)` markdown link.
*
* @param context - The chat context to serialize.
* @returns The markdown link string, or `null` when the context isn't a
* portable kind or its id field is missing/empty.
*/
function serializeChipContext(context: ChatContext): string | null {
if (!isPortableKind(context.kind)) return null
const id = getPortableId(context)
if (!id) return null
return `[${context.label}](${CHIP_LINK_SCHEME}:${context.kind}/${id})`
}
/**
* The textarea token to insert when re-creating a chip on paste. Delegates to
* {@link extractContextTokens} so the per-kind prefix (skill EM-SPACE sentinel,
* slash `/`, `@` for everything else) has a single source of truth. The `??`
* fallback is unreachable for portable contexts (they always have a label and
* are never `current_workflow`); it only satisfies the optional return type.
*/
export function chipDisplayToken(context: ChatContext): string {
return extractContextTokens([context])[0] ?? `@${context.label}`
}
/**
* Serializes a selected slice of input text for the clipboard.
*
* Reuses the overlay's exact tokenization
* ({@link computeMentionHighlightRanges} over {@link extractContextTokens}) so
* a chip that renders as a highlighted token is the one that gets converted.
* Every portable chip — resource, skill, integration, slash command — becomes a
* `[label](sim:kind/id)` markdown link carrying its true id. Only non-portable
* tokens and the plain text around chips fall through {@link
* restoreSkillTriggerText} (mapping any stray skill sentinel back to `/`).
*
* When there is nothing to convert the output is byte-identical to
* `restoreSkillTriggerText(selectedText)`; for a selection with no skill
* sentinels that equals the raw selection, letting callers detect "no change".
*
* @param selectedText - The raw selected substring from the textarea.
* @param contexts - The currently selected contexts (mention sources).
* @returns The clipboard-ready string.
*/
export function serializeSelectionForClipboard(
selectedText: string,
contexts: ChatContext[]
): string {
const ranges = computeMentionHighlightRanges(selectedText, extractContextTokens(contexts))
if (ranges.length === 0) return restoreSkillTriggerText(selectedText)
let result = ''
let lastIndex = 0
for (const range of ranges) {
if (range.start > lastIndex) {
result += restoreSkillTriggerText(selectedText.slice(lastIndex, range.start))
}
const label = stripMentionTrigger(range.token)
const matched = contexts.find((c) => c.label === label)
const serialized = matched ? serializeChipContext(matched) : null
result += serialized ?? restoreSkillTriggerText(range.token)
lastIndex = range.end
}
if (lastIndex < selectedText.length) {
result += restoreSkillTriggerText(selectedText.slice(lastIndex))
}
return result
}
/**
* Parses all portable chip markdown links from a string, in source order.
*
* Pure string→data: never fetches or executes. Matches whose kind is not a
* {@link PortableKind} are skipped so non-portable `sim:` shapes don't leak
* into the chip pipeline.
*
* @param text - The text to scan (e.g. pasted clipboard content).
* @returns Parsed links with their `start`/`end` source spans.
*/
export function parseChipLinks(text: string): ParsedChipLink[] {
const links: ParsedChipLink[] = []
const pattern = new RegExp(CHIP_LINK_PATTERN.source, 'g')
let match: RegExpExecArray | null
while ((match = pattern.exec(text)) !== null) {
const [full, label, kind, id] = match
if (!isPortableKind(kind)) continue
links.push({
kind,
id,
label,
start: match.index,
end: match.index + full.length,
})
}
return links
}
/**
* Reconstructs the exact {@link ChatContext} shape for a parsed chip link.
*
* The `switch` over the literal kind narrows the return so each branch builds
* a fully-typed context with no cast.
*
* @param link - A link produced by {@link parseChipLinks}.
* @returns The matching chat context.
*/
export function chipLinkToContext(link: ParsedChipLink): ChatContext {
switch (link.kind) {
case 'table':
return { kind: 'table', tableId: link.id, label: link.label }
case 'file':
return { kind: 'file', fileId: link.id, label: link.label }
case 'folder':
return { kind: 'folder', folderId: link.id, label: link.label }
case 'filefolder':
return { kind: 'filefolder', fileFolderId: link.id, label: link.label }
case 'scheduledtask':
return { kind: 'scheduledtask', scheduleId: link.id, label: link.label }
case 'knowledge':
return { kind: 'knowledge', knowledgeId: link.id, label: link.label }
case 'past_chat':
return { kind: 'past_chat', chatId: link.id, label: link.label }
case 'workflow':
return { kind: 'workflow', workflowId: link.id, label: link.label }
case 'logs':
return { kind: 'logs', executionId: link.id, label: link.label }
case 'skill':
return { kind: 'skill', skillId: link.id, label: link.label }
case 'integration':
return { kind: 'integration', blockType: link.id, label: link.label }
case 'slash_command':
return { kind: 'slash_command', command: link.id, label: link.label }
}
}
@@ -0,0 +1,121 @@
import { cn } from '@sim/emcn'
import type {
MothershipResource,
MothershipResourceType,
} from '@/app/workspace/[workspaceId]/home/types'
import type { ChatContext } from '@/stores/panel'
export interface SpeechRecognitionEvent extends Event {
resultIndex: number
results: SpeechRecognitionResultList
}
export interface SpeechRecognitionErrorEvent extends Event {
error: string
}
export interface SpeechRecognitionInstance extends EventTarget {
continuous: boolean
interimResults: boolean
lang: string
start(): void
stop(): void
abort(): void
onstart: ((ev: Event) => void) | null
onend: ((ev: Event) => void) | null
onresult: ((ev: SpeechRecognitionEvent) => void) | null
onerror: ((ev: SpeechRecognitionErrorEvent) => void) | null
}
interface SpeechRecognitionStatic {
new (): SpeechRecognitionInstance
}
export type WindowWithSpeech = Window & {
SpeechRecognition?: SpeechRecognitionStatic
webkitSpeechRecognition?: SpeechRecognitionStatic
}
export interface PlusMenuHandle {
/** Opens the menu anchored at a viewport position (caret or trigger rect). */
open: (anchor: { left: number; top: number }, options?: { mention?: boolean }) => void
close: () => void
moveActive: (delta: number) => void
selectActive: () => boolean
}
/**
* Box and typography shared by the textarea and its mirror overlay — both must
* produce identical line wrapping so the overlay text sits exactly over the
* (transparent) textarea text. The scale is the chat input's native prompt
* scale (`text-[15px]`, `-0.015em` tracking); the task modal's body inherits it
* so the editor reads the same whether it's the chat input or inside the modal.
*/
const FIELD_MIRROR_CLASSES = cn(
'm-0 box-border min-h-[24px] w-full break-words [overflow-wrap:anywhere] border-0 bg-transparent',
'px-1 py-1 font-body text-[15px] leading-[24px] tracking-[-0.015em]'
)
/**
* The textarea grows to its full content height (`h-auto`, no internal scroll);
* the shared scroller clips and scrolls it. Its text is transparent so the
* mirror overlay shows through; only the caret paints.
*/
export const TEXTAREA_BASE_CLASSES = cn(
FIELD_MIRROR_CLASSES,
'block h-auto resize-none overflow-hidden',
'text-transparent caret-[var(--text-primary)] outline-none',
'placeholder:font-[380] placeholder:text-[var(--text-subtle)]',
'focus-visible:ring-0 focus-visible:ring-offset-0'
)
/**
* Pinned over the full-height textarea (`inset-0` of the sizer). Both are flow
* children of the same scroller, so they scroll together natively — no JS
* scroll-sync, so the caret and mirrored text never drift apart.
*/
export const OVERLAY_CLASSES = cn(
FIELD_MIRROR_CLASSES,
'pointer-events-none absolute inset-0 whitespace-pre-wrap',
'text-[var(--text-primary)]'
)
/** Single scroll container for the textarea + overlay; caps height and hides its scrollbar. */
export const SCROLLER_CLASSES = cn(
'relative overflow-y-auto overflow-x-hidden',
'[-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden'
)
export const SEND_BUTTON_BASE = 'h-[28px] w-[28px] rounded-full border-0 p-0 transition-colors'
export const SEND_BUTTON_ACTIVE =
'bg-[#383838] hover:bg-[#575757] dark:bg-[#E0E0E0] dark:hover:bg-[#CFCFCF]'
export const SEND_BUTTON_DISABLED = 'bg-[#808080] dark:bg-[#808080]'
export const SPEECH_RECOGNITION_LANG = 'en-US'
/**
* Maps a {@link MothershipResource} (resource-picker domain) to a
* {@link ChatContext} (chat-input domain). Keyed by `MothershipResourceType`
* so adding a new resource type fails compilation here until a conversion is
* supplied — preventing silent drift between the two taxonomies.
*/
const RESOURCE_TO_CONTEXT: Record<
MothershipResourceType,
(resource: MothershipResource) => ChatContext
> = {
workflow: (r) => ({ kind: 'workflow', workflowId: r.id, label: r.title }),
knowledgebase: (r) => ({ kind: 'knowledge', knowledgeId: r.id, label: r.title }),
table: (r) => ({ kind: 'table', tableId: r.id, label: r.title }),
file: (r) => ({ kind: 'file', fileId: r.id, label: r.title }),
folder: (r) => ({ kind: 'folder', folderId: r.id, label: r.title }),
filefolder: (r) => ({ kind: 'filefolder', fileFolderId: r.id, label: r.title }),
task: (r) => ({ kind: 'past_chat', chatId: r.id, label: r.title }),
log: (r) => ({ kind: 'logs', executionId: r.id, label: r.title }),
integration: (r) => ({ kind: 'integration', blockType: r.id, label: r.title }),
scheduledtask: (r) => ({ kind: 'scheduledtask', scheduleId: r.id, label: r.title }),
generic: (r) => ({ kind: 'docs', label: r.title }),
}
export function mapResourceToContext(resource: MothershipResource): ChatContext {
return RESOURCE_TO_CONTEXT[resource.type](resource)
}
@@ -0,0 +1,41 @@
'use client'
import { memo } from 'react'
import {
AudioIcon,
CsvIcon,
DocxIcon,
JsonIcon,
MarkdownIcon,
PdfIcon,
TxtIcon,
VideoIcon,
XlsxIcon,
} from '@/components/icons/document-icons'
const DROP_OVERLAY_ICONS = [
PdfIcon,
DocxIcon,
XlsxIcon,
CsvIcon,
TxtIcon,
MarkdownIcon,
JsonIcon,
AudioIcon,
VideoIcon,
] as const
export const DropOverlay = memo(function DropOverlay() {
return (
<div className='pointer-events-none absolute inset-[6px] z-10 flex items-center justify-center rounded-[14px] border-[1.5px] border-[var(--border-1)] border-dashed bg-[var(--white)] dark:bg-[var(--surface-4)]'>
<div className='flex flex-col items-center gap-2'>
<span className='font-medium text-[13px] text-[var(--text-secondary)]'>Drop files</span>
<div className='flex items-center gap-2 text-[var(--text-icon)]'>
{DROP_OVERLAY_ICONS.map((Icon, i) => (
<Icon key={i} className='size-[14px]' />
))}
</div>
</div>
</div>
)
})
@@ -0,0 +1 @@
export { DropOverlay } from './drop-overlay'
@@ -0,0 +1,37 @@
export { AnimatedPlaceholderEffect } from './animated-placeholder-effect'
export { AttachedFilesList } from './attached-files-list'
export type { ParsedChipLink, PortableKind } from './chip-clipboard-codec'
export {
chipDisplayToken,
chipLinkToContext,
parseChipLinks,
serializeSelectionForClipboard,
} from './chip-clipboard-codec'
export type {
PlusMenuHandle,
SpeechRecognitionErrorEvent,
SpeechRecognitionEvent,
SpeechRecognitionInstance,
WindowWithSpeech,
} from './constants'
export {
mapResourceToContext,
OVERLAY_CLASSES,
SCROLLER_CLASSES,
SPEECH_RECOGNITION_LANG,
TEXTAREA_BASE_CLASSES,
} from './constants'
export { DropOverlay } from './drop-overlay'
export { MicButton } from './mic-button'
export type { AvailableResourceGroup } from './plus-menu-dropdown'
export { PlusMenuDropdown } from './plus-menu-dropdown'
export type {
PromptEditorInstance,
PromptEditorKeyPolicy,
PromptEditorProps,
UsePromptEditorProps,
} from './prompt-editor'
export { PromptEditor, usePromptEditor } from './prompt-editor'
export { SendButton } from './send-button'
export type { SkillsMenuHandle } from './skills-menu-dropdown/skills-menu-dropdown'
export { SkillsMenuDropdown } from './skills-menu-dropdown/skills-menu-dropdown'
@@ -0,0 +1 @@
export { MicButton } from './mic-button'
@@ -0,0 +1,32 @@
'use client'
import React from 'react'
import { cn, Mic, Tooltip } from '@sim/emcn'
interface MicButtonProps {
isListening: boolean
onToggle: () => void
}
export const MicButton = React.memo(function MicButton({ isListening, onToggle }: MicButtonProps) {
return (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<button
type='button'
onClick={onToggle}
aria-label={isListening ? 'Stop listening' : 'Voice input'}
className={cn(
'flex h-[28px] w-[28px] items-center justify-center rounded-full transition-colors',
isListening
? 'bg-red-500 text-white hover:bg-red-600'
: 'text-[var(--text-icon)] hover:bg-[#F7F7F7] dark:hover:bg-[#303030]'
)}
>
<Mic className='h-[16px] w-[16px]' />
</button>
</Tooltip.Trigger>
<Tooltip.Content side='top'>{isListening ? 'Stop listening' : 'Voice input'}</Tooltip.Content>
</Tooltip.Root>
)
})
@@ -0,0 +1 @@
export { type AvailableResourceGroup, PlusMenuDropdown } from './plus-menu-dropdown'
@@ -0,0 +1,389 @@
'use client'
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import {
cn,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSearchInput,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from '@sim/emcn'
import { Workflow } from '@sim/emcn/icons'
import {
buildFileFolderTree,
buildWorkflowFolderTree,
FileFolderTreeItems,
type useAvailableResources,
WorkflowFolderTreeItems,
} from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown'
import { getResourceConfig } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry'
import type { PlusMenuHandle } from '@/app/workspace/[workspaceId]/home/components/user-input/components/constants'
import type {
MothershipResource,
MothershipResourceType,
} from '@/app/workspace/[workspaceId]/home/types'
export type AvailableResourceGroup = ReturnType<typeof useAvailableResources>[number]
/**
* Resource types that are only offered via `@`-mention autocomplete and hidden
* from the `+` browse menu. Integrations are searchable inline (e.g. typing
* `@sla` surfaces Slack) but should not clutter the explicit attach menu.
*/
const MENTION_ONLY_RESOURCE_TYPES = new Set<MothershipResourceType>(['integration'])
interface PlusMenuDropdownProps {
availableResources: AvailableResourceGroup[]
onResourceSelect: (resource: MothershipResource) => void
onClose: () => void
textareaRef: React.RefObject<HTMLTextAreaElement | null>
pendingCursorRef: React.MutableRefObject<number | null>
/** When in mention mode the dropdown hides its search input and uses this query for filtering. */
mentionQuery?: string
}
export const PlusMenuDropdown = React.memo(
React.forwardRef<PlusMenuHandle, PlusMenuDropdownProps>(function PlusMenuDropdown(
{ availableResources, onResourceSelect, onClose, textareaRef, pendingCursorRef, mentionQuery },
ref
) {
const [open, setOpen] = useState(false)
const [isMention, setIsMention] = useState(false)
const [search, setSearch] = useState('')
const [anchorPos, setAnchorPos] = useState<{ left: number; top: number } | null>(null)
const [activeIndex, setActiveIndex] = useState(0)
const searchRef = useRef<HTMLInputElement>(null)
const contentRef = useRef<HTMLDivElement>(null)
const doOpen = useCallback(
(anchor: { left: number; top: number }, options?: { mention?: boolean }) => {
setAnchorPos(anchor)
setIsMention(!!options?.mention)
setOpen(true)
setSearch('')
setActiveIndex(0)
},
[]
)
const doClose = useCallback(() => {
setOpen(false)
}, [])
// The `+` browse menu hides mention-only resource types; `@`-mention mode
// exposes the full catalog so integrations remain searchable inline.
const visibleResources = useMemo(
() =>
isMention
? availableResources
: availableResources.filter(({ type }) => !MENTION_ONLY_RESOURCE_TYPES.has(type)),
[isMention, availableResources]
)
const workflowTree = useMemo(() => {
const workflowGroup = visibleResources.find((g) => g.type === 'workflow')
const folderGroup = visibleResources.find((g) => g.type === 'folder')
return buildWorkflowFolderTree(workflowGroup?.items ?? [], folderGroup?.items ?? [])
}, [visibleResources])
const fileFolderTree = useMemo(() => {
const fileGroup = visibleResources.find((g) => g.type === 'file')
const fileFolderGroup = visibleResources.find((g) => g.type === 'filefolder')
return buildFileFolderTree(fileGroup?.items ?? [], fileFolderGroup?.items ?? [])
}, [visibleResources])
const filteredItems = useMemo(() => {
const rawQuery = isMention ? (mentionQuery ?? '') : search
const q = rawQuery.toLowerCase().trim()
// In mention mode always render a flat filtered list — empty query = show everything.
if (!isMention && !q) return null
if (isMention && !q) {
return visibleResources.flatMap(({ type, items }) => items.map((item) => ({ type, item })))
}
return visibleResources.flatMap(({ type, items }) =>
items.filter((item) => item.name.toLowerCase().includes(q)).map((item) => ({ type, item }))
)
}, [isMention, mentionQuery, search, visibleResources])
const filteredItemsRef = useRef(filteredItems)
filteredItemsRef.current = filteredItems
const activeIndexRef = useRef(activeIndex)
activeIndexRef.current = activeIndex
const isMentionRef = useRef(isMention)
isMentionRef.current = isMention
// Reset highlight to the top whenever the mention query changes so the user always
// sees the best match selected as they type.
useEffect(() => {
if (isMention) setActiveIndex(0)
}, [isMention, mentionQuery])
const handleSelect = (resource: MothershipResource) => {
onResourceSelect(resource)
setOpen(false)
setSearch('')
setActiveIndex(0)
}
const handleSelectRef = useRef(handleSelect)
handleSelectRef.current = handleSelect
React.useImperativeHandle(
ref,
() => ({
open: doOpen,
close: doClose,
moveActive: (delta: number) => {
const items = filteredItemsRef.current
if (!items || items.length === 0) return
setActiveIndex((i) => {
const next = i + delta
if (next < 0) return items.length - 1
if (next >= items.length) return 0
return next
})
},
selectActive: () => {
const items = filteredItemsRef.current
if (!items || items.length === 0) return false
const target = items[activeIndexRef.current] ?? items[0]
if (!target) return false
handleSelectRef.current({
type: target.type,
id: target.item.id,
title: target.item.name,
})
return true
},
}),
[doOpen, doClose]
)
// Sync DOM scroll to the keyboard-highlighted filtered row.
useEffect(() => {
if (!filteredItems || filteredItems.length === 0) return
const row = contentRef.current?.querySelector<HTMLElement>(
`[data-filtered-idx="${activeIndex}"]`
)
row?.scrollIntoView({ block: 'nearest' })
}, [activeIndex, filteredItems])
const getVisibleMenuItems = (): HTMLElement[] =>
Array.from(
contentRef.current?.querySelectorAll<HTMLElement>('[role="menuitem"]') ?? []
).filter((el) => el.offsetParent !== null)
const handleSearchKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (!filteredItems) {
if (e.key === 'ArrowDown') {
e.preventDefault()
getVisibleMenuItems()[0]?.focus()
}
return
}
if (filteredItems.length === 0) return
if (e.key === 'ArrowDown') {
e.preventDefault()
setActiveIndex((i) => Math.min(i + 1, filteredItems.length - 1))
} else if (e.key === 'ArrowUp') {
e.preventDefault()
setActiveIndex((i) => Math.max(i - 1, 0))
} else if (e.key === 'Enter' || (e.key === 'Tab' && !e.shiftKey)) {
e.preventDefault()
const target = filteredItems[activeIndex] ?? filteredItems[0]
if (target) handleSelect({ type: target.type, id: target.item.id, title: target.item.name })
}
}
const handleContentKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
if (e.key === 'ArrowUp') {
const items = getVisibleMenuItems()
if (items[0] && items[0] === document.activeElement) {
e.preventDefault()
searchRef.current?.focus()
}
} else if (e.key === 'Tab') {
const focused = document.activeElement as HTMLElement | null
if (focused?.getAttribute('role') === 'menuitem') {
e.preventDefault()
focused.click()
}
}
}
const handleOpenChange = (isOpen: boolean) => {
setOpen(isOpen)
if (!isOpen) {
setSearch('')
setAnchorPos(null)
setActiveIndex(0)
onClose()
}
}
const handleCloseAutoFocus = (e: Event) => {
e.preventDefault()
const textarea = textareaRef.current
if (!textarea) return
if (pendingCursorRef.current !== null) {
textarea.setSelectionRange(pendingCursorRef.current, pendingCursorRef.current)
pendingCursorRef.current = null
}
textarea.focus()
}
// Radix's FocusScope normally focuses the content on open and traps focus inside.
// Preventing the mount auto-focus keeps the textarea focused AND, because the focus
// trap activates on focusin, the trap stays dormant — typing continues uninterrupted.
const handleOpenAutoFocus = (e: Event) => {
if (isMentionRef.current) e.preventDefault()
}
return (
<DropdownMenu open={open} onOpenChange={handleOpenChange}>
<DropdownMenuTrigger asChild>
<div
style={{
position: 'fixed',
left: anchorPos?.left ?? 0,
top: anchorPos?.top ?? 0,
width: 0,
height: 0,
pointerEvents: 'none',
}}
/>
</DropdownMenuTrigger>
<DropdownMenuContent
ref={contentRef}
align='start'
side='top'
sideOffset={8}
avoidCollisions
collisionPadding={8}
className={cn(
'flex flex-col overflow-hidden',
// Plus-click shows short fixed labels (Workflows, Tables, …) — let it size
// to its content via the emcn DropdownMenuContent default max-w.
// Mention mode renders resource names directly, so widen for breathing room.
isMention && 'max-w-[min(300px,calc(100vw-32px))]'
)}
onCloseAutoFocus={handleCloseAutoFocus}
onOpenAutoFocus={handleOpenAutoFocus}
onKeyDown={handleContentKeyDown}
>
{!isMention && (
<DropdownMenuSearchInput
ref={searchRef}
placeholder='Search resources...'
value={search}
onChange={(e) => {
setSearch(e.target.value)
setActiveIndex(0)
}}
onKeyDown={handleSearchKeyDown}
/>
)}
<div className='min-h-0 flex-1 overflow-y-auto overscroll-none'>
{/* Always-mounted; swapping this subtree with filtered results makes Radix's
menu FocusScope steal focus from the search input back to the content root. */}
<div hidden={filteredItems !== null}>
{workflowTree.length > 0 && (
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<Workflow className='size-[14px]' />
<span>Workflows</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className='max-w-[min(300px,calc(100vw-32px))]'>
<WorkflowFolderTreeItems nodes={workflowTree} onSelect={handleSelect} />
</DropdownMenuSubContent>
</DropdownMenuSub>
)}
{fileFolderTree.length > 0 && (
<DropdownMenuSub>
<DropdownMenuSubTrigger>
{(() => {
const Icon = getResourceConfig('file').icon
return <Icon className='size-[14px]' />
})()}
<span>Files</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className='max-w-[min(300px,calc(100vw-32px))]'>
<FileFolderTreeItems nodes={fileFolderTree} onSelect={handleSelect} />
</DropdownMenuSubContent>
</DropdownMenuSub>
)}
{visibleResources
.filter(
({ type }) =>
type !== 'workflow' &&
type !== 'folder' &&
type !== 'file' &&
type !== 'filefolder'
)
.map(({ type, items }) => {
if (items.length === 0) return null
const config = getResourceConfig(type)
const Icon = config.icon
return (
<DropdownMenuSub key={type}>
<DropdownMenuSubTrigger>
<Icon className='size-[14px]' />
<span>{config.label}</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className='max-w-[min(300px,calc(100vw-32px))]'>
{items.map((item) => (
<DropdownMenuItem
key={item.id}
onClick={() => {
handleSelect({ type, id: item.id, title: item.name })
}}
>
{config.renderDropdownItem({ item })}
</DropdownMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
)
})}
</div>
{/* Plain buttons, not DropdownMenuItem: mount/unmount must not mutate Radix's
menu Collection, or FocusScope restores focus to the content root. */}
{filteredItems !== null &&
(filteredItems.length > 0 ? (
filteredItems.map(({ type, item }, index) => {
const config = getResourceConfig(type)
const isActive = index === activeIndex
return (
<button
key={`${type}:${item.id}`}
type='button'
role='menuitem'
data-filtered-idx={index}
onMouseEnter={() => setActiveIndex(index)}
onClick={() => {
handleSelect({ type, id: item.id, title: item.name })
}}
className={cn(
'relative flex w-full min-w-0 cursor-pointer select-none items-center gap-2 rounded-[5px] px-2 py-1.5 text-left font-medium text-[var(--text-body)] text-caption outline-none transition-colors [&>span]:min-w-0 [&>span]:truncate [&_svg]:pointer-events-none [&_svg]:size-[14px] [&_svg]:shrink-0 [&_svg]:text-[var(--text-icon)]',
isActive && 'bg-[var(--surface-active)]'
)}
>
{config.renderDropdownItem({ item })}
</button>
)
})
) : (
<div className='px-2 py-1.5 text-center font-medium text-[var(--text-tertiary)] text-caption'>
No results
</div>
))}
</div>
</DropdownMenuContent>
</DropdownMenu>
)
})
)
@@ -0,0 +1,7 @@
export { PromptEditor, type PromptEditorProps } from './prompt-editor'
export {
type PromptEditorInstance,
type PromptEditorKeyPolicy,
type UsePromptEditorProps,
usePromptEditor,
} from './use-prompt-editor'
@@ -0,0 +1,227 @@
'use client'
import { useCallback, useEffect, useLayoutEffect, useMemo } from 'react'
import { cn } from '@sim/emcn'
import { ContextMentionIcon } from '@/app/workspace/[workspaceId]/home/components/context-mention-icon'
import {
OVERLAY_CLASSES,
SCROLLER_CLASSES,
TEXTAREA_BASE_CLASSES,
} from '@/app/workspace/[workspaceId]/home/components/user-input/components/constants'
import { PlusMenuDropdown } from '@/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown/plus-menu-dropdown'
import type {
PromptEditorInstance,
PromptEditorKeyPolicy,
} from '@/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/use-prompt-editor'
import { SkillsMenuDropdown } from '@/app/workspace/[workspaceId]/home/components/user-input/components/skills-menu-dropdown/skills-menu-dropdown'
import {
computeMentionHighlightRanges,
extractContextTokens,
stripMentionTrigger,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/utils'
export interface PromptEditorProps extends PromptEditorKeyPolicy {
/** Editor instance from {@link usePromptEditor}. */
editor: PromptEditorInstance
/** Placeholder shown while the editor is empty. */
placeholder?: string
/** Focuses the editor (caret at end) on mount. */
autoFocus?: boolean
/**
* Renders the editor as a non-editable display surface: the textarea becomes
* `readOnly` (so the chip overlay still paints `@`-mention / `/`-skill chips
* and the text stays selectable/copyable) and the caret-anchored resource and
* skill menus are not mounted. Use for read-only records — e.g. a finished
* scheduled task — where the prompt should render with chips but not be edited.
*/
readOnly?: boolean
/**
* Layout/sizing only — a height cap (`max-h-[200px]`) or fill (`flex-1`)
* for the scroll container. The text chrome is owned by the editor.
*/
className?: string
/** Accessible label for the textarea. */
'aria-label'?: string
}
/**
* The rendered face of {@link usePromptEditor}: a transparent-text textarea
* under a mirror overlay that paints mention chips (icon + label) in place of
* their tokens, plus the caret-anchored `@`-resource and `/`-skill menus. The
* textarea grows to its content height inside a single scroller, so overlay
* and caret co-scroll natively and never drift.
*
* Everything intrinsic to editing lives here; host-specific keys (Enter
* submit, ArrowUp history) are threaded in via {@link PromptEditorKeyPolicy}.
*
* @example
* ```tsx
* const editor = usePromptEditor({ workspaceId })
* <PromptEditor editor={editor} placeholder='Describe the task...' autoFocus onSubmit={save} />
* ```
*/
export function PromptEditor({
editor,
placeholder,
autoFocus = false,
readOnly = false,
className,
'aria-label': ariaLabel,
onSubmit,
onArrowUpOnEmpty,
}: PromptEditorProps) {
const { textareaRef, value } = editor
useLayoutEffect(() => {
const textarea = textareaRef.current
if (!textarea) return
// Grow the textarea to its full content height; the scroller caps the
// visible height and scrolls textarea + overlay together natively.
textarea.style.height = 'auto'
textarea.style.height = `${textarea.scrollHeight}px`
}, [value, textareaRef])
useEffect(() => {
if (autoFocus && !readOnly) editor.focusAtEnd()
// eslint-disable-next-line react-hooks/exhaustive-deps -- mount-only focus
}, [])
/**
* Clicking the editor's empty regions (padding, space below the last line)
* focuses the textarea; clicks on the textarea itself keep native caret
* placement. No-op in read-only mode: the surface is display-only, so a
* padding click should not pull focus onto the non-editable textarea.
*/
const handleSurfaceClick = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
if (readOnly) return
if (e.target === textareaRef.current) return
if ((e.target as HTMLElement).closest('button')) return
textareaRef.current?.focus()
},
[readOnly, textareaRef]
)
const overlayContent = useMemo(() => {
const contexts = editor.contexts
if (!value) {
return <span>{'\u00A0'}</span>
}
if (contexts.length === 0) {
const displayText = value.endsWith('\n') ? `${value}\u200B` : value
return <span>{displayText}</span>
}
const tokens = extractContextTokens(contexts)
const ranges = computeMentionHighlightRanges(value, tokens)
if (ranges.length === 0) {
const displayText = value.endsWith('\n') ? `${value}\u200B` : value
return <span>{displayText}</span>
}
const contextByLabel = new Map<string, (typeof contexts)[number]>()
for (const c of contexts) {
if (!contextByLabel.has(c.label)) contextByLabel.set(c.label, c)
}
const elements: React.ReactNode[] = []
let lastIndex = 0
for (let i = 0; i < ranges.length; i++) {
const range = ranges[i]
if (range.start > lastIndex) {
const before = value.slice(lastIndex, range.start)
elements.push(<span key={`text-${i}-${lastIndex}-${range.start}`}>{before}</span>)
}
const mentionLabel = stripMentionTrigger(range.token)
const matchingCtx = contextByLabel.get(mentionLabel)
const mentionIconNode = matchingCtx ? (
<ContextMentionIcon
context={matchingCtx}
className='absolute inset-0 m-auto size-[12px] translate-y-[1.25px] text-[var(--text-icon)]'
/>
) : null
elements.push(
<span key={`mention-${i}-${range.start}-${range.end}`}>
<span className='relative'>
{/* Invisible trigger glyph keeps the overlay's advance identical to
the transparent textarea; the icon centers over its slot. */}
<span className='invisible'>{range.token.charAt(0)}</span>
{mentionIconNode}
</span>
{mentionLabel}
</span>
)
lastIndex = range.end
}
const tail = value.slice(lastIndex)
if (tail) {
const displayTail = tail.endsWith('\n') ? `${tail}\u200B` : tail
elements.push(<span key={`tail-${lastIndex}`}>{displayTail}</span>)
}
return elements.length > 0 ? elements : <span>{'\u00A0'}</span>
}, [value, editor.contexts])
return (
<div className={cn(SCROLLER_CLASSES, 'cursor-text', className)} onClick={handleSurfaceClick}>
{/* Sizer for textarea + overlay: the textarea grows to full content
height and the overlay fills it via `inset-0`, so both are flow
children of the same scroller and co-scroll natively. */}
<div className='relative'>
<div className={OVERLAY_CLASSES} aria-hidden='true'>
{overlayContent}
</div>
<textarea
ref={textareaRef}
value={value}
readOnly={readOnly}
onChange={readOnly ? undefined : editor.handleInputChange}
onKeyDown={
readOnly ? undefined : (e) => editor.handleKeyDown(e, { onSubmit, onArrowUpOnEmpty })
}
onPaste={readOnly ? undefined : editor.handlePaste}
onCopy={editor.handleCopy}
onCut={readOnly ? undefined : editor.handleCut}
onSelect={readOnly ? undefined : editor.handleSelectAdjust}
onMouseUp={readOnly ? undefined : editor.handleSelectAdjust}
placeholder={placeholder}
aria-label={ariaLabel}
rows={1}
className={cn(TEXTAREA_BASE_CLASSES, readOnly && 'cursor-default caret-transparent')}
/>
</div>
{!readOnly && (
<>
<PlusMenuDropdown
ref={editor.plusMenuRef}
availableResources={editor.availableResources}
onResourceSelect={editor.insertResource}
onClose={editor.handlePlusMenuClose}
textareaRef={editor.textareaRef}
pendingCursorRef={editor.pendingCursorRef}
mentionQuery={editor.mentionQuery ?? undefined}
/>
<SkillsMenuDropdown
ref={editor.skillsMenuRef}
skills={editor.skills}
onSkillSelect={editor.handleSkillSelect}
onClose={editor.handleSkillsMenuClose}
textareaRef={editor.textareaRef}
pendingCursorRef={editor.pendingCursorRef}
slashQuery={editor.slashQuery ?? undefined}
/>
</>
)}
</div>
)
}
@@ -0,0 +1,248 @@
/**
* @vitest-environment jsdom
*/
import { act, type ReactNode } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('@/hooks/queries/skills', () => ({ useSkills: () => ({ data: [] }) }))
vi.mock('@/hooks/queries/workflows', () => ({ useWorkflows: () => ({ data: [] }) }))
vi.mock('@/hooks/queries/tables', () => ({ useTablesList: () => ({ data: [] }) }))
vi.mock('@/hooks/queries/workspace-files', () => ({ useWorkspaceFiles: () => ({ data: [] }) }))
vi.mock('@/hooks/queries/kb/knowledge', () => ({ useKnowledgeBasesQuery: () => ({ data: [] }) }))
vi.mock('@/hooks/queries/folders', () => ({ useFolders: () => ({ data: [] }) }))
vi.mock('@/hooks/queries/workspace-file-folders', () => ({
useWorkspaceFileFolders: () => ({ data: [] }),
}))
vi.mock('@/hooks/queries/mothership-chats', () => ({ useMothershipChats: () => ({ data: [] }) }))
vi.mock('@/hooks/queries/schedules', () => ({ useWorkspaceSchedules: () => ({ data: [] }) }))
vi.mock('@/hooks/queries/logs', () => ({ useLogsList: () => ({ data: undefined }) }))
vi.mock('@/blocks/integration-matcher', () => ({
getIntegrationMatcher: () => ({ regex: null, byName: new Map() }),
listIntegrations: () => [],
}))
import type { PlusMenuHandle } from '@/app/workspace/[workspaceId]/home/components/user-input/components/constants'
import {
type UsePromptEditorProps,
usePromptEditor,
} from '@/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/use-prompt-editor'
import type { SkillsMenuHandle } from '@/app/workspace/[workspaceId]/home/components/user-input/components/skills-menu-dropdown/skills-menu-dropdown'
/**
* Mounts `usePromptEditor` in a real React 19 root under jsdom (no
* `@testing-library/react` in this repo — see `hooks/queries/unsubscribe.test.tsx`
* for the established pattern) and wires a real `<textarea>` into its
* `textareaRef` so selection/caret-driven behavior (`handleSelectAdjust`,
* `syncMentionState`) runs exactly as it does in the rendered `PromptEditor`.
*/
function renderPromptEditor(props: UsePromptEditorProps) {
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
const container = document.createElement('div')
document.body.appendChild(container)
const root: Root = createRoot(container)
let latest: ReturnType<typeof usePromptEditor>
function Probe() {
latest = usePromptEditor(props)
return null
}
function Wrapper({ children }: { children: ReactNode }) {
return <>{children}</>
}
act(() => {
root.render(
<Wrapper>
<Probe />
</Wrapper>
)
})
const textarea = document.createElement('textarea')
document.body.appendChild(textarea)
latest!.textareaRef.current = textarea
return {
result: () => latest,
textarea,
unmount: () => {
act(() => root.unmount())
container.remove()
textarea.remove()
},
}
}
/** Fires a native `input` event carrying the new value, as the textarea would on a keystroke. */
function typeInto(textarea: HTMLTextAreaElement, value: string, caret = value.length) {
textarea.value = value
textarea.setSelectionRange(caret, caret)
textarea.dispatchEvent(new Event('input', { bubbles: true }))
}
describe('usePromptEditor mention menu dismissal', () => {
let openMenu: PlusMenuHandle
beforeEach(() => {
openMenu = {
open: vi.fn(),
close: vi.fn(),
moveActive: vi.fn(),
selectActive: vi.fn(() => false),
}
})
afterEach(() => {
vi.clearAllMocks()
})
it('reopens the menu while the user keeps typing an unmatched mention', () => {
const { result, textarea, unmount } = renderPromptEditor({ workspaceId: 'ws-1' })
result().plusMenuRef.current = openMenu
act(() => {
typeInto(textarea, '@f')
result().handleInputChange({
target: textarea,
currentTarget: textarea,
} as unknown as React.ChangeEvent<HTMLTextAreaElement>)
})
expect(result().mentionQuery).toBe('f')
expect(openMenu.open).toHaveBeenCalledTimes(1)
unmount()
})
it('stays closed across repeated clicks at the same position after the user clicks away, even if the caret lands back inside the open mention', () => {
const { result, textarea, unmount } = renderPromptEditor({ workspaceId: 'ws-1' })
result().plusMenuRef.current = openMenu
act(() => {
typeInto(textarea, '@f')
result().handleInputChange({
target: textarea,
currentTarget: textarea,
} as unknown as React.ChangeEvent<HTMLTextAreaElement>)
})
expect(result().mentionQuery).toBe('f')
act(() => {
result().handlePlusMenuClose()
})
expect(result().mentionQuery).toBeNull()
for (let i = 0; i < 3; i++) {
act(() => {
textarea.setSelectionRange(2, 2)
result().handleSelectAdjust()
})
}
expect(result().mentionQuery).toBeNull()
expect(openMenu.open).toHaveBeenCalledTimes(1)
unmount()
})
it('lets a further keystroke reopen the same mention after a dismiss', () => {
const { result, textarea, unmount } = renderPromptEditor({ workspaceId: 'ws-1' })
result().plusMenuRef.current = openMenu
act(() => {
typeInto(textarea, '@f')
result().handleInputChange({
target: textarea,
currentTarget: textarea,
} as unknown as React.ChangeEvent<HTMLTextAreaElement>)
})
act(() => {
result().handlePlusMenuClose()
})
act(() => {
textarea.setSelectionRange(2, 2)
result().handleSelectAdjust()
})
expect(openMenu.open).toHaveBeenCalledTimes(1)
act(() => {
typeInto(textarea, '@fo', 3)
result().handleInputChange({
target: textarea,
currentTarget: textarea,
} as unknown as React.ChangeEvent<HTMLTextAreaElement>)
})
expect(result().mentionQuery).toBe('fo')
expect(openMenu.open).toHaveBeenCalledTimes(2)
unmount()
})
it('does not suppress a different mention typed after a dismiss', () => {
const { result, textarea, unmount } = renderPromptEditor({ workspaceId: 'ws-1' })
result().plusMenuRef.current = openMenu
act(() => {
typeInto(textarea, '@f')
result().handleInputChange({
target: textarea,
currentTarget: textarea,
} as unknown as React.ChangeEvent<HTMLTextAreaElement>)
})
act(() => {
result().handlePlusMenuClose()
})
act(() => {
textarea.setSelectionRange(2, 2)
result().handleSelectAdjust()
})
expect(openMenu.open).toHaveBeenCalledTimes(1)
act(() => {
typeInto(textarea, '@f done. @g', 11)
result().handleInputChange({
target: textarea,
currentTarget: textarea,
} as unknown as React.ChangeEvent<HTMLTextAreaElement>)
})
expect(result().mentionQuery).toBe('g')
expect(openMenu.open).toHaveBeenCalledTimes(2)
unmount()
})
})
describe('usePromptEditor toolbar slash trigger after a dismiss', () => {
it('still opens the skills menu when the caret sits at the start of the previously dismissed token', () => {
const skillsMenu: SkillsMenuHandle = {
open: vi.fn(),
close: vi.fn(),
moveActive: vi.fn(),
selectActive: vi.fn(() => false),
}
const { result, textarea, unmount } = renderPromptEditor({ workspaceId: 'ws-1' })
result().skillsMenuRef.current = skillsMenu
act(() => {
result().insertSlashTrigger()
})
expect(skillsMenu.open).toHaveBeenCalledTimes(1)
act(() => {
result().handleSkillsMenuClose()
})
act(() => {
textarea.setSelectionRange(0, 0)
result().insertSlashTrigger()
})
expect(skillsMenu.open).toHaveBeenCalledTimes(2)
unmount()
})
})
@@ -0,0 +1 @@
export { SendButton } from './send-button'
@@ -0,0 +1,52 @@
'use client'
import React from 'react'
import { ArrowUp, Button, cn } from '@sim/emcn'
import {
SEND_BUTTON_ACTIVE,
SEND_BUTTON_BASE,
SEND_BUTTON_DISABLED,
} from '@/app/workspace/[workspaceId]/home/components/user-input/components/constants'
interface SendButtonProps {
isSending: boolean
canSubmit: boolean
onSubmit: () => void
onStopGeneration: () => void
}
export const SendButton = React.memo(function SendButton({
isSending,
canSubmit,
onSubmit,
onStopGeneration,
}: SendButtonProps) {
if (isSending) {
return (
<Button
onClick={onStopGeneration}
variant='ghost'
className={cn(SEND_BUTTON_BASE, SEND_BUTTON_ACTIVE)}
title='Stop generation'
>
<svg
className='block h-[14px] w-[14px] fill-white dark:fill-black'
viewBox='0 0 24 24'
xmlns='http://www.w3.org/2000/svg'
>
<rect x='4' y='4' width='16' height='16' rx='3' ry='3' />
</svg>
</Button>
)
}
return (
<Button
onClick={onSubmit}
variant='ghost'
disabled={!canSubmit}
className={cn(SEND_BUTTON_BASE, canSubmit ? SEND_BUTTON_ACTIVE : SEND_BUTTON_DISABLED)}
>
<ArrowUp className='block h-[16px] w-[16px] text-white dark:text-black' />
</Button>
)
})
@@ -0,0 +1,213 @@
'use client'
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { cn, DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from '@sim/emcn'
import { AgentSkillsIcon } from '@/components/icons'
import type { SkillDefinition } from '@/hooks/queries/skills'
/**
* Imperative handle for driving the skills menu from the host textarea's
* keyboard handler. Mirrors the shape of `PlusMenuHandle` but exposes only
* the operations the slash-trigger flow needs.
*/
export interface SkillsMenuHandle {
/** Opens the menu, optionally anchored at a caret position. */
open: (anchor?: { left: number; top: number }) => void
/** Closes the menu. */
close: () => void
/** Moves the active highlight by `delta` rows (wrapping). */
moveActive: (delta: number) => void
/** Selects the active row. Returns true when a skill was selected. */
selectActive: () => boolean
}
interface SkillsMenuDropdownProps {
/** Skills available in the current workspace. */
skills: SkillDefinition[]
/** Called when a skill row is chosen (click / keyboard). */
onSkillSelect: (skill: SkillDefinition) => void
/** Called when the menu closes so the host can reset slash state. */
onClose: () => void
/** Host textarea — focus is restored to it on close. */
textareaRef: React.RefObject<HTMLTextAreaElement | null>
/** Shared caret position restored after the dormant focus trap closes. */
pendingCursorRef: React.MutableRefObject<number | null>
/** Active `/`-query used to filter the list (case-insensitive substring). */
slashQuery?: string
}
/**
* Floating autocomplete list of workspace skills, anchored at the caret. It
* mirrors the anchored-trigger + dormant-focus-trap pattern of
* `PlusMenuDropdown` so the textarea keeps focus and typing continues
* uninterrupted while the user navigates skills with the keyboard.
*/
export const SkillsMenuDropdown = React.memo(
React.forwardRef<SkillsMenuHandle, SkillsMenuDropdownProps>(function SkillsMenuDropdown(
{ skills, onSkillSelect, onClose, textareaRef, pendingCursorRef, slashQuery },
ref
) {
const [open, setOpen] = useState(false)
const [anchorPos, setAnchorPos] = useState<{ left: number; top: number } | null>(null)
const [activeIndex, setActiveIndex] = useState(0)
const contentRef = useRef<HTMLDivElement>(null)
const filteredSkills = useMemo(() => {
const q = (slashQuery ?? '').toLowerCase().trim()
if (!q) return skills
return skills.filter((skill) => skill.name.toLowerCase().includes(q))
}, [skills, slashQuery])
const filteredSkillsRef = useRef(filteredSkills)
filteredSkillsRef.current = filteredSkills
const activeIndexRef = useRef(activeIndex)
activeIndexRef.current = activeIndex
const doOpen = useCallback((anchor?: { left: number; top: number }) => {
if (anchor) setAnchorPos(anchor)
setOpen(true)
setActiveIndex(0)
}, [])
const doClose = useCallback(() => {
setOpen(false)
}, [])
const handleSelect = useCallback(
(skill: SkillDefinition) => {
onSkillSelect(skill)
setOpen(false)
setActiveIndex(0)
},
[onSkillSelect]
)
const handleSelectRef = useRef(handleSelect)
handleSelectRef.current = handleSelect
React.useImperativeHandle(
ref,
() => ({
open: doOpen,
close: doClose,
moveActive: (delta: number) => {
const items = filteredSkillsRef.current
if (items.length === 0) return
setActiveIndex((i) => {
const next = i + delta
if (next < 0) return items.length - 1
if (next >= items.length) return 0
return next
})
},
selectActive: () => {
const items = filteredSkillsRef.current
if (items.length === 0) return false
const target = items[activeIndexRef.current] ?? items[0]
if (!target) return false
handleSelectRef.current(target)
return true
},
}),
[doOpen, doClose]
)
// Reset highlight to the top whenever the query changes so the best match
// is always selected as the user types.
useEffect(() => {
setActiveIndex(0)
}, [slashQuery])
// Sync DOM scroll to the keyboard-highlighted row.
useEffect(() => {
if (filteredSkills.length === 0) return
const row = contentRef.current?.querySelector<HTMLElement>(
`[data-filtered-idx="${activeIndex}"]`
)
row?.scrollIntoView({ block: 'nearest' })
}, [activeIndex, filteredSkills])
const handleOpenChange = (isOpen: boolean) => {
setOpen(isOpen)
if (!isOpen) {
setAnchorPos(null)
setActiveIndex(0)
onClose()
}
}
const handleCloseAutoFocus = (e: Event) => {
e.preventDefault()
const textarea = textareaRef.current
if (!textarea) return
if (pendingCursorRef.current !== null) {
textarea.setSelectionRange(pendingCursorRef.current, pendingCursorRef.current)
pendingCursorRef.current = null
}
textarea.focus()
}
// Preventing the mount auto-focus keeps the textarea focused and leaves the
// Radix focus trap dormant, so typing continues uninterrupted.
const handleOpenAutoFocus = (e: Event) => {
e.preventDefault()
}
return (
<DropdownMenu open={open} onOpenChange={handleOpenChange}>
<DropdownMenuTrigger asChild>
<div
style={{
position: 'fixed',
left: anchorPos?.left ?? 0,
top: anchorPos?.top ?? 0,
width: 0,
height: 0,
pointerEvents: 'none',
}}
/>
</DropdownMenuTrigger>
<DropdownMenuContent
ref={contentRef}
align='start'
side='top'
sideOffset={8}
avoidCollisions
collisionPadding={8}
className='flex max-w-[min(300px,calc(100vw-32px))] flex-col overflow-hidden'
onCloseAutoFocus={handleCloseAutoFocus}
onOpenAutoFocus={handleOpenAutoFocus}
>
<div className='min-h-0 flex-1 overflow-y-auto overscroll-none'>
{filteredSkills.length > 0 ? (
filteredSkills.map((skill, index) => {
const isActive = index === activeIndex
return (
<button
key={skill.id}
type='button'
role='menuitem'
data-filtered-idx={index}
onMouseEnter={() => setActiveIndex(index)}
onClick={() => handleSelect(skill)}
className={cn(
'relative flex w-full min-w-0 cursor-pointer select-none items-center gap-2 rounded-[5px] px-2 py-1.5 text-left font-medium text-[var(--text-body)] text-caption outline-none transition-colors [&>span]:min-w-0 [&>span]:truncate [&_svg]:pointer-events-none [&_svg]:size-[14px] [&_svg]:shrink-0 [&_svg]:text-[var(--text-icon)]',
isActive && 'bg-[var(--surface-active)]'
)}
>
<AgentSkillsIcon />
<span>{skill.name}</span>
</button>
)
})
) : (
<div className='px-2 py-1.5 text-center font-medium text-[var(--text-tertiary)] text-caption'>
No skills
</div>
)}
</div>
</DropdownMenuContent>
</DropdownMenu>
)
})
)
@@ -0,0 +1,188 @@
import { useCallback, useMemo, useRef } from 'react'
import {
escapeRegex,
SKILL_CHIP_TRIGGER,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/utils'
import type { SkillDefinition } from '@/hooks/queries/skills'
import type { ChatContext } from '@/stores/panel'
/**
* Characters that signal the user has completed a word. Used by the
* keystroke fast-path to detect that a typed skill name just ended. Mirrors
* the integration auto-mention boundary set so the two detectors behave
* symmetrically.
*/
const WORD_BOUNDARY_REGEX = /^[\s.,;:!?(){}[\]"'`/\\<>\n]$/
type SkillContext = Extract<ChatContext, { kind: 'skill' }>
/**
* A skill trigger — the typed `/` or the stored EM SPACE sentinel — only counts
* when it itself starts a token (position 0 or after whitespace). This prevents
* path-like patterns (`foo/bar`) from chipping as `/bar`, and lets a pasted or
* restored `<sentinel>name` token re-chip just like a freshly typed `/name`.
*/
function isTriggerPrefixAt(text: string, index: number): boolean {
const ch = text[index]
if (ch !== '/' && ch !== SKILL_CHIP_TRIGGER) return false
if (index === 0) return true
return /\s/.test(text[index - 1])
}
interface UseSkillAutoMentionProps {
/** Skills available in the current workspace. */
skills: SkillDefinition[]
/** Setter for the host's selected contexts. */
setSelectedContexts: React.Dispatch<React.SetStateAction<ChatContext[]>>
}
interface ProcessChangeArgs {
textarea: HTMLTextAreaElement
previousValue: string
nextValue: string
}
/**
* Auto-registers skill contexts when a typed `/skill-name` is completed.
*
* The user types `/skill-name`, but the displayed token stores an EM SPACE
* sentinel (`SKILL_CHIP_TRIGGER`) in place of the narrow `/` so the centered
* chip icon fits its overlay slot like `@` does. Both entry points accept
* either trigger — a freshly typed `/` or a pasted/restored sentinel — swap a
* typed `/` for the sentinel, and share one dedup-by-skillId helper:
* - `processChange`: keystroke fast-path. When a word-boundary char completes
* a `/name` token whose name matches a known skill, the leading `/` is
* swapped for the sentinel (caret preserved) and the skill context is
* registered.
* - `applyToText`: bulk path for paste, template insertion, draft restore, and
* speech-to-text. Swaps each matched typed `/` for the sentinel in the
* returned string and registers any matched skill contexts.
*/
export function useSkillAutoMention({ skills, setSelectedContexts }: UseSkillAutoMentionProps) {
/**
* Matcher built from skill names, longest-first so `/my-skill-extended`
* wins over `/my-skill`. The trailing guard rejects partial matches that
* continue into more name characters.
*/
const matcher = useMemo(() => {
const byName = new Map<string, SkillContext>()
for (const skill of skills) {
byName.set(skill.name.toLowerCase(), {
kind: 'skill',
skillId: skill.id,
label: skill.name,
})
}
const names = [...skills].map((s) => s.name).sort((a, b) => b.length - a.length)
if (names.length === 0) return { regex: null as RegExp | null, byName }
// Match either trigger: the typed '/' or the stored sentinel, so both fresh
// input and pasted/restored chips resolve. The trigger group is the match's
// first char (`text[match.index]`); group 1 is the skill name.
const trigger = `(?:/|${escapeRegex(SKILL_CHIP_TRIGGER)})`
const pattern = `${trigger}(${names.map(escapeRegex).join('|')})(?![A-Za-z0-9_-])`
return { regex: new RegExp(pattern, 'gi'), byName }
}, [skills])
const matcherRef = useRef(matcher)
matcherRef.current = matcher
const mergeContexts = useCallback(
(additions: SkillContext[]) => {
if (additions.length === 0) return
setSelectedContexts((prev) => {
const existing = new Set(
prev.filter((c): c is SkillContext => c.kind === 'skill').map((c) => c.skillId)
)
const fresh = additions.filter((c) => !existing.has(c.skillId))
return fresh.length > 0 ? [...prev, ...fresh] : prev
})
},
[setSelectedContexts]
)
const processChange = useCallback(
({ textarea, previousValue, nextValue }: ProcessChangeArgs): string => {
if (nextValue.length !== previousValue.length + 1) return nextValue
const { regex, byName } = matcherRef.current
if (!regex) return nextValue
let diffIndex = 0
while (
diffIndex < previousValue.length &&
previousValue[diffIndex] === nextValue[diffIndex]
) {
diffIndex++
}
const inserted = nextValue[diffIndex]
if (!inserted || !WORD_BOUNDARY_REGEX.test(inserted)) return nextValue
const before = nextValue.slice(0, diffIndex)
regex.lastIndex = 0
let completed: { start: number; name: string } | null = null
let match: RegExpExecArray | null
while ((match = regex.exec(before)) !== null) {
if (match.index + match[0].length === before.length) {
completed = { start: match.index, name: match[1] }
}
}
if (!completed) return nextValue
if (!isTriggerPrefixAt(nextValue, completed.start)) return nextValue
const context = byName.get(completed.name.toLowerCase())
if (!context) return nextValue
mergeContexts([context])
// A typed '/' becomes the wide sentinel so the centered chip icon fits its
// overlay slot; a sentinel that's already there (e.g. just-pasted) is left
// as-is. `setRangeText` with 'preserve' keeps the caret and folds into the
// keystroke's native undo step; the returned value mirrors the rewrite so
// the controlled state updates too.
if (nextValue[completed.start] !== '/') return nextValue
textarea.setRangeText(SKILL_CHIP_TRIGGER, completed.start, completed.start + 1, 'preserve')
return textarea.value
},
[matcherRef, mergeContexts]
)
const applyToText = useCallback(
(text: string): string => {
const { regex, byName } = matcherRef.current
if (!regex || !text) return text
regex.lastIndex = 0
const additions: SkillContext[] = []
const seen = new Set<string>()
const slashIndices: number[] = []
let match: RegExpExecArray | null
while ((match = regex.exec(text)) !== null) {
const index = match.index
if (!isTriggerPrefixAt(text, index)) continue
const context = byName.get(match[1].toLowerCase())
if (!context) continue
// Rewrite every confirmed typed '/' (even a repeated skill) so duplicate
// tokens chip consistently; tokens already on the sentinel need no edit.
if (text[index] === '/') slashIndices.push(index)
if (seen.has(context.skillId)) continue
seen.add(context.skillId)
additions.push(context)
}
mergeContexts(additions)
if (slashIndices.length === 0) return text
// Replace each matched '/' with the wide sentinel so paste, draft, and STT
// paths chip correctly. Splice by UTF-16 index (the regex's index space)
// descending so earlier replacements don't shift later indices; the
// sentinel is one code unit wide like '/'.
let result = text
for (const idx of [...slashIndices].sort((a, b) => b - a)) {
result = result.slice(0, idx) + SKILL_CHIP_TRIGGER + result.slice(idx + 1)
}
return result
},
[matcherRef, mergeContexts]
)
return { processChange, applyToText }
}
@@ -0,0 +1 @@
export { UserInput, type UserInputHandle } from './user-input'
@@ -0,0 +1,614 @@
'use client'
import type React from 'react'
import {
forwardRef,
memo,
useCallback,
useEffect,
useImperativeHandle,
useRef,
useState,
} from 'react'
import { Button, cn, Paperclip, Plus, Slash, Tooltip, toast } from '@sim/emcn'
import { createLogger } from '@sim/logger'
import { useParams } from 'next/navigation'
import { getMothershipAttachmentPreviewUrl } from '@/lib/copilot/chat/attachment-preview'
import { SIM_RESOURCE_DRAG_TYPE, SIM_RESOURCES_DRAG_TYPE } from '@/lib/copilot/resource-types'
import { CHAT_ACCEPT_ATTRIBUTE } from '@/lib/uploads/utils/validation'
import { useChatSurface } from '@/app/workspace/[workspaceId]/home/components/chat-surface-context'
import {
AnimatedPlaceholderEffect,
AttachedFilesList,
DropOverlay,
MicButton,
PromptEditor,
SendButton,
usePromptEditor,
} from '@/app/workspace/[workspaceId]/home/components/user-input/components'
import type {
FileAttachmentForApi,
MothershipResource,
QueuedMessage,
} from '@/app/workspace/[workspaceId]/home/types'
import { useFileAttachments } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks'
import type { AttachedFile } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-file-attachments'
import { mentionifyIntegrations } from '@/blocks/integration-matcher'
import { useSettingsNavigation } from '@/hooks/use-settings-navigation'
import { useSpeechToText } from '@/hooks/use-speech-to-text'
import { useMothershipDraftsStore } from '@/stores/mothership-drafts/store'
import type { ChatContext } from '@/stores/panel'
export type { FileAttachmentForApi } from '@/app/workspace/[workspaceId]/home/types'
const logger = createLogger('UserInput')
interface UserInputProps {
defaultValue?: string
draftScopeKey?: string
onSubmit: (
text: string,
fileAttachments?: FileAttachmentForApi[],
contexts?: ChatContext[]
) => void
isSending: boolean
onStopGeneration: () => void
isInitialView?: boolean
onSendQueuedHead?: () => void
onEditQueuedTail?: () => void
}
export interface UserInputHandle {
loadQueuedMessage: (msg: QueuedMessage) => void
/** Populates the textarea with a CURATED prompt (suggested action, template,
* etc. — never free-form user prose), running it through `mentionifyIntegrations`
* (bare `Slack` → `@Slack`) and then auto-mention chipification so integration
* names chip with brand icons. Focuses the input and places the caret at the
* end. Does NOT submit. Safe to call with the same text twice in a row. */
populatePrompt: (text: string) => void
}
/**
* The chat input: the {@link PromptEditor} editing core wrapped with the
* chat-specific chrome — file attachments (browse, drag-drop, paste), voice
* input, draft persistence, queued-message recall, and the send/stop button.
*/
const UserInputImpl = forwardRef<UserInputHandle, UserInputProps>(function UserInput(
{
defaultValue = '',
draftScopeKey,
onSubmit,
isSending,
onStopGeneration,
isInitialView = true,
onSendQueuedHead,
onEditQueuedTail,
},
ref
) {
const { workspaceId } = useParams<{ workspaceId: string }>()
const { navigateToSettings } = useSettingsNavigation()
const { userId, onContextAdd, onContextRemove } = useChatSurface()
const [initialValue] = useState(() => {
if (defaultValue) return defaultValue
if (!draftScopeKey) return ''
const text = useMothershipDraftsStore.getState().drafts[draftScopeKey]?.text
return typeof text === 'string' ? text : ''
})
const prevDefaultValueRef = useRef(defaultValue)
const files = useFileAttachments({
userId,
workspaceId,
disabled: false,
isLoading: isSending,
})
const hasFiles = files.attachedFiles.some((f) => !f.uploading && f.key)
const hasUploadingFiles = files.attachedFiles.some((f) => f.uploading)
const filesRef = useRef(files)
filesRef.current = files
const handlePasteFiles = useCallback((pasted: FileList) => {
filesRef.current.processFiles(pasted)
}, [])
const editor = usePromptEditor({
workspaceId,
initialValue,
onContextAdd,
onPasteFiles: handlePasteFiles,
})
const editorRef = useRef(editor)
editorRef.current = editor
const textareaRef = editor.textareaRef
const draftScopeKeyRef = useRef(draftScopeKey)
draftScopeKeyRef.current = draftScopeKey
const hasRestoredDraftRef = useRef(false)
useEffect(() => {
if (hasRestoredDraftRef.current || !draftScopeKey) return
hasRestoredDraftRef.current = true
let restoredContexts: ChatContext[] | null = null
let restoredFiles: AttachedFile[] | null = null
let caretText: string | null = null
try {
const draft = useMothershipDraftsStore.getState().drafts[draftScopeKey]
if (!draft) return
if (draft.contexts?.length) {
restoredContexts = draft.contexts
}
if (draft.fileAttachments?.length) {
restoredFiles = draft.fileAttachments.map((a) => ({
id: a.id,
name: a.filename,
size: a.size,
type: a.media_type,
path: a.path ?? '',
key: a.key,
uploading: false,
previewUrl: getMothershipAttachmentPreviewUrl(a),
}))
}
if (typeof draft.text === 'string' && draft.text.length > 0) {
caretText = draft.text
}
} catch (err) {
logger.error('Failed to read draft, clearing', { err })
useMothershipDraftsStore.getState().clearDraft(draftScopeKey)
return
}
if (restoredContexts) editor.setContexts(restoredContexts)
if (restoredFiles) files.restoreAttachedFiles(restoredFiles)
if (caretText !== null) {
const textarea = textareaRef.current
if (textarea) {
textarea.focus()
textarea.setSelectionRange(caretText.length, caretText.length)
}
}
}, []) // eslint-disable-line react-hooks/exhaustive-deps -- intentional mount-only restore
const isFirstSaveRef = useRef(true)
useEffect(() => {
if (isFirstSaveRef.current) {
isFirstSaveRef.current = false
return
}
if (!draftScopeKeyRef.current) return
const fileAttachments = files.attachedFiles
.filter((f) => !f.uploading && f.key)
.map((f) => ({
id: f.id,
key: f.key!,
filename: f.name,
media_type: f.type,
size: f.size,
...(f.path ? { path: f.path } : {}),
}))
useMothershipDraftsStore.getState().setDraft(draftScopeKeyRef.current, {
text: editor.value,
fileAttachments: fileAttachments.length > 0 ? fileAttachments : undefined,
contexts: editor.contexts.length > 0 ? editor.contexts : undefined,
})
}, [editor.value, files.attachedFiles, editor.contexts])
const onContextRemoveRef = useRef(onContextRemove)
onContextRemoveRef.current = onContextRemove
const prevSelectedContextsRef = useRef<ChatContext[]>([])
useEffect(() => {
const prev = prevSelectedContextsRef.current
const curr = editor.contexts
const contextId = (ctx: ChatContext): string => {
switch (ctx.kind) {
case 'workflow':
case 'current_workflow':
return `${ctx.kind}:${ctx.workflowId}`
case 'knowledge':
return `knowledge:${ctx.knowledgeId ?? ''}`
case 'table':
return `table:${ctx.tableId}`
case 'file':
return `file:${ctx.fileId}`
case 'folder':
return `folder:${ctx.folderId}`
case 'past_chat':
return `past_chat:${ctx.chatId}`
default:
return `${ctx.kind}:${ctx.label}`
}
}
const removed = prev.filter((p) => !curr.some((c) => contextId(c) === contextId(p)))
if (removed.length > 0) removed.forEach((ctx) => onContextRemoveRef.current?.(ctx))
prevSelectedContextsRef.current = curr
}, [editor.contexts])
const canSubmit = (editor.value.trim().length > 0 || hasFiles) && !isSending && !hasUploadingFiles
/**
* Sync the editor when the `defaultValue` prop changes post-mount — e.g.
* the user clicks a different template while UserInput is already mounted.
* `setValue` chipifies integration `@`-mentions consistently with the
* paste / draft restore flows.
*
* Deliberately does NOT run `mentionifyIntegrations` here: `defaultValue` is
* seeded from `LandingPromptStorage`, whose producers include the free-form
* landing prompt panel as well as curated CTAs. Curated producers opt their
* bare names in at the store seam (`storeCuratedPrompt`), so prose seeded here
* is never bare-chipped (the scunthorpe constraint).
*/
useEffect(() => {
if (defaultValue === prevDefaultValueRef.current) return
prevDefaultValueRef.current = defaultValue
if (defaultValue) editorRef.current.setValue(defaultValue)
}, [defaultValue])
const sttPrefixRef = useRef('')
function handleTranscript(text: string) {
const prefix = sttPrefixRef.current
const newVal = prefix ? `${prefix} ${text}` : text
editorRef.current.setValue(newVal)
}
function handleUsageLimitExceeded(message?: string, isMemberLimit?: boolean) {
// A per-member cap can only be raised by an org admin, so don't offer Upgrade
// (the member can't act on it) — the message already tells them to ask an admin.
toast.error(
message || 'You are out of credits.',
isMemberLimit
? undefined
: {
action: {
label: 'Upgrade',
onClick: () => navigateToSettings({ section: 'billing' }),
},
}
)
}
const {
isListening,
isSupported: isSttSupported,
toggleListening: rawToggle,
resetTranscript,
} = useSpeechToText({
onTranscript: handleTranscript,
onUsageLimitExceeded: handleUsageLimitExceeded,
workspaceId,
})
const toggleListening = useCallback(() => {
if (!isListening) {
sttPrefixRef.current = editorRef.current.getValue()
}
rawToggle()
}, [isListening, rawToggle])
const onSendQueuedHeadRef = useRef(onSendQueuedHead)
onSendQueuedHeadRef.current = onSendQueuedHead
const onEditQueuedTailRef = useRef(onEditQueuedTail)
onEditQueuedTailRef.current = onEditQueuedTail
const isSendingRef = useRef(isSending)
isSendingRef.current = isSending
const wasSendingRef = useRef(false)
useImperativeHandle(
ref,
() => ({
loadQueuedMessage: (msg: QueuedMessage) => {
const currentEditor = editorRef.current
currentEditor.setValue(msg.content)
const restored: AttachedFile[] = (msg.fileAttachments ?? []).map((a) => ({
id: a.id,
name: a.filename,
size: a.size,
type: a.media_type,
path: a.path ?? '',
key: a.key,
uploading: false,
previewUrl: getMothershipAttachmentPreviewUrl(a),
}))
filesRef.current.restoreAttachedFiles(restored)
currentEditor.setContexts(msg.contexts ?? [])
currentEditor.focusAtEnd()
},
populatePrompt: (text: string) => {
// `text` is a curated prompt, so opt its bare integration names into
// `@`-mention form before chipification (the auto-mention pipeline only
// chips already-`@`-prefixed names). Curated prompts arriving via the
// `defaultValue` seed are mentionified at their producer instead, since
// that path is also reused for free-form landing prose.
editorRef.current.setValue(mentionifyIntegrations(text))
editorRef.current.focusAtEnd()
},
}),
[]
)
const handleFileSelectStable = useCallback(() => {
filesRef.current.handleFileSelect()
}, [])
const handleFileClick = useCallback((file: AttachedFile) => {
filesRef.current.handleFileClick(file)
}, [])
const handleRemoveFile = useCallback((id: string) => {
filesRef.current.removeFile(id)
}, [])
const handleContainerDragOver = useCallback((e: React.DragEvent) => {
if (
e.dataTransfer.types.includes(SIM_RESOURCE_DRAG_TYPE) ||
e.dataTransfer.types.includes(SIM_RESOURCES_DRAG_TYPE)
) {
e.preventDefault()
e.stopPropagation()
e.dataTransfer.dropEffect = 'copy'
return
}
filesRef.current.handleDragOver(e)
}, [])
const handleContainerDrop = useCallback(
(e: React.DragEvent) => {
const resourcesJson = e.dataTransfer.getData(SIM_RESOURCES_DRAG_TYPE)
if (resourcesJson) {
e.preventDefault()
e.stopPropagation()
try {
const resources = JSON.parse(resourcesJson) as MothershipResource[]
editorRef.current.insertResources(resources)
} catch {}
textareaRef.current?.focus()
return
}
const resourceJson = e.dataTransfer.getData(SIM_RESOURCE_DRAG_TYPE)
if (resourceJson) {
e.preventDefault()
e.stopPropagation()
try {
const resource = JSON.parse(resourceJson) as MothershipResource
editorRef.current.insertResources([resource])
} catch {}
textareaRef.current?.focus()
return
}
filesRef.current.handleDrop(e)
requestAnimationFrame(() => textareaRef.current?.focus())
},
[textareaRef]
)
const handleDragEnter = useCallback((e: React.DragEvent) => {
const isResourceDrag =
e.dataTransfer.types.includes(SIM_RESOURCE_DRAG_TYPE) ||
e.dataTransfer.types.includes(SIM_RESOURCES_DRAG_TYPE)
if (!isResourceDrag) filesRef.current.handleDragEnter(e)
}, [])
const handleDragLeave = useCallback((e: React.DragEvent) => {
const isResourceDrag =
e.dataTransfer.types.includes(SIM_RESOURCE_DRAG_TYPE) ||
e.dataTransfer.types.includes(SIM_RESOURCES_DRAG_TYPE)
if (!isResourceDrag) filesRef.current.handleDragLeave(e)
}, [])
const handleFileChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
filesRef.current.handleFileChange(e)
}, [])
useEffect(() => {
if (wasSendingRef.current && !isSending) {
const active = document.activeElement
const isEditingElsewhere =
active instanceof HTMLTextAreaElement || active instanceof HTMLInputElement
if (!isEditingElsewhere) {
textareaRef.current?.focus()
}
}
wasSendingRef.current = isSending
}, [isSending, textareaRef])
useEffect(() => {
const raf = window.requestAnimationFrame(() => {
const active = document.activeElement
const isEditingElsewhere =
active instanceof HTMLTextAreaElement || active instanceof HTMLInputElement
if (!isEditingElsewhere) {
textareaRef.current?.focus()
}
})
return () => window.cancelAnimationFrame(raf)
}, [textareaRef])
const handleContainerClick = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
if ((e.target as HTMLElement).closest('button')) return
textareaRef.current?.focus()
},
[textareaRef]
)
const handleSubmit = useCallback(() => {
const currentFiles = filesRef.current
const currentEditor = editorRef.current
const fileAttachmentsForApi: FileAttachmentForApi[] = currentFiles.attachedFiles
.filter((f) => !f.uploading && f.key)
.map((f) => ({
id: f.id,
key: f.key!,
filename: f.name,
media_type: f.type,
size: f.size,
...(f.path ? { path: f.path } : {}),
}))
// getPlainValue restores skill chips' EM SPACE sentinel to a literal '/'
// so the message reads as clean `/skill-name` (skills travel via contexts
// regardless). Only the submitted copy is converted; the live input is not.
onSubmit(
currentEditor.getPlainValue(),
fileAttachmentsForApi.length > 0 ? fileAttachmentsForApi : undefined,
currentEditor.contexts.length > 0 ? currentEditor.contexts : undefined
)
currentEditor.clear()
sttPrefixRef.current = ''
if (draftScopeKeyRef.current) {
useMothershipDraftsStore.getState().clearDraft(draftScopeKeyRef.current)
}
resetTranscript()
currentFiles.clearAttachedFiles()
prevSelectedContextsRef.current = []
}, [onSubmit, resetTranscript])
/**
* Enter policy for the editor: mirror canSubmit's uploading guard (Enter
* reads refs, not rendered state), queue the head message when Enter lands
* on an empty input mid-stream, and otherwise submit.
*/
const handleEnterSubmit = useCallback(() => {
if (filesRef.current.attachedFiles.some((f) => f.uploading)) return
const hasSubmitPayload =
editorRef.current.getValue().trim().length > 0 ||
filesRef.current.attachedFiles.some((file) => !file.uploading && file.key)
if (!hasSubmitPayload) {
if (isSendingRef.current) {
onSendQueuedHeadRef.current?.()
}
return
}
handleSubmit()
}, [handleSubmit])
/**
* ArrowUp-on-empty policy: recall the queued tail message for editing. Only
* claims the key when there are no attachments and a queue handler exists.
*/
const handleArrowUpOnEmpty = useCallback((): boolean => {
if (filesRef.current.attachedFiles.length > 0) return false
const onEditQueuedTail = onEditQueuedTailRef.current
if (!onEditQueuedTail) return false
onEditQueuedTail()
return true
}, [])
const handlePlusClick = useCallback((e: React.MouseEvent<HTMLButtonElement>) => {
const rect = e.currentTarget.getBoundingClientRect()
editorRef.current.openResourceMenu({ left: rect.left, top: rect.top })
}, [])
const handleSlashTriggerClick = useCallback(() => {
editorRef.current.insertSlashTrigger()
}, [])
return (
<div
onClick={handleContainerClick}
className={cn(
'relative z-10 mx-auto w-full max-w-[48rem] cursor-text rounded-2xl border border-[var(--border-1)] bg-[var(--white)] px-2.5 py-2 dark:bg-[var(--surface-4)]',
isInitialView && 'shadow-sm'
)}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleContainerDragOver}
onDrop={handleContainerDrop}
>
<AnimatedPlaceholderEffect textareaRef={textareaRef} isInitialView={isInitialView} />
<AttachedFilesList
attachedFiles={files.attachedFiles}
onFileClick={handleFileClick}
onRemoveFile={handleRemoveFile}
/>
<PromptEditor
editor={editor}
placeholder='Ask Sim to '
onSubmit={handleEnterSubmit}
onArrowUpOnEmpty={handleArrowUpOnEmpty}
className={isInitialView ? 'max-h-[30vh]' : 'max-h-[200px]'}
/>
<div className='flex items-center justify-between'>
<div className='flex items-center gap-1'>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
type='button'
variant='ghost'
onClick={handlePlusClick}
aria-label='Add resources'
className='size-[28px] rounded-full p-0 hover-hover:bg-[var(--surface-hover)]'
>
<Plus className='size-[16px] text-[var(--text-icon)]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='top'>Add resources</Tooltip.Content>
</Tooltip.Root>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
type='button'
variant='ghost'
onClick={handleFileSelectStable}
aria-label='Attach file'
className='size-[28px] rounded-full p-0 hover-hover:bg-[var(--surface-hover)]'
>
<Paperclip className='size-[16px] text-[var(--text-icon)]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='top'>Attach file</Tooltip.Content>
</Tooltip.Root>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
type='button'
variant='ghost'
onClick={handleSlashTriggerClick}
aria-label='Skills'
className='size-[28px] rounded-full p-0 hover-hover:bg-[var(--surface-hover)]'
>
<Slash className='size-[16px] text-[var(--text-icon)]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='top'>Skills</Tooltip.Content>
</Tooltip.Root>
</div>
<div className='flex items-center gap-1.5'>
{isSttSupported && <MicButton isListening={isListening} onToggle={toggleListening} />}
<SendButton
isSending={isSending}
canSubmit={canSubmit}
onSubmit={handleSubmit}
onStopGeneration={onStopGeneration}
/>
</div>
</div>
<input
ref={files.fileInputRef}
type='file'
onChange={handleFileChange}
className='hidden'
accept={CHAT_ACCEPT_ATTRIBUTE}
multiple
/>
{files.isDragging && <DropOverlay />}
</div>
)
})
/**
* Memoized so streaming ticks in the parent transcript — which re-render
* {@link MothershipChat} on every chunk — do not re-render the entire input
* toolbar. Relies on callers passing stable callbacks (see `MothershipChat`).
*/
export const UserInput = memo(UserInputImpl)
@@ -0,0 +1 @@
export { UserMessageContent } from './user-message-content'
@@ -0,0 +1,171 @@
'use client'
import { useMemo } from 'react'
import { cn } from '@sim/emcn'
import { ContextMentionIcon } from '@/app/workspace/[workspaceId]/home/components/context-mention-icon'
import type { ChatMessageContext } from '@/app/workspace/[workspaceId]/home/types'
import { getIntegrationMatcher } from '@/blocks/integration-matcher'
const USER_MESSAGE_CLASSES =
'whitespace-pre-wrap break-words [overflow-wrap:anywhere] font-[430] font-[family-name:var(--font-inter)] text-base text-[var(--text-primary)] leading-[23px] tracking-[0] antialiased'
const COMPACT_CLASSES =
'truncate text-small leading-[20px] font-[430] font-[family-name:var(--font-inter)] text-[var(--text-primary)] tracking-[0] antialiased'
interface UserMessageContentProps {
content: string
contexts?: ChatMessageContext[]
className?: string
/** When true, render mentions as plain inline text (no icon/pill) so truncation flows naturally. */
plainMentions?: boolean
/** Use compact single-line layout with truncation. */
compact?: boolean
}
function escapeRegex(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
interface MentionRange {
start: number
end: number
context: ChatMessageContext
}
/**
* Backfills a renderable `blockType` onto integration contexts that are
* missing one (or carry one the registry no longer knows) by resolving the
* label through the integration matcher. Messages persisted before
* `blockType` was included in the save mapping would otherwise render a
* mention pill with no icon.
*/
function withResolvedBlockType(ctx: ChatMessageContext): ChatMessageContext {
if (ctx.kind !== 'integration' || !ctx.label) return ctx
const info = getIntegrationMatcher().byName.get(ctx.label.toLowerCase())
if (!info) return ctx
return { ...ctx, blockType: info.blockType }
}
function computeMentionRanges(text: string, contexts: ChatMessageContext[]): MentionRange[] {
const ranges: MentionRange[] = []
for (const rawCtx of contexts) {
if (!rawCtx.label) continue
const ctx = withResolvedBlockType(rawCtx)
const prefix = ctx.kind === 'skill' ? '/' : '@'
const token = `${prefix}${ctx.label}`
const pattern = new RegExp(`(^|\\s)(${escapeRegex(token)})(\\s|$)`, 'g')
let match: RegExpExecArray | null
while ((match = pattern.exec(text)) !== null) {
const leadingSpace = match[1]
const tokenStart = match.index + leadingSpace.length
const tokenEnd = tokenStart + token.length
ranges.push({ start: tokenStart, end: tokenEnd, context: ctx })
}
}
for (const range of computeIntegrationRanges(text, ranges)) {
ranges.push(range)
}
ranges.sort((a, b) => a.start - b.start)
return ranges
}
/**
* Scans the raw text for explicit, token-starting `@IntegrationName` mentions
* (any casing) and decorates them even when no matching context was stored —
* e.g. a message submitted before the input's auto-mention pass ran, or one
* authored outside the chat input. Ranges already claimed by stored contexts
* are skipped so the two sources never double-decorate.
*/
function computeIntegrationRanges(text: string, taken: MentionRange[]): MentionRange[] {
const { regex, byName } = getIntegrationMatcher()
if (!regex || !text) return []
regex.lastIndex = 0
const ranges: MentionRange[] = []
let match: RegExpExecArray | null
while ((match = regex.exec(text)) !== null) {
const atIndex = match.index - 1
if (atIndex < 0 || text[atIndex] !== '@') continue
if (atIndex > 0 && !/\s/.test(text[atIndex - 1])) continue
const info = byName.get(match[0].toLowerCase())
if (!info) continue
const start = atIndex
const end = match.index + match[0].length
if (taken.some((r) => start < r.end && end > r.start)) continue
ranges.push({
start,
end,
context: { kind: 'integration', blockType: info.blockType, label: info.name },
})
}
return ranges
}
function MentionHighlight({ context }: { context: ChatMessageContext }) {
return (
<span className='inline-flex items-baseline gap-1 rounded-[5px] bg-[var(--surface-5)] px-[5px]'>
<ContextMentionIcon
context={context}
className='relative top-0.5 size-[12px] flex-shrink-0 text-[var(--text-icon)]'
/>
{context.label}
</span>
)
}
export function UserMessageContent({
content,
contexts,
className,
plainMentions = false,
compact = false,
}: UserMessageContentProps) {
const trimmed = content.trim()
const classes = cn(compact ? COMPACT_CLASSES : USER_MESSAGE_CLASSES, className)
const ranges = useMemo(() => computeMentionRanges(content, contexts ?? []), [content, contexts])
if (ranges.length === 0) {
return <p className={classes}>{trimmed}</p>
}
const elements: React.ReactNode[] = []
let lastIndex = 0
for (let i = 0; i < ranges.length; i++) {
const range = ranges[i]
if (range.start > lastIndex) {
const before = content.slice(lastIndex, range.start)
elements.push(<span key={`text-${i}-${lastIndex}`}>{before}</span>)
}
if (plainMentions) {
elements.push(
<span
key={`mention-${i}-${range.start}`}
className='font-medium text-[var(--text-primary)]'
>
{content.slice(range.start, range.end)}
</span>
)
} else {
elements.push(
<MentionHighlight key={`mention-${i}-${range.start}`} context={range.context} />
)
}
lastIndex = range.end
}
const tail = content.slice(lastIndex)
if (tail) {
elements.push(<span key={`tail-${lastIndex}`}>{tail}</span>)
}
return <p className={classes}>{elements}</p>
}