fix(copilot): chat loading; refactor(copilot): components, utils, hooks

This commit is contained in:
Emir Karabeg
2026-01-19 14:32:27 -08:00
parent aa43c3f732
commit 4db6bf272a
47 changed files with 640 additions and 265 deletions

View File

@@ -0,0 +1,22 @@
import { PopoverSection } from '@/components/emcn'
/**
* Skeleton loading component for chat history dropdown
* Displays placeholder content while chats are being loaded
*/
export function ChatHistorySkeleton() {
return (
<>
<PopoverSection>
<div className='h-3 w-12 animate-pulse rounded bg-muted/40' />
</PopoverSection>
<div className='flex flex-col gap-0.5'>
{[1, 2, 3].map((i) => (
<div key={i} className='flex h-[25px] items-center px-[6px]'>
<div className='h-3 w-full animate-pulse rounded bg-muted/40' />
</div>
))}
</div>
</>
)
}

View File

@@ -0,0 +1,56 @@
import { Button } from '@/components/emcn'
interface CheckpointDiscardModalProps {
isProcessingDiscard: boolean
onCancel: () => void
onRevert: () => void
onContinue: () => void
}
/**
* Inline confirmation modal for discarding checkpoints during message editing
* Shows options to cancel, revert to checkpoint, or continue without reverting
*/
export function CheckpointDiscardModal({
isProcessingDiscard,
onCancel,
onRevert,
onContinue,
}: CheckpointDiscardModalProps) {
return (
<div className='mt-[8px] rounded-[4px] border border-[var(--border)] bg-[var(--surface-4)] p-[10px]'>
<p className='mb-[8px] text-[12px] text-[var(--text-primary)]'>
Continue from a previous message?
</p>
<div className='flex gap-[8px]'>
<Button
onClick={onCancel}
variant='active'
size='sm'
className='flex-1'
disabled={isProcessingDiscard}
>
Cancel
</Button>
<Button
onClick={onRevert}
variant='destructive'
size='sm'
className='flex-1'
disabled={isProcessingDiscard}
>
{isProcessingDiscard ? 'Reverting...' : 'Revert'}
</Button>
<Button
onClick={onContinue}
variant='tertiary'
size='sm'
className='flex-1'
disabled={isProcessingDiscard}
>
Continue
</Button>
</div>
</div>
)
}

View File

@@ -1,5 +1,7 @@
export * from './checkpoint-discard-modal'
export * from './file-display'
export { default as CopilotMarkdownRenderer } from './markdown-renderer'
export { CopilotMarkdownRenderer } from './markdown-renderer'
export * from './restore-checkpoint-modal'
export * from './smooth-streaming'
export * from './thinking-block'
export * from './usage-limit-actions'

View File

@@ -0,0 +1 @@
export { default as CopilotMarkdownRenderer } from './markdown-renderer'

View File

@@ -0,0 +1,46 @@
import { Button } from '@/components/emcn'
interface RestoreCheckpointModalProps {
isReverting: boolean
onCancel: () => void
onConfirm: () => void
}
/**
* Inline confirmation modal for restoring a checkpoint
* Warns user that the action cannot be undone
*/
export function RestoreCheckpointModal({
isReverting,
onCancel,
onConfirm,
}: RestoreCheckpointModalProps) {
return (
<div className='mt-[8px] rounded-[4px] border border-[var(--border)] bg-[var(--surface-4)] p-[10px]'>
<p className='mb-[8px] text-[12px] text-[var(--text-primary)]'>
Revert to checkpoint? This will restore your workflow to the state saved at this checkpoint.{' '}
<span className='text-[var(--text-error)]'>This action cannot be undone.</span>
</p>
<div className='flex gap-[8px]'>
<Button
onClick={onCancel}
variant='active'
size='sm'
className='flex-1'
disabled={isReverting}
>
Cancel
</Button>
<Button
onClick={onConfirm}
variant='destructive'
size='sm'
className='flex-1'
disabled={isReverting}
>
{isReverting ? 'Reverting...' : 'Revert'}
</Button>
</div>
</div>
)
}

View File

