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
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:
+102
@@ -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} />,
|
||||
},
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
export { CHAT_CONTEXT_KIND_REGISTRY } from './chat-context-kind-registry'
|
||||
+67
@@ -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'
|
||||
+105
@@ -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'
|
||||
+18
@@ -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'
|
||||
+71
@@ -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)
|
||||
})
|
||||
})
|
||||
+249
@@ -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>
|
||||
)
|
||||
}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
export type { AgentGroupItem, NestedAgentGroup } from './agent-group'
|
||||
export { AgentGroup, isAgentGroupResolved } from './agent-group'
|
||||
export { CircleStop } from './tool-call-item'
|
||||
+151
@@ -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>
|
||||
)
|
||||
}
|
||||
+22
@@ -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.')
|
||||
})
|
||||
})
|
||||
+525
@@ -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)
|
||||
+12
@@ -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')
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
export { ChatContent } from './chat-content'
|
||||
+5
@@ -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'
|
||||
+1
@@ -0,0 +1 @@
|
||||
export { Options } from './options'
|
||||
+25
@@ -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>
|
||||
)
|
||||
}
|
||||
+27
@@ -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'
|
||||
+91
@@ -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())
|
||||
})
|
||||
})
|
||||
+813
@@ -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'
|
||||
+268
@@ -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
|
||||
)
|
||||
})
|
||||
})
|
||||
+777
@@ -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'
|
||||
)
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
export { MothershipChatSkeleton } from './mothership-chat-skeleton'
|
||||
+1
@@ -0,0 +1 @@
|
||||
export { MothershipChatSkeleton } from './mothership-chat-skeleton'
|
||||
+51
@@ -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'
|
||||
+481
@@ -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>
|
||||
)
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
MothershipResourcesProvider,
|
||||
useMothershipResources,
|
||||
} from './mothership-resources-context'
|
||||
+69
@@ -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
|
||||
}
|
||||
+573
@@ -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>
|
||||
)
|
||||
}
|
||||
+14
@@ -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'
|
||||
+11
@@ -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'
|
||||
+75
@@ -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>
|
||||
)
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
export { GenericResourceContent } from './generic-resource-content'
|
||||
+1
@@ -0,0 +1 @@
|
||||
export { GenericResourceContent } from './generic-resource-content'
|
||||
+1
@@ -0,0 +1 @@
|
||||
export { ResourceActions, ResourceContent } from './resource-content'
|
||||
+932
@@ -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>
|
||||
)
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
export type { ResourceTypeConfig } from './resource-registry'
|
||||
export {
|
||||
getResourceConfig,
|
||||
invalidateResourceQueries,
|
||||
RESOURCE_REGISTRY,
|
||||
RESOURCE_TYPES,
|
||||
} from './resource-registry'
|
||||
+283
@@ -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)
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
export { ResourceTabs } from './resource-tabs'
|
||||
+5
@@ -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)]'
|
||||
+652
@@ -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'
|
||||
+156
@@ -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'
|
||||
+202
@@ -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'
|
||||
+434
@@ -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>
|
||||
)
|
||||
}
|
||||
+113
@@ -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 }
|
||||
}
|
||||
+25
@@ -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
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
export { AnimatedPlaceholderEffect } from './animated-placeholder-effect'
|
||||
+97
@@ -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>
|
||||
)
|
||||
})
|
||||
+1
@@ -0,0 +1 @@
|
||||
export { AttachedFilesList } from './attached-files-list'
|
||||
+228
@@ -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 }
|
||||
}
|
||||
}
|
||||
+121
@@ -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)
|
||||
}
|
||||
+41
@@ -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>
|
||||
)
|
||||
})
|
||||
+1
@@ -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'
|
||||
+1
@@ -0,0 +1 @@
|
||||
export { MicButton } from './mic-button'
|
||||
+32
@@ -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>
|
||||
)
|
||||
})
|
||||
+1
@@ -0,0 +1 @@
|
||||
export { type AvailableResourceGroup, PlusMenuDropdown } from './plus-menu-dropdown'
|
||||
+389
@@ -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>
|
||||
)
|
||||
})
|
||||
)
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
export { PromptEditor, type PromptEditorProps } from './prompt-editor'
|
||||
export {
|
||||
type PromptEditorInstance,
|
||||
type PromptEditorKeyPolicy,
|
||||
type UsePromptEditorProps,
|
||||
usePromptEditor,
|
||||
} from './use-prompt-editor'
|
||||
+227
@@ -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>
|
||||
)
|
||||
}
|
||||
+248
@@ -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()
|
||||
})
|
||||
})
|
||||
+1045
File diff suppressed because it is too large
Load Diff
+1
@@ -0,0 +1 @@
|
||||
export { SendButton } from './send-button'
|
||||
+52
@@ -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>
|
||||
)
|
||||
})
|
||||
+213
@@ -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>
|
||||
)
|
||||
})
|
||||
)
|
||||
+188
@@ -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'
|
||||
+171
@@ -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>
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
'use client'
|
||||
|
||||
import { type ErrorBoundaryProps, ErrorState } from '@/app/workspace/[workspaceId]/components'
|
||||
|
||||
export default function HomeError({ error, reset }: ErrorBoundaryProps) {
|
||||
return (
|
||||
<ErrorState
|
||||
error={error}
|
||||
reset={reset}
|
||||
title='Failed to load home'
|
||||
description='Something went wrong while loading your workspace home. Please try again.'
|
||||
loggerName='HomeError'
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Suspense fallback for the home/Chat surface. `Home` reads the `?resource=`
|
||||
* URL param via nuqs (`useQueryState`, which uses `useSearchParams`
|
||||
* internally), so it must sit under a Suspense boundary. This renders the
|
||||
* surface background so a suspend never flashes a blank frame before the chat
|
||||
* mounts.
|
||||
*/
|
||||
export function HomeFallback() {
|
||||
return <div className='h-full bg-[var(--bg)]' />
|
||||
}
|
||||
@@ -0,0 +1,563 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
type Dispatch,
|
||||
lazy,
|
||||
type SetStateAction,
|
||||
Suspense,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { Button } from '@sim/emcn'
|
||||
import { PanelLeft } from '@sim/emcn/icons'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { useQueryState } from 'nuqs'
|
||||
import { usePostHog } from 'posthog-js/react'
|
||||
import { requestJson } from '@/lib/api/client/request'
|
||||
import { createWorkflowContract } from '@/lib/api/contracts'
|
||||
import { canonicalWorkspaceFilePath } from '@/lib/copilot/vfs/path-utils'
|
||||
import {
|
||||
buildWorkflowAliasWorkflowEntries,
|
||||
resolveWorkflowAliasPath,
|
||||
resolveWorkspacePlanAliasPath,
|
||||
} from '@/lib/copilot/vfs/workflow-aliases'
|
||||
import {
|
||||
LandingPromptStorage,
|
||||
type LandingWorkflowSeed,
|
||||
LandingWorkflowSeedStorage,
|
||||
MothershipHandoffStorage,
|
||||
} from '@/lib/core/utils/browser-storage'
|
||||
import {
|
||||
MOTHERSHIP_SEND_MESSAGE_EVENT,
|
||||
type MothershipSendMessageDetail,
|
||||
} from '@/lib/mothership/events'
|
||||
import { captureEvent } from '@/lib/posthog/client'
|
||||
import { persistImportedWorkflow } from '@/lib/workflows/operations/import-export'
|
||||
import { resourceParam, resourceUrlKeys } from '@/app/workspace/[workspaceId]/home/search-params'
|
||||
import { useFolders } from '@/hooks/queries/folders'
|
||||
import {
|
||||
useMarkMothershipChatRead,
|
||||
useMothershipChatHistory,
|
||||
} from '@/hooks/queries/mothership-chats'
|
||||
import { useWorkflows } from '@/hooks/queries/workflows'
|
||||
import { useWorkspaceFiles } from '@/hooks/queries/workspace-files'
|
||||
import { useOAuthReturnRouter } from '@/hooks/use-oauth-return'
|
||||
import type { ChatContext } from '@/stores/panel'
|
||||
import {
|
||||
ChatSurfaceProvider,
|
||||
CreditsChip,
|
||||
MothershipChat,
|
||||
MothershipResourcesProvider,
|
||||
SuggestedActions,
|
||||
UserInput,
|
||||
type UserInputHandle,
|
||||
} from './components'
|
||||
import { getMothershipUseChatOptions, useChat, useMothershipResize } from './hooks'
|
||||
import type { FileAttachmentForApi, MothershipResource, MothershipResourceType } from './types'
|
||||
|
||||
const logger = createLogger('Home')
|
||||
|
||||
/**
|
||||
* The resource preview panel pulls in the file-viewer stack (rich-markdown
|
||||
* editor, CSV/PDF viewers). It only renders once a chat has messages, so it is
|
||||
* code-split out of the initial `/chat` bundle and loaded on demand.
|
||||
*/
|
||||
const MothershipView = lazy(() =>
|
||||
import('./components/mothership-view/mothership-view').then((m) => ({
|
||||
default: m.MothershipView,
|
||||
}))
|
||||
)
|
||||
|
||||
interface HomeProps {
|
||||
chatId?: string
|
||||
userName?: string
|
||||
userId?: string
|
||||
}
|
||||
|
||||
export function Home({ chatId, userName, userId }: HomeProps) {
|
||||
useOAuthReturnRouter()
|
||||
const { workspaceId } = useParams<{ workspaceId: string }>()
|
||||
const router = useRouter()
|
||||
/**
|
||||
* URL is the single source of truth for the selected resource. `Home` renders
|
||||
* client-side, so nuqs reads `?resource=` from the URL on mount — the same
|
||||
* value the page previously threaded through `initialResourceId` — and writes
|
||||
* it back with `history: 'replace'`, the previous behavior, minus the banned
|
||||
* `window.history.replaceState` param-mutation effect. The page wraps `Home`
|
||||
* in Suspense for the `useSearchParams` requirement.
|
||||
*/
|
||||
const [activeResourceParam, setResourceParam] = useQueryState(resourceParam.key, {
|
||||
...resourceParam.parser,
|
||||
...resourceUrlKeys,
|
||||
})
|
||||
/**
|
||||
* Strips any leftover URL fragment on selection change, preserving the old
|
||||
* effect's `url.hash = ''` (the only hash usage on this surface) without a
|
||||
* separate effect-sync mirror. This rewrites the fragment only — it never
|
||||
* mutates a query param via the History API.
|
||||
*
|
||||
* Order matters: the fragment is stripped synchronously BEFORE the nuqs write,
|
||||
* because nuqs re-appends `location.hash` on its (deferred) flush — clearing the
|
||||
* hash first ensures the param write doesn't carry the stale fragment back.
|
||||
*/
|
||||
const setActiveResourceUrl = useCallback<Dispatch<SetStateAction<string | null>>>(
|
||||
(action) => {
|
||||
if (typeof window !== 'undefined' && window.location.hash) {
|
||||
const { pathname, search } = window.location
|
||||
window.history.replaceState(window.history.state, '', `${pathname}${search}`)
|
||||
}
|
||||
void setResourceParam(action)
|
||||
},
|
||||
[setResourceParam]
|
||||
)
|
||||
/**
|
||||
* Controlled binding handed to `useChat` so the URL is the sole owner of the
|
||||
* selection with no dual source.
|
||||
*/
|
||||
const activeResourceState = useMemo<[string | null, Dispatch<SetStateAction<string | null>>]>(
|
||||
() => [activeResourceParam, setActiveResourceUrl],
|
||||
[activeResourceParam, setActiveResourceUrl]
|
||||
)
|
||||
const firstName = userName?.split(' ')[0] ?? ''
|
||||
const { data: workspaceFiles = [] } = useWorkspaceFiles(workspaceId)
|
||||
const { data: workflows = [] } = useWorkflows(workspaceId)
|
||||
const { data: folders = [] } = useFolders(workspaceId)
|
||||
const posthog = usePostHog()
|
||||
const posthogRef = useRef(posthog)
|
||||
posthogRef.current = posthog
|
||||
const [initialPrompt, setInitialPrompt] = useState('')
|
||||
const hasCheckedLandingStorageRef = useRef(false)
|
||||
const initialViewInputRef = useRef<HTMLDivElement>(null)
|
||||
const initialViewUserInputRef = useRef<UserInputHandle>(null)
|
||||
|
||||
const [isInputEntering, setIsInputEntering] = useState(false)
|
||||
|
||||
const createWorkflowFromLandingSeed = useCallback(
|
||||
async (seed: LandingWorkflowSeed) => {
|
||||
try {
|
||||
const result = await persistImportedWorkflow({
|
||||
content: seed.workflowJson,
|
||||
filename: `${seed.workflowName}.json`,
|
||||
workspaceId,
|
||||
nameOverride: seed.workflowName,
|
||||
descriptionOverride: seed.workflowDescription || 'Imported from landing template',
|
||||
createWorkflow: async ({ name, description, workspaceId }) => {
|
||||
return requestJson(createWorkflowContract, {
|
||||
body: {
|
||||
name,
|
||||
description,
|
||||
workspaceId,
|
||||
deduplicate: true,
|
||||
},
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
if (result?.workflowId) {
|
||||
window.location.href = `/workspace/${workspaceId}/w/${result.workflowId}`
|
||||
return
|
||||
}
|
||||
|
||||
logger.warn('Landing workflow seed did not produce a workflow', {
|
||||
templateId: seed.templateId,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Error creating workflow from landing workflow seed:', error)
|
||||
}
|
||||
},
|
||||
[workspaceId]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (hasCheckedLandingStorageRef.current) return
|
||||
hasCheckedLandingStorageRef.current = true
|
||||
|
||||
const workflowSeed = LandingWorkflowSeedStorage.consume()
|
||||
if (workflowSeed) {
|
||||
logger.info('Retrieved landing page workflow seed, creating workflow in workspace')
|
||||
void createWorkflowFromLandingSeed(workflowSeed)
|
||||
return
|
||||
}
|
||||
|
||||
const prompt = LandingPromptStorage.consume()
|
||||
if (prompt) {
|
||||
logger.info('Retrieved landing page prompt, populating home input')
|
||||
setInitialPrompt(prompt)
|
||||
}
|
||||
}, [createWorkflowFromLandingSeed])
|
||||
|
||||
const wasSendingRef = useRef(false)
|
||||
|
||||
const { isPending: isChatHistoryPending } = useMothershipChatHistory(chatId)
|
||||
const { mutate: markRead } = useMarkMothershipChatRead(workspaceId)
|
||||
|
||||
const { mothershipRef, handleResizePointerDown, clearWidth } = useMothershipResize()
|
||||
|
||||
const [isResourceCollapsed, setIsResourceCollapsed] = useState(true)
|
||||
const [skipResourceTransition, setSkipResourceTransition] = useState(false)
|
||||
const isResourceCollapsedRef = useRef(isResourceCollapsed)
|
||||
isResourceCollapsedRef.current = isResourceCollapsed
|
||||
|
||||
const collapseResource = useCallback(() => {
|
||||
clearWidth()
|
||||
setIsResourceCollapsed(true)
|
||||
}, [clearWidth])
|
||||
|
||||
function handleResourceEvent() {
|
||||
if (isResourceCollapsedRef.current) {
|
||||
setIsResourceCollapsed(false)
|
||||
}
|
||||
}
|
||||
|
||||
const {
|
||||
messages,
|
||||
isSending,
|
||||
isReconnecting,
|
||||
sendMessage,
|
||||
stopGeneration,
|
||||
resolvedChatId,
|
||||
resources,
|
||||
activeResourceId,
|
||||
setActiveResourceId,
|
||||
addResource,
|
||||
removeResource,
|
||||
reorderResources,
|
||||
messageQueue,
|
||||
removeFromQueue,
|
||||
sendNow,
|
||||
editQueuedMessage,
|
||||
cancelQueueEdit,
|
||||
editingQueuedId,
|
||||
dispatchingHeadId,
|
||||
previewSession,
|
||||
genericResourceData,
|
||||
getCurrentRequestId,
|
||||
} = useChat(
|
||||
workspaceId,
|
||||
chatId,
|
||||
getMothershipUseChatOptions({
|
||||
onResourceEvent: handleResourceEvent,
|
||||
activeResourceState,
|
||||
onRequestStarted: ({ requestId, userMessageId }) => {
|
||||
captureEvent(posthogRef.current, 'task_request_started', {
|
||||
workspace_id: workspaceId,
|
||||
view: 'mothership',
|
||||
request_id: requestId,
|
||||
user_message_id: userMessageId,
|
||||
})
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
wasSendingRef.current = false
|
||||
if (resolvedChatId) {
|
||||
markRead(resolvedChatId)
|
||||
} else {
|
||||
clearWidth()
|
||||
setIsResourceCollapsed(true)
|
||||
}
|
||||
}, [resolvedChatId, markRead, clearWidth])
|
||||
|
||||
useEffect(() => {
|
||||
if (wasSendingRef.current && !isSending && resolvedChatId) {
|
||||
markRead(resolvedChatId)
|
||||
}
|
||||
wasSendingRef.current = isSending
|
||||
}, [isSending, resolvedChatId, markRead])
|
||||
|
||||
useEffect(() => {
|
||||
if (!(resources.length > 0 && isResourceCollapsedRef.current)) return
|
||||
setIsResourceCollapsed(false)
|
||||
setSkipResourceTransition(true)
|
||||
const id = requestAnimationFrame(() => setSkipResourceTransition(false))
|
||||
return () => cancelAnimationFrame(id)
|
||||
}, [resources])
|
||||
|
||||
useEffect(() => {
|
||||
if (resources.length === 0 && !isResourceCollapsedRef.current) {
|
||||
collapseResource()
|
||||
}
|
||||
}, [resources, collapseResource])
|
||||
|
||||
const handleStopGeneration = useCallback(() => {
|
||||
captureEvent(posthogRef.current, 'task_generation_aborted', {
|
||||
workspace_id: workspaceId,
|
||||
view: 'mothership',
|
||||
request_id: getCurrentRequestId(),
|
||||
})
|
||||
void stopGeneration().catch(() => {})
|
||||
}, [workspaceId, getCurrentRequestId, stopGeneration])
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
(text: string, fileAttachments?: FileAttachmentForApi[], contexts?: ChatContext[]) => {
|
||||
const trimmed = text.trim()
|
||||
if (!trimmed && !(fileAttachments && fileAttachments.length > 0)) return
|
||||
|
||||
captureEvent(posthogRef.current, 'task_message_sent', {
|
||||
workspace_id: workspaceId,
|
||||
has_attachments: !!(fileAttachments && fileAttachments.length > 0),
|
||||
has_contexts: !!(contexts && contexts.length > 0),
|
||||
is_new_task: !chatId,
|
||||
})
|
||||
|
||||
if (initialViewInputRef.current) {
|
||||
setIsInputEntering(true)
|
||||
}
|
||||
|
||||
sendMessage(trimmed || 'Analyze the attached file(s).', fileAttachments, contexts)
|
||||
},
|
||||
[workspaceId, chatId, sendMessage]
|
||||
)
|
||||
|
||||
/**
|
||||
* Handles cross-surface send requests (terminal/console "Fix in Chat", the
|
||||
* log "Troubleshoot in Chat" action). `preventDefault` claims the event so a
|
||||
* producer that dispatched it while this chat is mounted knows a live chat
|
||||
* consumed the message and skips its navigate-and-persist fallback.
|
||||
*/
|
||||
useEffect(() => {
|
||||
const handler = (e: Event) => {
|
||||
const detail = (e as CustomEvent<MothershipSendMessageDetail>).detail
|
||||
if (!detail?.message) return
|
||||
e.preventDefault()
|
||||
sendMessage(detail.message, undefined, detail.contexts)
|
||||
}
|
||||
window.addEventListener(MOTHERSHIP_SEND_MESSAGE_EVENT, handler)
|
||||
return () => window.removeEventListener(MOTHERSHIP_SEND_MESSAGE_EVENT, handler)
|
||||
}, [sendMessage])
|
||||
|
||||
/**
|
||||
* Consumes a one-shot handoff left by another surface (e.g. "Troubleshoot in
|
||||
* Chat" on an errored log viewed from a different route) and auto-sends it
|
||||
* into this fresh chat, tagging the run so Sim can inspect the failure. Only
|
||||
* the cross-route path lands here — when a chat is already mounted the event
|
||||
* above delivers directly. Gated to the new-chat surface (`!chatId`): a
|
||||
* handoff always targets a fresh chat, so an existing `/chat/[chatId]` mount
|
||||
* must never claim it if navigation races. `consume` clears the entry
|
||||
* atomically, so it fires at most once even across a StrictMode remount.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (chatId) return
|
||||
const handoff = MothershipHandoffStorage.consume(workspaceId)
|
||||
if (handoff) sendMessage(handoff.message, undefined, handoff.contexts)
|
||||
}, [chatId, workspaceId, sendMessage])
|
||||
|
||||
function resolveResourceFromContext(
|
||||
context: ChatContext
|
||||
): { type: MothershipResourceType; id: string } | null {
|
||||
switch (context.kind) {
|
||||
case 'workflow':
|
||||
case 'current_workflow':
|
||||
return context.workflowId ? { type: 'workflow', id: context.workflowId } : null
|
||||
case 'knowledge':
|
||||
return context.knowledgeId ? { type: 'knowledgebase', id: context.knowledgeId } : null
|
||||
case 'table':
|
||||
return context.tableId ? { type: 'table', id: context.tableId } : null
|
||||
case 'file':
|
||||
return context.fileId ? { type: 'file', id: context.fileId } : null
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function handleContextAdd(context: ChatContext) {
|
||||
const resolved = resolveResourceFromContext(context)
|
||||
if (resolved) {
|
||||
addResource({ ...resolved, title: context.label })
|
||||
handleResourceEvent()
|
||||
}
|
||||
}
|
||||
|
||||
function handleInitialContextRemove(context: ChatContext) {
|
||||
const resolved = resolveResourceFromContext(context)
|
||||
if (!resolved) return
|
||||
removeResource(resolved.type, resolved.id)
|
||||
}
|
||||
|
||||
const workflowAliasEntries = useMemo(
|
||||
() =>
|
||||
buildWorkflowAliasWorkflowEntries(
|
||||
workflows.map((workflow) => ({
|
||||
id: workflow.id,
|
||||
name: workflow.name,
|
||||
folderId: workflow.folderId ?? null,
|
||||
})),
|
||||
folders.map((folder) => ({
|
||||
folderId: folder.id,
|
||||
folderName: folder.name,
|
||||
parentId: folder.parentId ?? null,
|
||||
}))
|
||||
),
|
||||
[folders, workflows]
|
||||
)
|
||||
|
||||
const resolveFileResource = useCallback(
|
||||
(resource: MothershipResource): MothershipResource => {
|
||||
if (resource.type !== 'file') return resource
|
||||
|
||||
const reference = (resource.path || resource.id).trim()
|
||||
const workspacePlanAlias = resolveWorkspacePlanAliasPath(reference)
|
||||
const workflowAlias = workspacePlanAlias
|
||||
? null
|
||||
: resolveWorkflowAliasPath(reference, workflowAliasEntries)
|
||||
const alias = workspacePlanAlias || workflowAlias
|
||||
const targetPath = alias && alias.kind !== 'plans_dir' ? alias.backingPath : reference
|
||||
|
||||
const file = workspaceFiles.find((candidate) => {
|
||||
const candidatePath = canonicalWorkspaceFilePath({
|
||||
folderPath: candidate.folderPath,
|
||||
name: candidate.name,
|
||||
})
|
||||
return (
|
||||
candidate.id === reference || candidatePath === reference || candidatePath === targetPath
|
||||
)
|
||||
})
|
||||
|
||||
if (!file) return resource
|
||||
return {
|
||||
...resource,
|
||||
id: file.id,
|
||||
title: resource.title || file.name,
|
||||
path: alias ? reference : resource.path,
|
||||
}
|
||||
},
|
||||
[workflowAliasEntries, workspaceFiles]
|
||||
)
|
||||
|
||||
function handleWorkspaceResourceSelect(resource: MothershipResource) {
|
||||
const resolvedResource = resolveFileResource(resource)
|
||||
const wasAdded = addResource(resolvedResource)
|
||||
if (!wasAdded) {
|
||||
setActiveResourceId(resolvedResource.id)
|
||||
}
|
||||
handleResourceEvent()
|
||||
}
|
||||
|
||||
const hasMessages = messages.length > 0
|
||||
const showChatSkeleton = Boolean(chatId) && !hasMessages && isChatHistoryPending
|
||||
const draftScopeKey = `${workspaceId}:${chatId ?? 'new'}`
|
||||
|
||||
if (!hasMessages && !showChatSkeleton) {
|
||||
return (
|
||||
<div className='relative h-full overflow-y-auto bg-[var(--bg)] [scrollbar-gutter:stable_both-edges]'>
|
||||
<div className='absolute top-[8.5px] right-[16px] z-10'>
|
||||
<CreditsChip />
|
||||
</div>
|
||||
{/* Asymmetric padding biases the group up so the full cluster (heading + input + suggestions) sits at the optical center */}
|
||||
<div className='flex min-h-full flex-col items-center justify-center px-6 pt-[2vh] pb-[22vh]'>
|
||||
<h1 className='mb-7 max-w-[48rem] text-balance font-season text-[30px] text-[var(--text-primary)]'>
|
||||
What should we get done{firstName ? `, ${firstName}` : ''}?
|
||||
</h1>
|
||||
<div ref={initialViewInputRef} className='relative w-full max-w-[48rem]'>
|
||||
<ChatSurfaceProvider
|
||||
userId={userId}
|
||||
onContextAdd={handleContextAdd}
|
||||
onContextRemove={handleInitialContextRemove}
|
||||
>
|
||||
<UserInput
|
||||
ref={initialViewUserInputRef}
|
||||
defaultValue={initialPrompt}
|
||||
draftScopeKey={draftScopeKey}
|
||||
onSubmit={handleSubmit}
|
||||
isSending={isSending}
|
||||
onStopGeneration={handleStopGeneration}
|
||||
/>
|
||||
</ChatSurfaceProvider>
|
||||
{/* Anchored out of flow so expanding/collapsing never shifts the centered input */}
|
||||
<div className='absolute inset-x-0 top-full'>
|
||||
<SuggestedActions
|
||||
onSelectPrompt={(prompt) => initialViewUserInputRef.current?.populatePrompt(prompt)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='relative flex h-full bg-[var(--bg)]'>
|
||||
<div className='flex h-full min-w-[320px] flex-1 flex-col'>
|
||||
<MothershipChat
|
||||
messages={messages}
|
||||
isSending={isSending}
|
||||
isReconnecting={isReconnecting}
|
||||
isLoading={showChatSkeleton}
|
||||
onSubmit={handleSubmit}
|
||||
onStopGeneration={handleStopGeneration}
|
||||
messageQueue={messageQueue}
|
||||
editingQueuedId={editingQueuedId}
|
||||
dispatchingHeadId={dispatchingHeadId}
|
||||
onRemoveQueuedMessage={removeFromQueue}
|
||||
onSendQueuedMessage={sendNow}
|
||||
onEditQueuedMessage={editQueuedMessage}
|
||||
onCancelQueueEdit={cancelQueueEdit}
|
||||
userId={userId}
|
||||
chatId={resolvedChatId}
|
||||
onContextAdd={handleContextAdd}
|
||||
onWorkspaceResourceSelect={handleWorkspaceResourceSelect}
|
||||
draftScopeKey={draftScopeKey}
|
||||
animateInput={isInputEntering}
|
||||
onInputAnimationEnd={isInputEntering ? () => setIsInputEntering(false) : undefined}
|
||||
initialScrollBlocked={resources.length > 0 && isResourceCollapsed}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Resize handle — zero-width flex child whose absolute child straddles the border */}
|
||||
{!isResourceCollapsed && (
|
||||
<div className='relative z-20 w-0 flex-none'>
|
||||
<div
|
||||
className='absolute inset-y-0 left-[-4px] w-[8px] cursor-ew-resize'
|
||||
role='separator'
|
||||
aria-orientation='vertical'
|
||||
aria-label='Resize resource panel'
|
||||
onPointerDown={handleResizePointerDown}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<MothershipResourcesProvider
|
||||
selectResource={setActiveResourceId}
|
||||
addResource={addResource}
|
||||
removeResource={removeResource}
|
||||
reorderResources={reorderResources}
|
||||
collapseResource={collapseResource}
|
||||
>
|
||||
<Suspense fallback={null}>
|
||||
<MothershipView
|
||||
ref={mothershipRef}
|
||||
workspaceId={workspaceId}
|
||||
chatId={resolvedChatId}
|
||||
resources={resources}
|
||||
activeResourceId={activeResourceId}
|
||||
isCollapsed={isResourceCollapsed}
|
||||
previewSession={previewSession}
|
||||
isAgentResponding={isSending}
|
||||
genericResourceData={genericResourceData ?? undefined}
|
||||
className={skipResourceTransition ? '!transition-none' : undefined}
|
||||
/>
|
||||
</Suspense>
|
||||
</MothershipResourcesProvider>
|
||||
|
||||
{isResourceCollapsed && (
|
||||
<div className='absolute top-[8.5px] right-[16px]'>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size={null}
|
||||
type='button'
|
||||
onClick={() => setIsResourceCollapsed(false)}
|
||||
className='size-[30px] rounded-[8px] hover-hover:bg-[var(--surface-active)]'
|
||||
aria-label='Expand resource view'
|
||||
>
|
||||
<PanelLeft className='size-[16px] text-[var(--text-icon)]' />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
getMothershipUseChatOptions,
|
||||
getWorkflowCopilotUseChatOptions,
|
||||
useChat,
|
||||
} from './use-chat'
|
||||
export { useMothershipResize } from './use-mothership-resize'
|
||||
+137
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { FilePreviewSession } from '@/lib/copilot/request/session'
|
||||
import { deriveFilePreviewSession } from './apply-file-preview-phase'
|
||||
|
||||
const NOW = '2026-06-08T00:00:00.000Z'
|
||||
|
||||
function session(overrides: Partial<FilePreviewSession>): FilePreviewSession {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
id: 'tool-1',
|
||||
streamId: 'stream-1',
|
||||
toolCallId: 'tool-1',
|
||||
status: 'streaming',
|
||||
fileName: 'deck.pptx',
|
||||
previewText: '',
|
||||
previewVersion: 1,
|
||||
updatedAt: NOW,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('deriveFilePreviewSession', () => {
|
||||
it('starts a pending session keyed to the tool call', () => {
|
||||
const next = deriveFilePreviewSession(
|
||||
undefined,
|
||||
{ previewPhase: 'file_preview_start', toolCallId: 'tool-1', toolName: 'workspace_file' },
|
||||
'stream-1',
|
||||
NOW
|
||||
)
|
||||
expect(next.status).toBe('pending')
|
||||
expect(next.id).toBe('tool-1')
|
||||
expect(next.previewVersion).toBe(0)
|
||||
expect(next.streamId).toBe('stream-1')
|
||||
})
|
||||
|
||||
it('captures target identity (fileId, name, kind, operation)', () => {
|
||||
const next = deriveFilePreviewSession(
|
||||
session({ status: 'pending' }),
|
||||
{
|
||||
previewPhase: 'file_preview_target',
|
||||
toolCallId: 'tool-1',
|
||||
toolName: 'workspace_file',
|
||||
operation: 'append',
|
||||
target: { kind: 'file_id', fileId: 'file-9', fileName: 'deck.pptx' },
|
||||
},
|
||||
'stream-1',
|
||||
NOW
|
||||
)
|
||||
expect(next.fileId).toBe('file-9')
|
||||
expect(next.targetKind).toBe('file_id')
|
||||
expect(next.operation).toBe('append')
|
||||
expect(next.status).toBe('pending')
|
||||
})
|
||||
|
||||
it('appends delta content and advances the version monotonically when none supplied', () => {
|
||||
const prev = session({ previewText: 'slide one', previewVersion: 2 })
|
||||
const next = deriveFilePreviewSession(
|
||||
prev,
|
||||
{
|
||||
previewPhase: 'file_preview_content',
|
||||
toolCallId: 'tool-1',
|
||||
toolName: 'workspace_file',
|
||||
content: ' slide two',
|
||||
contentMode: 'delta',
|
||||
fileName: 'deck.pptx',
|
||||
},
|
||||
'stream-1',
|
||||
NOW
|
||||
)
|
||||
expect(next.status).toBe('streaming')
|
||||
expect(next.previewText).toBe('slide one slide two')
|
||||
expect(next.previewVersion).toBe(3)
|
||||
})
|
||||
|
||||
it('appends delta content and uses the supplied version verbatim', () => {
|
||||
const prev = session({ previewText: 'slide one', previewVersion: 2 })
|
||||
const next = deriveFilePreviewSession(
|
||||
prev,
|
||||
{
|
||||
previewPhase: 'file_preview_content',
|
||||
toolCallId: 'tool-1',
|
||||
toolName: 'workspace_file',
|
||||
content: ' slide two',
|
||||
contentMode: 'delta',
|
||||
previewVersion: 9,
|
||||
fileName: 'deck.pptx',
|
||||
},
|
||||
'stream-1',
|
||||
NOW
|
||||
)
|
||||
expect(next.previewText).toBe('slide one slide two')
|
||||
expect(next.previewVersion).toBe(9)
|
||||
})
|
||||
|
||||
it('replaces text on a snapshot and carries forward prior fileId', () => {
|
||||
const prev = session({ previewText: 'old', fileId: 'file-9', previewVersion: 4 })
|
||||
const next = deriveFilePreviewSession(
|
||||
prev,
|
||||
{
|
||||
previewPhase: 'file_preview_content',
|
||||
toolCallId: 'tool-1',
|
||||
toolName: 'workspace_file',
|
||||
content: 'fresh snapshot',
|
||||
contentMode: 'snapshot',
|
||||
previewVersion: 5,
|
||||
fileName: 'deck.pptx',
|
||||
},
|
||||
undefined,
|
||||
NOW
|
||||
)
|
||||
expect(next.previewText).toBe('fresh snapshot')
|
||||
expect(next.fileId).toBe('file-9')
|
||||
expect(next.streamId).toBe('stream-1')
|
||||
})
|
||||
|
||||
it('marks completion with completedAt and a resolved version', () => {
|
||||
const prev = session({ previewText: 'final', previewVersion: 7, fileId: 'file-9' })
|
||||
const next = deriveFilePreviewSession(
|
||||
prev,
|
||||
{
|
||||
previewPhase: 'file_preview_complete',
|
||||
toolCallId: 'tool-1',
|
||||
toolName: 'workspace_file',
|
||||
fileId: 'file-9',
|
||||
},
|
||||
'stream-1',
|
||||
NOW
|
||||
)
|
||||
expect(next.status).toBe('complete')
|
||||
expect(next.completedAt).toBe(NOW)
|
||||
expect(next.previewVersion).toBe(7)
|
||||
expect(next.fileId).toBe('file-9')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,93 @@
|
||||
import type { SyntheticFilePreviewPayload } from '@/lib/copilot/request/session'
|
||||
import type {
|
||||
FilePreviewSession,
|
||||
FilePreviewTargetKind,
|
||||
} from '@/lib/copilot/request/session/file-preview-session-contract'
|
||||
|
||||
function toTargetKind(value: string | undefined): FilePreviewTargetKind | undefined {
|
||||
return value === 'new_file' || value === 'file_id' ? value : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Derives the next {@link FilePreviewSession} from a synthetic preview-phase
|
||||
* payload and the prior session for that tool call. Pure: identity fields prefer
|
||||
* the payload then fall back to the previous session, content accumulates by
|
||||
* delta/snapshot, and `previewVersion` advances monotonically. `now` is injected
|
||||
* so the result is deterministic; the caller supplies a single timestamp used for
|
||||
* both `updatedAt` and (on completion) `completedAt`.
|
||||
*/
|
||||
export function deriveFilePreviewSession(
|
||||
prev: FilePreviewSession | undefined,
|
||||
payload: SyntheticFilePreviewPayload,
|
||||
streamId: string | undefined,
|
||||
now: string
|
||||
): FilePreviewSession {
|
||||
const id = payload.toolCallId
|
||||
|
||||
let targetKind = prev?.targetKind
|
||||
let fileId = prev?.fileId
|
||||
let fileName = prev?.fileName ?? ''
|
||||
let operation = prev?.operation
|
||||
let edit = prev?.edit
|
||||
|
||||
if (payload.previewPhase === 'file_preview_target') {
|
||||
targetKind = toTargetKind(payload.target.kind) ?? targetKind
|
||||
fileId = payload.target.fileId ?? fileId
|
||||
fileName = payload.target.fileName ?? fileName
|
||||
operation = payload.operation ?? operation
|
||||
} else if (payload.previewPhase === 'file_preview_content') {
|
||||
targetKind = toTargetKind(payload.targetKind) ?? targetKind
|
||||
fileId = payload.fileId ?? fileId
|
||||
fileName = payload.fileName ?? fileName
|
||||
operation = payload.operation ?? operation
|
||||
edit = payload.edit ?? edit
|
||||
} else if (payload.previewPhase === 'file_preview_edit_meta') {
|
||||
edit = payload.edit ?? edit
|
||||
} else if (payload.previewPhase === 'file_preview_complete') {
|
||||
fileId = payload.fileId ?? fileId
|
||||
}
|
||||
|
||||
const base: FilePreviewSession = {
|
||||
schemaVersion: 1,
|
||||
id,
|
||||
streamId: streamId ?? prev?.streamId ?? '',
|
||||
toolCallId: id,
|
||||
status: prev?.status ?? 'pending',
|
||||
fileName,
|
||||
...(fileId ? { fileId } : {}),
|
||||
...(targetKind ? { targetKind } : {}),
|
||||
...(operation ? { operation } : {}),
|
||||
...(edit ? { edit } : {}),
|
||||
previewText: prev?.previewText ?? '',
|
||||
previewVersion: prev?.previewVersion ?? 0,
|
||||
updatedAt: now,
|
||||
...(prev?.completedAt ? { completedAt: prev.completedAt } : {}),
|
||||
}
|
||||
|
||||
switch (payload.previewPhase) {
|
||||
case 'file_preview_start':
|
||||
case 'file_preview_target':
|
||||
case 'file_preview_edit_meta':
|
||||
return base
|
||||
|
||||
case 'file_preview_content': {
|
||||
const previewText =
|
||||
payload.contentMode === 'delta'
|
||||
? (prev?.previewText ?? '') + payload.content
|
||||
: payload.content
|
||||
const previewVersion =
|
||||
typeof payload.previewVersion === 'number' && Number.isFinite(payload.previewVersion)
|
||||
? payload.previewVersion
|
||||
: (prev?.previewVersion ?? 0) + 1
|
||||
return { ...base, status: 'streaming', previewText, previewVersion }
|
||||
}
|
||||
|
||||
case 'file_preview_complete':
|
||||
return {
|
||||
...base,
|
||||
status: 'complete',
|
||||
previewVersion: payload.previewVersion ?? prev?.previewVersion ?? 0,
|
||||
completedAt: now,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export { useFilePreviewController } from './use-file-preview-controller'
|
||||
export {
|
||||
hasRenderableFilePreviewContent,
|
||||
shouldReplaceSession,
|
||||
} from './use-file-preview-sessions'
|
||||
+412
@@ -0,0 +1,412 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
type Dispatch,
|
||||
type MutableRefObject,
|
||||
type SetStateAction,
|
||||
useCallback,
|
||||
useRef,
|
||||
} from 'react'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import type { SyntheticFilePreviewPayload } from '@/lib/copilot/request/session'
|
||||
import type { FilePreviewSession } from '@/lib/copilot/request/session/file-preview-session-contract'
|
||||
import { invalidateResourceQueries } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry'
|
||||
import { deriveFilePreviewSession } from '@/app/workspace/[workspaceId]/home/hooks/preview/apply-file-preview-phase'
|
||||
import {
|
||||
buildCompletedPreviewSessions,
|
||||
type FilePreviewSessionsState,
|
||||
hasRenderableFilePreviewContent,
|
||||
INITIAL_FILE_PREVIEW_SESSIONS_STATE,
|
||||
reduceFilePreviewSessions,
|
||||
useFilePreviewSessions,
|
||||
} from '@/app/workspace/[workspaceId]/home/hooks/preview/use-file-preview-sessions'
|
||||
import type { MothershipResource } from '@/app/workspace/[workspaceId]/home/types'
|
||||
import { workspaceFilesKeys } from '@/hooks/queries/workspace-files'
|
||||
|
||||
interface FilePreviewControllerDeps {
|
||||
workspaceId: string
|
||||
setResources: Dispatch<SetStateAction<MothershipResource[]>>
|
||||
setActiveResourceId: Dispatch<SetStateAction<string | null>>
|
||||
activeResourceIdRef: MutableRefObject<string | null>
|
||||
}
|
||||
|
||||
function asPayloadRecord(value: unknown): Record<string, unknown> | undefined {
|
||||
return value && typeof value === 'object' && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Owns the file-preview state machine for the mothership chat: the preview-session
|
||||
* store, the streaming-to-committed resource handoff bookkeeping, and translation
|
||||
* of synthetic preview-phase events into session updates and resource chrome. The
|
||||
* viewer renders committed binaries (see `useDocPreviewBinary`); this controller
|
||||
* decides which resource is active and when a generated file is promoted from the
|
||||
* synthetic `streaming-file` placeholder to its real workspace file.
|
||||
*/
|
||||
export function useFilePreviewController({
|
||||
workspaceId,
|
||||
setResources,
|
||||
setActiveResourceId,
|
||||
activeResourceIdRef,
|
||||
}: FilePreviewControllerDeps) {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const previewActivationOwnerRef = useRef<Map<string, string | null>>(new Map())
|
||||
const completedPreviewResourceHandoffRef = useRef<
|
||||
Map<string, { sessionId: string; suppressActivation: boolean }>
|
||||
>(new Map())
|
||||
|
||||
const rememberPreviewActivationOwner = useCallback(
|
||||
(session: FilePreviewSession) => {
|
||||
if (!session.fileId || previewActivationOwnerRef.current.has(session.id)) {
|
||||
return
|
||||
}
|
||||
previewActivationOwnerRef.current.set(session.id, activeResourceIdRef.current)
|
||||
},
|
||||
[activeResourceIdRef]
|
||||
)
|
||||
|
||||
const shouldAutoActivatePreviewSession = useCallback(
|
||||
(session: FilePreviewSession) => {
|
||||
if (!session.fileId) {
|
||||
return false
|
||||
}
|
||||
const currentActiveResourceId = activeResourceIdRef.current
|
||||
const activationOwnerId = previewActivationOwnerRef.current.get(session.id)
|
||||
return (
|
||||
currentActiveResourceId === null ||
|
||||
currentActiveResourceId === session.fileId ||
|
||||
currentActiveResourceId === 'streaming-file' ||
|
||||
currentActiveResourceId === activationOwnerId
|
||||
)
|
||||
},
|
||||
[activeResourceIdRef]
|
||||
)
|
||||
|
||||
const seedCompletedPreviewContentCache = useCallback(
|
||||
(fileId: string, previewText: string) => {
|
||||
queryClient.setQueriesData<string>(
|
||||
{ queryKey: workspaceFilesKeys.content(workspaceId, fileId, 'text') },
|
||||
previewText
|
||||
)
|
||||
|
||||
const activeFiles = queryClient.getQueryData<Array<{ id: string; key: string }>>(
|
||||
workspaceFilesKeys.list(workspaceId, 'active')
|
||||
)
|
||||
const fileKey = activeFiles?.find((file) => file.id === fileId)?.key
|
||||
if (fileKey) {
|
||||
queryClient.setQueryData(
|
||||
[...workspaceFilesKeys.content(workspaceId, fileId, 'text'), fileKey],
|
||||
previewText
|
||||
)
|
||||
}
|
||||
},
|
||||
[queryClient, workspaceId]
|
||||
)
|
||||
|
||||
const {
|
||||
previewSession,
|
||||
previewSessionsById,
|
||||
activePreviewSessionId,
|
||||
hydratePreviewSessions,
|
||||
upsertPreviewSession,
|
||||
completePreviewSession,
|
||||
removePreviewSession,
|
||||
resetPreviewSessions,
|
||||
} = useFilePreviewSessions()
|
||||
const previewSessionRef = useRef(previewSession)
|
||||
previewSessionRef.current = previewSession
|
||||
const previewSessionsRef = useRef(previewSessionsById)
|
||||
previewSessionsRef.current = previewSessionsById
|
||||
const activePreviewSessionIdRef = useRef(activePreviewSessionId)
|
||||
activePreviewSessionIdRef.current = activePreviewSessionId
|
||||
const latestPreviewTargetToolCallIdRef = useRef<string | null>(null)
|
||||
const previewSessionsStateRef = useRef<FilePreviewSessionsState>({
|
||||
activeSessionId: activePreviewSessionId,
|
||||
sessions: previewSessionsById,
|
||||
})
|
||||
previewSessionsStateRef.current = {
|
||||
activeSessionId: activePreviewSessionId,
|
||||
sessions: previewSessionsById,
|
||||
}
|
||||
|
||||
const syncPreviewSessionRefs = useCallback((nextState: FilePreviewSessionsState) => {
|
||||
previewSessionsStateRef.current = nextState
|
||||
previewSessionsRef.current = nextState.sessions
|
||||
activePreviewSessionIdRef.current = nextState.activeSessionId
|
||||
previewSessionRef.current =
|
||||
nextState.activeSessionId !== null
|
||||
? (nextState.sessions[nextState.activeSessionId] ?? null)
|
||||
: null
|
||||
}, [])
|
||||
|
||||
const applyPreviewSessionUpdate = useCallback(
|
||||
(session: FilePreviewSession, options?: { activate?: boolean }) => {
|
||||
const nextState = reduceFilePreviewSessions(previewSessionsStateRef.current, {
|
||||
type: 'upsert',
|
||||
session,
|
||||
...(options?.activate === false ? { activate: false } : {}),
|
||||
})
|
||||
syncPreviewSessionRefs(nextState)
|
||||
upsertPreviewSession(session, options)
|
||||
return nextState
|
||||
},
|
||||
[syncPreviewSessionRefs, upsertPreviewSession]
|
||||
)
|
||||
|
||||
const applyCompletedPreviewSession = useCallback(
|
||||
(session: FilePreviewSession) => {
|
||||
const nextState = reduceFilePreviewSessions(previewSessionsStateRef.current, {
|
||||
type: 'complete',
|
||||
session,
|
||||
})
|
||||
syncPreviewSessionRefs(nextState)
|
||||
completePreviewSession(session)
|
||||
return nextState
|
||||
},
|
||||
[completePreviewSession, syncPreviewSessionRefs]
|
||||
)
|
||||
|
||||
const reconcileTerminalPreviewSessions = useCallback(() => {
|
||||
const completedAt = new Date().toISOString()
|
||||
const completedSessions = buildCompletedPreviewSessions(
|
||||
previewSessionsStateRef.current.sessions,
|
||||
completedAt
|
||||
)
|
||||
|
||||
for (const session of completedSessions) {
|
||||
applyCompletedPreviewSession(session)
|
||||
}
|
||||
}, [applyCompletedPreviewSession])
|
||||
|
||||
const removePreviewSessionImmediate = useCallback(
|
||||
(sessionId: string) => {
|
||||
const nextState = reduceFilePreviewSessions(previewSessionsStateRef.current, {
|
||||
type: 'remove',
|
||||
sessionId,
|
||||
})
|
||||
syncPreviewSessionRefs(nextState)
|
||||
removePreviewSession(sessionId)
|
||||
return nextState
|
||||
},
|
||||
[removePreviewSession, syncPreviewSessionRefs]
|
||||
)
|
||||
|
||||
const resetEphemeralPreviewState = useCallback(
|
||||
(options?: { removeStreamingResource?: boolean }) => {
|
||||
previewActivationOwnerRef.current.clear()
|
||||
completedPreviewResourceHandoffRef.current.clear()
|
||||
latestPreviewTargetToolCallIdRef.current = null
|
||||
syncPreviewSessionRefs(INITIAL_FILE_PREVIEW_SESSIONS_STATE)
|
||||
resetPreviewSessions()
|
||||
if (options?.removeStreamingResource) {
|
||||
setResources((current) => current.filter((resource) => resource.id !== 'streaming-file'))
|
||||
}
|
||||
},
|
||||
[resetPreviewSessions, setResources, syncPreviewSessionRefs]
|
||||
)
|
||||
|
||||
const promoteFileResource = useCallback(
|
||||
(fileId: string, title: string) => {
|
||||
setResources((current) => {
|
||||
const withoutStreaming = current.filter((resource) => resource.id !== 'streaming-file')
|
||||
if (
|
||||
withoutStreaming.some((resource) => resource.type === 'file' && resource.id === fileId)
|
||||
) {
|
||||
return withoutStreaming
|
||||
}
|
||||
return [...withoutStreaming, { type: 'file', id: fileId, title }]
|
||||
})
|
||||
},
|
||||
[setResources]
|
||||
)
|
||||
|
||||
const syncPreviewResourceChrome = useCallback(
|
||||
(session: FilePreviewSession, options?: { activate?: boolean }) => {
|
||||
if (session.targetKind === 'new_file') {
|
||||
setResources((current) => {
|
||||
const existing = current.find((resource) => resource.id === 'streaming-file')
|
||||
if (existing) {
|
||||
return current.map((resource) =>
|
||||
resource.id === 'streaming-file'
|
||||
? { ...resource, title: session.fileName || 'Writing file...' }
|
||||
: resource
|
||||
)
|
||||
}
|
||||
return [
|
||||
...current,
|
||||
{ type: 'file', id: 'streaming-file', title: session.fileName || 'Writing file...' },
|
||||
]
|
||||
})
|
||||
setActiveResourceId('streaming-file')
|
||||
return
|
||||
}
|
||||
|
||||
if (session.fileId && hasRenderableFilePreviewContent(session)) {
|
||||
promoteFileResource(session.fileId, session.fileName || 'File')
|
||||
if (options?.activate !== false) {
|
||||
setActiveResourceId(session.fileId)
|
||||
}
|
||||
}
|
||||
},
|
||||
[promoteFileResource, setActiveResourceId, setResources]
|
||||
)
|
||||
|
||||
const seedPreviewSessions = useCallback(
|
||||
(sessions: FilePreviewSession[]) => {
|
||||
if (sessions.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const nextState = reduceFilePreviewSessions(previewSessionsStateRef.current, {
|
||||
type: 'hydrate',
|
||||
sessions,
|
||||
})
|
||||
syncPreviewSessionRefs(nextState)
|
||||
hydratePreviewSessions(sessions)
|
||||
const active =
|
||||
nextState.activeSessionId !== null
|
||||
? (nextState.sessions[nextState.activeSessionId] ?? null)
|
||||
: null
|
||||
if (active) {
|
||||
syncPreviewResourceChrome(active, {
|
||||
activate: active.targetKind === 'new_file' || shouldAutoActivatePreviewSession(active),
|
||||
})
|
||||
}
|
||||
},
|
||||
[
|
||||
hydratePreviewSessions,
|
||||
shouldAutoActivatePreviewSession,
|
||||
syncPreviewResourceChrome,
|
||||
syncPreviewSessionRefs,
|
||||
]
|
||||
)
|
||||
|
||||
const onPreviewPhase = useCallback(
|
||||
(payload: SyntheticFilePreviewPayload, streamId: string | undefined) => {
|
||||
const id = payload.toolCallId
|
||||
const prevSession = previewSessionsRef.current[id]
|
||||
const nextSession = deriveFilePreviewSession(
|
||||
prevSession,
|
||||
payload,
|
||||
streamId,
|
||||
new Date().toISOString()
|
||||
)
|
||||
|
||||
if (payload.previewPhase === 'file_preview_start') {
|
||||
latestPreviewTargetToolCallIdRef.current = id
|
||||
applyPreviewSessionUpdate(nextSession)
|
||||
return
|
||||
}
|
||||
|
||||
if (payload.previewPhase === 'file_preview_target') {
|
||||
latestPreviewTargetToolCallIdRef.current = id
|
||||
rememberPreviewActivationOwner(nextSession)
|
||||
const nextState = applyPreviewSessionUpdate(nextSession)
|
||||
const activePreview =
|
||||
nextState.activeSessionId !== null
|
||||
? (nextState.sessions[nextState.activeSessionId] ?? null)
|
||||
: null
|
||||
if (activePreview?.id === nextSession.id) {
|
||||
syncPreviewResourceChrome(activePreview, {
|
||||
activate:
|
||||
activePreview.targetKind === 'new_file' ||
|
||||
shouldAutoActivatePreviewSession(activePreview),
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (payload.previewPhase === 'file_preview_edit_meta') {
|
||||
applyPreviewSessionUpdate(nextSession)
|
||||
return
|
||||
}
|
||||
|
||||
if (payload.previewPhase === 'file_preview_content') {
|
||||
applyPreviewSessionUpdate(nextSession)
|
||||
if (!prevSession || !hasRenderableFilePreviewContent(prevSession)) {
|
||||
syncPreviewResourceChrome(nextSession, {
|
||||
activate:
|
||||
nextSession.targetKind === 'new_file' ||
|
||||
shouldAutoActivatePreviewSession(nextSession),
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const resultData = asPayloadRecord(payload.output)
|
||||
const outputData = asPayloadRecord(resultData?.data) ?? resultData
|
||||
const wasRenderableBeforeComplete =
|
||||
prevSession !== undefined && hasRenderableFilePreviewContent(prevSession)
|
||||
const nextState = applyCompletedPreviewSession(nextSession)
|
||||
const fileId = nextSession.fileId
|
||||
|
||||
if (fileId && resultData?.success === true && outputData?.id === fileId) {
|
||||
const fileName = (outputData.name as string) ?? nextSession.fileName ?? 'File'
|
||||
promoteFileResource(fileId, fileName)
|
||||
const completedExt = fileName.includes('.')
|
||||
? (fileName.split('.').pop()?.toLowerCase() ?? '')
|
||||
: ''
|
||||
const isCompiledDocPreview = ['docx', 'pptx', 'pdf', 'xlsx'].includes(completedExt)
|
||||
const shouldActivateOnComplete =
|
||||
(isCompiledDocPreview ||
|
||||
(!wasRenderableBeforeComplete && hasRenderableFilePreviewContent(nextSession))) &&
|
||||
shouldAutoActivatePreviewSession(nextSession)
|
||||
if (shouldActivateOnComplete) {
|
||||
setActiveResourceId(fileId)
|
||||
}
|
||||
completedPreviewResourceHandoffRef.current.set(fileId, {
|
||||
sessionId: nextSession.id,
|
||||
suppressActivation: !shouldActivateOnComplete,
|
||||
})
|
||||
if (hasRenderableFilePreviewContent(nextSession)) {
|
||||
seedCompletedPreviewContentCache(fileId, nextSession.previewText)
|
||||
}
|
||||
invalidateResourceQueries(queryClient, workspaceId, 'file', fileId)
|
||||
} else {
|
||||
const activePreview =
|
||||
nextState.activeSessionId !== null
|
||||
? (nextState.sessions[nextState.activeSessionId] ?? null)
|
||||
: null
|
||||
if (activePreview) {
|
||||
syncPreviewResourceChrome(activePreview, {
|
||||
activate:
|
||||
activePreview.targetKind === 'new_file' ||
|
||||
shouldAutoActivatePreviewSession(activePreview),
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
[
|
||||
applyCompletedPreviewSession,
|
||||
applyPreviewSessionUpdate,
|
||||
promoteFileResource,
|
||||
queryClient,
|
||||
rememberPreviewActivationOwner,
|
||||
seedCompletedPreviewContentCache,
|
||||
setActiveResourceId,
|
||||
shouldAutoActivatePreviewSession,
|
||||
syncPreviewResourceChrome,
|
||||
workspaceId,
|
||||
]
|
||||
)
|
||||
|
||||
return {
|
||||
previewSession,
|
||||
previewSessionRef,
|
||||
previewSessionsRef,
|
||||
activePreviewSessionIdRef,
|
||||
latestPreviewTargetToolCallIdRef,
|
||||
previewActivationOwnerRef,
|
||||
completedPreviewResourceHandoffRef,
|
||||
shouldAutoActivatePreviewSession,
|
||||
applyPreviewSessionUpdate,
|
||||
removePreviewSessionImmediate,
|
||||
reconcileTerminalPreviewSessions,
|
||||
resetEphemeralPreviewState,
|
||||
promoteFileResource,
|
||||
seedPreviewSessions,
|
||||
onPreviewPhase,
|
||||
}
|
||||
}
|
||||
+567
@@ -0,0 +1,567 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { FilePreviewSession } from '@/lib/copilot/request/session'
|
||||
import {
|
||||
buildCompletedPreviewSessions,
|
||||
hasRenderableFilePreviewContent,
|
||||
INITIAL_FILE_PREVIEW_SESSIONS_STATE,
|
||||
pickActiveSessionId,
|
||||
reduceFilePreviewSessions,
|
||||
shouldReplaceSession,
|
||||
} from '@/app/workspace/[workspaceId]/home/hooks/preview/use-file-preview-sessions'
|
||||
|
||||
function createSession(
|
||||
overrides: Partial<FilePreviewSession> & Pick<FilePreviewSession, 'id' | 'toolCallId'>
|
||||
): FilePreviewSession {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
id: overrides.id,
|
||||
streamId: overrides.streamId ?? 'stream-1',
|
||||
toolCallId: overrides.toolCallId,
|
||||
status: overrides.status ?? 'streaming',
|
||||
fileName: overrides.fileName ?? `${overrides.id}.md`,
|
||||
previewText: overrides.previewText ?? '',
|
||||
previewVersion: overrides.previewVersion ?? 1,
|
||||
updatedAt: overrides.updatedAt ?? '2026-04-10T00:00:00.000Z',
|
||||
...(overrides.fileId ? { fileId: overrides.fileId } : {}),
|
||||
...(overrides.targetKind ? { targetKind: overrides.targetKind } : {}),
|
||||
...(overrides.operation ? { operation: overrides.operation } : {}),
|
||||
...(overrides.edit ? { edit: overrides.edit } : {}),
|
||||
...(overrides.completedAt ? { completedAt: overrides.completedAt } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
describe('reduceFilePreviewSessions', () => {
|
||||
it('does not treat a pending empty preview as renderable content', () => {
|
||||
expect(
|
||||
hasRenderableFilePreviewContent(
|
||||
createSession({
|
||||
id: 'preview-1',
|
||||
toolCallId: 'preview-1',
|
||||
status: 'pending',
|
||||
previewText: '',
|
||||
previewVersion: 0,
|
||||
})
|
||||
)
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('treats emitted preview snapshots as renderable even when empty', () => {
|
||||
expect(
|
||||
hasRenderableFilePreviewContent(
|
||||
createSession({
|
||||
id: 'preview-1',
|
||||
toolCallId: 'preview-1',
|
||||
status: 'streaming',
|
||||
previewText: '',
|
||||
previewVersion: 1,
|
||||
})
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('does not replace a completed session with same-version replayed streaming events', () => {
|
||||
const completed = createSession({
|
||||
id: 'preview-1',
|
||||
toolCallId: 'preview-1',
|
||||
status: 'complete',
|
||||
previewText: 'final',
|
||||
previewVersion: 2,
|
||||
updatedAt: '2026-04-10T00:00:02.000Z',
|
||||
completedAt: '2026-04-10T00:00:02.000Z',
|
||||
})
|
||||
const replayedStreaming = createSession({
|
||||
id: 'preview-1',
|
||||
toolCallId: 'preview-1',
|
||||
status: 'streaming',
|
||||
previewText: 'final',
|
||||
previewVersion: 2,
|
||||
updatedAt: '2026-04-10T00:00:03.000Z',
|
||||
})
|
||||
|
||||
expect(shouldReplaceSession(completed, replayedStreaming)).toBe(false)
|
||||
})
|
||||
|
||||
it('builds complete sessions for terminal stream reconciliation', () => {
|
||||
const completedAt = '2026-04-10T00:00:10.000Z'
|
||||
const nextSessions = buildCompletedPreviewSessions(
|
||||
{
|
||||
'preview-1': createSession({
|
||||
id: 'preview-1',
|
||||
toolCallId: 'preview-1',
|
||||
status: 'pending',
|
||||
previewText: 'draft',
|
||||
}),
|
||||
'preview-2': createSession({
|
||||
id: 'preview-2',
|
||||
toolCallId: 'preview-2',
|
||||
status: 'streaming',
|
||||
previewText: 'partial',
|
||||
}),
|
||||
'preview-3': createSession({
|
||||
id: 'preview-3',
|
||||
toolCallId: 'preview-3',
|
||||
status: 'complete',
|
||||
previewText: 'done',
|
||||
completedAt: '2026-04-10T00:00:03.000Z',
|
||||
}),
|
||||
},
|
||||
completedAt
|
||||
)
|
||||
|
||||
expect(nextSessions).toHaveLength(2)
|
||||
expect(nextSessions.map((session) => session.id)).toEqual(['preview-1', 'preview-2'])
|
||||
expect(nextSessions.every((session) => session.status === 'complete')).toBe(true)
|
||||
expect(nextSessions.every((session) => session.updatedAt === completedAt)).toBe(true)
|
||||
expect(nextSessions.every((session) => session.completedAt === completedAt)).toBe(true)
|
||||
})
|
||||
|
||||
it('hydrates the latest active preview session', () => {
|
||||
const state = reduceFilePreviewSessions(INITIAL_FILE_PREVIEW_SESSIONS_STATE, {
|
||||
type: 'hydrate',
|
||||
sessions: [
|
||||
createSession({
|
||||
id: 'preview-1',
|
||||
toolCallId: 'preview-1',
|
||||
previewVersion: 1,
|
||||
updatedAt: '2026-04-10T00:00:00.000Z',
|
||||
}),
|
||||
createSession({
|
||||
id: 'preview-2',
|
||||
toolCallId: 'preview-2',
|
||||
previewVersion: 2,
|
||||
updatedAt: '2026-04-10T00:00:01.000Z',
|
||||
previewText: 'latest',
|
||||
}),
|
||||
],
|
||||
})
|
||||
|
||||
expect(state.activeSessionId).toBe('preview-2')
|
||||
expect(state.sessions['preview-2']?.previewText).toBe('latest')
|
||||
})
|
||||
|
||||
it('drops the active session when it completes and promotes the next active session', () => {
|
||||
const hydratedState = reduceFilePreviewSessions(INITIAL_FILE_PREVIEW_SESSIONS_STATE, {
|
||||
type: 'hydrate',
|
||||
sessions: [
|
||||
createSession({
|
||||
id: 'preview-1',
|
||||
toolCallId: 'preview-1',
|
||||
previewVersion: 1,
|
||||
updatedAt: '2026-04-10T00:00:00.000Z',
|
||||
}),
|
||||
createSession({
|
||||
id: 'preview-2',
|
||||
toolCallId: 'preview-2',
|
||||
previewVersion: 2,
|
||||
updatedAt: '2026-04-10T00:00:01.000Z',
|
||||
}),
|
||||
],
|
||||
})
|
||||
const completedState = reduceFilePreviewSessions(hydratedState, {
|
||||
type: 'complete',
|
||||
session: createSession({
|
||||
id: 'preview-2',
|
||||
toolCallId: 'preview-2',
|
||||
status: 'complete',
|
||||
previewVersion: 3,
|
||||
updatedAt: '2026-04-10T00:00:02.000Z',
|
||||
completedAt: '2026-04-10T00:00:02.000Z',
|
||||
}),
|
||||
})
|
||||
|
||||
expect(completedState.activeSessionId).toBe('preview-1')
|
||||
expect(completedState.sessions['preview-1']?.id).toBe('preview-1')
|
||||
})
|
||||
|
||||
it('lingers on the completed session when it is the only one (no successor)', () => {
|
||||
const onlyStreaming = reduceFilePreviewSessions(INITIAL_FILE_PREVIEW_SESSIONS_STATE, {
|
||||
type: 'upsert',
|
||||
session: createSession({
|
||||
id: 'preview-1',
|
||||
toolCallId: 'preview-1',
|
||||
previewVersion: 2,
|
||||
updatedAt: '2026-04-10T00:00:01.000Z',
|
||||
previewText: 'final',
|
||||
}),
|
||||
})
|
||||
|
||||
const completed = reduceFilePreviewSessions(onlyStreaming, {
|
||||
type: 'complete',
|
||||
session: createSession({
|
||||
id: 'preview-1',
|
||||
toolCallId: 'preview-1',
|
||||
status: 'complete',
|
||||
previewVersion: 3,
|
||||
updatedAt: '2026-04-10T00:00:02.000Z',
|
||||
completedAt: '2026-04-10T00:00:02.000Z',
|
||||
previewText: 'final',
|
||||
}),
|
||||
})
|
||||
|
||||
expect(completed.activeSessionId).toBe('preview-1')
|
||||
expect(completed.sessions['preview-1']?.status).toBe('complete')
|
||||
})
|
||||
|
||||
it('releases the linger when a new non-complete session upserts', () => {
|
||||
const lingered = reduceFilePreviewSessions(INITIAL_FILE_PREVIEW_SESSIONS_STATE, {
|
||||
type: 'upsert',
|
||||
session: createSession({
|
||||
id: 'preview-1',
|
||||
toolCallId: 'preview-1',
|
||||
previewVersion: 2,
|
||||
previewText: 'section one',
|
||||
}),
|
||||
})
|
||||
const afterComplete = reduceFilePreviewSessions(lingered, {
|
||||
type: 'complete',
|
||||
session: createSession({
|
||||
id: 'preview-1',
|
||||
toolCallId: 'preview-1',
|
||||
status: 'complete',
|
||||
previewVersion: 3,
|
||||
completedAt: '2026-04-10T00:00:02.000Z',
|
||||
previewText: 'section one',
|
||||
}),
|
||||
})
|
||||
|
||||
// New tool call arrives with content — should switch active to the new session.
|
||||
const afterNew = reduceFilePreviewSessions(afterComplete, {
|
||||
type: 'upsert',
|
||||
session: createSession({
|
||||
id: 'preview-2',
|
||||
toolCallId: 'preview-2',
|
||||
status: 'streaming',
|
||||
previewVersion: 1,
|
||||
previewText: 'section two',
|
||||
}),
|
||||
})
|
||||
|
||||
expect(afterNew.activeSessionId).toBe('preview-2')
|
||||
})
|
||||
|
||||
it('holds the linger when an empty pending session arrives (no content yet)', () => {
|
||||
const lingered = reduceFilePreviewSessions(INITIAL_FILE_PREVIEW_SESSIONS_STATE, {
|
||||
type: 'upsert',
|
||||
session: createSession({
|
||||
id: 'preview-1',
|
||||
toolCallId: 'preview-1',
|
||||
previewVersion: 2,
|
||||
previewText: 'existing content',
|
||||
}),
|
||||
})
|
||||
const afterComplete = reduceFilePreviewSessions(lingered, {
|
||||
type: 'complete',
|
||||
session: createSession({
|
||||
id: 'preview-1',
|
||||
toolCallId: 'preview-1',
|
||||
status: 'complete',
|
||||
previewVersion: 3,
|
||||
completedAt: '2026-04-10T00:00:02.000Z',
|
||||
previewText: 'existing content',
|
||||
}),
|
||||
})
|
||||
|
||||
const afterEmptyUpsert = reduceFilePreviewSessions(afterComplete, {
|
||||
type: 'upsert',
|
||||
session: createSession({
|
||||
id: 'preview-2',
|
||||
toolCallId: 'preview-2',
|
||||
status: 'pending',
|
||||
previewVersion: 0,
|
||||
previewText: '',
|
||||
}),
|
||||
})
|
||||
|
||||
expect(afterEmptyUpsert.activeSessionId).toBe('preview-1')
|
||||
expect(pickActiveSessionId(afterEmptyUpsert.sessions, null)).toBe('preview-2')
|
||||
|
||||
const afterContent = reduceFilePreviewSessions(afterEmptyUpsert, {
|
||||
type: 'upsert',
|
||||
session: createSession({
|
||||
id: 'preview-2',
|
||||
toolCallId: 'preview-2',
|
||||
status: 'streaming',
|
||||
previewVersion: 1,
|
||||
previewText: 'new content',
|
||||
}),
|
||||
})
|
||||
|
||||
expect(afterContent.activeSessionId).toBe('preview-2')
|
||||
})
|
||||
|
||||
it('ignores stale complete events for a newer active session', () => {
|
||||
const activeState = reduceFilePreviewSessions(INITIAL_FILE_PREVIEW_SESSIONS_STATE, {
|
||||
type: 'upsert',
|
||||
session: createSession({
|
||||
id: 'preview-1',
|
||||
toolCallId: 'preview-1',
|
||||
previewVersion: 3,
|
||||
updatedAt: '2026-04-10T00:00:03.000Z',
|
||||
}),
|
||||
})
|
||||
|
||||
const staleCompleteState = reduceFilePreviewSessions(activeState, {
|
||||
type: 'complete',
|
||||
session: createSession({
|
||||
id: 'preview-1',
|
||||
toolCallId: 'preview-1',
|
||||
status: 'complete',
|
||||
previewVersion: 2,
|
||||
updatedAt: '2026-04-10T00:00:02.000Z',
|
||||
completedAt: '2026-04-10T00:00:02.000Z',
|
||||
}),
|
||||
})
|
||||
|
||||
expect(staleCompleteState.activeSessionId).toBe('preview-1')
|
||||
expect(staleCompleteState.sessions['preview-1']?.status).toBe('streaming')
|
||||
expect(staleCompleteState.sessions['preview-1']?.previewVersion).toBe(3)
|
||||
})
|
||||
|
||||
it('removes a session and clears activeSessionId when the active session is removed', () => {
|
||||
const withSession = reduceFilePreviewSessions(INITIAL_FILE_PREVIEW_SESSIONS_STATE, {
|
||||
type: 'upsert',
|
||||
session: createSession({
|
||||
id: 'preview-1',
|
||||
toolCallId: 'preview-1',
|
||||
previewVersion: 1,
|
||||
previewText: 'content',
|
||||
}),
|
||||
})
|
||||
|
||||
const removed = reduceFilePreviewSessions(withSession, {
|
||||
type: 'remove',
|
||||
sessionId: 'preview-1',
|
||||
})
|
||||
|
||||
expect(removed.activeSessionId).toBeNull()
|
||||
expect(removed.sessions['preview-1']).toBeUndefined()
|
||||
})
|
||||
|
||||
it('removes a non-active session without changing activeSessionId', () => {
|
||||
let state = reduceFilePreviewSessions(INITIAL_FILE_PREVIEW_SESSIONS_STATE, {
|
||||
type: 'upsert',
|
||||
session: createSession({
|
||||
id: 'preview-1',
|
||||
toolCallId: 'preview-1',
|
||||
previewVersion: 1,
|
||||
previewText: 'active',
|
||||
}),
|
||||
})
|
||||
state = reduceFilePreviewSessions(state, {
|
||||
type: 'upsert',
|
||||
session: createSession({
|
||||
id: 'preview-2',
|
||||
toolCallId: 'preview-2',
|
||||
previewVersion: 1,
|
||||
previewText: 'inactive',
|
||||
status: 'complete',
|
||||
completedAt: '2026-04-10T00:00:01.000Z',
|
||||
}),
|
||||
})
|
||||
|
||||
const removed = reduceFilePreviewSessions(state, {
|
||||
type: 'remove',
|
||||
sessionId: 'preview-2',
|
||||
})
|
||||
|
||||
expect(removed.activeSessionId).toBe('preview-1')
|
||||
expect(removed.sessions['preview-2']).toBeUndefined()
|
||||
expect(removed.sessions['preview-1']).toBeDefined()
|
||||
})
|
||||
|
||||
it('removing a non-existent session is a no-op', () => {
|
||||
const state = reduceFilePreviewSessions(INITIAL_FILE_PREVIEW_SESSIONS_STATE, {
|
||||
type: 'upsert',
|
||||
session: createSession({ id: 'preview-1', toolCallId: 'preview-1', previewVersion: 1 }),
|
||||
})
|
||||
|
||||
const next = reduceFilePreviewSessions(state, { type: 'remove', sessionId: 'does-not-exist' })
|
||||
|
||||
expect(next).toBe(state)
|
||||
})
|
||||
|
||||
it('reset clears all sessions and activeSessionId', () => {
|
||||
let state = reduceFilePreviewSessions(INITIAL_FILE_PREVIEW_SESSIONS_STATE, {
|
||||
type: 'upsert',
|
||||
session: createSession({ id: 'preview-1', toolCallId: 'preview-1', previewVersion: 1 }),
|
||||
})
|
||||
state = reduceFilePreviewSessions(state, {
|
||||
type: 'complete',
|
||||
session: createSession({
|
||||
id: 'preview-1',
|
||||
toolCallId: 'preview-1',
|
||||
status: 'complete',
|
||||
previewVersion: 2,
|
||||
completedAt: '2026-04-10T00:00:02.000Z',
|
||||
previewText: 'final',
|
||||
}),
|
||||
})
|
||||
|
||||
const reset = reduceFilePreviewSessions(state, { type: 'reset' })
|
||||
|
||||
expect(reset.activeSessionId).toBeNull()
|
||||
expect(Object.keys(reset.sessions)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('hydrate with an empty sessions array is a no-op', () => {
|
||||
const state = reduceFilePreviewSessions(INITIAL_FILE_PREVIEW_SESSIONS_STATE, {
|
||||
type: 'upsert',
|
||||
session: createSession({ id: 'preview-1', toolCallId: 'preview-1', previewVersion: 1 }),
|
||||
})
|
||||
|
||||
const next = reduceFilePreviewSessions(state, { type: 'hydrate', sessions: [] })
|
||||
|
||||
expect(next).toBe(state)
|
||||
})
|
||||
|
||||
it('hydrate merges incoming sessions into existing state without replacing non-stale sessions', () => {
|
||||
const existing = reduceFilePreviewSessions(INITIAL_FILE_PREVIEW_SESSIONS_STATE, {
|
||||
type: 'upsert',
|
||||
session: createSession({
|
||||
id: 'preview-1',
|
||||
toolCallId: 'preview-1',
|
||||
previewVersion: 3,
|
||||
updatedAt: '2026-04-10T00:00:03.000Z',
|
||||
previewText: 'current',
|
||||
}),
|
||||
})
|
||||
|
||||
const hydrated = reduceFilePreviewSessions(existing, {
|
||||
type: 'hydrate',
|
||||
sessions: [
|
||||
createSession({
|
||||
id: 'preview-1',
|
||||
toolCallId: 'preview-1',
|
||||
previewVersion: 2,
|
||||
updatedAt: '2026-04-10T00:00:02.000Z',
|
||||
previewText: 'stale',
|
||||
}),
|
||||
createSession({
|
||||
id: 'preview-2',
|
||||
toolCallId: 'preview-2',
|
||||
previewVersion: 1,
|
||||
updatedAt: '2026-04-10T00:00:04.000Z',
|
||||
previewText: 'new',
|
||||
}),
|
||||
],
|
||||
})
|
||||
|
||||
expect(hydrated.sessions['preview-1']?.previewVersion).toBe(3)
|
||||
expect(hydrated.sessions['preview-1']?.previewText).toBe('current')
|
||||
expect(hydrated.sessions['preview-2']?.previewText).toBe('new')
|
||||
})
|
||||
|
||||
it('hydrate preserves linger when no non-complete session exists in incoming batch', () => {
|
||||
const lingered = reduceFilePreviewSessions(
|
||||
reduceFilePreviewSessions(INITIAL_FILE_PREVIEW_SESSIONS_STATE, {
|
||||
type: 'upsert',
|
||||
session: createSession({
|
||||
id: 'preview-1',
|
||||
toolCallId: 'preview-1',
|
||||
previewVersion: 2,
|
||||
previewText: 'final',
|
||||
}),
|
||||
}),
|
||||
{
|
||||
type: 'complete',
|
||||
session: createSession({
|
||||
id: 'preview-1',
|
||||
toolCallId: 'preview-1',
|
||||
status: 'complete',
|
||||
previewVersion: 3,
|
||||
completedAt: '2026-04-10T00:00:02.000Z',
|
||||
previewText: 'final',
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
||||
// Hydrate with the same completed session — no non-complete successor.
|
||||
const afterHydrate = reduceFilePreviewSessions(lingered, {
|
||||
type: 'hydrate',
|
||||
sessions: [
|
||||
createSession({
|
||||
id: 'preview-1',
|
||||
toolCallId: 'preview-1',
|
||||
status: 'complete',
|
||||
previewVersion: 3,
|
||||
previewText: 'final',
|
||||
completedAt: '2026-04-10T00:00:02.000Z',
|
||||
}),
|
||||
],
|
||||
})
|
||||
|
||||
expect(afterHydrate.activeSessionId).toBe('preview-1')
|
||||
})
|
||||
|
||||
it('hydrate releases linger when a non-complete session is present in the incoming batch', () => {
|
||||
const lingered = reduceFilePreviewSessions(
|
||||
reduceFilePreviewSessions(INITIAL_FILE_PREVIEW_SESSIONS_STATE, {
|
||||
type: 'upsert',
|
||||
session: createSession({
|
||||
id: 'preview-1',
|
||||
toolCallId: 'preview-1',
|
||||
previewVersion: 2,
|
||||
previewText: 'final',
|
||||
}),
|
||||
}),
|
||||
{
|
||||
type: 'complete',
|
||||
session: createSession({
|
||||
id: 'preview-1',
|
||||
toolCallId: 'preview-1',
|
||||
status: 'complete',
|
||||
previewVersion: 3,
|
||||
completedAt: '2026-04-10T00:00:02.000Z',
|
||||
previewText: 'final',
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
||||
const afterHydrate = reduceFilePreviewSessions(lingered, {
|
||||
type: 'hydrate',
|
||||
sessions: [
|
||||
createSession({
|
||||
id: 'preview-2',
|
||||
toolCallId: 'preview-2',
|
||||
status: 'streaming',
|
||||
previewVersion: 1,
|
||||
previewText: 'new content',
|
||||
}),
|
||||
],
|
||||
})
|
||||
|
||||
expect(afterHydrate.activeSessionId).toBe('preview-2')
|
||||
})
|
||||
|
||||
it('complete for a non-active session updates the session but keeps activeSessionId', () => {
|
||||
let state = reduceFilePreviewSessions(INITIAL_FILE_PREVIEW_SESSIONS_STATE, {
|
||||
type: 'upsert',
|
||||
session: createSession({ id: 'preview-1', toolCallId: 'preview-1', previewVersion: 1 }),
|
||||
})
|
||||
state = reduceFilePreviewSessions(state, {
|
||||
type: 'upsert',
|
||||
session: createSession({
|
||||
id: 'preview-2',
|
||||
toolCallId: 'preview-2',
|
||||
previewVersion: 1,
|
||||
previewText: 'background',
|
||||
}),
|
||||
})
|
||||
const completed = reduceFilePreviewSessions(state, {
|
||||
type: 'complete',
|
||||
session: createSession({
|
||||
id: 'preview-1',
|
||||
toolCallId: 'preview-1',
|
||||
status: 'complete',
|
||||
previewVersion: 2,
|
||||
completedAt: '2026-04-10T00:00:02.000Z',
|
||||
}),
|
||||
})
|
||||
|
||||
expect(completed.activeSessionId).toBe('preview-2')
|
||||
expect(completed.sessions['preview-1']?.status).toBe('complete')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,224 @@
|
||||
import { useCallback, useMemo, useReducer } from 'react'
|
||||
import type { FilePreviewSession } from '@/lib/copilot/request/session'
|
||||
|
||||
export interface FilePreviewSessionsState {
|
||||
activeSessionId: string | null
|
||||
sessions: Record<string, FilePreviewSession>
|
||||
}
|
||||
|
||||
export type FilePreviewSessionsAction =
|
||||
| { type: 'hydrate'; sessions: FilePreviewSession[] }
|
||||
| { type: 'upsert'; session: FilePreviewSession; activate?: boolean }
|
||||
| { type: 'complete'; session: FilePreviewSession }
|
||||
| { type: 'remove'; sessionId: string }
|
||||
| { type: 'reset' }
|
||||
|
||||
export const INITIAL_FILE_PREVIEW_SESSIONS_STATE: FilePreviewSessionsState = {
|
||||
activeSessionId: null,
|
||||
sessions: {},
|
||||
}
|
||||
|
||||
export function hasRenderableFilePreviewContent(session: FilePreviewSession): boolean {
|
||||
return session.previewText.length > 0 || session.previewVersion > 0
|
||||
}
|
||||
|
||||
export function shouldReplaceSession(
|
||||
current: FilePreviewSession | undefined,
|
||||
next: FilePreviewSession
|
||||
): boolean {
|
||||
if (!current) return true
|
||||
if (
|
||||
current.status === 'complete' &&
|
||||
next.status !== 'complete' &&
|
||||
next.previewVersion <= current.previewVersion
|
||||
) {
|
||||
return false
|
||||
}
|
||||
if (next.previewVersion !== current.previewVersion) {
|
||||
return next.previewVersion > current.previewVersion
|
||||
}
|
||||
return next.updatedAt >= current.updatedAt
|
||||
}
|
||||
|
||||
export function pickActiveSessionId(
|
||||
sessions: Record<string, FilePreviewSession>,
|
||||
preferredId?: string | null
|
||||
): string | null {
|
||||
if (preferredId && sessions[preferredId]?.status !== 'complete') {
|
||||
return preferredId
|
||||
}
|
||||
|
||||
let latestActive: FilePreviewSession | null = null
|
||||
for (const session of Object.values(sessions)) {
|
||||
if (session.status === 'complete') continue
|
||||
if (!latestActive || shouldReplaceSession(latestActive, session)) {
|
||||
latestActive = session
|
||||
}
|
||||
}
|
||||
|
||||
return latestActive?.id ?? null
|
||||
}
|
||||
|
||||
export function buildCompletedPreviewSessions(
|
||||
sessions: Record<string, FilePreviewSession>,
|
||||
completedAt: string
|
||||
): FilePreviewSession[] {
|
||||
return Object.values(sessions)
|
||||
.filter((session) => session.status !== 'complete')
|
||||
.map((session) => ({
|
||||
...session,
|
||||
status: 'complete' as const,
|
||||
updatedAt: completedAt,
|
||||
completedAt,
|
||||
}))
|
||||
}
|
||||
|
||||
export function reduceFilePreviewSessions(
|
||||
state: FilePreviewSessionsState,
|
||||
action: FilePreviewSessionsAction
|
||||
): FilePreviewSessionsState {
|
||||
switch (action.type) {
|
||||
case 'hydrate': {
|
||||
if (action.sessions.length === 0) {
|
||||
return state
|
||||
}
|
||||
|
||||
const nextSessions = { ...state.sessions }
|
||||
for (const session of action.sessions) {
|
||||
if (shouldReplaceSession(nextSessions[session.id], session)) {
|
||||
nextSessions[session.id] = session
|
||||
}
|
||||
}
|
||||
|
||||
const successor = pickActiveSessionId(nextSessions, state.activeSessionId)
|
||||
return {
|
||||
sessions: nextSessions,
|
||||
activeSessionId: successor ?? state.activeSessionId,
|
||||
}
|
||||
}
|
||||
|
||||
case 'upsert': {
|
||||
if (!shouldReplaceSession(state.sessions[action.session.id], action.session)) {
|
||||
return state
|
||||
}
|
||||
|
||||
const nextSessions = {
|
||||
...state.sessions,
|
||||
[action.session.id]: action.session,
|
||||
}
|
||||
|
||||
let nextActiveSessionId: string | null
|
||||
if (action.activate === false || action.session.status === 'complete') {
|
||||
const successor = pickActiveSessionId(nextSessions, state.activeSessionId)
|
||||
nextActiveSessionId = successor ?? state.activeSessionId
|
||||
} else {
|
||||
// Don't switch to a new session until it has renderable content — keeps the viewer mounted.
|
||||
const currentActive = state.activeSessionId ? nextSessions[state.activeSessionId] : null
|
||||
const currentHasContent = currentActive
|
||||
? hasRenderableFilePreviewContent(currentActive)
|
||||
: false
|
||||
const incomingHasContent = hasRenderableFilePreviewContent(action.session)
|
||||
nextActiveSessionId =
|
||||
currentHasContent && !incomingHasContent ? state.activeSessionId : action.session.id
|
||||
}
|
||||
|
||||
return { sessions: nextSessions, activeSessionId: nextActiveSessionId }
|
||||
}
|
||||
|
||||
case 'complete': {
|
||||
if (!shouldReplaceSession(state.sessions[action.session.id], action.session)) {
|
||||
return state
|
||||
}
|
||||
|
||||
const nextSessions = {
|
||||
...state.sessions,
|
||||
[action.session.id]: action.session,
|
||||
}
|
||||
|
||||
if (state.activeSessionId !== action.session.id) {
|
||||
return { sessions: nextSessions, activeSessionId: state.activeSessionId }
|
||||
}
|
||||
|
||||
const successor = pickActiveSessionId(nextSessions, null)
|
||||
return {
|
||||
sessions: nextSessions,
|
||||
// Linger on this session until a successor upserts. Without it, streamingContent
|
||||
// becomes undefined between tool calls, collapsing the viewer and clipping scrollTop.
|
||||
activeSessionId: successor ?? action.session.id,
|
||||
}
|
||||
}
|
||||
|
||||
case 'remove': {
|
||||
if (!state.sessions[action.sessionId]) {
|
||||
return state
|
||||
}
|
||||
|
||||
const nextSessions = { ...state.sessions }
|
||||
delete nextSessions[action.sessionId]
|
||||
|
||||
return {
|
||||
sessions: nextSessions,
|
||||
activeSessionId:
|
||||
state.activeSessionId === action.sessionId
|
||||
? pickActiveSessionId(nextSessions, null)
|
||||
: state.activeSessionId,
|
||||
}
|
||||
}
|
||||
|
||||
case 'reset':
|
||||
return INITIAL_FILE_PREVIEW_SESSIONS_STATE
|
||||
|
||||
default:
|
||||
return state
|
||||
}
|
||||
}
|
||||
|
||||
export function useFilePreviewSessions() {
|
||||
const [state, dispatch] = useReducer(
|
||||
reduceFilePreviewSessions,
|
||||
INITIAL_FILE_PREVIEW_SESSIONS_STATE
|
||||
)
|
||||
|
||||
const previewSession = useMemo(
|
||||
() => (state.activeSessionId ? (state.sessions[state.activeSessionId] ?? null) : null),
|
||||
[state.activeSessionId, state.sessions]
|
||||
)
|
||||
|
||||
const hydratePreviewSessions = useCallback((sessions: FilePreviewSession[]) => {
|
||||
dispatch({ type: 'hydrate', sessions })
|
||||
}, [])
|
||||
|
||||
const upsertPreviewSession = useCallback(
|
||||
(session: FilePreviewSession, options?: { activate?: boolean }) => {
|
||||
dispatch({
|
||||
type: 'upsert',
|
||||
session,
|
||||
...(options?.activate === false ? { activate: false } : {}),
|
||||
})
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const completePreviewSession = useCallback((session: FilePreviewSession) => {
|
||||
dispatch({ type: 'complete', session })
|
||||
}, [])
|
||||
|
||||
const removePreviewSession = useCallback((sessionId: string) => {
|
||||
dispatch({ type: 'remove', sessionId })
|
||||
}, [])
|
||||
|
||||
const resetPreviewSessions = useCallback(() => {
|
||||
dispatch({ type: 'reset' })
|
||||
}, [])
|
||||
|
||||
return {
|
||||
previewSession,
|
||||
previewSessionsById: state.sessions,
|
||||
activePreviewSessionId: state.activeSessionId,
|
||||
hydratePreviewSessions,
|
||||
upsertPreviewSession,
|
||||
completePreviewSession,
|
||||
removePreviewSession,
|
||||
resetPreviewSessions,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { MothershipStreamV1EventType } from '@/lib/copilot/generated/mothership-stream-v1'
|
||||
import type { PersistedStreamEventEnvelope } from '@/lib/copilot/request/session/contract'
|
||||
import type { StreamLoopContext } from './stream-context'
|
||||
|
||||
const handlers = vi.hoisted(() => ({
|
||||
handleSessionEvent: vi.fn(),
|
||||
handleTextEvent: vi.fn(),
|
||||
handleToolEvent: vi.fn(),
|
||||
handleResourceEvent: vi.fn(),
|
||||
handleRunEvent: vi.fn(),
|
||||
handleSpanEvent: vi.fn(),
|
||||
handleErrorEvent: vi.fn(),
|
||||
handleCompleteEvent: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/workspace/[workspaceId]/home/hooks/stream/handle-session-event', () => ({
|
||||
handleSessionEvent: handlers.handleSessionEvent,
|
||||
}))
|
||||
vi.mock('@/app/workspace/[workspaceId]/home/hooks/stream/handle-text-event', () => ({
|
||||
handleTextEvent: handlers.handleTextEvent,
|
||||
}))
|
||||
vi.mock('@/app/workspace/[workspaceId]/home/hooks/stream/handle-tool-event', () => ({
|
||||
handleToolEvent: handlers.handleToolEvent,
|
||||
}))
|
||||
vi.mock('@/app/workspace/[workspaceId]/home/hooks/stream/handle-resource-event', () => ({
|
||||
handleResourceEvent: handlers.handleResourceEvent,
|
||||
}))
|
||||
vi.mock('@/app/workspace/[workspaceId]/home/hooks/stream/handle-run-event', () => ({
|
||||
handleRunEvent: handlers.handleRunEvent,
|
||||
}))
|
||||
vi.mock('@/app/workspace/[workspaceId]/home/hooks/stream/handle-span-event', () => ({
|
||||
handleSpanEvent: handlers.handleSpanEvent,
|
||||
}))
|
||||
vi.mock('@/app/workspace/[workspaceId]/home/hooks/stream/handle-error-event', () => ({
|
||||
handleErrorEvent: handlers.handleErrorEvent,
|
||||
}))
|
||||
vi.mock('@/app/workspace/[workspaceId]/home/hooks/stream/handle-complete-event', () => ({
|
||||
handleCompleteEvent: handlers.handleCompleteEvent,
|
||||
}))
|
||||
|
||||
import { dispatchStreamEvent } from './dispatch-stream-event'
|
||||
import { createTurnModel } from './turn-model'
|
||||
|
||||
function makeCtx(): StreamLoopContext {
|
||||
return {
|
||||
state: { model: createTurnModel() } as StreamLoopContext['state'],
|
||||
deps: {} as StreamLoopContext['deps'],
|
||||
ops: {} as unknown as StreamLoopContext['ops'],
|
||||
}
|
||||
}
|
||||
|
||||
function event(type: string, scope?: Record<string, unknown>): PersistedStreamEventEnvelope {
|
||||
return {
|
||||
type,
|
||||
payload: {},
|
||||
scope,
|
||||
seq: 1,
|
||||
stream: { streamId: 's' },
|
||||
ts: '2026-01-01T00:00:00Z',
|
||||
v: 1,
|
||||
} as unknown as PersistedStreamEventEnvelope
|
||||
}
|
||||
|
||||
describe('dispatchStreamEvent', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('routes every event type to exactly its handler', () => {
|
||||
const ctx = makeCtx()
|
||||
dispatchStreamEvent(ctx, event(MothershipStreamV1EventType.session))
|
||||
dispatchStreamEvent(ctx, event(MothershipStreamV1EventType.text))
|
||||
dispatchStreamEvent(ctx, event(MothershipStreamV1EventType.tool))
|
||||
dispatchStreamEvent(ctx, event(MothershipStreamV1EventType.resource))
|
||||
dispatchStreamEvent(ctx, event(MothershipStreamV1EventType.run))
|
||||
dispatchStreamEvent(ctx, event(MothershipStreamV1EventType.span))
|
||||
dispatchStreamEvent(ctx, event(MothershipStreamV1EventType.error))
|
||||
dispatchStreamEvent(ctx, event(MothershipStreamV1EventType.complete))
|
||||
|
||||
expect(handlers.handleSessionEvent).toHaveBeenCalledTimes(1)
|
||||
expect(handlers.handleTextEvent).toHaveBeenCalledTimes(1)
|
||||
expect(handlers.handleToolEvent).toHaveBeenCalledTimes(1)
|
||||
expect(handlers.handleResourceEvent).toHaveBeenCalledTimes(1)
|
||||
expect(handlers.handleRunEvent).toHaveBeenCalledTimes(1)
|
||||
expect(handlers.handleSpanEvent).toHaveBeenCalledTimes(1)
|
||||
expect(handlers.handleErrorEvent).toHaveBeenCalledTimes(1)
|
||||
expect(handlers.handleCompleteEvent).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('computes and passes per-event scope to the span handler', () => {
|
||||
const ctx = makeCtx()
|
||||
dispatchStreamEvent(
|
||||
ctx,
|
||||
event(MothershipStreamV1EventType.span, { spanId: 'span-9', agentId: 'agent-x' })
|
||||
)
|
||||
const call = handlers.handleSpanEvent.mock.calls[0]
|
||||
expect(call[0]).toBe(ctx)
|
||||
const scope = call[2]
|
||||
expect(scope.scopedSpanId).toBe('span-9')
|
||||
expect(scope.scopedAgentId).toBe('agent-x')
|
||||
})
|
||||
|
||||
it('invokes ctx-only handlers (session/run/complete) without a scope argument', () => {
|
||||
const ctx = makeCtx()
|
||||
const sessionEvent = event(MothershipStreamV1EventType.session)
|
||||
dispatchStreamEvent(ctx, sessionEvent)
|
||||
expect(handlers.handleSessionEvent).toHaveBeenCalledWith(ctx, sessionEvent)
|
||||
expect(handlers.handleSessionEvent.mock.calls[0]).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('ignores unknown event types without throwing', () => {
|
||||
const ctx = makeCtx()
|
||||
expect(() => dispatchStreamEvent(ctx, event('totally-unknown'))).not.toThrow()
|
||||
expect(handlers.handleSessionEvent).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,71 @@
|
||||
import { MothershipStreamV1EventType } from '@/lib/copilot/generated/mothership-stream-v1'
|
||||
import type { PersistedStreamEventEnvelope } from '@/lib/copilot/request/session/contract'
|
||||
import { handleCompleteEvent } from '@/app/workspace/[workspaceId]/home/hooks/stream/handle-complete-event'
|
||||
import { handleErrorEvent } from '@/app/workspace/[workspaceId]/home/hooks/stream/handle-error-event'
|
||||
import { handleResourceEvent } from '@/app/workspace/[workspaceId]/home/hooks/stream/handle-resource-event'
|
||||
import { handleRunEvent } from '@/app/workspace/[workspaceId]/home/hooks/stream/handle-run-event'
|
||||
import { handleSessionEvent } from '@/app/workspace/[workspaceId]/home/hooks/stream/handle-session-event'
|
||||
import { handleSpanEvent } from '@/app/workspace/[workspaceId]/home/hooks/stream/handle-span-event'
|
||||
import { handleTextEvent } from '@/app/workspace/[workspaceId]/home/hooks/stream/handle-text-event'
|
||||
import { handleToolEvent } from '@/app/workspace/[workspaceId]/home/hooks/stream/handle-tool-event'
|
||||
import type {
|
||||
StreamEventScope,
|
||||
StreamLoopContext,
|
||||
} from '@/app/workspace/[workspaceId]/home/hooks/stream/stream-context'
|
||||
import { reduceEvent } from '@/app/workspace/[workspaceId]/home/hooks/stream/turn-model'
|
||||
|
||||
// The model owns subagent attribution by scope identity; only the span handler
|
||||
// needs scope, and only these three fields (agent id for file-preview seeding,
|
||||
// span/parent ids for lane identity).
|
||||
function computeEventScope(parsed: PersistedStreamEventEnvelope): StreamEventScope {
|
||||
return {
|
||||
scopedParentToolCallId:
|
||||
typeof parsed.scope?.parentToolCallId === 'string'
|
||||
? parsed.scope.parentToolCallId
|
||||
: undefined,
|
||||
scopedAgentId: typeof parsed.scope?.agentId === 'string' ? parsed.scope.agentId : undefined,
|
||||
scopedSpanId: typeof parsed.scope?.spanId === 'string' ? parsed.scope.spanId : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Folds a parsed stream event into the model (the single source of truth), then
|
||||
* routes it to its side-effect handler. Span scope is computed only for the span
|
||||
* handler (handlers no longer nest blocks — the model does). The caller's
|
||||
* transport loop owns staleness, cursor dedup, and `streamId`/`streamRequestId`.
|
||||
*/
|
||||
export function dispatchStreamEvent(
|
||||
ctx: StreamLoopContext,
|
||||
parsed: PersistedStreamEventEnvelope
|
||||
): void {
|
||||
// The model is the single source of truth: fold every event into it first,
|
||||
// then run the handlers for their side effects (resource/query/preview) and
|
||||
// the snapshot flush, which serializes the model.
|
||||
reduceEvent(ctx.state.model, parsed)
|
||||
switch (parsed.type) {
|
||||
case MothershipStreamV1EventType.session:
|
||||
handleSessionEvent(ctx, parsed)
|
||||
break
|
||||
case MothershipStreamV1EventType.text:
|
||||
handleTextEvent(ctx, parsed)
|
||||
break
|
||||
case MothershipStreamV1EventType.tool:
|
||||
handleToolEvent(ctx, parsed)
|
||||
break
|
||||
case MothershipStreamV1EventType.resource:
|
||||
handleResourceEvent(ctx, parsed)
|
||||
break
|
||||
case MothershipStreamV1EventType.run:
|
||||
handleRunEvent(ctx, parsed)
|
||||
break
|
||||
case MothershipStreamV1EventType.span:
|
||||
handleSpanEvent(ctx, parsed, computeEventScope(parsed))
|
||||
break
|
||||
case MothershipStreamV1EventType.error:
|
||||
handleErrorEvent(ctx, parsed)
|
||||
break
|
||||
case MothershipStreamV1EventType.complete:
|
||||
handleCompleteEvent(ctx, parsed)
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { PersistedStreamEventEnvelope } from '@/lib/copilot/request/session/contract'
|
||||
import type { StreamLoopContext } from '@/app/workspace/[workspaceId]/home/hooks/stream/stream-context'
|
||||
|
||||
type CompleteEvent = Extract<PersistedStreamEventEnvelope, { type: 'complete' }>
|
||||
|
||||
/**
|
||||
* Turn termination and the deterministic propagation of the outcome to any
|
||||
* still-open node are folded into the model by `reduceEvent` (which skips an
|
||||
* async pause). This handler only records the terminal flag and flushes.
|
||||
*/
|
||||
export function handleCompleteEvent(ctx: StreamLoopContext, _parsed: CompleteEvent): void {
|
||||
ctx.state.sawCompleteEvent = true
|
||||
ctx.ops.flush()
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { PersistedStreamEventEnvelope } from '@/lib/copilot/request/session/contract'
|
||||
import type { StreamLoopContext } from '@/app/workspace/[workspaceId]/home/hooks/stream/stream-context'
|
||||
|
||||
type ErrorEvent = Extract<PersistedStreamEventEnvelope, { type: 'error' }>
|
||||
|
||||
/**
|
||||
* The inline error tag is folded into the model by `reduceEvent` (scoped to the
|
||||
* erroring lane). This handler owns the side effects: flag the stream error and
|
||||
* surface the message, then flush the serialized snapshot.
|
||||
*/
|
||||
export function handleErrorEvent(ctx: StreamLoopContext, parsed: ErrorEvent): void {
|
||||
const { state, ops, deps } = ctx
|
||||
state.sawStreamError = true
|
||||
deps.setError(parsed.payload.message || parsed.payload.error || 'An error occurred')
|
||||
ops.flush()
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import {
|
||||
type MothershipStreamV1EventType,
|
||||
MothershipStreamV1ResourceOp,
|
||||
} from '@/lib/copilot/generated/mothership-stream-v1'
|
||||
import type { FilePreviewSession } from '@/lib/copilot/request/session'
|
||||
import type { PersistedStreamEventEnvelope } from '@/lib/copilot/request/session/contract'
|
||||
import { invalidateResourceQueries } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry'
|
||||
import {
|
||||
hasRenderableFilePreviewContent,
|
||||
shouldReplaceSession,
|
||||
} from '@/app/workspace/[workspaceId]/home/hooks/preview'
|
||||
import type { StreamLoopContext } from '@/app/workspace/[workspaceId]/home/hooks/stream/stream-context'
|
||||
import type { MothershipResourceType } from '@/app/workspace/[workspaceId]/home/types'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
|
||||
type ResourceEvent = Extract<
|
||||
PersistedStreamEventEnvelope,
|
||||
{ type: typeof MothershipStreamV1EventType.resource }
|
||||
>
|
||||
|
||||
/**
|
||||
* Applies a streamed resource upsert/remove to the mothership resource list,
|
||||
* reconciling it with any in-flight or just-completed file-preview handoff so a
|
||||
* generated file is not activated out from under the user while its preview is
|
||||
* still streaming. Workflow resources are mirrored into the workflow registry.
|
||||
*/
|
||||
export function handleResourceEvent(ctx: StreamLoopContext, parsed: ResourceEvent): void {
|
||||
const {
|
||||
workspaceId,
|
||||
queryClient,
|
||||
addResource,
|
||||
removeResource,
|
||||
setResources,
|
||||
setActiveResourceId,
|
||||
resourcesRef,
|
||||
activeResourceIdRef,
|
||||
previewSessionsRef,
|
||||
completedPreviewResourceHandoffRef,
|
||||
previewActivationOwnerRef,
|
||||
shouldAutoActivatePreviewSession,
|
||||
ensureWorkflowInRegistry,
|
||||
onResourceEventRef,
|
||||
} = ctx.deps
|
||||
const onResourceEvent = onResourceEventRef.current
|
||||
const payload = parsed.payload
|
||||
const resource = payload.resource
|
||||
|
||||
if (payload.op === MothershipStreamV1ResourceOp.remove) {
|
||||
removeResource(resource.type as MothershipResourceType, resource.id)
|
||||
invalidateResourceQueries(
|
||||
queryClient,
|
||||
workspaceId,
|
||||
resource.type as MothershipResourceType,
|
||||
resource.id
|
||||
)
|
||||
onResourceEvent?.()
|
||||
return
|
||||
}
|
||||
|
||||
const nextResource = {
|
||||
type: resource.type as MothershipResourceType,
|
||||
id: resource.id,
|
||||
title: typeof resource.title === 'string' ? resource.title : resource.id,
|
||||
}
|
||||
const completedPreviewHandoff =
|
||||
nextResource.type === 'file'
|
||||
? completedPreviewResourceHandoffRef.current.get(nextResource.id)
|
||||
: undefined
|
||||
const matchingPreviewSessions =
|
||||
nextResource.type === 'file'
|
||||
? Object.values(previewSessionsRef.current).filter(
|
||||
(session) => session.fileId === nextResource.id
|
||||
)
|
||||
: []
|
||||
const latestPreviewForResource = (
|
||||
sessions: FilePreviewSession[]
|
||||
): FilePreviewSession | undefined =>
|
||||
sessions.reduce<FilePreviewSession | undefined>(
|
||||
(latest, session) => (shouldReplaceSession(latest, session) ? session : latest),
|
||||
undefined
|
||||
)
|
||||
const latestActivePreviewForResource = latestPreviewForResource(
|
||||
matchingPreviewSessions.filter((session) => session.status !== 'complete')
|
||||
)
|
||||
const previewForResource =
|
||||
latestActivePreviewForResource ?? latestPreviewForResource(matchingPreviewSessions)
|
||||
const isCompletedPreviewHandoffCurrent =
|
||||
completedPreviewHandoff !== undefined &&
|
||||
(!latestActivePreviewForResource ||
|
||||
latestActivePreviewForResource.id === completedPreviewHandoff.sessionId)
|
||||
if (completedPreviewHandoff && !isCompletedPreviewHandoffCurrent) {
|
||||
completedPreviewResourceHandoffRef.current.delete(nextResource.id)
|
||||
previewActivationOwnerRef.current.delete(completedPreviewHandoff.sessionId)
|
||||
}
|
||||
const shouldSuppressFileResourceActivation =
|
||||
(isCompletedPreviewHandoffCurrent && completedPreviewHandoff?.suppressActivation === true) ||
|
||||
(previewForResource !== undefined &&
|
||||
previewForResource.status !== 'complete' &&
|
||||
(!hasRenderableFilePreviewContent(previewForResource) ||
|
||||
!shouldAutoActivatePreviewSession(previewForResource)))
|
||||
const wasAdded = shouldSuppressFileResourceActivation
|
||||
? !resourcesRef.current.some((r) => r.type === nextResource.type && r.id === nextResource.id)
|
||||
: addResource(nextResource)
|
||||
if (shouldSuppressFileResourceActivation && wasAdded) {
|
||||
setResources((current) =>
|
||||
current.some((r) => r.type === nextResource.type && r.id === nextResource.id)
|
||||
? current
|
||||
: [...current, nextResource]
|
||||
)
|
||||
}
|
||||
if (completedPreviewHandoff && isCompletedPreviewHandoffCurrent) {
|
||||
completedPreviewResourceHandoffRef.current.delete(nextResource.id)
|
||||
previewActivationOwnerRef.current.delete(completedPreviewHandoff.sessionId)
|
||||
}
|
||||
invalidateResourceQueries(queryClient, workspaceId, nextResource.type, nextResource.id)
|
||||
|
||||
if (
|
||||
!shouldSuppressFileResourceActivation &&
|
||||
!wasAdded &&
|
||||
activeResourceIdRef.current !== nextResource.id
|
||||
) {
|
||||
setActiveResourceId(nextResource.id)
|
||||
}
|
||||
onResourceEvent?.()
|
||||
|
||||
if (nextResource.type === 'workflow') {
|
||||
const wasRegistered = ensureWorkflowInRegistry(nextResource.id, nextResource.title, workspaceId)
|
||||
if (wasAdded && wasRegistered) {
|
||||
useWorkflowRegistry.getState().setActiveWorkflow(nextResource.id)
|
||||
} else {
|
||||
useWorkflowRegistry.getState().loadWorkflowState(nextResource.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { PersistedStreamEventEnvelope } from '@/lib/copilot/request/session/contract'
|
||||
import type { StreamLoopContext } from '@/app/workspace/[workspaceId]/home/hooks/stream/stream-context'
|
||||
|
||||
type RunEvent = Extract<PersistedStreamEventEnvelope, { type: 'run' }>
|
||||
|
||||
/**
|
||||
* Compaction lifecycle is folded into the model by `reduceEvent` (it opens and
|
||||
* closes a `context_compaction` node with titles). This handler only flushes the
|
||||
* serialized snapshot.
|
||||
*/
|
||||
export function handleRunEvent(ctx: StreamLoopContext, _parsed: RunEvent): void {
|
||||
ctx.ops.flush()
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { getLiveAssistantMessageId } from '@/lib/copilot/chat/effective-transcript'
|
||||
import { MothershipStreamV1SessionKind } from '@/lib/copilot/generated/mothership-stream-v1'
|
||||
import type { PersistedStreamEventEnvelope } from '@/lib/copilot/request/session/contract'
|
||||
import type { StreamLoopContext } from '@/app/workspace/[workspaceId]/home/hooks/stream/stream-context'
|
||||
import { type MothershipChatHistory, mothershipChatKeys } from '@/hooks/queries/mothership-chats'
|
||||
|
||||
type SessionEvent = Extract<PersistedStreamEventEnvelope, { type: 'session' }>
|
||||
|
||||
export function handleSessionEvent(ctx: StreamLoopContext, parsed: SessionEvent): void {
|
||||
const { deps } = ctx
|
||||
const payload = parsed.payload
|
||||
const payloadChatId =
|
||||
payload.kind === MothershipStreamV1SessionKind.chat
|
||||
? payload.chatId
|
||||
: typeof parsed.stream?.chatId === 'string'
|
||||
? parsed.stream.chatId
|
||||
: undefined
|
||||
|
||||
if (payload.kind === MothershipStreamV1SessionKind.chat && payloadChatId) {
|
||||
const isNewChat = !deps.chatIdRef.current
|
||||
deps.chatIdRef.current = payloadChatId
|
||||
const selected = deps.selectedChatIdRef.current
|
||||
if (selected == null) {
|
||||
if (isNewChat) {
|
||||
deps.setResolvedChatId(payloadChatId)
|
||||
}
|
||||
} else if (payloadChatId === selected) {
|
||||
deps.setResolvedChatId(payloadChatId)
|
||||
}
|
||||
deps.queryClient.invalidateQueries({ queryKey: mothershipChatKeys.list(deps.workspaceId) })
|
||||
if (isNewChat) {
|
||||
const userMsg = deps.pendingUserMsgRef.current
|
||||
const activeStreamId = deps.streamIdRef.current
|
||||
if (userMsg && activeStreamId) {
|
||||
const assistantMessage = deps.buildAssistantSnapshotMessage({
|
||||
id:
|
||||
deps.activeTurnRef.current?.assistantMessageId ??
|
||||
getLiveAssistantMessageId(activeStreamId),
|
||||
content: deps.streamingContentRef.current,
|
||||
contentBlocks: deps.streamingBlocksRef.current,
|
||||
})
|
||||
const seededMessages = [userMsg, assistantMessage]
|
||||
deps.queryClient.setQueryData<MothershipChatHistory>(
|
||||
mothershipChatKeys.detail(payloadChatId),
|
||||
{
|
||||
id: payloadChatId,
|
||||
title: null,
|
||||
messages: seededMessages,
|
||||
activeStreamId,
|
||||
resources: deps.resourcesRef.current,
|
||||
}
|
||||
)
|
||||
}
|
||||
deps.setPendingMessages([])
|
||||
if (!deps.workflowIdRef.current) {
|
||||
window.history.replaceState(
|
||||
null,
|
||||
'',
|
||||
`/workspace/${deps.workspaceId}/chat/${payloadChatId}`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (payload.kind === MothershipStreamV1SessionKind.title) {
|
||||
deps.queryClient.invalidateQueries({ queryKey: mothershipChatKeys.list(deps.workspaceId) })
|
||||
deps.onTitleUpdateRef.current?.()
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user