Merge branch 'feat/mothership-copilot' of github.com:simstudioai/sim into feat/mothership-copilot

This commit is contained in:
Vikhyath Mondreti
2026-03-09 15:28:07 -07:00
37 changed files with 4924 additions and 4197 deletions

View File

@@ -251,9 +251,18 @@ export async function POST(request: NextRequest) {
}
}
// Handle image-only contexts (copilot, chat, profile-pictures)
// Handle copilot, chat, profile-pictures contexts
if (context === 'copilot' || context === 'chat' || context === 'profile-pictures') {
if (!isImageFileType(file.type)) {
if (context === 'copilot') {
const { isSupportedFileType: isCopilotSupported } = await import(
'@/lib/uploads/contexts/copilot/copilot-file-manager'
)
if (!isImageFileType(file.type) && !isCopilotSupported(file.type)) {
throw new InvalidRequestError(
'Unsupported file type. Allowed: images, PDF, and text files (TXT, CSV, MD, HTML, JSON, XML).'
)
}
} else if (!isImageFileType(file.type)) {
throw new InvalidRequestError(
`Only image files (JPEG, PNG, GIF, WebP, SVG) are allowed for ${context} uploads`
)

View File

@@ -184,12 +184,15 @@ export async function POST(req: NextRequest) {
timestamp: new Date().toISOString(),
}
const assistantMessage = {
const assistantMessage: Record<string, unknown> = {
id: crypto.randomUUID(),
role: 'assistant' as const,
content: result.content,
timestamp: new Date().toISOString(),
}
if (result.toolCalls.length > 0) {
assistantMessage.toolCalls = result.toolCalls
}
const updatedMessages = [...conversationHistory, userMessage, assistantMessage]

View File

@@ -8,6 +8,7 @@ import {
ChatFileDownloadAll,
} from '@/app/chat/components/message/components/file-download'
import MarkdownRenderer from '@/app/chat/components/message/components/markdown-renderer'
import { useThrottledValue } from '@/hooks/use-throttled-value'
export interface ChatAttachment {
id: string
@@ -39,7 +40,8 @@ export interface ChatMessage {
}
function EnhancedMarkdownRenderer({ content }: { content: string }) {
return <MarkdownRenderer content={content} />
const throttled = useThrottledValue(content)
return <MarkdownRenderer content={throttled} />
}
export const ClientChatMessage = memo(

View File

@@ -78,18 +78,15 @@ export function useChatStreaming() {
abortControllerRef.current.abort()
abortControllerRef.current = null
// Add a message indicating the response was stopped
const latestContent = accumulatedTextRef.current
setMessages((prev) => {
const lastMessage = prev[prev.length - 1]
// Only modify if the last message is from the assistant (as expected)
if (lastMessage && lastMessage.type === 'assistant') {
// Append a note that the response was stopped
const content = latestContent || lastMessage.content
const updatedContent =
lastMessage.content +
(lastMessage.content
? '\n\n_Response stopped by user._'
: '_Response stopped by user._')
content + (content ? '\n\n_Response stopped by user._' : '_Response stopped by user._')
return [
...prev.slice(0, -1),
@@ -100,7 +97,6 @@ export function useChatStreaming() {
return prev
})
// Reset streaming state immediately
setIsStreamingResponse(false)
accumulatedTextRef.current = ''
lastStreamedPositionRef.current = 0
@@ -139,9 +135,49 @@ export function useChatStreaming() {
let accumulatedText = ''
let lastAudioPosition = 0
// Track which blocks have streamed content (like chat panel)
const messageIdMap = new Map<string, string>()
const messageId = crypto.randomUUID()
const UI_BATCH_MAX_MS = 50
let uiDirty = false
let uiRAF: number | null = null
let uiTimer: ReturnType<typeof setTimeout> | null = null
let lastUIFlush = 0
const flushUI = () => {
if (uiRAF !== null) {
cancelAnimationFrame(uiRAF)
uiRAF = null
}
if (uiTimer !== null) {
clearTimeout(uiTimer)
uiTimer = null
}
if (!uiDirty) return
uiDirty = false
lastUIFlush = performance.now()
const snapshot = accumulatedText
setMessages((prev) =>
prev.map((msg) => {
if (msg.id !== messageId) return msg
if (!msg.isStreaming) return msg
return { ...msg, content: snapshot }
})
)
}
const scheduleUIFlush = () => {
if (uiRAF !== null) return
const elapsed = performance.now() - lastUIFlush
if (elapsed >= UI_BATCH_MAX_MS) {
flushUI()
return
}
uiRAF = requestAnimationFrame(flushUI)
if (uiTimer === null) {
uiTimer = setTimeout(flushUI, Math.max(0, UI_BATCH_MAX_MS - elapsed))
}
}
setMessages((prev) => [
...prev,
{
@@ -165,6 +201,7 @@ export function useChatStreaming() {
const { done, value } = await reader.read()
if (done) {
flushUI()
// Stream any remaining text for TTS
if (
shouldPlayAudio &&
@@ -217,6 +254,7 @@ export function useChatStreaming() {
}
if (eventType === 'final' && json.data) {
flushUI()
const finalData = json.data as {
success: boolean
error?: string | { message?: string }
@@ -367,6 +405,7 @@ export function useChatStreaming() {
}
accumulatedText += contentChunk
accumulatedTextRef.current = accumulatedText
logger.debug('[useChatStreaming] Received chunk', {
blockId,
chunkLength: contentChunk.length,
@@ -374,11 +413,8 @@ export function useChatStreaming() {
messageId,
chunk: contentChunk.substring(0, 20),
})
setMessages((prev) =>
prev.map((msg) =>
msg.id === messageId ? { ...msg, content: accumulatedText } : msg
)
)
uiDirty = true
scheduleUIFlush()
// Real-time TTS for voice mode
if (shouldPlayAudio && streamingOptions?.audioStreamHandler) {
@@ -419,10 +455,13 @@ export function useChatStreaming() {
}
} catch (error) {
logger.error('Error processing stream:', error)
flushUI()
setMessages((prev) =>
prev.map((msg) => (msg.id === messageId ? { ...msg, isStreaming: false } : msg))
)
} finally {
if (uiRAF !== null) cancelAnimationFrame(uiRAF)
if (uiTimer !== null) clearTimeout(uiTimer)
setIsStreamingResponse(false)
abortControllerRef.current = null

View File

@@ -3,6 +3,7 @@
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import { cn } from '@/lib/core/utils/cn'
import { useThrottledValue } from '@/hooks/use-throttled-value'
import type { ContentBlock, ToolCallStatus } from '../../types'
const REMARK_PLUGINS = [remarkGfm]
@@ -96,6 +97,15 @@ function parseBlocks(blocks: ContentBlock[], isStreaming: boolean): MessageSegme
return segments
}
function ThrottledTextSegment({ content }: { content: string }) {
const throttled = useThrottledValue(content)
return (
<div className={PROSE_CLASSES}>
<ReactMarkdown remarkPlugins={REMARK_PLUGINS}>{throttled}</ReactMarkdown>
</div>
)
}
interface MessageContentProps {
blocks: ContentBlock[]
fallbackContent: string
@@ -118,11 +128,7 @@ export function MessageContent({ blocks, fallbackContent, isStreaming }: Message
<div className='space-y-[10px]'>
{segments.map((segment, i) => {
if (segment.type === 'text') {
return (
<div key={`text-${i}`} className={PROSE_CLASSES}>
<ReactMarkdown remarkPlugins={REMARK_PLUGINS}>{segment.content}</ReactMarkdown>
</div>
)
return <ThrottledTextSegment key={`text-${i}`} content={segment.content} />
}
return (

View File

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

View File

@@ -0,0 +1 @@
export { ResourceContent } from './resource-content'

View File

@@ -0,0 +1,74 @@
'use client'
import { lazy, Suspense, useMemo } from 'react'
import { Skeleton } from '@/components/emcn'
import { FileViewer } from '@/app/workspace/[workspaceId]/files/components/file-viewer'
import type { MothershipResource } from '@/app/workspace/[workspaceId]/home/types'
import { Table } from '@/app/workspace/[workspaceId]/tables/[tableId]/components'
import { useWorkspaceFiles } from '@/hooks/queries/workspace-files'
const Workflow = lazy(() => import('@/app/workspace/[workspaceId]/w/[workflowId]/workflow'))
const LOADING_SKELETON = (
<div className='flex h-full flex-col gap-[8px] p-[24px]'>
<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
}
/**
* Renders the content for the currently active mothership resource.
* Handles table, file, and workflow resource types with appropriate
* embedded rendering for each.
*/
export function ResourceContent({ workspaceId, resource }: ResourceContentProps) {
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} />
case 'workflow':
return (
<Suspense fallback={LOADING_SKELETON}>
<Workflow key={resource.id} workspaceId={workspaceId} workflowId={resource.id} embedded />
</Suspense>
)
default:
return null
}
}
interface EmbeddedFileProps {
workspaceId: string
fileId: string
}
function EmbeddedFile({ workspaceId, fileId }: EmbeddedFileProps) {
const { data: files = [], isLoading } = useWorkspaceFiles(workspaceId)
const file = useMemo(() => files.find((f) => f.id === fileId), [files, fileId])
if (isLoading) return LOADING_SKELETON
if (!file) {
return (
<div className='flex h-full items-center justify-center'>
<span className='text-[13px] text-[var(--text-muted)]'>File not found</span>
</div>
)
}
return (
<div className='flex h-full flex-col overflow-hidden'>
<FileViewer key={file.id} file={file} workspaceId={workspaceId} canEdit={true} />
</div>
)
}

View File

@@ -0,0 +1 @@
export { ResourceTabs } from './resource-tabs'

View File

@@ -0,0 +1,36 @@
'use client'
import { cn } from '@/lib/core/utils/cn'
import type { MothershipResource } from '@/app/workspace/[workspaceId]/home/types'
interface ResourceTabsProps {
resources: MothershipResource[]
activeId: string | null
onSelect: (id: string) => void
}
/**
* Horizontal tab bar for switching between mothership resources.
* Mirrors the role of ResourceHeader in the Resource abstraction.
*/
export function ResourceTabs({ resources, activeId, onSelect }: ResourceTabsProps) {
return (
<div className='flex shrink-0 gap-[2px] overflow-x-auto border-[var(--border)] border-b px-[12px]'>
{resources.map((resource) => (
<button
key={resource.id}
type='button'
onClick={() => onSelect(resource.id)}
className={cn(
'shrink-0 cursor-pointer border-b-[2px] px-[12px] py-[10px] text-[13px] transition-colors',
activeId === resource.id
? 'border-[var(--text-primary)] font-medium text-[var(--text-primary)]'
: 'border-transparent text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
)}
>
{resource.title}
</button>
))}
</div>
)
}

View File

@@ -1,12 +1,7 @@
'use client'
import { useMemo } from 'react'
import { Skeleton } from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
import { FileViewer } from '@/app/workspace/[workspaceId]/files/components/file-viewer'
import type { MothershipResource } from '@/app/workspace/[workspaceId]/home/types'
import { Table } from '@/app/workspace/[workspaceId]/tables/[tableId]/components'
import { useWorkspaceFiles } from '@/hooks/queries/workspace-files'
import { ResourceContent, ResourceTabs } from './components'
interface MothershipViewProps {
workspaceId: string
@@ -15,6 +10,11 @@ interface MothershipViewProps {
onSelectResource: (id: string) => void
}
/**
* Split-pane view that renders embedded resources (tables, files, workflows)
* alongside the chat conversation. Composes ResourceTabs for navigation
* and ResourceContent for rendering the active resource.
*/
export function MothershipView({
workspaceId,
resources,
@@ -25,66 +25,14 @@ export function MothershipView({
return (
<div className='flex h-full w-[50%] min-w-[400px] flex-col border-[var(--border)] border-l'>
<div className='flex shrink-0 gap-[2px] overflow-x-auto border-[var(--border)] border-b px-[12px]'>
{resources.map((r) => (
<button
key={r.id}
type='button'
onClick={() => onSelectResource(r.id)}
className={cn(
'shrink-0 cursor-pointer border-b-[2px] px-[12px] py-[10px] text-[13px] transition-colors',
active?.id === r.id
? 'border-[var(--text-primary)] font-medium text-[var(--text-primary)]'
: 'border-transparent text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
)}
>
{r.title}
</button>
))}
</div>
<ResourceTabs
resources={resources}
activeId={active?.id ?? null}
onSelect={onSelectResource}
/>
<div className='min-h-0 flex-1 overflow-hidden'>
{active?.type === 'table' && (
<Table key={active.id} workspaceId={workspaceId} tableId={active.id} embedded />
)}
{active?.type === 'file' && (
<EmbeddedFile key={active.id} workspaceId={workspaceId} fileId={active.id} />
)}
{active && <ResourceContent workspaceId={workspaceId} resource={active} />}
</div>
</div>
)
}
interface EmbeddedFileProps {
workspaceId: string
fileId: string
}
function EmbeddedFile({ workspaceId, fileId }: EmbeddedFileProps) {
const { data: files = [], isLoading } = useWorkspaceFiles(workspaceId)
const file = useMemo(() => files.find((f) => f.id === fileId), [files, fileId])
if (isLoading) {
return (
<div className='flex h-full flex-col gap-[8px] p-[24px]'>
<Skeleton className='h-[16px] w-[60%]' />
<Skeleton className='h-[16px] w-[80%]' />
<Skeleton className='h-[16px] w-[40%]' />
</div>
)
}
if (!file) {
return (
<div className='flex h-full items-center justify-center'>
<span className='text-[13px] text-[var(--text-muted)]'>File not found</span>
</div>
)
}
return (
<div className='flex h-full flex-col overflow-hidden'>
<FileViewer key={file.id} file={file} workspaceId={workspaceId} canEdit={true} />
</div>
)
}

View File

@@ -1,9 +1,10 @@
'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
import { ArrowUp, Mic, Paperclip } from 'lucide-react'
import { ArrowUp, FileText, Loader2, Mic, Paperclip, X } from 'lucide-react'
import { Button } from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
import { useFileAttachments } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-file-attachments'
import { useAnimatedPlaceholder } from '../../hooks'
const TEXTAREA_CLASSES = cn(
@@ -27,13 +28,25 @@ function autoResizeTextarea(e: React.FormEvent<HTMLTextAreaElement>) {
target.style.height = `${Math.min(target.scrollHeight, window.innerHeight * 0.3)}px`
}
export interface FileAttachmentForApi {
id: string
key: string
filename: string
media_type: string
size: number
}
const ACCEPTED_FILE_TYPES =
'image/*,.pdf,.txt,.csv,.md,.html,.json,.xml,text/plain,text/csv,text/markdown,text/html,application/json,application/xml,application/pdf'
interface UserInputProps {
value: string
onChange: (value: string) => void
onSubmit: () => void
onSubmit: (fileAttachments?: FileAttachmentForApi[]) => void
isSending: boolean
onStopGeneration: () => void
isInitialView?: boolean
userId?: string
}
export function UserInput({
@@ -43,10 +56,14 @@ export function UserInput({
isSending,
onStopGeneration,
isInitialView = true,
userId,
}: UserInputProps) {
const animatedPlaceholder = useAnimatedPlaceholder()
const placeholder = isInitialView ? animatedPlaceholder : 'Send message to Sim'
const canSubmit = value.trim().length > 0 && !isSending
const files = useFileAttachments({ userId, disabled: false, isLoading: isSending })
const hasFiles = files.attachedFiles.some((f) => !f.uploading && f.key)
const canSubmit = (value.trim().length > 0 || hasFiles) && !isSending
const [isListening, setIsListening] = useState(false)
const recognitionRef = useRef<SpeechRecognition | null>(null)
@@ -65,14 +82,29 @@ export function UserInput({
textareaRef.current?.focus()
}, [])
const handleSubmit = useCallback(() => {
const fileAttachmentsForApi = 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,
}))
onSubmit(fileAttachmentsForApi.length > 0 ? fileAttachmentsForApi : undefined)
files.clearAttachedFiles()
}, [onSubmit, files])
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
onSubmit()
handleSubmit()
}
},
[onSubmit]
[handleSubmit]
)
const toggleListening = useCallback(() => {
@@ -133,26 +165,89 @@ export function UserInput({
onClick={handleContainerClick}
className={cn(
'mx-auto w-full max-w-[640px] cursor-text rounded-[20px] border border-[var(--border-1)] bg-[var(--white)] px-[10px] py-[8px] dark:bg-[var(--surface-4)]',
isInitialView && 'shadow-sm'
isInitialView && 'shadow-sm',
files.isDragging && 'ring-[1.75px] ring-[var(--brand-secondary)]'
)}
onDragEnter={files.handleDragEnter}
onDragLeave={files.handleDragLeave}
onDragOver={files.handleDragOver}
onDrop={files.handleDrop}
>
{/* Attached files */}
{files.attachedFiles.length > 0 && (
<div className='mb-[6px] flex gap-[6px] overflow-x-auto [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden'>
{files.attachedFiles.map((file) => {
const isImage = file.type.startsWith('image/')
return (
<div
key={file.id}
className='group relative h-[56px] w-[56px] flex-shrink-0 cursor-pointer overflow-hidden rounded-[8px] border border-[var(--border-1)] bg-[var(--surface-5)] transition-all hover:bg-[var(--surface-4)]'
title={`${file.name} (${files.formatFileSize(file.size)})`}
onClick={() => files.handleFileClick(file)}
>
{isImage && file.previewUrl ? (
<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-[2px]'>
{file.type.includes('pdf') ? (
<FileText className='h-[18px] w-[18px] text-red-500' />
) : (
<FileText className='h-[18px] w-[18px] text-blue-500' />
)}
<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'>
<Loader2 className='h-[14px] w-[14px] animate-spin text-white' />
</div>
)}
{!file.uploading && (
<button
type='button'
onClick={(e) => {
e.stopPropagation()
files.removeFile(file.id)
}}
className='absolute top-[2px] right-[2px] flex h-[16px] w-[16px] items-center justify-center rounded-full bg-black/60 opacity-0 transition-opacity group-hover:opacity-100'
>
<X className='h-[10px] w-[10px] text-white' />
</button>
)}
</div>
)
})}
</div>
)}
<textarea
ref={textareaRef}
value={value}
onChange={(e) => onChange(e.target.value)}
onKeyDown={handleKeyDown}
onInput={autoResizeTextarea}
placeholder={placeholder}
placeholder={files.isDragging ? 'Drop files here...' : placeholder}
rows={1}
className={TEXTAREA_CLASSES}
/>
<div className='flex items-center justify-between'>
<div className='flex h-[28px] w-[28px] cursor-pointer items-center justify-center rounded-full border border-[#F0F0F0] transition-colors hover:bg-[#F7F7F7] dark:border-[#3d3d3d] dark:hover:bg-[#303030]'>
<button
type='button'
onClick={files.handleFileSelect}
className='flex h-[28px] w-[28px] cursor-pointer items-center justify-center rounded-full border border-[#F0F0F0] transition-colors hover:bg-[#F7F7F7] dark:border-[#3d3d3d] dark:hover:bg-[#303030]'
title='Attach file'
>
<Paperclip
className='h-[14px] w-[14px] text-[var(--text-muted)] dark:text-[var(--text-secondary)]'
strokeWidth={2}
/>
</div>
</button>
<div className='flex items-center gap-[6px]'>
<button
type='button'
@@ -183,7 +278,7 @@ export function UserInput({
</Button>
) : (
<Button
onClick={onSubmit}
onClick={handleSubmit}
disabled={!canSubmit}
className={cn(
SEND_BUTTON_BASE,
@@ -198,6 +293,16 @@ export function UserInput({
)}
</div>
</div>
{/* Hidden file input */}
<input
ref={files.fileInputRef}
type='file'
onChange={files.handleFileChange}
className='hidden'
accept={ACCEPTED_FILE_TYPES}
multiple
/>
</div>
)
}

View File

@@ -3,6 +3,7 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { useParams, useRouter } from 'next/navigation'
import { useSession } from '@/lib/auth/auth-client'
import {
LandingPromptStorage,
LandingTemplateStorage,
@@ -11,6 +12,7 @@ import {
} from '@/lib/core/utils/browser-storage'
import { persistImportedWorkflow } from '@/lib/workflows/operations/import-export'
import { MessageContent, MothershipView, UserInput } from './components'
import type { FileAttachmentForApi } from './components/user-input/user-input'
import { useChat } from './hooks'
const logger = createLogger('Home')
@@ -22,6 +24,7 @@ interface HomeProps {
export function Home({ chatId }: HomeProps = {}) {
const { workspaceId } = useParams<{ workspaceId: string }>()
const router = useRouter()
const { data: session } = useSession()
const [inputValue, setInputValue] = useState('')
const hasCheckedLandingStorageRef = useRef(false)
@@ -107,12 +110,15 @@ export function Home({ chatId }: HomeProps = {}) {
setActiveResourceId,
} = useChat(workspaceId, chatId)
const handleSubmit = useCallback(() => {
const trimmed = inputValue.trim()
if (!trimmed) return
setInputValue('')
sendMessage(trimmed)
}, [inputValue, sendMessage])
const handleSubmit = useCallback(
(fileAttachments?: FileAttachmentForApi[]) => {
const trimmed = inputValue.trim()
if (!trimmed && !(fileAttachments && fileAttachments.length > 0)) return
setInputValue('')
sendMessage(trimmed || 'Analyze the attached file(s).', fileAttachments)
},
[inputValue, sendMessage]
)
const hasMessages = messages.length > 0
@@ -128,6 +134,7 @@ export function Home({ chatId }: HomeProps = {}) {
onSubmit={handleSubmit}
isSending={isSending}
onStopGeneration={stopGeneration}
userId={session?.user?.id}
/>
</div>
)
@@ -189,6 +196,7 @@ export function Home({ chatId }: HomeProps = {}) {
isSending={isSending}
onStopGeneration={stopGeneration}
isInitialView={false}
userId={session?.user?.id}
/>
</div>
</div>

View File

@@ -8,10 +8,12 @@ import {
type TaskChatHistory,
type TaskStoredContentBlock,
type TaskStoredMessage,
type TaskStoredToolCall,
taskKeys,
useChatHistory,
} from '@/hooks/queries/tasks'
import { workspaceFilesKeys } from '@/hooks/queries/workspace-files'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import type {
ChatMessage,
ContentBlock,
@@ -22,12 +24,27 @@ import type {
ToolCallStatus,
} from '../types'
import { SUBAGENT_LABELS } from '../types'
import {
extractFileResource,
extractTableResource,
extractWorkflowResource,
RESOURCE_TOOL_NAMES,
} from '../utils'
export interface UseChatReturn {
messages: ChatMessage[]
isSending: boolean
error: string | null
sendMessage: (message: string) => Promise<void>
sendMessage: (
message: string,
fileAttachments?: Array<{
id: string
key: string
filename: string
media_type: string
size: number
}>
) => Promise<void>
stopGeneration: () => void
chatBottomRef: React.RefObject<HTMLDivElement | null>
resources: MothershipResource[]
@@ -52,12 +69,28 @@ function mapStoredBlock(block: TaskStoredContentBlock): ContentBlock {
name: block.toolCall.name ?? 'unknown',
status: STATE_TO_STATUS[block.toolCall.state ?? ''] ?? 'success',
displayTitle: block.toolCall.display?.text,
result: block.toolCall.result,
}
}
return mapped
}
function mapStoredToolCall(tc: TaskStoredToolCall): ContentBlock {
return {
type: 'tool_call',
toolCall: {
id: tc.id,
name: tc.name,
status: (STATE_TO_STATUS[tc.status] ?? 'success') as ToolCallStatus,
result:
tc.result != null
? { success: tc.status === 'success', output: tc.result, error: tc.error }
: undefined,
},
}
}
function mapStoredMessage(msg: TaskStoredMessage): ChatMessage {
const mapped: ChatMessage = {
id: msg.id,
@@ -65,7 +98,9 @@ function mapStoredMessage(msg: TaskStoredMessage): ChatMessage {
content: msg.content,
}
if (Array.isArray(msg.contentBlocks) && msg.contentBlocks.length > 0) {
if (Array.isArray(msg.toolCalls) && msg.toolCalls.length > 0) {
mapped.contentBlocks = msg.toolCalls.map(mapStoredToolCall)
} else if (Array.isArray(msg.contentBlocks) && msg.contentBlocks.length > 0) {
mapped.contentBlocks = msg.contentBlocks.map(mapStoredBlock)
}
@@ -78,60 +113,6 @@ function getPayloadData(payload: SSEPayload): SSEPayloadData | undefined {
return typeof payload.data === 'object' ? payload.data : undefined
}
const RESOURCE_TOOL_NAMES = new Set(['user_table', 'workspace_file'])
function getResultData(parsed: SSEPayload): Record<string, unknown> | undefined {
const topResult = parsed.result as Record<string, unknown> | undefined
const nestedResult =
typeof parsed.data === 'object' ? (parsed.data?.result as Record<string, unknown>) : undefined
const result = topResult ?? nestedResult
return result?.data as Record<string, unknown> | undefined
}
function extractTableResource(
parsed: SSEPayload,
storedArgs: Record<string, unknown> | undefined,
fallbackTableId: string | null
): MothershipResource | null {
const data = getResultData(parsed)
const storedInnerArgs = storedArgs?.args as Record<string, unknown> | undefined
const table = data?.table as Record<string, unknown> | undefined
if (table?.id) {
return { type: 'table', id: table.id as string, title: (table.name as string) || 'Table' }
}
const tableId =
(data?.tableId as string) ?? storedInnerArgs?.tableId ?? storedArgs?.tableId ?? fallbackTableId
const tableName = (data?.tableName as string) || (table?.name as string) || 'Table'
if (tableId) return { type: 'table', id: tableId as string, title: tableName }
return null
}
function extractFileResource(
parsed: SSEPayload,
storedArgs: Record<string, unknown> | undefined
): MothershipResource | null {
const data = getResultData(parsed)
const storedInnerArgs = storedArgs?.args as Record<string, unknown> | undefined
const file = data?.file as Record<string, unknown> | undefined
if (file?.id) {
return { type: 'file', id: file.id as string, title: (file.name as string) || 'File' }
}
const fileId = (data?.fileId as string) ?? (data?.id as string)
const fileName =
(data?.fileName as string) ||
(data?.name as string) ||
(storedInnerArgs?.fileName as string) ||
'File'
if (fileId && typeof fileId === 'string') return { type: 'file', id: fileId, title: fileName }
return null
}
export function useChat(workspaceId: string, initialChatId?: string): UseChatReturn {
const pathname = usePathname()
const queryClient = useQueryClient()
@@ -211,6 +192,7 @@ export function useChat(workspaceId: string, initialChatId?: string): UseChatRet
const blocks: ContentBlock[] = []
const toolMap = new Map<string, number>()
let lastTableId: string | null = null
let lastWorkflowId: string | null = null
const ensureTextBlock = (): ContentBlock => {
const last = blocks[blocks.length - 1]
@@ -323,7 +305,13 @@ export function useChat(workspaceId: string, initialChatId?: string): UseChatRet
if (!id) break
const idx = toolMap.get(id)
if (idx !== undefined && blocks[idx].toolCall) {
blocks[idx].toolCall!.status = parsed.success ? 'success' : 'error'
const tc = blocks[idx].toolCall!
tc.status = parsed.success ? 'success' : 'error'
tc.result = {
success: !!parsed.success,
output: parsed.result ?? getPayloadData(parsed)?.result,
error: (parsed.error ?? getPayloadData(parsed)?.error) as string | undefined,
}
flush()
}
@@ -349,6 +337,32 @@ export function useChat(workspaceId: string, initialChatId?: string): UseChatRet
queryKey: workspaceFilesKeys.content(workspaceId, resource.id),
})
}
} else if (toolName === 'create_workflow' || toolName === 'edit_workflow') {
resource = extractWorkflowResource(parsed, lastWorkflowId)
if (resource) {
lastWorkflowId = resource.id
const registry = useWorkflowRegistry.getState()
if (!registry.workflows[resource.id]) {
useWorkflowRegistry.setState((state) => ({
workflows: {
...state.workflows,
[resource!.id]: {
id: resource!.id,
name: resource!.title,
lastModified: new Date(),
createdAt: new Date(),
color: '#7F2FFF',
workspaceId,
folderId: null,
sortOrder: 0,
},
},
}))
registry.setActiveWorkflow(resource.id)
} else {
registry.loadWorkflowState(resource.id)
}
}
}
if (resource) addResource(resource)
@@ -440,7 +454,16 @@ export function useChat(workspaceId: string, initialChatId?: string): UseChatRet
}, [chatHistory?.activeStreamId, processSSEStream, finalize])
const sendMessage = useCallback(
async (message: string) => {
async (
message: string,
fileAttachments?: Array<{
id: string
key: string
filename: string
media_type: string
size: number
}>
) => {
if (!message.trim() || !workspaceId) return
abortControllerRef.current?.abort()
@@ -489,6 +512,7 @@ export function useChat(workspaceId: string, initialChatId?: string): UseChatRet
userMessageId,
createNewChat: !chatIdRef.current,
...(chatIdRef.current ? { chatId: chatIdRef.current } : {}),
...(fileAttachments && fileAttachments.length > 0 ? { fileAttachments } : {}),
userTimezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
}),
signal: abortController.signal,

View File

@@ -5,6 +5,7 @@ export interface ToolCallInfo {
name: string
status: ToolCallStatus
displayTitle?: string
result?: { success: boolean; output?: unknown; error?: string }
}
export type ContentBlockType = 'text' | 'tool_call' | 'subagent'
@@ -49,7 +50,8 @@ export interface SSEPayloadData {
agent?: string
arguments?: Record<string, unknown>
input?: Record<string, unknown>
result?: Record<string, unknown>
result?: unknown
error?: string
}
export interface SSEPayload {
@@ -61,12 +63,12 @@ export interface SSEPayload {
toolName?: string
ui?: SSEPayloadUI
success?: boolean
result?: unknown
error?: string
subagent?: string
result?: Record<string, unknown>
}
export type MothershipResourceType = 'table' | 'file'
export type MothershipResourceType = 'table' | 'file' | 'workflow'
export interface MothershipResource {
type: MothershipResourceType

View File

@@ -0,0 +1,80 @@
import type { MothershipResource, SSEPayload } from './types'
export const RESOURCE_TOOL_NAMES = new Set([
'user_table',
'workspace_file',
'create_workflow',
'edit_workflow',
])
function getResultData(parsed: SSEPayload): Record<string, unknown> | undefined {
const topResult = parsed.result as Record<string, unknown> | undefined
const nestedResult =
typeof parsed.data === 'object' ? (parsed.data?.result as Record<string, unknown>) : undefined
const result = topResult ?? nestedResult
return result?.data as Record<string, unknown> | undefined
}
export function extractTableResource(
parsed: SSEPayload,
storedArgs: Record<string, unknown> | undefined,
fallbackTableId: string | null
): MothershipResource | null {
const data = getResultData(parsed)
const storedInnerArgs = storedArgs?.args as Record<string, unknown> | undefined
const table = data?.table as Record<string, unknown> | undefined
if (table?.id) {
return { type: 'table', id: table.id as string, title: (table.name as string) || 'Table' }
}
const tableId =
(data?.tableId as string) ?? storedInnerArgs?.tableId ?? storedArgs?.tableId ?? fallbackTableId
const tableName = (data?.tableName as string) || (table?.name as string) || 'Table'
if (tableId) return { type: 'table', id: tableId as string, title: tableName }
return null
}
export function extractFileResource(
parsed: SSEPayload,
storedArgs: Record<string, unknown> | undefined
): MothershipResource | null {
const data = getResultData(parsed)
const storedInnerArgs = storedArgs?.args as Record<string, unknown> | undefined
const file = data?.file as Record<string, unknown> | undefined
if (file?.id) {
return { type: 'file', id: file.id as string, title: (file.name as string) || 'File' }
}
const fileId = (data?.fileId as string) ?? (data?.id as string)
const fileName =
(data?.fileName as string) ||
(data?.name as string) ||
(storedInnerArgs?.fileName as string) ||
'File'
if (fileId && typeof fileId === 'string') return { type: 'file', id: fileId, title: fileName }
return null
}
export function extractWorkflowResource(
parsed: SSEPayload,
fallbackWorkflowId: string | null
): MothershipResource | null {
const topResult = (parsed.result ??
(typeof parsed.data === 'object' ? parsed.data?.result : undefined)) as
| Record<string, unknown>
| undefined
const data = topResult?.data as Record<string, unknown> | undefined
const workflowId =
(topResult?.workflowId as string) ?? (data?.workflowId as string) ?? fallbackWorkflowId
const workflowName =
(topResult?.workflowName as string) ?? (data?.workflowName as string) ?? 'Workflow'
if (workflowId) return { type: 'workflow', id: workflowId, title: workflowName }
return null
}

View File

@@ -1607,7 +1607,7 @@ const PlaceholderRows = React.memo(function PlaceholderRows({
}}
onMouseEnter={() => onRowMouseEnter(globalRowIndex)}
>
<span className='text-[11px] text-[var(--text-tertiary)] tabular-nums'>
<span className='block text-[11px] text-[var(--text-tertiary)] tabular-nums'>
{dataRowCount + i + 1}
</span>
</td>

View File

@@ -522,10 +522,46 @@ export function Chat() {
let accumulatedContent = ''
let buffer = ''
const BATCH_MAX_MS = 50
let pendingChunks = ''
let batchRAF: number | null = null
let batchTimer: ReturnType<typeof setTimeout> | null = null
let lastFlush = 0
const flushChunks = () => {
if (batchRAF !== null) {
cancelAnimationFrame(batchRAF)
batchRAF = null
}
if (batchTimer !== null) {
clearTimeout(batchTimer)
batchTimer = null
}
if (pendingChunks) {
appendMessageContent(responseMessageId, pendingChunks)
pendingChunks = ''
}
lastFlush = performance.now()
}
const scheduleFlush = () => {
if (batchRAF !== null) return
const elapsed = performance.now() - lastFlush
if (elapsed >= BATCH_MAX_MS) {
flushChunks()
return
}
batchRAF = requestAnimationFrame(flushChunks)
if (batchTimer === null) {
batchTimer = setTimeout(flushChunks, Math.max(0, BATCH_MAX_MS - elapsed))
}
}
try {
while (true) {
const { done, value } = await reader.read()
if (done) {
flushChunks()
finalizeMessageStream(responseMessageId)
break
}
@@ -558,6 +594,7 @@ export function Chat() {
if ('success' in result && !result.success) {
const errorMessage = result.error || 'Workflow execution failed'
flushChunks()
appendMessageContent(
responseMessageId,
`${accumulatedContent ? '\n\n' : ''}Error: ${errorMessage}`
@@ -566,10 +603,12 @@ export function Chat() {
return
}
flushChunks()
finalizeMessageStream(responseMessageId)
} else if (contentChunk) {
accumulatedContent += contentChunk
appendMessageContent(responseMessageId, contentChunk)
pendingChunks += contentChunk
scheduleFlush()
}
} catch (e) {
logger.error('Error parsing stream data:', e)
@@ -580,8 +619,11 @@ export function Chat() {
if ((error as Error)?.name !== 'AbortError') {
logger.error('Error processing stream:', error)
}
flushChunks()
finalizeMessageStream(responseMessageId)
} finally {
if (batchRAF !== null) cancelAnimationFrame(batchRAF)
if (batchTimer !== null) clearTimeout(batchTimer)
if (streamReaderRef.current === reader) {
streamReaderRef.current = null
}

View File

@@ -1,5 +1,6 @@
import { useMemo } from 'react'
import { StreamingIndicator } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/smooth-streaming'
import { useThrottledValue } from '@/hooks/use-throttled-value'
interface ChatAttachment {
id: string
@@ -93,13 +94,16 @@ const WordWrap = ({ text }: { text: string }) => {
* Renders a chat message with optional file attachments
*/
export function ChatMessage({ message }: ChatMessageProps) {
const formattedContent = useMemo(() => {
const rawContent = useMemo(() => {
if (typeof message.content === 'object' && message.content !== null) {
return JSON.stringify(message.content, null, 2)
}
return String(message.content || '')
}, [message.content])
const throttled = useThrottledValue(rawContent)
const formattedContent = message.type === 'user' ? rawContent : throttled
const handleAttachmentClick = (attachment: ChatAttachment) => {
const validDataUrl = attachment.dataUrl?.trim()
if (validDataUrl?.startsWith('data:')) {

View File

@@ -112,11 +112,6 @@ export function useFileAttachments(props: UseFileAttachmentsProps) {
}
for (const file of Array.from(fileList)) {
if (!file.type.startsWith('image/')) {
logger.warn(`File ${file.name} is not an image. Only image files are allowed.`)
continue
}
let previewUrl: string | undefined
if (file.type.startsWith('image/')) {
previewUrl = URL.createObjectURL(file)

View File

@@ -127,12 +127,13 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
const params = useParams()
const workspaceId = params.workspaceId as string
const copilotStore = useCopilotStore()
const workflowId =
workflowIdOverride !== undefined ? workflowIdOverride : copilotStore.workflowId
const storeWorkflowId = useCopilotStore((s) => s.workflowId)
const storeSelectedModel = useCopilotStore((s) => s.selectedModel)
const storeSetSelectedModel = useCopilotStore((s) => s.setSelectedModel)
const workflowId = workflowIdOverride !== undefined ? workflowIdOverride : storeWorkflowId
const selectedModel =
selectedModelOverride !== undefined ? selectedModelOverride : copilotStore.selectedModel
const setSelectedModel = onModelChangeOverride || copilotStore.setSelectedModel
selectedModelOverride !== undefined ? selectedModelOverride : storeSelectedModel
const setSelectedModel = onModelChangeOverride || storeSetSelectedModel
const [internalMessage, setInternalMessage] = useState('')
const [isNearTop, setIsNearTop] = useState(false)
@@ -882,7 +883,7 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
type='file'
onChange={fileAttachments.handleFileChange}
className='hidden'
accept='image/*'
accept='image/*,.pdf,.txt,.csv,.md,.html,.json,.xml,application/pdf,text/plain,text/csv,text/markdown'
multiple
disabled={disabled}
/>

View File

@@ -40,6 +40,7 @@ import {
useTodoManagement,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/hooks'
import { useScrollManagement } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
import { useProgressiveList } from '@/hooks/use-progressive-list'
import type { ChatContext } from '@/stores/panel'
import { useCopilotStore } from '@/stores/panel'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
@@ -90,7 +91,6 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
isSendingMessage,
isAborting,
mode,
inputValue,
planTodos,
showPlanTodos,
streamingPlanContent,
@@ -98,7 +98,6 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
abortMessage,
createNewChat,
setMode,
setInputValue,
chatsLoadedForWorkflow,
setWorkflowId: setCopilotWorkflowId,
loadChats,
@@ -116,6 +115,8 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
resumeActiveStream,
} = useCopilotStore()
const [inputValue, setInputValue] = useState('')
// Initialize copilot
const { isInitialized } = useCopilotInitialization({
activeWorkflowId,
@@ -133,6 +134,9 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
// Handle scroll management
const { scrollAreaRef, scrollToBottom } = useScrollManagement(messages, isSendingMessage)
const chatKey = currentChat?.id ?? ''
const { staged: stagedMessages } = useProgressiveList(messages, chatKey)
// Handle chat history grouping
const { groupedChats, handleHistoryDropdownOpen: handleHistoryDropdownOpenHook } = useChatHistory(
{
@@ -468,19 +472,21 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
showPlanTodos && planTodos.length > 0 ? 'pb-14' : 'pb-10'
}`}
>
{messages.map((message, index) => {
{stagedMessages.map((message, index) => {
let isDimmed = false
const globalIndex = messages.length - stagedMessages.length + index
if (editingMessageId) {
const editingIndex = messages.findIndex((m) => m.id === editingMessageId)
isDimmed = editingIndex !== -1 && index > editingIndex
isDimmed = editingIndex !== -1 && globalIndex > editingIndex
}
if (!isDimmed && revertingMessageId) {
const revertingIndex = messages.findIndex(
(m) => m.id === revertingMessageId
)
isDimmed = revertingIndex !== -1 && index > revertingIndex
isDimmed = revertingIndex !== -1 && globalIndex > revertingIndex
}
const checkpointCount = messageCheckpoints[message.id]?.length || 0
@@ -501,7 +507,7 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
onRevertModeChange={(isReverting) =>
handleRevertModeChange(message.id, isReverting)
}
isLastMessage={index === messages.length - 1}
isLastMessage={globalIndex === messages.length - 1}
/>
)
})}

View File

@@ -2,33 +2,34 @@
import { useCallback, useEffect, useRef, useState } from 'react'
/**
* Options for configuring scroll behavior
*/
const AUTO_SCROLL_GRACE_MS = 120
function distanceFromBottom(el: HTMLElement) {
return el.scrollHeight - el.scrollTop - el.clientHeight
}
interface UseScrollManagementOptions {
/**
* Scroll behavior for programmatic scrolls
* @remarks
* Scroll behavior for programmatic scrolls.
* - `smooth`: Animated scroll (default, used by Copilot)
* - `auto`: Immediate scroll to bottom (used by floating chat to avoid jitter)
*/
behavior?: 'auto' | 'smooth'
/**
* Distance from bottom (in pixels) within which auto-scroll stays active
* @remarks Lower values = less sticky (user can scroll away easier)
* Distance from bottom (in pixels) within which auto-scroll stays active.
* @defaultValue 30
*/
stickinessThreshold?: number
}
/**
* Custom hook to manage scroll behavior in scrollable message panels.
* Handles auto-scrolling during message streaming and user-initiated scrolling.
* Manages auto-scrolling during message streaming using ResizeObserver
* instead of a polling interval.
*
* @param messages - Array of messages to track for scroll behavior
* @param isSendingMessage - Whether a message is currently being sent/streamed
* @param options - Optional configuration for scroll behavior
* @returns Scroll management utilities
* Tracks whether scrolls are programmatic (via a timestamp grace window)
* to avoid falsely treating our own scrolls as the user scrolling away.
* Handles nested scrollable regions marked with `data-scrollable` so that
* scrolling inside tool output or code blocks doesn't break follow-mode.
*/
export function useScrollManagement(
messages: any[],
@@ -37,68 +38,98 @@ export function useScrollManagement(
) {
const scrollAreaRef = useRef<HTMLDivElement>(null)
const [userHasScrolledAway, setUserHasScrolledAway] = useState(false)
const programmaticScrollRef = useRef(false)
const programmaticUntilRef = useRef(0)
const lastScrollTopRef = useRef(0)
const scrollBehavior = options?.behavior ?? 'smooth'
const stickinessThreshold = options?.stickinessThreshold ?? 30
/** Scrolls the container to the bottom */
const isSendingRef = useRef(isSendingMessage)
isSendingRef.current = isSendingMessage
const userScrolledRef = useRef(userHasScrolledAway)
userScrolledRef.current = userHasScrolledAway
const markProgrammatic = useCallback(() => {
programmaticUntilRef.current = Date.now() + AUTO_SCROLL_GRACE_MS
}, [])
const isProgrammatic = useCallback(() => {
return Date.now() < programmaticUntilRef.current
}, [])
const scrollToBottom = useCallback(() => {
const container = scrollAreaRef.current
if (!container) return
programmaticScrollRef.current = true
markProgrammatic()
container.scrollTo({ top: container.scrollHeight, behavior: scrollBehavior })
}, [scrollBehavior, markProgrammatic])
window.setTimeout(() => {
programmaticScrollRef.current = false
}, 200)
}, [scrollBehavior])
/** Handles scroll events to track user position */
const handleScroll = useCallback(() => {
const container = scrollAreaRef.current
if (!container || programmaticScrollRef.current) return
const { scrollTop, scrollHeight, clientHeight } = container
const distanceFromBottom = scrollHeight - scrollTop - clientHeight
const nearBottom = distanceFromBottom <= stickinessThreshold
const delta = scrollTop - lastScrollTopRef.current
if (isSendingMessage) {
// User scrolled up during streaming - break away
if (delta < -2) {
setUserHasScrolledAway(true)
}
// User scrolled back down to bottom - re-stick
if (userHasScrolledAway && delta > 2 && nearBottom) {
setUserHasScrolledAway(false)
}
}
lastScrollTopRef.current = scrollTop
}, [isSendingMessage, userHasScrolledAway, stickinessThreshold])
/** Attaches scroll listener to container */
useEffect(() => {
const container = scrollAreaRef.current
if (!container) return
const handleScroll = () => {
const { scrollTop, scrollHeight, clientHeight } = container
const dist = scrollHeight - scrollTop - clientHeight
if (isProgrammatic()) {
lastScrollTopRef.current = scrollTop
if (dist < stickinessThreshold && userScrolledRef.current) {
setUserHasScrolledAway(false)
}
return
}
const nearBottom = dist <= stickinessThreshold
const delta = scrollTop - lastScrollTopRef.current
if (isSendingRef.current) {
if (delta < -2 && !userScrolledRef.current) {
setUserHasScrolledAway(true)
}
if (userScrolledRef.current && delta > 2 && nearBottom) {
setUserHasScrolledAway(false)
}
}
lastScrollTopRef.current = scrollTop
}
container.addEventListener('scroll', handleScroll, { passive: true })
lastScrollTopRef.current = container.scrollTop
return () => container.removeEventListener('scroll', handleScroll)
}, [handleScroll])
}, [stickinessThreshold, isProgrammatic])
// Ignore upward wheel events inside nested [data-scrollable] regions
// (tool output, code blocks) so they don't break follow-mode.
useEffect(() => {
const container = scrollAreaRef.current
if (!container) return
const handleWheel = (e: WheelEvent) => {
if (e.deltaY >= 0) return
const target = e.target instanceof Element ? e.target : undefined
const nested = target?.closest('[data-scrollable]')
if (nested && nested !== container) return
if (!userScrolledRef.current && isSendingRef.current) {
setUserHasScrolledAway(true)
}
}
container.addEventListener('wheel', handleWheel, { passive: true })
return () => container.removeEventListener('wheel', handleWheel)
}, [])
/** Handles auto-scroll when new messages are added */
useEffect(() => {
if (messages.length === 0) return
const lastMessage = messages[messages.length - 1]
const isUserMessage = lastMessage?.role === 'user'
// Always scroll for user messages, respect scroll state for assistant messages
if (isUserMessage) {
setUserHasScrolledAway(false)
scrollToBottom()
@@ -107,35 +138,42 @@ export function useScrollManagement(
}
}, [messages, userHasScrolledAway, scrollToBottom])
/** Resets scroll state when streaming completes */
useEffect(() => {
if (!isSendingMessage) {
setUserHasScrolledAway(false)
}
}, [isSendingMessage])
/** Keeps scroll pinned during streaming - uses interval, stops when user scrolls away */
useEffect(() => {
// Early return stops the interval when user scrolls away (state change re-runs effect)
if (!isSendingMessage || userHasScrolledAway) {
return
}
if (!isSendingMessage || userHasScrolledAway) return
const intervalId = window.setInterval(() => {
const container = scrollAreaRef.current
if (!container) return
const container = scrollAreaRef.current
if (!container) return
const { scrollTop, scrollHeight, clientHeight } = container
const distanceFromBottom = scrollHeight - scrollTop - clientHeight
const content = container.firstElementChild as HTMLElement | null
if (!content) return
if (distanceFromBottom > 1) {
const observer = new ResizeObserver(() => {
if (distanceFromBottom(container) > 1) {
scrollToBottom()
}
}, 100)
})
return () => window.clearInterval(intervalId)
observer.observe(content)
return () => observer.disconnect()
}, [isSendingMessage, userHasScrolledAway, scrollToBottom])
// overflow-anchor: none during streaming prevents the browser from
// fighting our programmatic scrollToBottom calls (Chromium/Firefox only;
// Safari does not support this property).
useEffect(() => {
const container = scrollAreaRef.current
if (!container) return
container.style.overflowAnchor = isSendingMessage && !userHasScrolledAway ? 'none' : 'auto'
}, [isSendingMessage, userHasScrolledAway])
return {
scrollAreaRef,
scrollToBottom,

View File

@@ -1,6 +1,6 @@
'use client'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { memo, useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from 'react'
import { Command } from 'cmdk'
import { Database, Files, HelpCircle, Settings } from 'lucide-react'
import { useParams, useRouter } from 'next/navigation'
@@ -19,23 +19,34 @@ import type {
SearchToolOperationItem,
} from '@/stores/modals/search/types'
function customFilter(value: string, search: string): number {
const searchLower = search.toLowerCase()
function scoreMatch(value: string, search: string): number {
if (!search) return 1
const valueLower = value.toLowerCase()
const searchLower = search.toLowerCase()
if (valueLower === searchLower) return 1
if (valueLower.startsWith(searchLower)) return 0.9
if (valueLower.includes(searchLower)) return 0.7
const searchWords = searchLower.split(/\s+/).filter(Boolean)
if (searchWords.length > 1) {
const allWordsMatch = searchWords.every((word) => valueLower.includes(word))
if (allWordsMatch) return 0.5
const words = searchLower.split(/\s+/).filter(Boolean)
if (words.length > 1) {
if (words.every((w) => valueLower.includes(w))) return 0.5
}
return 0
}
function filterAndSort<T>(items: T[], toValue: (item: T) => string, search: string): T[] {
if (!search) return items
const scored: [T, number][] = []
for (const item of items) {
const s = scoreMatch(toValue(item), search)
if (s > 0) scored.push([item, s])
}
scored.sort((a, b) => b[1] - a[1])
return scored.map(([item]) => item)
}
interface TaskItem {
id: string
name: string
@@ -165,20 +176,27 @@ export function SearchModal({
)
useEffect(() => {
if (open && inputRef.current) {
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
window.HTMLInputElement.prototype,
'value'
)?.set
if (nativeInputValueSetter) {
nativeInputValueSetter.call(inputRef.current, '')
inputRef.current.dispatchEvent(new Event('input', { bubbles: true }))
if (open) {
setSearch('')
if (inputRef.current) {
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
window.HTMLInputElement.prototype,
'value'
)?.set
if (nativeInputValueSetter) {
nativeInputValueSetter.call(inputRef.current, '')
inputRef.current.dispatchEvent(new Event('input', { bubbles: true }))
}
inputRef.current.focus()
}
inputRef.current.focus()
}
}, [open])
const handleSearchChange = useCallback(() => {
const [search, setSearch] = useState('')
const deferredSearch = useDeferredValue(search)
const handleSearchChange = useCallback((value: string) => {
setSearch(value)
requestAnimationFrame(() => {
const list = document.querySelector('[cmdk-list]')
if (list) {
@@ -274,11 +292,51 @@ export function SearchModal({
[onOpenChange]
)
const showBlocks = isOnWorkflowPage && blocks.length > 0
const showTools = isOnWorkflowPage && tools.length > 0
const showTriggers = isOnWorkflowPage && triggers.length > 0
const showToolOperations = isOnWorkflowPage && toolOperations.length > 0
const showDocs = isOnWorkflowPage && docs.length > 0
const filteredBlocks = useMemo(() => {
if (!isOnWorkflowPage) return []
return filterAndSort(blocks, (b) => `${b.name} block-${b.id}`, deferredSearch)
}, [isOnWorkflowPage, blocks, deferredSearch])
const filteredTools = useMemo(() => {
if (!isOnWorkflowPage) return []
return filterAndSort(tools, (t) => `${t.name} tool-${t.id}`, deferredSearch)
}, [isOnWorkflowPage, tools, deferredSearch])
const filteredTriggers = useMemo(() => {
if (!isOnWorkflowPage) return []
return filterAndSort(triggers, (t) => `${t.name} trigger-${t.id}`, deferredSearch)
}, [isOnWorkflowPage, triggers, deferredSearch])
const filteredToolOps = useMemo(() => {
if (!isOnWorkflowPage) return []
return filterAndSort(
toolOperations,
(op) => `${op.searchValue} operation-${op.id}`,
deferredSearch
)
}, [isOnWorkflowPage, toolOperations, deferredSearch])
const filteredDocs = useMemo(() => {
if (!isOnWorkflowPage) return []
return filterAndSort(docs, (d) => `${d.name} docs documentation doc-${d.id}`, deferredSearch)
}, [isOnWorkflowPage, docs, deferredSearch])
const filteredWorkflows = useMemo(
() => filterAndSort(workflows, (w) => `${w.name} workflow-${w.id}`, deferredSearch),
[workflows, deferredSearch]
)
const filteredTasks = useMemo(
() => filterAndSort(tasks, (t) => `${t.name} task-${t.id}`, deferredSearch),
[tasks, deferredSearch]
)
const filteredWorkspaces = useMemo(
() => filterAndSort(workspaces, (w) => `${w.name} workspace-${w.id}`, deferredSearch),
[workspaces, deferredSearch]
)
const filteredPages = useMemo(
() => filterAndSort(pages, (p) => `${p.name} page-${p.id}`, deferredSearch),
[pages, deferredSearch]
)
if (!mounted) return null
@@ -294,7 +352,6 @@ export function SearchModal({
aria-hidden={!open}
/>
{/* Command palette - always rendered for instant opening, hidden with CSS */}
<div
role='dialog'
aria-modal={open}
@@ -306,7 +363,7 @@ export function SearchModal({
)}
style={{ left: 'calc(50% + var(--sidebar-width, 0px) / 2)' }}
>
<Command label='Search' filter={customFilter}>
<Command label='Search' shouldFilter={false}>
<Command.Input
ref={inputRef}
autoFocus
@@ -319,10 +376,10 @@ export function SearchModal({
No results found.
</Command.Empty>
{showBlocks && (
{filteredBlocks.length > 0 && (
<Command.Group heading='Blocks' className={groupHeadingClassName}>
{blocks.map((block) => (
<CommandItem
{filteredBlocks.map((block) => (
<MemoizedCommandItem
key={block.id}
value={`${block.name} block-${block.id}`}
onSelect={() => handleBlockSelect(block, 'block')}
@@ -331,15 +388,15 @@ export function SearchModal({
showColoredIcon
>
{block.name}
</CommandItem>
</MemoizedCommandItem>
))}
</Command.Group>
)}
{showTools && (
{filteredTools.length > 0 && (
<Command.Group heading='Tools' className={groupHeadingClassName}>
{tools.map((tool) => (
<CommandItem
{filteredTools.map((tool) => (
<MemoizedCommandItem
key={tool.id}
value={`${tool.name} tool-${tool.id}`}
onSelect={() => handleBlockSelect(tool, 'tool')}
@@ -348,15 +405,15 @@ export function SearchModal({
showColoredIcon
>
{tool.name}
</CommandItem>
</MemoizedCommandItem>
))}
</Command.Group>
)}
{showTriggers && (
{filteredTriggers.length > 0 && (
<Command.Group heading='Triggers' className={groupHeadingClassName}>
{triggers.map((trigger) => (
<CommandItem
{filteredTriggers.map((trigger) => (
<MemoizedCommandItem
key={trigger.id}
value={`${trigger.name} trigger-${trigger.id}`}
onSelect={() => handleBlockSelect(trigger, 'trigger')}
@@ -365,14 +422,14 @@ export function SearchModal({
showColoredIcon
>
{trigger.name}
</CommandItem>
</MemoizedCommandItem>
))}
</Command.Group>
)}
{workflows.length > 0 && (
{filteredWorkflows.length > 0 && open && (
<Command.Group heading='Workflows' className={groupHeadingClassName}>
{workflows.map((workflow) => (
{filteredWorkflows.map((workflow) => (
<Command.Item
key={workflow.id}
value={`${workflow.name} workflow-${workflow.id}`}
@@ -396,9 +453,9 @@ export function SearchModal({
</Command.Group>
)}
{tasks.length > 0 && (
{filteredTasks.length > 0 && open && (
<Command.Group heading='Tasks' className={groupHeadingClassName}>
{tasks.map((task) => (
{filteredTasks.map((task) => (
<Command.Item
key={task.id}
value={`${task.name} task-${task.id}`}
@@ -419,10 +476,10 @@ export function SearchModal({
</Command.Group>
)}
{showToolOperations && (
{filteredToolOps.length > 0 && (
<Command.Group heading='Tool Operations' className={groupHeadingClassName}>
{toolOperations.map((op) => (
<CommandItem
{filteredToolOps.map((op) => (
<MemoizedCommandItem
key={op.id}
value={`${op.searchValue} operation-${op.id}`}
onSelect={() => handleToolOperationSelect(op)}
@@ -431,14 +488,14 @@ export function SearchModal({
showColoredIcon
>
{op.name}
</CommandItem>
</MemoizedCommandItem>
))}
</Command.Group>
)}
{workspaces.length > 0 && (
{filteredWorkspaces.length > 0 && open && (
<Command.Group heading='Workspaces' className={groupHeadingClassName}>
{workspaces.map((workspace) => (
{filteredWorkspaces.map((workspace) => (
<Command.Item
key={workspace.id}
value={`${workspace.name} workspace-${workspace.id}`}
@@ -454,10 +511,10 @@ export function SearchModal({
</Command.Group>
)}
{showDocs && (
{filteredDocs.length > 0 && (
<Command.Group heading='Docs' className={groupHeadingClassName}>
{docs.map((doc) => (
<CommandItem
{filteredDocs.map((doc) => (
<MemoizedCommandItem
key={doc.id}
value={`${doc.name} docs documentation doc-${doc.id}`}
onSelect={() => handleDocSelect(doc)}
@@ -466,14 +523,14 @@ export function SearchModal({
showColoredIcon
>
{doc.name}
</CommandItem>
</MemoizedCommandItem>
))}
</Command.Group>
)}
{pages.length > 0 && (
{filteredPages.length > 0 && open && (
<Command.Group heading='Pages' className={groupHeadingClassName}>
{pages.map((page) => {
{filteredPages.map((page) => {
const Icon = page.icon
return (
<Command.Item
@@ -518,36 +575,46 @@ interface CommandItemProps {
children: React.ReactNode
}
function CommandItem({
value,
onSelect,
icon: Icon,
bgColor,
showColoredIcon,
children,
}: CommandItemProps) {
return (
<Command.Item
value={value}
onSelect={onSelect}
className='group flex h-[28px] w-full cursor-pointer items-center gap-[8px] rounded-[6px] px-[10px] text-left text-[15px] aria-selected:bg-[var(--border)] aria-selected:shadow-sm data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50'
>
<div
className='relative flex h-[16px] w-[16px] flex-shrink-0 items-center justify-center overflow-hidden rounded-[4px]'
style={{ background: showColoredIcon ? bgColor : 'transparent' }}
// onSelect is safe to exclude: cmdk stores it in a ref (useAsRef) internally,
// so the latest closure is always invoked regardless of whether React re-renders.
const MemoizedCommandItem = memo(
function CommandItem({
value,
onSelect,
icon: Icon,
bgColor,
showColoredIcon,
children,
}: CommandItemProps) {
return (
<Command.Item
value={value}
onSelect={onSelect}
className='group flex h-[28px] w-full cursor-pointer items-center gap-[8px] rounded-[6px] px-[10px] text-left text-[15px] aria-selected:bg-[var(--border)] aria-selected:shadow-sm data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50'
>
<Icon
className={cn(
'transition-transform duration-100 group-hover:scale-110',
showColoredIcon
? '!h-[10px] !w-[10px] text-white'
: 'h-[14px] w-[14px] text-[var(--text-tertiary)] group-aria-selected:text-[var(--text-primary)]'
)}
/>
</div>
<span className='truncate font-base text-[var(--text-tertiary)] group-aria-selected:text-[var(--text-primary)]'>
{children}
</span>
</Command.Item>
)
}
<div
className='relative flex h-[16px] w-[16px] flex-shrink-0 items-center justify-center overflow-hidden rounded-[4px]'
style={{ background: showColoredIcon ? bgColor : 'transparent' }}
>
<Icon
className={cn(
'transition-transform duration-100 group-hover:scale-110',
showColoredIcon
? '!h-[10px] !w-[10px] text-white'
: 'h-[14px] w-[14px] text-[var(--text-tertiary)] group-aria-selected:text-[var(--text-primary)]'
)}
/>
</div>
<span className='truncate font-base text-[var(--text-tertiary)] group-aria-selected:text-[var(--text-primary)]'>
{children}
</span>
</Command.Item>
)
},
(prev, next) =>
prev.value === next.value &&
prev.icon === next.icon &&
prev.bgColor === next.bgColor &&
prev.showColoredIcon === next.showColoredIcon &&
prev.children === next.children
)

View File

@@ -13,10 +13,21 @@ export interface TaskChatHistory {
activeStreamId: string | null
}
export interface TaskStoredToolCall {
id: string
name: string
status: string
params?: Record<string, unknown>
result?: unknown
error?: string
durationMs?: number
}
export interface TaskStoredMessage {
id: string
role: 'user' | 'assistant'
content: string
toolCalls?: TaskStoredToolCall[]
contentBlocks?: TaskStoredContentBlock[]
}
@@ -27,6 +38,8 @@ export interface TaskStoredContentBlock {
id?: string
name?: string
state?: string
params?: Record<string, unknown>
result?: { success: boolean; output?: unknown; error?: string }
display?: { text?: string }
} | null
}

View File

@@ -0,0 +1,101 @@
'use client'
import { useEffect, useRef, useState } from 'react'
interface ProgressiveListOptions {
/** Number of items to render in the initial batch (most recent items) */
initialBatch?: number
/** Number of items to add per animation frame */
batchSize?: number
}
const DEFAULTS = {
initialBatch: 10,
batchSize: 5,
} satisfies Required<ProgressiveListOptions>
/**
* Progressively renders a list of items so that first paint is fast.
*
* On mount (or when `key` changes), only the most recent `initialBatch`
* items are rendered. The rest are added in `batchSize` increments via
* `requestAnimationFrame` so the browser never blocks on a large DOM mount.
*
* Once staging completes for a given key it never re-stages -- new items
* appended to the list are rendered immediately.
*
* @param items Full list of items to render.
* @param key A session/conversation identifier. When it changes,
* staging restarts for the new list.
* @param options Tuning knobs for batch sizes.
* @returns The currently staged (visible) subset of items.
*/
export function useProgressiveList<T>(
items: T[],
key: string,
options?: ProgressiveListOptions
): { staged: T[]; isStaging: boolean } {
const initialBatch = options?.initialBatch ?? DEFAULTS.initialBatch
const batchSize = options?.batchSize ?? DEFAULTS.batchSize
const completedKeysRef = useRef(new Set<string>())
const prevKeyRef = useRef(key)
const stagingCountRef = useRef(initialBatch)
const [count, setCount] = useState(() => {
if (items.length <= initialBatch) return items.length
return initialBatch
})
useEffect(() => {
if (completedKeysRef.current.has(key)) {
setCount(items.length)
return
}
if (items.length <= initialBatch) {
setCount(items.length)
completedKeysRef.current.add(key)
return
}
let current = Math.max(stagingCountRef.current, initialBatch)
setCount(current)
let frame: number | undefined
const step = () => {
const total = items.length
current = Math.min(total, current + batchSize)
stagingCountRef.current = current
setCount(current)
if (current >= total) {
completedKeysRef.current.add(key)
frame = undefined
return
}
frame = requestAnimationFrame(step)
}
frame = requestAnimationFrame(step)
return () => {
if (frame !== undefined) cancelAnimationFrame(frame)
}
}, [key, items.length, initialBatch, batchSize])
let effectiveCount = count
if (prevKeyRef.current !== key) {
effectiveCount = items.length <= initialBatch ? items.length : initialBatch
stagingCountRef.current = initialBatch
}
prevKeyRef.current = key
const isCompleted = completedKeysRef.current.has(key)
const isStaging = !isCompleted && effectiveCount < items.length
const staged =
isCompleted || effectiveCount >= items.length
? items
: items.slice(Math.max(0, items.length - effectiveCount))
return { staged, isStaging }
}

View File

@@ -0,0 +1,50 @@
'use client'
import { useEffect, useRef, useState } from 'react'
const TEXT_RENDER_THROTTLE_MS = 100
/**
* Trailing-edge throttle for rendered string values.
*
* The underlying data accumulates instantly via the caller's state, but this
* hook gates DOM re-renders to at most every {@link TEXT_RENDER_THROTTLE_MS}ms.
* When streaming stops (i.e. the value settles), the final value is flushed
* immediately so no trailing content is lost.
*/
export function useThrottledValue(value: string): string {
const [displayed, setDisplayed] = useState(value)
const lastFlushRef = useRef(0)
const timerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
useEffect(() => {
const now = Date.now()
const remaining = TEXT_RENDER_THROTTLE_MS - (now - lastFlushRef.current)
if (remaining <= 0) {
if (timerRef.current !== undefined) {
clearTimeout(timerRef.current)
timerRef.current = undefined
}
lastFlushRef.current = now
setDisplayed(value)
} else {
if (timerRef.current !== undefined) clearTimeout(timerRef.current)
timerRef.current = setTimeout(() => {
lastFlushRef.current = Date.now()
setDisplayed(value)
timerRef.current = undefined
}, remaining)
}
return () => {
if (timerRef.current !== undefined) {
clearTimeout(timerRef.current)
timerRef.current = undefined
}
}
}, [value])
return displayed
}

View File

@@ -38,7 +38,8 @@ export async function processFileAttachments(
for (const { buffer, attachment } of processedAttachments) {
const fileContent = createFileContent(buffer, attachment.media_type)
if (fileContent) {
processedFileContents.push(fileContent as FileContent)
const enriched: FileContent = { ...fileContent, filename: attachment.filename }
processedFileContents.push(enriched)
}
}

View File

@@ -199,6 +199,222 @@ function appendThinkingContent(context: ClientStreamingContext, text: string) {
context.currentTextBlock = null
}
function processContentBuffer(
context: ClientStreamingContext,
get: () => CopilotStore,
set: StoreSet
) {
let contentToProcess = context.pendingContent
let hasProcessedContent = false
const thinkingStartRegex = /<thinking>/
const thinkingEndRegex = /<\/thinking>/
const designWorkflowStartRegex = /<design_workflow>/
const designWorkflowEndRegex = /<\/design_workflow>/
const splitTrailingPartialTag = (
text: string,
tags: string[]
): { text: string; remaining: string } => {
const partialIndex = text.lastIndexOf('<')
if (partialIndex < 0) {
return { text, remaining: '' }
}
const possibleTag = text.substring(partialIndex)
const matchesTagStart = tags.some((tag) => tag.startsWith(possibleTag))
if (!matchesTagStart) {
return { text, remaining: '' }
}
return {
text: text.substring(0, partialIndex),
remaining: possibleTag,
}
}
while (contentToProcess.length > 0) {
if (context.isInDesignWorkflowBlock) {
const endMatch = designWorkflowEndRegex.exec(contentToProcess)
if (endMatch) {
const designContent = contentToProcess.substring(0, endMatch.index)
context.designWorkflowContent += designContent
context.isInDesignWorkflowBlock = false
logger.info('[design_workflow] Tag complete, setting plan content', {
contentLength: context.designWorkflowContent.length,
})
set({ streamingPlanContent: context.designWorkflowContent })
contentToProcess = contentToProcess.substring(endMatch.index + endMatch[0].length)
hasProcessedContent = true
} else {
const { text, remaining } = splitTrailingPartialTag(contentToProcess, [
'</design_workflow>',
])
context.designWorkflowContent += text
set({ streamingPlanContent: context.designWorkflowContent })
contentToProcess = remaining
hasProcessedContent = true
if (remaining) {
break
}
}
continue
}
if (!context.isInThinkingBlock && !context.isInDesignWorkflowBlock) {
const designStartMatch = designWorkflowStartRegex.exec(contentToProcess)
if (designStartMatch) {
const textBeforeDesign = contentToProcess.substring(0, designStartMatch.index)
if (textBeforeDesign) {
appendTextBlock(context, textBeforeDesign)
hasProcessedContent = true
}
context.isInDesignWorkflowBlock = true
context.designWorkflowContent = ''
contentToProcess = contentToProcess.substring(
designStartMatch.index + designStartMatch[0].length
)
hasProcessedContent = true
continue
}
const nextMarkIndex = contentToProcess.indexOf('<marktodo>')
const nextCheckIndex = contentToProcess.indexOf('<checkofftodo>')
const hasMark = nextMarkIndex >= 0
const hasCheck = nextCheckIndex >= 0
const nextTagIndex =
hasMark && hasCheck
? Math.min(nextMarkIndex, nextCheckIndex)
: hasMark
? nextMarkIndex
: hasCheck
? nextCheckIndex
: -1
if (nextTagIndex >= 0) {
const isMarkTodo = hasMark && nextMarkIndex === nextTagIndex
const tagStart = isMarkTodo ? '<marktodo>' : '<checkofftodo>'
const tagEnd = isMarkTodo ? '</marktodo>' : '</checkofftodo>'
const closingIndex = contentToProcess.indexOf(tagEnd, nextTagIndex + tagStart.length)
if (closingIndex === -1) {
break
}
const todoId = contentToProcess
.substring(nextTagIndex + tagStart.length, closingIndex)
.trim()
logger.info(
isMarkTodo ? '[TODO] Detected marktodo tag' : '[TODO] Detected checkofftodo tag',
{ todoId }
)
if (todoId) {
try {
get().updatePlanTodoStatus(todoId, isMarkTodo ? 'executing' : 'completed')
logger.info(
isMarkTodo
? '[TODO] Successfully marked todo in progress'
: '[TODO] Successfully checked off todo',
{ todoId }
)
} catch (e) {
logger.error(
isMarkTodo
? '[TODO] Failed to mark todo in progress'
: '[TODO] Failed to checkoff todo',
{ todoId, error: e }
)
}
} else {
logger.warn('[TODO] Empty todoId extracted from todo tag', { tagType: tagStart })
}
let beforeTag = contentToProcess.substring(0, nextTagIndex)
let afterTag = contentToProcess.substring(closingIndex + tagEnd.length)
const hadNewlineBefore = /(\r?\n)+$/.test(beforeTag)
const hadNewlineAfter = /^(\r?\n)+/.test(afterTag)
beforeTag = beforeTag.replace(/(\r?\n)+$/, '')
afterTag = afterTag.replace(/^(\r?\n)+/, '')
contentToProcess = beforeTag + (hadNewlineBefore && hadNewlineAfter ? '\n' : '') + afterTag
context.currentTextBlock = null
hasProcessedContent = true
continue
}
}
if (context.isInThinkingBlock) {
const endMatch = thinkingEndRegex.exec(contentToProcess)
if (endMatch) {
const thinkingContent = contentToProcess.substring(0, endMatch.index)
appendThinkingContent(context, thinkingContent)
finalizeThinkingBlock(context)
contentToProcess = contentToProcess.substring(endMatch.index + endMatch[0].length)
hasProcessedContent = true
} else {
const { text, remaining } = splitTrailingPartialTag(contentToProcess, ['</thinking>'])
if (text) {
appendThinkingContent(context, text)
hasProcessedContent = true
}
contentToProcess = remaining
if (remaining) {
break
}
}
} else {
const startMatch = thinkingStartRegex.exec(contentToProcess)
if (startMatch) {
const textBeforeThinking = contentToProcess.substring(0, startMatch.index)
if (textBeforeThinking) {
appendTextBlock(context, textBeforeThinking)
hasProcessedContent = true
}
context.isInThinkingBlock = true
context.currentTextBlock = null
contentToProcess = contentToProcess.substring(startMatch.index + startMatch[0].length)
hasProcessedContent = true
} else {
let partialTagIndex = contentToProcess.lastIndexOf('<')
const partialMarkTodo = contentToProcess.lastIndexOf('<marktodo')
const partialCheckoffTodo = contentToProcess.lastIndexOf('<checkofftodo')
if (partialMarkTodo > partialTagIndex) {
partialTagIndex = partialMarkTodo
}
if (partialCheckoffTodo > partialTagIndex) {
partialTagIndex = partialCheckoffTodo
}
let textToAdd = contentToProcess
let remaining = ''
if (partialTagIndex >= 0 && partialTagIndex > contentToProcess.length - 50) {
textToAdd = contentToProcess.substring(0, partialTagIndex)
remaining = contentToProcess.substring(partialTagIndex)
}
if (textToAdd) {
appendTextBlock(context, textToAdd)
hasProcessedContent = true
}
contentToProcess = remaining
break
}
}
}
context.pendingContent = contentToProcess
if (hasProcessedContent) {
updateStreamingMessage(set, context)
}
}
export const sseHandlers: Record<string, SSEHandler> = {
chat_id: async (data, context, get, set) => {
context.newChatId = data.chatId
@@ -704,217 +920,7 @@ export const sseHandlers: Record<string, SSEHandler> = {
content: (data, context, get, set) => {
if (!data.data) return
context.pendingContent += data.data
let contentToProcess = context.pendingContent
let hasProcessedContent = false
const thinkingStartRegex = /<thinking>/
const thinkingEndRegex = /<\/thinking>/
const designWorkflowStartRegex = /<design_workflow>/
const designWorkflowEndRegex = /<\/design_workflow>/
const splitTrailingPartialTag = (
text: string,
tags: string[]
): { text: string; remaining: string } => {
const partialIndex = text.lastIndexOf('<')
if (partialIndex < 0) {
return { text, remaining: '' }
}
const possibleTag = text.substring(partialIndex)
const matchesTagStart = tags.some((tag) => tag.startsWith(possibleTag))
if (!matchesTagStart) {
return { text, remaining: '' }
}
return {
text: text.substring(0, partialIndex),
remaining: possibleTag,
}
}
while (contentToProcess.length > 0) {
if (context.isInDesignWorkflowBlock) {
const endMatch = designWorkflowEndRegex.exec(contentToProcess)
if (endMatch) {
const designContent = contentToProcess.substring(0, endMatch.index)
context.designWorkflowContent += designContent
context.isInDesignWorkflowBlock = false
logger.info('[design_workflow] Tag complete, setting plan content', {
contentLength: context.designWorkflowContent.length,
})
set({ streamingPlanContent: context.designWorkflowContent })
contentToProcess = contentToProcess.substring(endMatch.index + endMatch[0].length)
hasProcessedContent = true
} else {
const { text, remaining } = splitTrailingPartialTag(contentToProcess, [
'</design_workflow>',
])
context.designWorkflowContent += text
set({ streamingPlanContent: context.designWorkflowContent })
contentToProcess = remaining
hasProcessedContent = true
if (remaining) {
break
}
}
continue
}
if (!context.isInThinkingBlock && !context.isInDesignWorkflowBlock) {
const designStartMatch = designWorkflowStartRegex.exec(contentToProcess)
if (designStartMatch) {
const textBeforeDesign = contentToProcess.substring(0, designStartMatch.index)
if (textBeforeDesign) {
appendTextBlock(context, textBeforeDesign)
hasProcessedContent = true
}
context.isInDesignWorkflowBlock = true
context.designWorkflowContent = ''
contentToProcess = contentToProcess.substring(
designStartMatch.index + designStartMatch[0].length
)
hasProcessedContent = true
continue
}
const nextMarkIndex = contentToProcess.indexOf('<marktodo>')
const nextCheckIndex = contentToProcess.indexOf('<checkofftodo>')
const hasMark = nextMarkIndex >= 0
const hasCheck = nextCheckIndex >= 0
const nextTagIndex =
hasMark && hasCheck
? Math.min(nextMarkIndex, nextCheckIndex)
: hasMark
? nextMarkIndex
: hasCheck
? nextCheckIndex
: -1
if (nextTagIndex >= 0) {
const isMarkTodo = hasMark && nextMarkIndex === nextTagIndex
const tagStart = isMarkTodo ? '<marktodo>' : '<checkofftodo>'
const tagEnd = isMarkTodo ? '</marktodo>' : '</checkofftodo>'
const closingIndex = contentToProcess.indexOf(tagEnd, nextTagIndex + tagStart.length)
if (closingIndex === -1) {
break
}
const todoId = contentToProcess
.substring(nextTagIndex + tagStart.length, closingIndex)
.trim()
logger.info(
isMarkTodo ? '[TODO] Detected marktodo tag' : '[TODO] Detected checkofftodo tag',
{ todoId }
)
if (todoId) {
try {
get().updatePlanTodoStatus(todoId, isMarkTodo ? 'executing' : 'completed')
logger.info(
isMarkTodo
? '[TODO] Successfully marked todo in progress'
: '[TODO] Successfully checked off todo',
{ todoId }
)
} catch (e) {
logger.error(
isMarkTodo
? '[TODO] Failed to mark todo in progress'
: '[TODO] Failed to checkoff todo',
{ todoId, error: e }
)
}
} else {
logger.warn('[TODO] Empty todoId extracted from todo tag', { tagType: tagStart })
}
let beforeTag = contentToProcess.substring(0, nextTagIndex)
let afterTag = contentToProcess.substring(closingIndex + tagEnd.length)
const hadNewlineBefore = /(\r?\n)+$/.test(beforeTag)
const hadNewlineAfter = /^(\r?\n)+/.test(afterTag)
beforeTag = beforeTag.replace(/(\r?\n)+$/, '')
afterTag = afterTag.replace(/^(\r?\n)+/, '')
contentToProcess =
beforeTag + (hadNewlineBefore && hadNewlineAfter ? '\n' : '') + afterTag
context.currentTextBlock = null
hasProcessedContent = true
continue
}
}
if (context.isInThinkingBlock) {
const endMatch = thinkingEndRegex.exec(contentToProcess)
if (endMatch) {
const thinkingContent = contentToProcess.substring(0, endMatch.index)
appendThinkingContent(context, thinkingContent)
finalizeThinkingBlock(context)
contentToProcess = contentToProcess.substring(endMatch.index + endMatch[0].length)
hasProcessedContent = true
} else {
const { text, remaining } = splitTrailingPartialTag(contentToProcess, ['</thinking>'])
if (text) {
appendThinkingContent(context, text)
hasProcessedContent = true
}
contentToProcess = remaining
if (remaining) {
break
}
}
} else {
const startMatch = thinkingStartRegex.exec(contentToProcess)
if (startMatch) {
const textBeforeThinking = contentToProcess.substring(0, startMatch.index)
if (textBeforeThinking) {
appendTextBlock(context, textBeforeThinking)
hasProcessedContent = true
}
context.isInThinkingBlock = true
context.currentTextBlock = null
contentToProcess = contentToProcess.substring(startMatch.index + startMatch[0].length)
hasProcessedContent = true
} else {
let partialTagIndex = contentToProcess.lastIndexOf('<')
const partialMarkTodo = contentToProcess.lastIndexOf('<marktodo')
const partialCheckoffTodo = contentToProcess.lastIndexOf('<checkofftodo')
if (partialMarkTodo > partialTagIndex) {
partialTagIndex = partialMarkTodo
}
if (partialCheckoffTodo > partialTagIndex) {
partialTagIndex = partialCheckoffTodo
}
let textToAdd = contentToProcess
let remaining = ''
if (partialTagIndex >= 0 && partialTagIndex > contentToProcess.length - 50) {
textToAdd = contentToProcess.substring(0, partialTagIndex)
remaining = contentToProcess.substring(partialTagIndex)
}
if (textToAdd) {
appendTextBlock(context, textToAdd)
hasProcessedContent = true
}
contentToProcess = remaining
break
}
}
}
context.pendingContent = contentToProcess
if (hasProcessedContent) {
updateStreamingMessage(set, context)
}
processContentBuffer(context, get, set)
},
done: (_data, context) => {
logger.info('[SSE] DONE EVENT RECEIVED', {

View File

@@ -113,7 +113,7 @@ function inferToolSuccess(data: Record<string, unknown> | undefined): {
const explicitSuccess = data?.success ?? resultObj.success
const hasResultData = data?.result !== undefined || data?.data !== undefined
const hasError = !!data?.error || !!resultObj.error
const success = hasExplicitSuccess ? !!explicitSuccess : hasResultData && !hasError
const success = hasExplicitSuccess ? !!explicitSuccess : !hasError
return { success, hasResultData, hasError }
}

View File

@@ -105,19 +105,21 @@ export async function runStreamLoop(
const normalizedEvent = normalizeSseEvent(event)
// Skip duplicate tool events.
// Skip duplicate tool events — both forwarding AND handler dispatch.
const shouldSkipToolCall = shouldSkipToolCallEvent(normalizedEvent)
const shouldSkipToolResult = shouldSkipToolResultEvent(normalizedEvent)
if (!shouldSkipToolCall && !shouldSkipToolResult) {
try {
await options.onEvent?.(normalizedEvent)
} catch (error) {
logger.warn('Failed to forward SSE event', {
type: normalizedEvent.type,
error: error instanceof Error ? error.message : String(error),
})
}
if (shouldSkipToolCall || shouldSkipToolResult) {
continue
}
try {
await options.onEvent?.(normalizedEvent)
} catch (error) {
logger.warn('Failed to forward SSE event', {
type: normalizedEvent.type,
error: error instanceof Error ? error.message : String(error),
})
}
// Let the caller intercept before standard dispatch.
@@ -178,14 +180,22 @@ export async function runStreamLoop(
* Build a ToolCallSummary array from the streaming context.
*/
export function buildToolCallSummaries(context: StreamingContext): ToolCallSummary[] {
return Array.from(context.toolCalls.values()).map((toolCall) => ({
id: toolCall.id,
name: toolCall.name,
status: toolCall.status,
params: toolCall.params,
result: toolCall.result?.output,
error: toolCall.error,
durationMs:
toolCall.endTime && toolCall.startTime ? toolCall.endTime - toolCall.startTime : undefined,
}))
return Array.from(context.toolCalls.values()).map((toolCall) => {
let status = toolCall.status
if (toolCall.result && toolCall.result.success !== undefined) {
status = toolCall.result.success ? 'success' : 'error'
} else if (status === 'pending' || status === 'executing') {
status = toolCall.error ? 'error' : 'success'
}
return {
id: toolCall.id,
name: toolCall.name,
status,
params: toolCall.params,
result: toolCall.result?.output,
error: toolCall.error,
durationMs:
toolCall.endTime && toolCall.startTime ? toolCall.endTime - toolCall.startTime : undefined,
}
})
}

View File

@@ -650,7 +650,6 @@ export const userTableServerTool: BaseServerTool<UserTableArgs, UserTableResult>
| {
name: string
type: string
required?: boolean
unique?: boolean
position?: number
}
@@ -720,12 +719,11 @@ export const userTableServerTool: BaseServerTool<UserTableArgs, UserTableResult>
return { success: false, message: 'columnName is required' }
}
const newType = (args as Record<string, unknown>).newType as string | undefined
const reqFlag = (args as Record<string, unknown>).required as boolean | undefined
const uniqFlag = (args as Record<string, unknown>).unique as boolean | undefined
if (newType === undefined && reqFlag === undefined && uniqFlag === undefined) {
if (newType === undefined && uniqFlag === undefined) {
return {
success: false,
message: 'At least one of newType, required, or unique must be provided',
message: 'At least one of newType or unique must be provided',
}
}
const requestId = crypto.randomUUID().slice(0, 8)
@@ -736,9 +734,9 @@ export const userTableServerTool: BaseServerTool<UserTableArgs, UserTableResult>
requestId
)
}
if (reqFlag !== undefined || uniqFlag !== undefined) {
if (uniqFlag !== undefined) {
result = await updateColumnConstraints(
{ tableId: args.tableId, columnName: colName, required: reqFlag, unique: uniqFlag },
{ tableId: args.tableId, columnName: colName, unique: uniqFlag },
requestId
)
}

View File

@@ -144,7 +144,6 @@ export const UserTableArgsSchema = z.object({
.object({
name: z.string(),
type: z.string(),
required: z.boolean().optional(),
unique: z.boolean().optional(),
position: z.number().optional(),
})
@@ -152,7 +151,6 @@ export const UserTableArgsSchema = z.object({
columnName: z.string().optional(),
newName: z.string().optional(),
newType: z.string().optional(),
required: z.boolean().optional(),
unique: z.boolean().optional(),
})
.optional(),

View File

@@ -9,20 +9,30 @@ import type { PresignedUrlResponse } from '@/lib/uploads/shared/types'
const logger = createLogger('CopilotFileManager')
const SUPPORTED_IMAGE_TYPES = [
const SUPPORTED_FILE_TYPES = [
// Images
'image/jpeg',
'image/jpg',
'image/png',
'image/gif',
'image/webp',
'image/svg+xml',
// Documents
'application/pdf',
'text/plain',
'text/csv',
'text/markdown',
'text/html',
'application/json',
'application/xml',
'text/xml',
]
/**
* Check if a file type is a supported image format for copilot
* Check if a file type is supported for copilot attachments
*/
export function isSupportedFileType(mimeType: string): boolean {
return SUPPORTED_IMAGE_TYPES.includes(mimeType.toLowerCase())
return SUPPORTED_FILE_TYPES.includes(mimeType.toLowerCase())
}
/**
@@ -49,12 +59,12 @@ export interface GenerateCopilotUploadUrlOptions {
/**
* Generate a presigned URL for copilot file upload
*
* Only image files are allowed for copilot uploads.
* Images and document files are allowed for copilot uploads.
* Requires authenticated user session.
*
* @param options Upload URL generation options
* @returns Presigned URL response with upload URL and file key
* @throws Error if file type is not an image or user is not authenticated
* @throws Error if file type is unsupported or user is not authenticated
*/
export async function generateCopilotUploadUrl(
options: GenerateCopilotUploadUrlOptions
@@ -65,8 +75,10 @@ export async function generateCopilotUploadUrl(
throw new Error('Authenticated user session is required for copilot uploads')
}
if (!isImageFileType(contentType)) {
throw new Error('Only image files (JPEG, PNG, GIF, WebP, SVG) are allowed for copilot uploads')
if (!isSupportedFileType(contentType) && !isImageFileType(contentType)) {
throw new Error(
'Unsupported file type. Allowed: images (JPEG, PNG, GIF, WebP), PDF, and text files (TXT, CSV, MD, HTML, JSON, XML).'
)
}
const presignedUrlResponse = await generatePresignedUploadUrl({

View File

@@ -462,7 +462,7 @@ function prepareSendContext(
if (revertState) {
const currentMessages = get().messages
newMessages = [...currentMessages, userMessage, streamingMessage]
set({ revertState: null, inputValue: '' })
set({ revertState: null })
} else {
const currentMessages = get().messages
const existingIndex = messageId ? currentMessages.findIndex((m) => m.id === messageId) : -1
@@ -1037,7 +1037,6 @@ const initialState = {
chatsLastLoadedAt: null as Date | null,
chatsLoadedForWorkflow: null as string | null,
revertState: null as { messageId: string; messageContent: string } | null,
inputValue: '',
planTodos: [] as Array<{ id: string; content: string; completed?: boolean; executing?: boolean }>,
showPlanTodos: false,
streamingPlanContent: '',
@@ -2222,8 +2221,6 @@ export const useCopilotStore = create<CopilotStore>()(
set(initialState)
},
// Input controls
setInputValue: (value: string) => set({ inputValue: value }),
clearRevertState: () => set({ revertState: null }),
// Todo list (UI only)

View File

@@ -155,7 +155,6 @@ export interface CopilotState {
chatsLoadedForWorkflow: string | null
revertState: { messageId: string; messageContent: string } | null
inputValue: string
planTodos: Array<{ id: string; content: string; completed?: boolean; executing?: boolean }>
showPlanTodos: boolean
@@ -235,7 +234,6 @@ export interface CopilotActions {
cleanup: () => void
reset: () => void
setInputValue: (value: string) => void
clearRevertState: () => void
setPlanTodos: (