@@ -1,6 +1,6 @@
import { memo, useEffect, useRef, useState } from 'react'
import { cn } from '@/lib/core/utils/cn'
import CopilotMarkdownRenderer from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/markdown-renderer'
import { CopilotMarkdownRenderer } from '../markdown-renderer'
/**
* Character animation delay in milliseconds

View File

@@ -3,7 +3,7 @@
import { memo, useEffect, useMemo, useRef, useState } from 'react'
import clsx from 'clsx'
import { ChevronUp } from 'lucide-react'
import CopilotMarkdownRenderer from './markdown-renderer'
import { CopilotMarkdownRenderer } from '../markdown-renderer'
/**
* Removes thinking tags (raw or escaped) from streamed content.

View File

@@ -9,18 +9,22 @@ import {
ToolCall,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components'
import {
CheckpointDiscardModal,
FileAttachmentDisplay,
RestoreCheckpointModal,
SmoothStreamingText,
StreamingIndicator,
ThinkingBlock,
UsageLimitActions,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components'
import CopilotMarkdownRenderer from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/markdown-renderer'
import { CopilotMarkdownRenderer } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/markdown-renderer'
import {
useCheckpointManagement,
useMessageContentAnalysis,
useMessageEditing,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/hooks'
import { UserInput } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/user-input'
import { buildMentionHighlightNodes } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/utils'
import type { CopilotMessage as CopilotMessageType } from '@/stores/panel'
import { useCopilotStore } from '@/stores/panel'
@@ -191,6 +195,9 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
[sendMessage]
)
// Analyze message content for visibility (used for assistant messages)
const { hasVisibleContent } = useMessageContentAnalysis({ message })
// Memoize content blocks to avoid re-rendering unchanged blocks
// No entrance animations to prevent layout shift
const memoizedContentBlocks = useMemo(() => {
@@ -290,40 +297,12 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
{/* Inline Checkpoint Discard Confirmation - shown below input in edit mode */}
{showCheckpointDiscardModal && (
<div className='mt-[8px] rounded-[4px] border border-[var(--border)] bg-[var(--surface-4)] p-[10px]'>
<p className='mb-[8px] text-[12px] text-[var(--text-primary)]'>
Continue from a previous message?
</p>
<div className='flex gap-[8px]'>
<Button
onClick={handleCancelCheckpointDiscard}
variant='active'
size='sm'
className='flex-1'
disabled={isProcessingDiscard}
>
Cancel
</Button>
<Button
onClick={handleContinueAndRevert}
variant='destructive'
size='sm'
className='flex-1'
disabled={isProcessingDiscard}
>
{isProcessingDiscard ? 'Reverting...' : 'Revert'}
</Button>
<Button
onClick={handleContinueWithoutRevert}
variant='tertiary'
size='sm'
className='flex-1'
disabled={isProcessingDiscard}
>
Continue
</Button>
</div>
</div>
<CheckpointDiscardModal
isProcessingDiscard={isProcessingDiscard}
onCancel={handleCancelCheckpointDiscard}
onRevert={handleContinueAndRevert}
onContinue={handleContinueWithoutRevert}
/>
)}
</div>
) : (
@@ -348,46 +327,15 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
ref={messageContentRef}
className={`relative whitespace-pre-wrap break-words px-[2px] py-1 font-medium font-sans text-[var(--text-primary)] text-sm leading-[1.25rem] ${isSendingMessage && isLastUserMessage && isHoveringMessage ? 'pr-7' : ''} ${!isExpanded && needsExpansion ? 'max-h-[60px] overflow-hidden' : 'overflow-visible'}`}
>
{(() => {
const text = message.content || ''
const contexts: any[] = Array.isArray((message as any).contexts)
? ((message as any).contexts as any[])
: []
// Build tokens with their prefixes (@ for mentions, / for commands)
const tokens = contexts
.filter((c) => c?.kind !== 'current_workflow' && c?.label)
.map((c) => {
const prefix = c?.kind === 'slash_command' ? '/' : '@'
return `${prefix}${c.label}`
})
if (!tokens.length) return text
const escapeRegex = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const pattern = new RegExp(`(${tokens.map(escapeRegex).join('|')})`, 'g')
const nodes: React.ReactNode[] = []
let lastIndex = 0
let match: RegExpExecArray | null
while ((match = pattern.exec(text)) !== null) {
const i = match.index
const before = text.slice(lastIndex, i)
if (before) nodes.push(before)
const mention = match[0]
nodes.push(
<span
key={`mention-${i}-${lastIndex}`}
className='rounded-[4px] bg-[rgba(50,189,126,0.65)] py-[1px]'
>
{mention}
</span>
)
lastIndex = i + mention.length
}
const tail = text.slice(lastIndex)
if (tail) nodes.push(tail)
return nodes
})()}
{buildMentionHighlightNodes(
message.content || '',
message.contexts || [],
(token, key) => (
<span key={key} className='rounded-[4px] bg-[rgba(50,189,126,0.65)] py-[1px]'>
{token}
</span>
)
)}
</div>
{/* Gradient fade when truncated - applies to entire message box */}
@@ -439,50 +387,16 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
{/* Inline Restore Checkpoint Confirmation */}
{showRestoreConfirmation && (
<div className='mt-[8px] rounded-[4px] border border-[var(--border)] bg-[var(--surface-4)] p-[10px]'>
<p className='mb-[8px] text-[12px] text-[var(--text-primary)]'>
Revert to checkpoint? This will restore your workflow to the state saved at this
checkpoint.{' '}
<span className='text-[var(--text-error)]'>This action cannot be undone.</span>
</p>
<div className='flex gap-[8px]'>
<Button
onClick={handleCancelRevert}
variant='active'
size='sm'
className='flex-1'
disabled={isReverting}
>
Cancel
</Button>
<Button
onClick={handleConfirmRevert}
variant='destructive'
size='sm'
className='flex-1'
disabled={isReverting}
>
{isReverting ? 'Reverting...' : 'Revert'}
</Button>
</div>
</div>
<RestoreCheckpointModal
isReverting={isReverting}
onCancel={handleCancelRevert}
onConfirm={handleConfirmRevert}
/>
)}
</div>
)
}
// Check if there's any visible content in the blocks
const hasVisibleContent = useMemo(() => {
if (!message.contentBlocks || message.contentBlocks.length === 0) return false
return message.contentBlocks.some((block) => {
if (block.type === 'text') {
const parsed = parseSpecialTags(block.content)
return parsed.cleanContent.trim().length > 0
}
return block.type === 'thinking' || block.type === 'tool_call'
})
}, [message.contentBlocks])
if (isAssistant) {
return (
<div

View File

@@ -1,2 +1,3 @@
export { useCheckpointManagement } from './use-checkpoint-management'
export { useMessageContentAnalysis } from './use-message-content-analysis'
export { useMessageEditing } from './use-message-editing'

View File

@@ -0,0 +1,31 @@
import { useMemo } from 'react'
import { parseSpecialTags } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components'
import type { CopilotMessage } from '@/stores/panel'
interface UseMessageContentAnalysisProps {
message: CopilotMessage
}
/**
* Hook to analyze message content blocks for visibility and content
* Determines if there's any visible content to display
*
* @param props - Configuration containing the message to analyze
* @returns Object containing visibility analysis results
*/
export function useMessageContentAnalysis({ message }: UseMessageContentAnalysisProps) {
const hasVisibleContent = useMemo(() => {
if (!message.contentBlocks || message.contentBlocks.length === 0) return false
return message.contentBlocks.some((block) => {
if (block.type === 'text') {
const parsed = parseSpecialTags(block.content)
return parsed.cleanContent.trim().length > 0
}
return block.type === 'thinking' || block.type === 'tool_call'
})
}, [message.contentBlocks])
return {
hasVisibleContent,
}
}

View File

@@ -2,11 +2,18 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import type { CopilotMessage } from '@/stores/panel'
import type { ChatContext, CopilotMessage, MessageFileAttachment } from '@/stores/panel'
import { useCopilotStore } from '@/stores/panel'
const logger = createLogger('useMessageEditing')
/**
* Ref interface for UserInput component
*/
interface UserInputRef {
focus: () => void
}
/**
* Message truncation height in pixels
*/
@@ -32,8 +39,8 @@ interface UseMessageEditingProps {
setShowCheckpointDiscardModal: (show: boolean) => void
pendingEditRef: React.MutableRefObject<{
message: string
fileAttachments?: any[]
contexts?: any[]
fileAttachments?: MessageFileAttachment[]
contexts?: ChatContext[]
} | null>
/**
* When true, disables the internal document click-outside handler.
@@ -69,7 +76,7 @@ export function useMessageEditing(props: UseMessageEditingProps) {
const editContainerRef = useRef<HTMLDivElement>(null)
const messageContentRef = useRef<HTMLDivElement>(null)
const userInputRef = useRef<any>(null)
const userInputRef = useRef<UserInputRef>(null)
const { sendMessage, isSendingMessage, abortMessage, currentChat } = useCopilotStore()
@@ -121,7 +128,11 @@ export function useMessageEditing(props: UseMessageEditingProps) {
* Truncates messages after edited message and resends with same ID
*/
const performEdit = useCallback(
async (editedMessage: string, fileAttachments?: any[], contexts?: any[]) => {
async (
editedMessage: string,
fileAttachments?: MessageFileAttachment[],
contexts?: ChatContext[]
) => {
const currentMessages = messages
const editIndex = currentMessages.findIndex((m) => m.id === message.id)
@@ -134,7 +145,7 @@ export function useMessageEditing(props: UseMessageEditingProps) {
...message,
content: editedMessage,
fileAttachments: fileAttachments || message.fileAttachments,
contexts: contexts || (message as any).contexts,
contexts: contexts || message.contexts,
}
useCopilotStore.setState({ messages: [...truncatedMessages, updatedMessage] })
@@ -153,7 +164,7 @@ export function useMessageEditing(props: UseMessageEditingProps) {
timestamp: m.timestamp,
...(m.contentBlocks && { contentBlocks: m.contentBlocks }),
...(m.fileAttachments && { fileAttachments: m.fileAttachments }),
...((m as any).contexts && { contexts: (m as any).contexts }),
...(m.contexts && { contexts: m.contexts }),
})),
}),
})
@@ -164,7 +175,7 @@ export function useMessageEditing(props: UseMessageEditingProps) {
await sendMessage(editedMessage, {
fileAttachments: fileAttachments || message.fileAttachments,
contexts: contexts || (message as any).contexts,
contexts: contexts || message.contexts,
messageId: message.id,
queueIfBusy: false,
})
@@ -178,7 +189,11 @@ export function useMessageEditing(props: UseMessageEditingProps) {
* Checks for checkpoints and shows confirmation if needed
*/
const handleSubmitEdit = useCallback(
async (editedMessage: string, fileAttachments?: any[], contexts?: any[]) => {
async (
editedMessage: string,
fileAttachments?: MessageFileAttachment[],
contexts?: ChatContext[]
) => {
if (!editedMessage.trim()) return
if (isSendingMessage) {

View File

@@ -1,7 +1,8 @@
export * from './copilot-message/copilot-message'
export * from './plan-mode-section/plan-mode-section'
export * from './queued-messages/queued-messages'
export * from './todo-list/todo-list'
export * from './tool-call/tool-call'
export * from './user-input/user-input'
export * from './welcome/welcome'
export * from './chat-history-skeleton'
export * from './copilot-message'
export * from './plan-mode-section'
export * from './queued-messages'
export * from './todo-list'
export * from './tool-call'
export * from './user-input'
export * from './welcome'

View File

@@ -29,7 +29,7 @@ import { Check, GripHorizontal, Pencil, X } from 'lucide-react'
import { Button, Textarea } from '@/components/emcn'
import { Trash } from '@/components/emcn/icons/trash'
import { cn } from '@/lib/core/utils/cn'
import CopilotMarkdownRenderer from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/markdown-renderer'
import { CopilotMarkdownRenderer } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/markdown-renderer'
/**
* Shared border and background styles

View File

@@ -15,7 +15,7 @@ import {
hasInterrupt as hasInterruptFromConfig,
isSpecialTool as isSpecialToolFromConfig,
} from '@/lib/copilot/tools/client/ui-config'
import CopilotMarkdownRenderer from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/markdown-renderer'
import { CopilotMarkdownRenderer } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/markdown-renderer'
import { SmoothStreamingText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/smooth-streaming'
import { ThinkingBlock } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/thinking-block'
import { getDisplayValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block'

View File

@@ -0,0 +1,127 @@
'use client'
import { ArrowUp, Image, Loader2 } from 'lucide-react'
import { Badge, Button } from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
import { ModeSelector } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/components/mode-selector/mode-selector'
import { ModelSelector } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/components/model-selector/model-selector'
interface BottomControlsProps {
mode: 'ask' | 'build' | 'plan'
onModeChange?: (mode: 'ask' | 'build' | 'plan') => void
selectedModel: string
onModelSelect: (model: string) => void
isNearTop: boolean
disabled: boolean
hideModeSelector: boolean
canSubmit: boolean
isLoading: boolean
isAborting: boolean
showAbortButton: boolean
onSubmit: () => void
onAbort: () => void
onFileSelect: () => void
}
/**
* Bottom controls section of the user input
* Contains mode selector, model selector, file attachment button, and submit/abort buttons
*/
export function BottomControls({
mode,
onModeChange,
selectedModel,
onModelSelect,
isNearTop,
disabled,
hideModeSelector,
canSubmit,
isLoading,
isAborting,
showAbortButton,
onSubmit,
onAbort,
onFileSelect,
}: BottomControlsProps) {
return (
<div className='flex items-center justify-between gap-2'>
{/* Left side: Mode Selector + Model Selector */}
<div className='flex min-w-0 flex-1 items-center gap-[8px]'>
{!hideModeSelector && (
<ModeSelector
mode={mode}
onModeChange={onModeChange}
isNearTop={isNearTop}
disabled={disabled}
/>
)}
<ModelSelector
selectedModel={selectedModel}
isNearTop={isNearTop}
onModelSelect={onModelSelect}
/>
</div>
{/* Right side: Attach Button + Send Button */}
<div className='flex flex-shrink-0 items-center gap-[10px]'>
<Badge
onClick={onFileSelect}
title='Attach file'
className={cn(
'cursor-pointer rounded-[6px] border-0 bg-transparent p-[0px] dark:bg-transparent',
disabled && 'cursor-not-allowed opacity-50'
)}
>
<Image className='!h-3.5 !w-3.5 scale-x-110' />
</Badge>
{showAbortButton ? (
<Button
onClick={onAbort}
disabled={isAborting}
className={cn(
'h-[20px] w-[20px] rounded-full border-0 p-0 transition-colors',
!isAborting
? 'bg-[var(--c-383838)] hover:bg-[var(--c-575757)] dark:bg-[var(--c-E0E0E0)] dark:hover:bg-[var(--c-CFCFCF)]'
: 'bg-[var(--c-383838)] dark:bg-[var(--c-E0E0E0)]'
)}
title='Stop generation'
>
{isAborting ? (
<Loader2 className='block h-[13px] w-[13px] animate-spin text-white dark:text-black' />
) : (
<svg
className='block h-[13px] w-[13px] 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>
) : (
<Button
onClick={onSubmit}
disabled={!canSubmit}
className={cn(
'h-[22px] w-[22px] rounded-full border-0 p-0 transition-colors',
canSubmit
? 'bg-[var(--c-383838)] hover:bg-[var(--c-575757)] dark:bg-[var(--c-E0E0E0)] dark:hover:bg-[var(--c-CFCFCF)]'
: 'bg-[var(--c-808080)] dark:bg-[var(--c-808080)]'
)}
>
{isLoading ? (
<Loader2 className='block h-3.5 w-3.5 animate-spin text-white dark:text-black' />
) : (
<ArrowUp
className='block h-3.5 w-3.5 text-white dark:text-black'
strokeWidth={2.25}
/>
)}
</Button>
)}
</div>
</div>
)
}

View File

@@ -1,6 +1,7 @@
export { AttachedFilesDisplay } from './attached-files-display/attached-files-display'
export { ContextPills } from './context-pills/context-pills'
export { type MentionFolderNav, MentionMenu } from './mention-menu/mention-menu'
export { ModeSelector } from './mode-selector/mode-selector'
export { ModelSelector } from './model-selector/model-selector'
export { type SlashFolderNav, SlashMenu } from './slash-menu/slash-menu'
export { AttachedFilesDisplay } from './attached-files-display'
export { BottomControls } from './bottom-controls'
export { ContextPills } from './context-pills'
export { type MentionFolderNav, MentionMenu } from './mention-menu'
export { ModeSelector } from './mode-selector'
export { ModelSelector } from './model-selector'
export { type SlashFolderNav, SlashMenu } from './slash-menu'

View File

@@ -5,5 +5,6 @@ export { useMentionData } from './use-mention-data'
export { useMentionInsertHandlers } from './use-mention-insert-handlers'
export { useMentionKeyboard } from './use-mention-keyboard'
export { useMentionMenu } from './use-mention-menu'
export { useMentionSystem } from './use-mention-system'
export { useMentionTokens } from './use-mention-tokens'
export { useTextareaAutoResize } from './use-textarea-auto-resize'

View File

@@ -1,5 +1,6 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import {
escapeRegex,
filterOutContext,
isContextAlreadySelected,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/utils'
@@ -22,9 +23,6 @@ interface UseContextManagementProps {
export function useContextManagement({ message, initialContexts }: UseContextManagementProps) {
const [selectedContexts, setSelectedContexts] = useState<ChatContext[]>(initialContexts ?? [])
const initializedRef = useRef(false)
const escapeRegex = useCallback((value: string) => {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}, [])
// Initialize with initial contexts when they're first provided (for edit mode)
useEffect(() => {

View File

@@ -0,0 +1,107 @@
import type { MentionFolderNav } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/components'
import { useContextManagement } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-context-management'
import { useFileAttachments } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-file-attachments'
import { useMentionData } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-data'
import { useMentionInsertHandlers } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-insert-handlers'
import { useMentionKeyboard } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-keyboard'
import { useMentionMenu } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-menu'
import { useMentionTokens } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-tokens'
import { useTextareaAutoResize } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-textarea-auto-resize'
import type { ChatContext } from '@/stores/panel'
interface UseMentionSystemProps {
message: string
setMessage: (message: string) => void
workflowId: string | null
workspaceId: string
userId?: string
panelWidth: number
disabled: boolean
isLoading: boolean
inputContainerRef: HTMLDivElement | null
initialContexts?: ChatContext[]
mentionFolderNav: MentionFolderNav | null
}
/**
* Composite hook that combines all mention-related hooks into a single interface.
* Reduces import complexity in components that need full mention functionality.
*
* @param props - Configuration for all mention system hooks
* @returns Combined interface for mention system functionality
*/
export function useMentionSystem({
message,
setMessage,
workflowId,
workspaceId,
userId,
panelWidth,
disabled,
isLoading,
inputContainerRef,
initialContexts,
mentionFolderNav,
}: UseMentionSystemProps) {
const contextManagement = useContextManagement({ message, initialContexts })
const mentionMenu = useMentionMenu({
message,
selectedContexts: contextManagement.selectedContexts,
onContextSelect: contextManagement.addContext,
onMessageChange: setMessage,
})
const mentionTokens = useMentionTokens({
message,
selectedContexts: contextManagement.selectedContexts,
mentionMenu,
setMessage,
setSelectedContexts: contextManagement.setSelectedContexts,
})
const { overlayRef } = useTextareaAutoResize({
message,
panelWidth,
selectedContexts: contextManagement.selectedContexts,
textareaRef: mentionMenu.textareaRef,
containerRef: inputContainerRef,
})
const mentionData = useMentionData({
workflowId,
workspaceId,
})
const fileAttachments = useFileAttachments({
userId,
disabled,
isLoading,
})
const insertHandlers = useMentionInsertHandlers({
mentionMenu,
workflowId,
selectedContexts: contextManagement.selectedContexts,
onContextAdd: contextManagement.addContext,
mentionFolderNav,
})
const mentionKeyboard = useMentionKeyboard({
mentionMenu,
mentionData,
insertHandlers,
mentionFolderNav,
})
return {
contextManagement,
mentionMenu,
mentionTokens,
overlayRef,
mentionData,
fileAttachments,
insertHandlers,
mentionKeyboard,
}
}

View File

@@ -9,19 +9,19 @@ import {
useState,
} from 'react'
import { createLogger } from '@sim/logger'
import { ArrowUp, AtSign, Image, Loader2 } from 'lucide-react'
import { AtSign } from 'lucide-react'
import { useParams } from 'next/navigation'
import { createPortal } from 'react-dom'
import { Badge, Button, Textarea } from '@/components/emcn'
import { useSession } from '@/lib/auth/auth-client'
import type { CopilotModelId } from '@/lib/copilot/models'
import { cn } from '@/lib/core/utils/cn'
import {
AttachedFilesDisplay,
BottomControls,
ContextPills,
type MentionFolderNav,
MentionMenu,
ModelSelector,
ModeSelector,
type SlashFolderNav,
SlashMenu,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/components'
@@ -44,6 +44,10 @@ import {
useTextareaAutoResize,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks'
import type { MessageFileAttachment } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-file-attachments'
import {
computeMentionHighlightRanges,
extractContextTokens,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/utils'
import type { ChatContext } from '@/stores/panel'
import { useCopilotStore } from '@/stores/panel'
@@ -306,7 +310,7 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
size: f.size,
}))
onSubmit(trimmedMessage, fileAttachmentsForApi, contextManagement.selectedContexts as any)
onSubmit(trimmedMessage, fileAttachmentsForApi, contextManagement.selectedContexts)
const shouldClearInput = clearOnSubmit && !options.preserveInput && !overrideMessage
if (shouldClearInput) {
@@ -657,7 +661,7 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
const handleModelSelect = useCallback(
(model: string) => {
setSelectedModel(model as any)
setSelectedModel(model as CopilotModelId)
},
[setSelectedModel]
)
@@ -677,15 +681,17 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
return <span>{displayText}</span>
}
const elements: React.ReactNode[] = []
const ranges = mentionTokensWithContext.computeMentionRanges()
const tokens = extractContextTokens(contexts)
const ranges = computeMentionHighlightRanges(message, tokens)
if (ranges.length === 0) {
const displayText = message.endsWith('\n') ? `${message}\u200B` : message
return <span>{displayText}</span>
}
const elements: React.ReactNode[] = []
let lastIndex = 0
for (let i = 0; i < ranges.length; i++) {
const range = ranges[i]
@@ -694,13 +700,12 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
elements.push(<span key={`text-${i}-${lastIndex}-${range.start}`}>{before}</span>)
}
const mentionText = message.slice(range.start, range.end)
elements.push(
<span
key={`mention-${i}-${range.start}-${range.end}`}
className='rounded-[4px] bg-[rgba(50,189,126,0.65)] py-[1px]'
>
{mentionText}
{range.token}
</span>
)
lastIndex = range.end
@@ -713,7 +718,7 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
}
return elements.length > 0 ? elements : <span>{'\u00A0'}</span>
}, [message, contextManagement.selectedContexts, mentionTokensWithContext])
}, [message, contextManagement.selectedContexts])
return (
<div
@@ -855,87 +860,22 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
</div>
{/* Bottom Row: Mode Selector + Model Selector + Attach Button + Send Button */}
<div className='flex items-center justify-between gap-2'>
{/* Left side: Mode Selector + Model Selector */}
<div className='flex min-w-0 flex-1 items-center gap-[8px]'>
{!hideModeSelector && (
<ModeSelector
mode={mode}
onModeChange={onModeChange}
isNearTop={isNearTop}
disabled={disabled}
/>
)}
<ModelSelector
selectedModel={selectedModel}
isNearTop={isNearTop}
onModelSelect={handleModelSelect}
/>
</div>
{/* Right side: Attach Button + Send Button */}
<div className='flex flex-shrink-0 items-center gap-[10px]'>
<Badge
onClick={fileAttachments.handleFileSelect}
title='Attach file'
className={cn(
'cursor-pointer rounded-[6px] border-0 bg-transparent p-[0px] dark:bg-transparent',
disabled && 'cursor-not-allowed opacity-50'
)}
>
<Image className='!h-3.5 !w-3.5 scale-x-110' />
</Badge>
{showAbortButton ? (
<Button
onClick={handleAbort}
disabled={isAborting}
className={cn(
'h-[20px] w-[20px] rounded-full border-0 p-0 transition-colors',
!isAborting
? 'bg-[var(--c-383838)] hover:bg-[var(--c-575757)] dark:bg-[var(--c-E0E0E0)] dark:hover:bg-[var(--c-CFCFCF)]'
: 'bg-[var(--c-383838)] dark:bg-[var(--c-E0E0E0)]'
)}
title='Stop generation'
>
{isAborting ? (
<Loader2 className='block h-[13px] w-[13px] animate-spin text-white dark:text-black' />
) : (
<svg
className='block h-[13px] w-[13px] 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>
) : (
<Button
onClick={() => {
void handleSubmit()
}}
disabled={!canSubmit}
className={cn(
'h-[22px] w-[22px] rounded-full border-0 p-0 transition-colors',
canSubmit
? 'bg-[var(--c-383838)] hover:bg-[var(--c-575757)] dark:bg-[var(--c-E0E0E0)] dark:hover:bg-[var(--c-CFCFCF)]'
: 'bg-[var(--c-808080)] dark:bg-[var(--c-808080)]'
)}
>
{isLoading ? (
<Loader2 className='block h-3.5 w-3.5 animate-spin text-white dark:text-black' />
) : (
<ArrowUp
className='block h-3.5 w-3.5 text-white dark:text-black'
strokeWidth={2.25}
/>
)}
</Button>
)}
</div>
</div>
<BottomControls
mode={mode}
onModeChange={onModeChange}
selectedModel={selectedModel}
onModelSelect={handleModelSelect}
isNearTop={isNearTop}
disabled={disabled}
hideModeSelector={hideModeSelector}
canSubmit={canSubmit}
isLoading={isLoading}
isAborting={isAborting}
showAbortButton={Boolean(showAbortButton)}
onSubmit={() => void handleSubmit()}
onAbort={handleAbort}
onFileSelect={fileAttachments.handleFileSelect}
/>
{/* Hidden File Input - enabled during streaming so users can prepare images for the next message */}
<input

View File

@@ -1,3 +1,4 @@
import type { ReactNode } from 'react'
import {
FOLDER_CONFIGS,
type MentionFolderId,
@@ -5,6 +6,102 @@ import {
import type { MentionDataReturn } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-data'
import type { ChatContext } from '@/stores/panel'
/**
* Escapes special regex characters in a string
* @param value - String to escape
* @returns Escaped string safe for use in RegExp
*/
export function escapeRegex(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
/**
* Extracts mention tokens from contexts for display/matching
* Filters out current_workflow contexts and builds prefixed labels
* @param contexts - Array of chat contexts
* @returns Array of prefixed token strings (e.g., "@workflow", "/web")
*/
export function extractContextTokens(contexts: ChatContext[]): string[] {
return contexts
.filter((c) => c.kind !== 'current_workflow' && c.label)
.map((c) => {
const prefix = c.kind === 'slash_command' ? '/' : '@'
return `${prefix}${c.label}`
})
}
/**
* Mention range for text highlighting
*/
export interface MentionHighlightRange {
start: number
end: number
token: string
}
/**
* Computes mention ranges in text for highlighting
* @param text - Text to search
* @param tokens - Prefixed tokens to find (e.g., "@workflow", "/web")
* @returns Array of ranges with start, end, and matched token
*/
export function computeMentionHighlightRanges(
text: string,
tokens: string[]
): MentionHighlightRange[] {
if (!tokens.length || !text) return []
const pattern = new RegExp(`(${tokens.map(escapeRegex).join('|')})`, 'g')
const ranges: MentionHighlightRange[] = []
let match: RegExpExecArray | null
while ((match = pattern.exec(text)) !== null) {
ranges.push({
start: match.index,
end: match.index + match[0].length,
token: match[0],
})
}
return ranges
}
/**
* Builds React nodes with highlighted mention tokens
* @param text - Text to render
* @param contexts - Chat contexts to highlight
* @param createHighlightSpan - Function to create highlighted span element
* @returns Array of React nodes with highlighted mentions
*/
export function buildMentionHighlightNodes(
text: string,
contexts: ChatContext[],
createHighlightSpan: (token: string, key: string) => ReactNode
): ReactNode[] {
const tokens = extractContextTokens(contexts)
if (!tokens.length) return [text]
const ranges = computeMentionHighlightRanges(text, tokens)
if (!ranges.length) return [text]
const nodes: ReactNode[] = []
let lastIndex = 0
for (const range of ranges) {
if (range.start > lastIndex) {
nodes.push(text.slice(lastIndex, range.start))
}
nodes.push(createHighlightSpan(range.token, `mention-${range.start}-${range.end}`))
lastIndex = range.end
}
if (lastIndex < text.length) {
nodes.push(text.slice(lastIndex))
}
return nodes
}
/**
* Gets the data array for a folder ID from mentionData.
* Uses FOLDER_CONFIGS as the source of truth for key mapping.

View File

@@ -24,6 +24,7 @@ import {
import { Trash } from '@/components/emcn/icons/trash'
import { cn } from '@/lib/core/utils/cn'
import {
ChatHistorySkeleton,
CopilotMessage,
PlanModeSection,
QueuedMessages,
@@ -40,6 +41,7 @@ import {
useTodoManagement,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/hooks'
import { useScrollManagement } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
import type { ChatContext } from '@/stores/panel'
import { useCopilotStore } from '@/stores/panel'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
@@ -74,10 +76,12 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
const copilotContainerRef = useRef<HTMLDivElement>(null)
const cancelEditCallbackRef = useRef<(() => void) | null>(null)
const [editingMessageId, setEditingMessageId] = useState<string | null>(null)
const [isEditingMessage, setIsEditingMessage] = useState(false)
const [revertingMessageId, setRevertingMessageId] = useState<string | null>(null)
const [isHistoryDropdownOpen, setIsHistoryDropdownOpen] = useState(false)
// Derived state - editing when there's an editingMessageId
const isEditingMessage = editingMessageId !== null
const { activeWorkflowId } = useWorkflowRegistry()
const {
@@ -106,9 +110,9 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
areChatsFresh,
workflowId: copilotWorkflowId,
setPlanTodos,
closePlanTodos,
clearPlanArtifact,
savePlanArtifact,
setSelectedModel,
loadAutoAllowedTools,
} = useCopilotStore()
@@ -292,6 +296,15 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
}
}, [abortMessage, showPlanTodos])
/**
* Handles closing the plan todos section
* Calls store action and clears the todos
*/
const handleClosePlanTodos = useCallback(() => {
closePlanTodos()
setPlanTodos([])
}, [closePlanTodos, setPlanTodos])
/**
* Handles message submission to the copilot
* @param query - The message text to send
@@ -299,13 +312,12 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
* @param contexts - Optional context references
*/
const handleSubmit = useCallback(
async (query: string, fileAttachments?: MessageFileAttachment[], contexts?: any[]) => {
async (query: string, fileAttachments?: MessageFileAttachment[], contexts?: ChatContext[]) => {
// Allow submission even when isSendingMessage - store will queue the message
if (!query || !activeWorkflowId) return
if (showPlanTodos) {
const store = useCopilotStore.getState()
store.setPlanTodos([])
setPlanTodos([])
}
try {
@@ -319,7 +331,7 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
logger.error('Failed to send message:', error)
}
},
[activeWorkflowId, sendMessage, showPlanTodos]
[activeWorkflowId, sendMessage, showPlanTodos, setPlanTodos]
)
/**
@@ -330,7 +342,6 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
const handleEditModeChange = useCallback(
(messageId: string, isEditing: boolean, cancelCallback?: () => void) => {
setEditingMessageId(isEditing ? messageId : null)
setIsEditingMessage(isEditing)
cancelEditCallbackRef.current = isEditing ? cancelCallback || null : null
logger.info('Edit mode changed', { messageId, isEditing, willDimMessages: isEditing })
},
@@ -375,24 +386,6 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
[handleHistoryDropdownOpenHook]
)
/**
* Skeleton loading component for chat history
*/
const ChatHistorySkeleton = () => (
<>
<PopoverSection>
<div className='h-3 w-12 animate-pulse rounded bg-muted/40' />
</PopoverSection>
<div className='flex flex-col gap-0.5'>
{[1, 2, 3].map((i) => (
<div key={i} className='flex h-[25px] items-center px-[6px]'>
<div className='h-3 w-full animate-pulse rounded bg-muted/40' />
</div>
))}
</div>
</>
)
return (
<>
<div
@@ -588,11 +581,7 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
<TodoList
todos={planTodos}
collapsed={todosCollapsed}
onClose={() => {
const store = useCopilotStore.getState()
store.closePlanTodos?.()
useCopilotStore.setState({ planTodos: [] })
}}
onClose={handleClosePlanTodos}
/>
</div>
)}

View File

@@ -1736,8 +1736,13 @@ const sseHandlers: Record<string, SSEHandler> = {
}
},
done: (_data, context) => {
logger.info('[SSE] DONE EVENT RECEIVED', {
doneEventCount: context.doneEventCount,
data: _data,
})
context.doneEventCount++
if (context.doneEventCount >= 1) {
logger.info('[SSE] Setting streamComplete = true, stream will terminate')
context.streamComplete = true
}
},