improvement(mothership): copilot, files, compaction, tools, persistence, duplication constraints (#3682)

* Improve

* Hide is hosted

* Remove hardcoded

* fix

* Fixes

* v0

* Fix bugs

* Restore settings

* Handle compaction event type

* Add keepalive

* File streaming

* Error tags

* Abort defense

* Edit hashes

* DB backed tools

* Fixes

* progress on autolayout improvements

* Abort fixes

* vertical insertion improvement

* Consolidate file attachments

* Fix lint

* Manage agent result card fix

* Remove hardcoded ff

* Fix file streaming

* Fix persisted writing file tab

* Fix lint

* Fix streaming file flash

* Always set url to /file on file view

* Edit perms for tables

* Fix file edit perms

* remove inline tool call json dump

* Enforce name uniqueness (#3679)

* Enforce name uniqueness

* Use established pattern for error handling

* Fix lint

* Fix lint

* Add kb name uniqueness to db

* Fix lint

* Handle name getting taken before restore

* Enforce duplicate file name

* Fix lint

---------

Co-authored-by: Theodore Li <theo@sim.ai>

* fix temp file creation

* fix types

* Streaming fixes

* type xml tag structures + return invalid id linter errors back to LLM

* Add image gen and viz tools

* Tags

* Workflow tags

* Fix lint

* Fix subagent abort

* Fix subagent persistence

* Fix subagent aborts

* Nuke db migs

* Re add db migrations

* Fix lint

---------

Co-authored-by: Theodore Li <teddy@zenobiapay.com>
Co-authored-by: Vikhyath Mondreti <vikhyath@simstudio.ai>
Co-authored-by: Theodore Li <theodoreqili@gmail.com>
Co-authored-by: Theodore Li <theo@sim.ai>
This commit is contained in:
Siddharth Ganesan
2026-03-22 00:46:13 -07:00
committed by GitHub
parent 506d3821bd
commit d6bf12da24
195 changed files with 19798 additions and 14984 deletions

View File

@@ -11,7 +11,7 @@ function makeQueryClient() {
retryOnMount: false,
},
mutations: {
retry: 1,
retry: false,
},
dehydrate: {
shouldDehydrateQuery: (query) =>

View File

@@ -54,6 +54,11 @@ export async function POST(req: NextRequest) {
const body = await req.json()
const { chatId, resource } = AddResourceSchema.parse(body)
// Ephemeral UI tab (client does not POST this; guard for old clients / bugs).
if (resource.id === 'streaming-file') {
return NextResponse.json({ success: true })
}
if (!VALID_RESOURCE_TYPES.has(resource.type)) {
return createBadRequestResponse(`Invalid resource type: ${resource.type}`)
}

View File

@@ -1,7 +1,7 @@
import { db } from '@sim/db'
import { copilotChats } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, desc, eq } from 'drizzle-orm'
import { and, desc, eq, sql } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { getSession } from '@/lib/auth'
@@ -15,6 +15,7 @@ import {
import { COPILOT_REQUEST_MODES } from '@/lib/copilot/models'
import { orchestrateCopilotStream } from '@/lib/copilot/orchestrator'
import { getStreamMeta, readStreamEvents } from '@/lib/copilot/orchestrator/stream/buffer'
import type { OrchestratorResult } from '@/lib/copilot/orchestrator/types'
import {
authenticateCopilotRequestSessionOnly,
createBadRequestResponse,
@@ -31,6 +32,8 @@ import {
getUserEntityPermissions,
} from '@/lib/workspaces/permissions/utils'
export const maxDuration = 3600
const logger = createLogger('CopilotChatAPI')
const FileAttachmentSchema = z.object({
@@ -48,7 +51,7 @@ const ChatMessageSchema = z.object({
workflowId: z.string().optional(),
workspaceId: z.string().optional(),
workflowName: z.string().optional(),
model: z.string().optional().default('claude-opus-4-5'),
model: z.string().optional().default('claude-opus-4-6'),
mode: z.enum(COPILOT_REQUEST_MODES).optional().default('agent'),
prefetch: z.boolean().optional(),
createNewChat: z.boolean().optional().default(false),
@@ -69,6 +72,8 @@ const ChatMessageSchema = z.object({
'knowledge',
'templates',
'docs',
'table',
'file',
]),
label: z.string(),
chatId: z.string().optional(),
@@ -78,6 +83,8 @@ const ChatMessageSchema = z.object({
blockIds: z.array(z.string()).optional(),
templateId: z.string().optional(),
executionId: z.string().optional(),
tableId: z.string().optional(),
fileId: z.string().optional(),
})
)
.optional(),
@@ -180,6 +187,29 @@ export async function POST(req: NextRequest) {
})
} catch {}
let currentChat: any = null
let conversationHistory: any[] = []
let actualChatId = chatId
const selectedModel = model || 'claude-opus-4-6'
if (chatId || createNewChat) {
const chatResult = await resolveOrCreateChat({
chatId,
userId: authenticatedUserId,
workflowId,
model: selectedModel,
})
currentChat = chatResult.chat
actualChatId = chatResult.chatId || chatId
conversationHistory = Array.isArray(chatResult.conversationHistory)
? chatResult.conversationHistory
: []
if (chatId && !currentChat) {
return createBadRequestResponse('Chat not found')
}
}
let agentContexts: Array<{ type: string; content: string }> = []
if (Array.isArray(normalizedContexts) && normalizedContexts.length > 0) {
try {
@@ -188,7 +218,8 @@ export async function POST(req: NextRequest) {
normalizedContexts as any,
authenticatedUserId,
message,
resolvedWorkspaceId
resolvedWorkspaceId,
actualChatId
)
agentContexts = processed
logger.info(`[${tracker.requestId}] Contexts processed for request`, {
@@ -210,29 +241,6 @@ export async function POST(req: NextRequest) {
}
}
let currentChat: any = null
let conversationHistory: any[] = []
let actualChatId = chatId
const selectedModel = model || 'claude-opus-4-5'
if (chatId || createNewChat) {
const chatResult = await resolveOrCreateChat({
chatId,
userId: authenticatedUserId,
workflowId,
model: selectedModel,
})
currentChat = chatResult.chat
actualChatId = chatResult.chatId || chatId
conversationHistory = Array.isArray(chatResult.conversationHistory)
? chatResult.conversationHistory
: []
if (chatId && !currentChat) {
return createBadRequestResponse('Chat not found')
}
}
const effectiveMode = mode === 'agent' ? 'build' : mode
const userPermission = resolvedWorkspaceId
@@ -283,11 +291,44 @@ export async function POST(req: NextRequest) {
})
} catch {}
if (actualChatId) {
const userMsg = {
id: userMessageIdToUse,
role: 'user' as const,
content: message,
timestamp: new Date().toISOString(),
...(fileAttachments && fileAttachments.length > 0 && { fileAttachments }),
...(Array.isArray(normalizedContexts) &&
normalizedContexts.length > 0 && {
contexts: normalizedContexts,
}),
}
const [updated] = await db
.update(copilotChats)
.set({
messages: sql`${copilotChats.messages} || ${JSON.stringify([userMsg])}::jsonb`,
conversationId: userMessageIdToUse,
updatedAt: new Date(),
})
.where(eq(copilotChats.id, actualChatId))
.returning({ messages: copilotChats.messages })
if (updated) {
const freshMessages: any[] = Array.isArray(updated.messages) ? updated.messages : []
conversationHistory = freshMessages.filter((m: any) => m.id !== userMessageIdToUse)
}
}
if (stream) {
const executionId = crypto.randomUUID()
const runId = crypto.randomUUID()
const sseStream = createSSEStream({
requestPayload,
userId: authenticatedUserId,
streamId: userMessageIdToUse,
executionId,
runId,
chatId: actualChatId,
currentChat,
isNewChat: conversationHistory.length === 0,
@@ -295,14 +336,83 @@ export async function POST(req: NextRequest) {
titleModel: selectedModel,
titleProvider: provider,
requestId: tracker.requestId,
workspaceId: resolvedWorkspaceId,
orchestrateOptions: {
userId: authenticatedUserId,
workflowId,
chatId: actualChatId,
executionId,
runId,
goRoute: '/api/copilot',
autoExecuteTools: true,
interactive: true,
promptForToolApproval: true,
promptForToolApproval: false,
onComplete: async (result: OrchestratorResult) => {
if (!actualChatId) return
const assistantMessage: Record<string, unknown> = {
id: crypto.randomUUID(),
role: 'assistant' as const,
content: result.content,
timestamp: new Date().toISOString(),
...(result.requestId ? { requestId: result.requestId } : {}),
}
if (result.toolCalls.length > 0) {
assistantMessage.toolCalls = result.toolCalls
}
if (result.contentBlocks.length > 0) {
assistantMessage.contentBlocks = result.contentBlocks.map((block) => {
const stored: Record<string, unknown> = { type: block.type }
if (block.content) stored.content = block.content
if (block.type === 'tool_call' && block.toolCall) {
stored.toolCall = {
id: block.toolCall.id,
name: block.toolCall.name,
state:
block.toolCall.result?.success !== undefined
? block.toolCall.result.success
? 'success'
: 'error'
: block.toolCall.status,
result: block.toolCall.result,
...(block.calledBy ? { calledBy: block.calledBy } : {}),
}
}
return stored
})
}
try {
const [row] = await db
.select({ messages: copilotChats.messages })
.from(copilotChats)
.where(eq(copilotChats.id, actualChatId))
.limit(1)
const msgs: any[] = Array.isArray(row?.messages) ? row.messages : []
const userIdx = msgs.findIndex((m: any) => m.id === userMessageIdToUse)
const alreadyHasResponse =
userIdx >= 0 &&
userIdx + 1 < msgs.length &&
(msgs[userIdx + 1] as any)?.role === 'assistant'
if (!alreadyHasResponse) {
await db
.update(copilotChats)
.set({
messages: sql`${copilotChats.messages} || ${JSON.stringify([assistantMessage])}::jsonb`,
conversationId: sql`CASE WHEN ${copilotChats.conversationId} = ${userMessageIdToUse} THEN NULL ELSE ${copilotChats.conversationId} END`,
updatedAt: new Date(),
})
.where(eq(copilotChats.id, actualChatId))
}
} catch (error) {
logger.error(`[${tracker.requestId}] Failed to persist chat messages`, {
chatId: actualChatId,
error: error instanceof Error ? error.message : 'Unknown error',
})
}
},
},
})
@@ -316,7 +426,7 @@ export async function POST(req: NextRequest) {
goRoute: '/api/copilot',
autoExecuteTools: true,
interactive: true,
promptForToolApproval: true,
promptForToolApproval: false,
})
const responseData = {

View File

@@ -8,9 +8,11 @@ import {
import { authenticateCopilotRequestSessionOnly } from '@/lib/copilot/request-helpers'
import { SSE_HEADERS } from '@/lib/core/utils/sse'
export const maxDuration = 3600
const logger = createLogger('CopilotChatStreamAPI')
const POLL_INTERVAL_MS = 250
const MAX_STREAM_MS = 10 * 60 * 1000
const MAX_STREAM_MS = 60 * 60 * 1000
function encodeEvent(event: Record<string, any>): Uint8Array {
return new TextEncoder().encode(`data: ${JSON.stringify(event)}\n\n`)
@@ -67,6 +69,8 @@ export async function GET(request: NextRequest) {
success: true,
events: filteredEvents,
status: meta.status,
executionId: meta.executionId,
runId: meta.runId,
})
}
@@ -75,6 +79,7 @@ export async function GET(request: NextRequest) {
const stream = new ReadableStream({
async start(controller) {
let lastEventId = Number.isFinite(fromEventId) ? fromEventId : 0
let latestMeta = meta
const flushEvents = async () => {
const events = await readStreamEvents(streamId, lastEventId)
@@ -91,6 +96,8 @@ export async function GET(request: NextRequest) {
...entry.event,
eventId: entry.eventId,
streamId: entry.streamId,
executionId: latestMeta?.executionId,
runId: latestMeta?.runId,
}
controller.enqueue(encodeEvent(payload))
}
@@ -102,6 +109,7 @@ export async function GET(request: NextRequest) {
while (Date.now() - startTime < MAX_STREAM_MS) {
const currentMeta = await getStreamMeta(streamId)
if (!currentMeta) break
latestMeta = currentMeta
await flushEvents()

View File

@@ -3,14 +3,27 @@ import { copilotChats, permissions, workflow, workspace } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, desc, eq, isNull, or, sql } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { resolveOrCreateChat } from '@/lib/copilot/chat-lifecycle'
import {
authenticateCopilotRequestSessionOnly,
createBadRequestResponse,
createInternalServerErrorResponse,
createUnauthorizedResponse,
} from '@/lib/copilot/request-helpers'
import { taskPubSub } from '@/lib/copilot/task-events'
import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils'
import { assertActiveWorkspaceAccess } from '@/lib/workspaces/permissions/utils'
const logger = createLogger('CopilotChatsListAPI')
const CreateWorkflowCopilotChatSchema = z.object({
workspaceId: z.string().min(1),
workflowId: z.string().min(1),
})
const DEFAULT_COPILOT_MODEL = 'claude-opus-4-6'
export async function GET(_request: NextRequest) {
try {
const { userId, isAuthenticated } = await authenticateCopilotRequestSessionOnly()
@@ -24,6 +37,7 @@ export async function GET(_request: NextRequest) {
title: copilotChats.title,
workflowId: copilotChats.workflowId,
workspaceId: copilotChats.workspaceId,
conversationId: copilotChats.conversationId,
updatedAt: copilotChats.updatedAt,
})
.from(copilotChats)
@@ -68,3 +82,60 @@ export async function GET(_request: NextRequest) {
return createInternalServerErrorResponse('Failed to fetch user chats')
}
}
/**
* POST /api/copilot/chats
* Creates an empty workflow-scoped copilot chat (same lifecycle as {@link resolveOrCreateChat}).
* Matches mothership's POST /api/mothership/chats pattern so the client always selects a real row id.
*/
export async function POST(request: NextRequest) {
try {
const { userId, isAuthenticated } = await authenticateCopilotRequestSessionOnly()
if (!isAuthenticated || !userId) {
return createUnauthorizedResponse()
}
const body = await request.json()
const { workspaceId, workflowId } = CreateWorkflowCopilotChatSchema.parse(body)
await assertActiveWorkspaceAccess(workspaceId, userId)
const authorization = await authorizeWorkflowByWorkspacePermission({
workflowId,
userId,
action: 'read',
})
if (!authorization.allowed || !authorization.workflow) {
return NextResponse.json(
{ success: false, error: authorization.message ?? 'Forbidden' },
{ status: authorization.status }
)
}
if (authorization.workflow.workspaceId !== workspaceId) {
return createBadRequestResponse('workflow does not belong to this workspace')
}
const result = await resolveOrCreateChat({
userId,
workflowId,
workspaceId,
model: DEFAULT_COPILOT_MODEL,
type: 'copilot',
})
if (!result.chatId) {
return createInternalServerErrorResponse('Failed to create chat')
}
taskPubSub?.publishStatusChanged({ workspaceId, chatId: result.chatId, type: 'created' })
return NextResponse.json({ success: true, id: result.chatId })
} catch (error) {
if (error instanceof z.ZodError) {
return createBadRequestResponse('workspaceId and workflowId are required')
}
logger.error('Error creating workflow copilot chat:', error)
return createInternalServerErrorResponse('Failed to create chat')
}
}

View File

@@ -240,7 +240,7 @@ describe('Copilot Confirm API Route', () => {
})
})
it('should return 400 when Redis client is not available', async () => {
it('should succeed when Redis client is not available', async () => {
setAuthenticated()
mockGetRedisClient.mockReturnValue(null)
@@ -252,9 +252,15 @@ describe('Copilot Confirm API Route', () => {
const response = await POST(req)
expect(response.status).toBe(400)
expect(response.status).toBe(200)
const responseData = await response.json()
expect(responseData.error).toBe('Failed to update tool call status or tool call not found')
expect(responseData).toEqual({
success: true,
message: 'Tool call tool-call-123 has been success',
toolCallId: 'tool-call-123',
status: 'success',
})
expect(mockRedisSet).not.toHaveBeenCalled()
})
it('should return 400 when Redis set fails', async () => {

View File

@@ -1,6 +1,7 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { completeAsyncToolCall, upsertAsyncToolCall } from '@/lib/copilot/async-runs/repository'
import { REDIS_TOOL_CALL_PREFIX, REDIS_TOOL_CALL_TTL_SECONDS } from '@/lib/copilot/constants'
import {
authenticateCopilotRequestSessionOnly,
@@ -34,10 +35,38 @@ async function updateToolCallStatus(
message?: string,
data?: Record<string, unknown>
): Promise<boolean> {
const durableStatus =
status === 'success'
? 'completed'
: status === 'cancelled'
? 'cancelled'
: status === 'error' || status === 'rejected'
? 'failed'
: 'pending'
await upsertAsyncToolCall({
runId: crypto.randomUUID(),
toolCallId,
toolName: 'client_tool',
args: {},
status: durableStatus,
}).catch(() => {})
if (
durableStatus === 'completed' ||
durableStatus === 'failed' ||
durableStatus === 'cancelled'
) {
await completeAsyncToolCall({
toolCallId,
status: durableStatus,
result: data ?? null,
error: status === 'success' ? null : message || status,
}).catch(() => {})
}
const redis = getRedisClient()
if (!redis) {
logger.warn('Redis client not available for tool confirmation')
return false
logger.warn('Redis client not available for tool confirmation; durable DB mirror only')
return true
}
try {

View File

@@ -5,7 +5,7 @@ import '@/lib/uploads/core/setup.server'
import { getSession } from '@/lib/auth'
import type { StorageContext } from '@/lib/uploads/config'
import { generateWorkspaceFileKey } from '@/lib/uploads/contexts/workspace/workspace-file-manager'
import { isImageFileType, resolveFileType } from '@/lib/uploads/utils/file-utils'
import { isImageFileType } from '@/lib/uploads/utils/file-utils'
import {
SUPPORTED_AUDIO_EXTENSIONS,
SUPPORTED_DOCUMENT_EXTENSIONS,
@@ -280,19 +280,8 @@ export async function POST(request: NextRequest) {
continue
}
// Handle copilot, chat, profile-pictures contexts
if (context === 'copilot' || context === 'chat' || context === 'profile-pictures') {
if (context === 'copilot') {
const { isSupportedFileType: isCopilotSupported } = await import(
'@/lib/uploads/contexts/copilot/copilot-file-manager'
)
const resolvedType = resolveFileType(file)
if (!isImageFileType(resolvedType) && !isCopilotSupported(resolvedType)) {
throw new InvalidRequestError(
'Unsupported file type. Allowed: images, PDF, and text files (TXT, CSV, MD, HTML, JSON, XML).'
)
}
} else if (!isImageFileType(file.type)) {
if (context !== 'copilot' && !isImageFileType(file.type)) {
throw new InvalidRequestError(
`Only image files (JPEG, PNG, GIF, WebP, SVG) are allowed for ${context} uploads`
)

View File

@@ -6,7 +6,7 @@ import { type NextRequest, NextResponse } from 'next/server'
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { restoreKnowledgeBase } from '@/lib/knowledge/service'
import { KnowledgeBaseConflictError, restoreKnowledgeBase } from '@/lib/knowledge/service'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
const logger = createLogger('RestoreKnowledgeBaseAPI')
@@ -64,6 +64,10 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
return NextResponse.json({ success: true })
} catch (error) {
if (error instanceof KnowledgeBaseConflictError) {
return NextResponse.json({ error: error.message }, { status: 409 })
}
logger.error(`[${requestId}] Error restoring knowledge base ${id}`, error)
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Internal server error' },

View File

@@ -98,11 +98,15 @@ vi.mock('@sim/db/schema', () => ({
vi.mock('@/lib/audit/log', () => auditMock)
vi.mock('@/lib/knowledge/service', () => ({
getKnowledgeBaseById: vi.fn(),
updateKnowledgeBase: vi.fn(),
deleteKnowledgeBase: vi.fn(),
}))
vi.mock('@/lib/knowledge/service', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/knowledge/service')>()
return {
...actual,
getKnowledgeBaseById: vi.fn(),
updateKnowledgeBase: vi.fn(),
deleteKnowledgeBase: vi.fn(),
}
})
vi.mock('@/app/api/knowledge/utils', () => ({
checkKnowledgeBaseAccess: vi.fn(),

View File

@@ -8,6 +8,7 @@ import { generateRequestId } from '@/lib/core/utils/request'
import {
deleteKnowledgeBase,
getKnowledgeBaseById,
KnowledgeBaseConflictError,
updateKnowledgeBase,
} from '@/lib/knowledge/service'
import { checkKnowledgeBaseAccess, checkKnowledgeBaseWriteAccess } from '@/app/api/knowledge/utils'
@@ -166,6 +167,10 @@ export async function PUT(req: NextRequest, { params }: { params: Promise<{ id:
throw validationError
}
} catch (error) {
if (error instanceof KnowledgeBaseConflictError) {
return NextResponse.json({ error: error.message }, { status: 409 })
}
logger.error(`[${requestId}] Error updating knowledge base`, error)
return NextResponse.json({ error: 'Failed to update knowledge base' }, { status: 500 })
}

View File

@@ -15,6 +15,7 @@ const { mockGetSession, mockDbChain } = vi.hoisted(() => {
where: vi.fn().mockReturnThis(),
groupBy: vi.fn().mockReturnThis(),
orderBy: vi.fn().mockResolvedValue([]),
limit: vi.fn().mockResolvedValue([]),
insert: vi.fn().mockReturnThis(),
values: vi.fn().mockResolvedValue(undefined),
}
@@ -113,7 +114,7 @@ describe('Knowledge Base API Route', () => {
Object.values(mockDbChain).forEach((fn) => {
if (typeof fn === 'function') {
fn.mockClear()
if (fn !== mockDbChain.orderBy && fn !== mockDbChain.values) {
if (fn !== mockDbChain.orderBy && fn !== mockDbChain.values && fn !== mockDbChain.limit) {
fn.mockReturnThis()
}
}

View File

@@ -8,6 +8,7 @@ import { generateRequestId } from '@/lib/core/utils/request'
import {
createKnowledgeBase,
getKnowledgeBases,
KnowledgeBaseConflictError,
type KnowledgeBaseScope,
} from '@/lib/knowledge/service'
@@ -149,6 +150,10 @@ export async function POST(req: NextRequest) {
throw validationError
}
} catch (error) {
if (error instanceof KnowledgeBaseConflictError) {
return NextResponse.json({ error: error.message }, { status: 409 })
}
logger.error(`[${requestId}] Error creating knowledge base`, error)
return NextResponse.json({ error: 'Failed to create knowledge base' }, { status: 500 })
}

View File

@@ -36,11 +36,11 @@ import {
const logger = createLogger('CopilotMcpAPI')
const mcpRateLimiter = new RateLimiter()
const DEFAULT_COPILOT_MODEL = 'claude-opus-4-5'
const DEFAULT_COPILOT_MODEL = 'claude-opus-4-6'
export const dynamic = 'force-dynamic'
export const runtime = 'nodejs'
export const maxDuration = 300
export const maxDuration = 3600
interface CopilotKeyAuthResult {
success: boolean
@@ -517,7 +517,7 @@ async function handleMcpRequestWithSdk(
try {
await transport.handleRequest(requestAdapter as any, responseCapture as any, parsedBody)
await responseCapture.waitForHeaders()
// Must exceed the longest possible tool execution (build = 5 min).
// Must exceed the longest possible tool execution.
// Using ORCHESTRATION_TIMEOUT_MS + 60 s buffer so the orchestrator can
// finish or time-out on its own before the transport is torn down.
await responseCapture.waitForEnd(ORCHESTRATION_TIMEOUT_MS + 60_000)
@@ -630,7 +630,11 @@ async function handleDirectToolCall(
userId: string
): Promise<CallToolResult> {
try {
const execContext = await prepareExecutionContext(userId, (args.workflowId as string) || '')
const execContext = await prepareExecutionContext(
userId,
(args.workflowId as string) || '',
(args.chatId as string) || undefined
)
const toolCall = {
id: randomUUID(),
@@ -729,7 +733,7 @@ async function handleBuildToolCall(
chatId,
goRoute: '/api/mcp',
autoExecuteTools: true,
timeout: 300000,
timeout: ORCHESTRATION_TIMEOUT_MS,
interactive: false,
abortSignal,
})

View File

@@ -22,6 +22,8 @@ import {
getUserEntityPermissions,
} from '@/lib/workspaces/permissions/utils'
export const maxDuration = 3600
const logger = createLogger('MothershipChatAPI')
const FileAttachmentSchema = z.object({
@@ -114,6 +116,29 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: 'Workspace not found or access denied' }, { status: 403 })
}
let currentChat: any = null
let conversationHistory: any[] = []
let actualChatId = chatId
if (chatId || createNewChat) {
const chatResult = await resolveOrCreateChat({
chatId,
userId: authenticatedUserId,
workspaceId,
model: 'claude-opus-4-6',
type: 'mothership',
})
currentChat = chatResult.chat
actualChatId = chatResult.chatId || chatId
conversationHistory = Array.isArray(chatResult.conversationHistory)
? chatResult.conversationHistory
: []
if (chatId && !currentChat) {
return NextResponse.json({ error: 'Chat not found' }, { status: 404 })
}
}
let agentContexts: Array<{ type: string; content: string }> = []
if (Array.isArray(contexts) && contexts.length > 0) {
try {
@@ -121,7 +146,8 @@ export async function POST(req: NextRequest) {
contexts as any,
authenticatedUserId,
message,
workspaceId
workspaceId,
actualChatId
)
} catch (e) {
logger.error(`[${tracker.requestId}] Failed to process contexts`, e)
@@ -135,7 +161,8 @@ export async function POST(req: NextRequest) {
r.type,
r.id,
workspaceId,
authenticatedUserId
authenticatedUserId,
actualChatId
)
if (!ctx) return null
return {
@@ -156,29 +183,6 @@ export async function POST(req: NextRequest) {
}
}
let currentChat: any = null
let conversationHistory: any[] = []
let actualChatId = chatId
if (chatId || createNewChat) {
const chatResult = await resolveOrCreateChat({
chatId,
userId: authenticatedUserId,
workspaceId,
model: 'claude-opus-4-5',
type: 'mothership',
})
currentChat = chatResult.chat
actualChatId = chatResult.chatId || chatId
conversationHistory = Array.isArray(chatResult.conversationHistory)
? chatResult.conversationHistory
: []
if (chatId && !currentChat) {
return NextResponse.json({ error: 'Chat not found' }, { status: 404 })
}
}
if (actualChatId) {
const userMsg = {
id: userMessageId,
@@ -252,21 +256,27 @@ export async function POST(req: NextRequest) {
await waitForPendingChatStream(actualChatId)
}
const executionId = crypto.randomUUID()
const runId = crypto.randomUUID()
const stream = createSSEStream({
requestPayload,
userId: authenticatedUserId,
streamId: userMessageId,
executionId,
runId,
chatId: actualChatId,
currentChat,
isNewChat: conversationHistory.length === 0,
message,
titleModel: 'claude-opus-4-5',
titleModel: 'claude-opus-4-6',
requestId: tracker.requestId,
workspaceId,
orchestrateOptions: {
userId: authenticatedUserId,
workspaceId,
chatId: actualChatId,
executionId,
runId,
goRoute: '/api/mothership',
autoExecuteTools: true,
interactive: true,

View File

@@ -86,7 +86,7 @@ export async function POST(request: NextRequest) {
workspaceId,
type: 'mothership',
title: null,
model: 'claude-opus-4-5',
model: 'claude-opus-4-6',
messages: [],
updatedAt: now,
lastSeenAt: now,

View File

@@ -10,6 +10,8 @@ import {
getUserEntityPermissions,
} from '@/lib/workspaces/permissions/utils'
export const maxDuration = 3600
const logger = createLogger('MothershipExecuteAPI')
const MessageSchema = z.object({

View File

@@ -3,7 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server'
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { getTableById, restoreTable } from '@/lib/table'
import { getTableById, restoreTable, TableConflictError } from '@/lib/table'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
const logger = createLogger('RestoreTableAPI')
@@ -50,6 +50,10 @@ export async function POST(
return NextResponse.json({ success: true })
} catch (error) {
if (error instanceof TableConflictError) {
return NextResponse.json({ error: error.message }, { status: 409 })
}
logger.error(`[${requestId}] Error restoring table ${tableId}`, error)
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Internal server error' },

View File

@@ -3,7 +3,14 @@ import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { deleteTable, NAME_PATTERN, renameTable, TABLE_LIMITS, type TableSchema } from '@/lib/table'
import {
deleteTable,
NAME_PATTERN,
renameTable,
TABLE_LIMITS,
TableConflictError,
type TableSchema,
} from '@/lib/table'
import { accessError, checkAccess, normalizeColumn } from '@/app/api/table/utils'
const logger = createLogger('TableDetailAPI')
@@ -136,6 +143,10 @@ export async function PATCH(request: NextRequest, { params }: TableRouteParams)
)
}
if (error instanceof TableConflictError) {
return NextResponse.json({ error: error.message }, { status: 409 })
}
logger.error(`[${requestId}] Error renaming table:`, error)
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Failed to rename table' },

View File

@@ -6,8 +6,10 @@ import { orchestrateCopilotStream } from '@/lib/copilot/orchestrator'
import { getWorkflowById, resolveWorkflowIdForUser } from '@/lib/workflows/utils'
import { authenticateV1Request } from '@/app/api/v1/auth'
export const maxDuration = 3600
const logger = createLogger('CopilotHeadlessAPI')
const DEFAULT_COPILOT_MODEL = 'claude-opus-4-5'
const DEFAULT_COPILOT_MODEL = 'claude-opus-4-6'
const RequestSchema = z.object({
message: z.string().min(1, 'message is required'),
@@ -17,7 +19,7 @@ const RequestSchema = z.object({
mode: z.enum(COPILOT_REQUEST_MODES).optional().default('agent'),
model: z.string().optional(),
autoExecuteTools: z.boolean().optional().default(true),
timeout: z.number().optional().default(300000),
timeout: z.number().optional().default(3_600_000),
})
/**

View File

@@ -4,6 +4,7 @@ import { z } from 'zod'
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
import { generateRequestId } from '@/lib/core/utils/request'
import {
FileConflictError,
getWorkspaceFile,
listWorkspaceFiles,
uploadWorkspaceFile,
@@ -182,7 +183,8 @@ export async function POST(request: NextRequest) {
})
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Failed to upload file'
const isDuplicate = errorMessage.includes('already exists')
const isDuplicate =
error instanceof FileConflictError || errorMessage.includes('already exists')
if (isDuplicate) {
return NextResponse.json({ error: errorMessage }, { status: 409 })

View File

@@ -42,7 +42,7 @@ export async function POST(
const { getBaseUrl } = await import('@/lib/core/utils/urls')
const serveUrl = `${getBaseUrl()}/api/files/serve/${encodeURIComponent(fileRecord.key)}?context=workspace`
const viewerUrl = `${getBaseUrl()}/workspace/${workspaceId}/files/${fileId}/view`
const viewerUrl = `${getBaseUrl()}/workspace/${workspaceId}/files/${fileId}`
logger.info(`[${requestId}] Generated download URL for workspace file: ${fileRecord.name}`)

View File

@@ -3,7 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server'
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
import { getSession } from '@/lib/auth'
import { generateRequestId } from '@/lib/core/utils/request'
import { restoreWorkspaceFile } from '@/lib/uploads/contexts/workspace'
import { FileConflictError, restoreWorkspaceFile } from '@/lib/uploads/contexts/workspace'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
const logger = createLogger('RestoreWorkspaceFileAPI')
@@ -45,6 +45,9 @@ export async function POST(
return NextResponse.json({ success: true })
} catch (error) {
if (error instanceof FileConflictError) {
return NextResponse.json({ error: error.message }, { status: 409 })
}
logger.error(`[${requestId}] Error restoring workspace file ${fileId}`, error)
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Internal server error' },

View File

@@ -4,6 +4,7 @@ import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
import { getSession } from '@/lib/auth'
import { generateRequestId } from '@/lib/core/utils/request'
import {
FileConflictError,
listWorkspaceFiles,
uploadWorkspaceFile,
type WorkspaceFileScope,
@@ -135,9 +136,9 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
} catch (error) {
logger.error(`[${requestId}] Error uploading workspace file:`, error)
// Check if it's a duplicate file error
const errorMessage = error instanceof Error ? error.message : 'Failed to upload file'
const isDuplicate = errorMessage.includes('already exists')
const isDuplicate =
error instanceof FileConflictError || errorMessage.includes('already exists')
return NextResponse.json(
{

View File

@@ -0,0 +1,39 @@
'use client'
import type { ReactNode } from 'react'
import { Blimp } from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
interface ConversationListItemProps {
title: string
isActive?: boolean
isUnread?: boolean
className?: string
titleClassName?: string
actions?: ReactNode
}
export function ConversationListItem({
title,
isActive = false,
isUnread = false,
className,
titleClassName,
actions,
}: ConversationListItemProps) {
return (
<div className={cn('flex w-full min-w-0 items-center gap-[8px]', className)}>
<span className='relative flex-shrink-0'>
<Blimp className='h-[16px] w-[16px] text-[var(--text-icon)]' />
{isActive && (
<span className='-right-[1px] -bottom-[1px] absolute h-[6px] w-[6px] rounded-full border border-[var(--surface-1)] bg-amber-400' />
)}
{!isActive && isUnread && (
<span className='-right-[1px] -bottom-[1px] absolute h-[6px] w-[6px] rounded-full border border-[var(--surface-1)] bg-[#33C482]' />
)}
</span>
<span className={cn('min-w-0 flex-1 truncate', titleClassName)}>{title}</span>
{actions && <div className='ml-auto flex flex-shrink-0 items-center'>{actions}</div>}
</div>
)
}

View File

@@ -1,3 +1,4 @@
export { ConversationListItem } from './conversation-list-item'
export { ErrorState, type ErrorStateProps } from './error'
export { InlineRenameInput } from './inline-rename-input'
export { MessageActions } from './message-actions'

View File

@@ -0,0 +1,39 @@
import type { Metadata } from 'next'
import { redirect } from 'next/navigation'
import { getSession } from '@/lib/auth'
import { verifyWorkspaceMembership } from '@/app/api/workflows/utils'
import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check'
import { Files } from '../files'
export const metadata: Metadata = {
title: 'Files',
robots: { index: false },
}
interface FileDetailPageProps {
params: Promise<{
workspaceId: string
fileId: string
}>
}
export default async function FileDetailPage({ params }: FileDetailPageProps) {
const { workspaceId } = await params
const session = await getSession()
if (!session?.user?.id) {
redirect('/')
}
const hasPermission = await verifyWorkspaceMembership(session.user.id, workspaceId)
if (!hasPermission) {
redirect('/')
}
const permissionConfig = await getUserPermissionConfig(session.user.id)
if (permissionConfig?.hideFilesTab) {
redirect(`/workspace/${workspaceId}`)
}
return <Files />
}

View File

@@ -11,6 +11,7 @@ import {
useWorkspaceFileContent,
} from '@/hooks/queries/workspace-files'
import { useAutosave } from '@/hooks/use-autosave'
import { useStreamingText } from '@/hooks/use-streaming-text'
import { PreviewPanel, resolvePreviewType } from './preview-panel'
const logger = createLogger('FileViewer')
@@ -44,15 +45,20 @@ const TEXT_EDITABLE_EXTENSIONS = new Set([
const IFRAME_PREVIEWABLE_MIME_TYPES = new Set(['application/pdf'])
const IFRAME_PREVIEWABLE_EXTENSIONS = new Set(['pdf'])
type FileCategory = 'text-editable' | 'iframe-previewable' | 'unsupported'
const IMAGE_PREVIEWABLE_MIME_TYPES = new Set(['image/png', 'image/jpeg', 'image/gif', 'image/webp'])
const IMAGE_PREVIEWABLE_EXTENSIONS = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp'])
type FileCategory = 'text-editable' | 'iframe-previewable' | 'image-previewable' | 'unsupported'
function resolveFileCategory(mimeType: string | null, filename: string): FileCategory {
if (mimeType && TEXT_EDITABLE_MIME_TYPES.has(mimeType)) return 'text-editable'
if (mimeType && IFRAME_PREVIEWABLE_MIME_TYPES.has(mimeType)) return 'iframe-previewable'
if (mimeType && IMAGE_PREVIEWABLE_MIME_TYPES.has(mimeType)) return 'image-previewable'
const ext = getFileExtension(filename)
if (TEXT_EDITABLE_EXTENSIONS.has(ext)) return 'text-editable'
if (IFRAME_PREVIEWABLE_EXTENSIONS.has(ext)) return 'iframe-previewable'
if (IMAGE_PREVIEWABLE_EXTENSIONS.has(ext)) return 'image-previewable'
return 'unsupported'
}
@@ -77,6 +83,7 @@ interface FileViewerProps {
onDirtyChange?: (isDirty: boolean) => void
onSaveStatusChange?: (status: 'idle' | 'saving' | 'saved' | 'error') => void
saveRef?: React.MutableRefObject<(() => Promise<void>) | null>
streamingContent?: string
}
export function FileViewer({
@@ -89,6 +96,7 @@ export function FileViewer({
onDirtyChange,
onSaveStatusChange,
saveRef,
streamingContent,
}: FileViewerProps) {
const category = resolveFileCategory(file.type, file.name)
@@ -97,12 +105,13 @@ export function FileViewer({
<TextEditor
file={file}
workspaceId={workspaceId}
canEdit={canEdit}
canEdit={streamingContent !== undefined ? false : canEdit}
previewMode={previewMode ?? (showPreview ? 'preview' : 'editor')}
autoFocus={autoFocus}
onDirtyChange={onDirtyChange}
onSaveStatusChange={onSaveStatusChange}
saveRef={saveRef}
streamingContent={streamingContent}
/>
)
}
@@ -111,6 +120,10 @@ export function FileViewer({
return <IframePreview file={file} />
}
if (category === 'image-previewable') {
return <ImagePreview file={file} />
}
return <UnsupportedPreview file={file} />
}
@@ -123,6 +136,7 @@ interface TextEditorProps {
onDirtyChange?: (isDirty: boolean) => void
onSaveStatusChange?: (status: 'idle' | 'saving' | 'saved' | 'error') => void
saveRef?: React.MutableRefObject<(() => Promise<void>) | null>
streamingContent?: string
}
function TextEditor({
@@ -134,6 +148,7 @@ function TextEditor({
onDirtyChange,
onSaveStatusChange,
saveRef,
streamingContent,
}: TextEditorProps) {
const initializedRef = useRef(false)
const contentRef = useRef('')
@@ -157,6 +172,13 @@ function TextEditor({
const savedContentRef = useRef('')
useEffect(() => {
if (streamingContent !== undefined) {
setContent(streamingContent)
contentRef.current = streamingContent
initializedRef.current = true
return
}
if (fetchedContent === undefined) return
if (!initializedRef.current) {
@@ -180,7 +202,7 @@ function TextEditor({
savedContentRef.current = fetchedContent
contentRef.current = fetchedContent
}
}, [fetchedContent, dataUpdatedAt, autoFocus])
}, [streamingContent, fetchedContent, dataUpdatedAt, autoFocus])
const handleContentChange = useCallback((value: string) => {
setContent(value)
@@ -249,34 +271,76 @@ function TextEditor({
}
}, [isResizing])
if (isLoading) {
return (
<div className='flex flex-1 flex-col gap-[8px] p-[24px]'>
<Skeleton className='h-[16px] w-[60%]' />
<Skeleton className='h-[16px] w-[80%]' />
<Skeleton className='h-[16px] w-[40%]' />
<Skeleton className='h-[16px] w-[70%]' />
</div>
)
const isStreaming = streamingContent !== undefined
const revealedContent = useStreamingText(content, isStreaming)
const textareaStuckRef = useRef(true)
useEffect(() => {
if (!isStreaming) return
textareaStuckRef.current = true
const el = textareaRef.current
if (!el) return
const onWheel = (e: WheelEvent) => {
if (e.deltaY < 0) textareaStuckRef.current = false
}
const onScroll = () => {
const dist = el.scrollHeight - el.scrollTop - el.clientHeight
if (dist <= 5) textareaStuckRef.current = true
}
el.addEventListener('wheel', onWheel, { passive: true })
el.addEventListener('scroll', onScroll, { passive: true })
return () => {
el.removeEventListener('wheel', onWheel)
el.removeEventListener('scroll', onScroll)
}
}, [isStreaming])
useEffect(() => {
if (!isStreaming || !textareaStuckRef.current) return
const el = textareaRef.current
if (!el) return
el.scrollTop = el.scrollHeight
}, [isStreaming, revealedContent])
if (streamingContent === undefined) {
if (isLoading) {
return (
<div className='flex flex-1 flex-col gap-[8px] p-[24px]'>
<Skeleton className='h-[16px] w-[60%]' />
<Skeleton className='h-[16px] w-[80%]' />
<Skeleton className='h-[16px] w-[40%]' />
<Skeleton className='h-[16px] w-[70%]' />
</div>
)
}
if (error) {
return (
<div className='flex flex-1 items-center justify-center'>
<p className='text-[13px] text-[var(--text-muted)]'>Failed to load file content</p>
</div>
)
}
}
if (error) {
return (
<div className='flex flex-1 items-center justify-center'>
<p className='text-[13px] text-[var(--text-muted)]'>Failed to load file content</p>
</div>
)
}
const showEditor = previewMode !== 'preview'
const showPreviewPane = previewMode !== 'editor'
const previewType = resolvePreviewType(file.type, file.name)
const isIframeRendered = previewType === 'html' || previewType === 'svg'
const effectiveMode = isStreaming && isIframeRendered ? 'editor' : previewMode
const showEditor = effectiveMode !== 'preview'
const showPreviewPane = effectiveMode !== 'editor'
return (
<div ref={containerRef} className='relative flex flex-1 overflow-hidden'>
{showEditor && (
<textarea
ref={textareaRef}
value={content}
value={isStreaming ? revealedContent : content}
onChange={(e) => handleContentChange(e.target.value)}
readOnly={!canEdit}
spellCheck={false}
@@ -308,7 +372,12 @@ function TextEditor({
<div
className={cn('min-w-0 flex-1 overflow-hidden', isResizing && 'pointer-events-none')}
>
<PreviewPanel content={content} mimeType={file.type} filename={file.name} />
<PreviewPanel
content={revealedContent}
mimeType={file.type}
filename={file.name}
isStreaming={isStreaming}
/>
</div>
</>
)}
@@ -333,6 +402,21 @@ function IframePreview({ file }: { file: WorkspaceFileRecord }) {
)
}
function ImagePreview({ file }: { file: WorkspaceFileRecord }) {
const serveUrl = `/api/files/serve/${encodeURIComponent(file.key)}?context=workspace`
return (
<div className='flex flex-1 items-center justify-center overflow-auto bg-[var(--surface-1)] p-6'>
<img
src={serveUrl}
alt={file.name}
className='max-h-full max-w-full rounded-md object-contain'
loading='eager'
/>
</div>
)
}
function UnsupportedPreview({ file }: { file: WorkspaceFileRecord }) {
const ext = getFileExtension(file.name)

View File

@@ -4,7 +4,10 @@ import { memo, useMemo } from 'react'
import ReactMarkdown from 'react-markdown'
import remarkBreaks from 'remark-breaks'
import remarkGfm from 'remark-gfm'
import { cn } from '@/lib/core/utils/cn'
import { getFileExtension } from '@/lib/uploads/utils/file-utils'
import { useAutoScroll } from '@/app/workspace/[workspaceId]/home/hooks/use-auto-scroll'
import { useStreamingReveal } from '@/app/workspace/[workspaceId]/home/hooks/use-streaming-reveal'
type PreviewType = 'markdown' | 'html' | 'csv' | 'svg' | null
@@ -36,12 +39,14 @@ interface PreviewPanelProps {
content: string
mimeType: string | null
filename: string
isStreaming?: boolean
}
export function PreviewPanel({ content, mimeType, filename }: PreviewPanelProps) {
export function PreviewPanel({ content, mimeType, filename, isStreaming }: PreviewPanelProps) {
const previewType = resolvePreviewType(mimeType, filename)
if (previewType === 'markdown') return <MarkdownPreview content={content} />
if (previewType === 'markdown')
return <MarkdownPreview content={content} isStreaming={isStreaming} />
if (previewType === 'html') return <HtmlPreview content={content} />
if (previewType === 'csv') return <CsvPreview content={content} />
if (previewType === 'svg') return <SvgPreview content={content} />
@@ -49,121 +54,145 @@ export function PreviewPanel({ content, mimeType, filename }: PreviewPanelProps)
return null
}
const MarkdownPreview = memo(function MarkdownPreview({ content }: { content: string }) {
return (
<div className='h-full overflow-auto p-[24px]'>
<ReactMarkdown
remarkPlugins={[remarkGfm, remarkBreaks]}
components={{
p: ({ children }: any) => (
<p className='mb-3 break-words text-[14px] text-[var(--text-primary)] leading-[1.6] last:mb-0'>
{children}
</p>
),
h1: ({ children }: any) => (
<h1 className='mt-6 mb-4 break-words border-[var(--border)] border-b pb-2 font-semibold text-[24px] text-[var(--text-primary)] first:mt-0'>
{children}
</h1>
),
h2: ({ children }: any) => (
<h2 className='mt-5 mb-3 break-words border-[var(--border)] border-b pb-1.5 font-semibold text-[20px] text-[var(--text-primary)] first:mt-0'>
{children}
</h2>
),
h3: ({ children }: any) => (
<h3 className='mt-4 mb-2 break-words font-semibold text-[16px] text-[var(--text-primary)] first:mt-0'>
{children}
</h3>
),
h4: ({ children }: any) => (
<h4 className='mt-3 mb-2 break-words font-semibold text-[14px] text-[var(--text-primary)] first:mt-0'>
{children}
</h4>
),
ul: ({ children }: any) => (
<ul className='mt-1 mb-3 list-disc space-y-1 break-words pl-6 text-[14px] text-[var(--text-primary)]'>
{children}
</ul>
),
ol: ({ children }: any) => (
<ol className='mt-1 mb-3 list-decimal space-y-1 break-words pl-6 text-[14px] text-[var(--text-primary)]'>
{children}
</ol>
),
li: ({ children }: any) => <li className='break-words leading-[1.6]'>{children}</li>,
code: ({ inline, className, children, ...props }: any) => {
const isInline = inline || !className?.includes('language-')
const REMARK_PLUGINS = [remarkGfm, remarkBreaks]
if (isInline) {
return (
<code
{...props}
className='whitespace-normal rounded bg-[var(--surface-5)] px-1.5 py-0.5 font-mono text-[#F59E0B] text-[13px]'
>
{children}
</code>
)
}
const PREVIEW_MARKDOWN_COMPONENTS = {
p: ({ children }: any) => (
<p className='mb-3 break-words text-[14px] text-[var(--text-primary)] leading-[1.6] last:mb-0'>
{children}
</p>
),
h1: ({ children }: any) => (
<h1 className='mt-6 mb-4 break-words border-[var(--border)] border-b pb-2 font-semibold text-[24px] text-[var(--text-primary)] first:mt-0'>
{children}
</h1>
),
h2: ({ children }: any) => (
<h2 className='mt-5 mb-3 break-words border-[var(--border)] border-b pb-1.5 font-semibold text-[20px] text-[var(--text-primary)] first:mt-0'>
{children}
</h2>
),
h3: ({ children }: any) => (
<h3 className='mt-4 mb-2 break-words font-semibold text-[16px] text-[var(--text-primary)] first:mt-0'>
{children}
</h3>
),
h4: ({ children }: any) => (
<h4 className='mt-3 mb-2 break-words font-semibold text-[14px] text-[var(--text-primary)] first:mt-0'>
{children}
</h4>
),
ul: ({ children }: any) => (
<ul className='mt-1 mb-3 list-disc space-y-1 break-words pl-6 text-[14px] text-[var(--text-primary)]'>
{children}
</ul>
),
ol: ({ children }: any) => (
<ol className='mt-1 mb-3 list-decimal space-y-1 break-words pl-6 text-[14px] text-[var(--text-primary)]'>
{children}
</ol>
),
li: ({ children }: any) => <li className='break-words leading-[1.6]'>{children}</li>,
code: ({ inline, className, children, ...props }: any) => {
const isInline = inline || !className?.includes('language-')
return (
<code
{...props}
className='my-3 block whitespace-pre-wrap break-words rounded-md bg-[var(--surface-5)] p-4 font-mono text-[13px] text-[var(--text-primary)]'
>
{children}
</code>
)
},
pre: ({ children }: any) => <>{children}</>,
a: ({ href, children }: any) => (
<a
href={href}
target='_blank'
rel='noopener noreferrer'
className='break-all text-[var(--brand-secondary)] underline-offset-2 hover:underline'
>
{children}
</a>
),
strong: ({ children }: any) => (
<strong className='break-words font-semibold text-[var(--text-primary)]'>
{children}
</strong>
),
em: ({ children }: any) => (
<em className='break-words text-[var(--text-tertiary)]'>{children}</em>
),
blockquote: ({ children }: any) => (
<blockquote className='my-4 break-words border-[var(--border-1)] border-l-4 py-1 pl-4 text-[var(--text-tertiary)] italic'>
{children}
</blockquote>
),
hr: () => <hr className='my-6 border-[var(--border)]' />,
img: ({ src, alt }: any) => (
<img src={src} alt={alt ?? ''} className='my-3 max-w-full rounded-md' loading='lazy' />
),
table: ({ children }: any) => (
<div className='my-4 max-w-full overflow-x-auto rounded-md border border-[var(--border)]'>
<table className='w-full border-collapse text-[13px]'>{children}</table>
</div>
),
thead: ({ children }: any) => <thead className='bg-[var(--surface-2)]'>{children}</thead>,
tbody: ({ children }: any) => <tbody>{children}</tbody>,
tr: ({ children }: any) => (
<tr className='border-[var(--border)] border-b last:border-b-0'>{children}</tr>
),
th: ({ children }: any) => (
<th className='px-3 py-2 text-left font-semibold text-[12px] text-[var(--text-primary)]'>
{children}
</th>
),
td: ({ children }: any) => (
<td className='px-3 py-2 text-[var(--text-secondary)]'>{children}</td>
),
}}
if (isInline) {
return (
<code
{...props}
className='whitespace-normal rounded bg-[var(--surface-5)] px-1.5 py-0.5 font-mono text-[#F59E0B] text-[13px]'
>
{children}
</code>
)
}
return (
<code
{...props}
className='my-3 block whitespace-pre-wrap break-words rounded-md bg-[var(--surface-5)] p-4 font-mono text-[13px] text-[var(--text-primary)]'
>
{content}
</ReactMarkdown>
{children}
</code>
)
},
pre: ({ children }: any) => <>{children}</>,
a: ({ href, children }: any) => (
<a
href={href}
target='_blank'
rel='noopener noreferrer'
className='break-all text-[var(--brand-secondary)] underline-offset-2 hover:underline'
>
{children}
</a>
),
strong: ({ children }: any) => (
<strong className='break-words font-semibold text-[var(--text-primary)]'>{children}</strong>
),
em: ({ children }: any) => (
<em className='break-words text-[var(--text-tertiary)]'>{children}</em>
),
blockquote: ({ children }: any) => (
<blockquote className='my-4 break-words border-[var(--border-1)] border-l-4 py-1 pl-4 text-[var(--text-tertiary)] italic'>
{children}
</blockquote>
),
hr: () => <hr className='my-6 border-[var(--border)]' />,
img: ({ src, alt }: any) => (
<img src={src} alt={alt ?? ''} className='my-3 max-w-full rounded-md' loading='lazy' />
),
table: ({ children }: any) => (
<div className='my-4 max-w-full overflow-x-auto rounded-md border border-[var(--border)]'>
<table className='w-full border-collapse text-[13px]'>{children}</table>
</div>
),
thead: ({ children }: any) => <thead className='bg-[var(--surface-2)]'>{children}</thead>,
tbody: ({ children }: any) => <tbody>{children}</tbody>,
tr: ({ children }: any) => (
<tr className='border-[var(--border)] border-b last:border-b-0'>{children}</tr>
),
th: ({ children }: any) => (
<th className='px-3 py-2 text-left font-semibold text-[12px] text-[var(--text-primary)]'>
{children}
</th>
),
td: ({ children }: any) => <td className='px-3 py-2 text-[var(--text-secondary)]'>{children}</td>,
}
const MarkdownPreview = memo(function MarkdownPreview({
content,
isStreaming = false,
}: {
content: string
isStreaming?: boolean
}) {
const { ref: scrollRef } = useAutoScroll(isStreaming)
const { committed, incoming, generation } = useStreamingReveal(content, isStreaming)
const committedMarkdown = useMemo(
() =>
committed ? (
<ReactMarkdown remarkPlugins={REMARK_PLUGINS} components={PREVIEW_MARKDOWN_COMPONENTS}>
{committed}
</ReactMarkdown>
) : null,
[committed]
)
return (
<div ref={scrollRef} className='h-full overflow-auto p-[24px]'>
{committedMarkdown}
{incoming && (
<div
key={generation}
className={cn(isStreaming && 'animate-stream-fade-in', '[&>:first-child]:mt-0')}
>
<ReactMarkdown remarkPlugins={REMARK_PLUGINS} components={PREVIEW_MARKDOWN_COMPONENTS}>
{incoming}
</ReactMarkdown>
</div>
)}
</div>
)
})

View File

@@ -2,7 +2,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { useParams } from 'next/navigation'
import { useParams, useRouter } from 'next/navigation'
import {
Button,
Columns2,
@@ -123,7 +123,10 @@ export function Files() {
const saveRef = useRef<(() => Promise<void>) | null>(null)
const params = useParams()
const router = useRouter()
const workspaceId = params?.workspaceId as string
const fileIdFromRoute =
typeof params?.fileId === 'string' && params.fileId.length > 0 ? params.fileId : null
const userPermissions = useUserPermissionsContext()
const { data: files = [], isLoading, error } = useWorkspaceFiles(workspaceId)
@@ -157,7 +160,6 @@ export function Files() {
const [uploading, setUploading] = useState(false)
const [uploadProgress, setUploadProgress] = useState({ completed: 0, total: 0 })
const [searchTerm, setSearchTerm] = useState('')
const [selectedFileId, setSelectedFileId] = useState<string | null>(null)
const [creatingFile, setCreatingFile] = useState(false)
const [isDirty, setIsDirty] = useState(false)
const [saveStatus, setSaveStatus] = useState<SaveStatus>('idle')
@@ -178,8 +180,8 @@ export function Files() {
})
const selectedFile = useMemo(
() => (selectedFileId ? files.find((f) => f.id === selectedFileId) : null),
[selectedFileId, files]
() => (fileIdFromRoute ? files.find((f) => f.id === fileIdFromRoute) : null),
[fileIdFromRoute, files]
)
const filteredFiles = useMemo(() => {
@@ -297,15 +299,15 @@ export function Files() {
})
setShowDeleteConfirm(false)
setDeleteTargetFile(null)
if (selectedFileId === target.id) {
if (fileIdFromRoute === target.id) {
setIsDirty(false)
setSaveStatus('idle')
setSelectedFileId(null)
router.push(`/workspace/${workspaceId}/files`)
}
} catch (err) {
logger.error('Failed to delete file:', err)
}
}, [deleteTargetFile, workspaceId, selectedFileId])
}, [deleteTargetFile, workspaceId, fileIdFromRoute, router])
const handleSave = useCallback(async () => {
if (!saveRef.current || !isDirty || saveStatus === 'saving') return
@@ -317,9 +319,9 @@ export function Files() {
setShowUnsavedChangesAlert(true)
} else {
setPreviewMode('editor')
setSelectedFileId(null)
router.push(`/workspace/${workspaceId}/files`)
}
}, [isDirty])
}, [isDirty, router, workspaceId])
const handleStartHeaderRename = useCallback(() => {
if (selectedFile) headerRename.startRename(selectedFile.id, selectedFile.name)
@@ -391,8 +393,8 @@ export function Files() {
setIsDirty(false)
setSaveStatus('idle')
setPreviewMode('editor')
setSelectedFileId(null)
}, [])
router.push(`/workspace/${workspaceId}/files`)
}, [router, workspaceId])
const handleCreateFile = useCallback(async () => {
if (creatingFile) return
@@ -414,14 +416,14 @@ export function Files() {
const fileId = result.file?.id
if (fileId) {
justCreatedFileIdRef.current = fileId
setSelectedFileId(fileId)
router.push(`/workspace/${workspaceId}/files/${fileId}`)
}
} catch (err) {
logger.error('Failed to create file:', err)
} finally {
setCreatingFile(false)
}
}, [creatingFile, files, workspaceId])
}, [creatingFile, files, workspaceId, router])
const handleRowContextMenu = useCallback(
(e: React.MouseEvent, rowId: string) => {
@@ -436,9 +438,9 @@ export function Files() {
const handleContextMenuOpen = useCallback(() => {
if (!contextMenuFile) return
setSelectedFileId(contextMenuFile.id)
router.push(`/workspace/${workspaceId}/files/${contextMenuFile.id}`)
closeContextMenu()
}, [contextMenuFile, closeContextMenu])
}, [contextMenuFile, closeContextMenu, router, workspaceId])
const handleContextMenuDownload = useCallback(() => {
if (!contextMenuFile) return
@@ -478,18 +480,19 @@ export function Files() {
}, [closeListContextMenu])
useEffect(() => {
const isJustCreated = selectedFileId != null && justCreatedFileIdRef.current === selectedFileId
const isJustCreated =
fileIdFromRoute != null && justCreatedFileIdRef.current === fileIdFromRoute
if (justCreatedFileIdRef.current && !isJustCreated) {
justCreatedFileIdRef.current = null
}
if (isJustCreated) {
setPreviewMode('editor')
} else {
const file = selectedFileId ? filesRef.current.find((f) => f.id === selectedFileId) : null
const file = fileIdFromRoute ? filesRef.current.find((f) => f.id === fileIdFromRoute) : null
const canPreview = file ? isPreviewable(file) : false
setPreviewMode(canPreview ? 'preview' : 'editor')
}
}, [selectedFileId])
}, [fileIdFromRoute])
useEffect(() => {
if (!selectedFile) return
@@ -597,13 +600,16 @@ export function Files() {
handleDeleteSelected,
])
if (selectedFileId && !selectedFile) {
if (fileIdFromRoute && !selectedFile) {
return (
<div className='flex h-full flex-1 flex-col overflow-hidden bg-[var(--bg)]'>
<ResourceHeader
icon={FilesIcon}
breadcrumbs={[
{ label: 'Files', onClick: () => setSelectedFileId(null) },
{
label: 'Files',
onClick: () => router.push(`/workspace/${workspaceId}/files`),
},
{ label: '...' },
]}
/>
@@ -699,7 +705,9 @@ export function Files() {
columns={COLUMNS}
rows={rows}
onRowClick={(id) => {
if (listRename.editingId !== id && !headerRename.editingId) setSelectedFileId(id)
if (listRename.editingId !== id && !headerRename.editingId) {
router.push(`/workspace/${workspaceId}/files/${id}`)
}
}}
onRowContextMenu={handleRowContextMenu}
isLoading={isLoading}

View File

@@ -0,0 +1,46 @@
'use client'
import { getDocumentIcon } from '@/components/icons/document-icons'
import { cn } from '@/lib/core/utils/cn'
import type { ChatMessageAttachment } from '../types'
function FileAttachmentPill(props: { mediaType: string; filename: string }) {
const Icon = getDocumentIcon(props.mediaType, props.filename)
return (
<div className='flex max-w-[140px] items-center gap-[5px] rounded-[10px] bg-[var(--surface-5)] px-[6px] py-[3px]'>
<Icon className='h-[14px] w-[14px] flex-shrink-0 text-[var(--text-icon)]' />
<span className='truncate text-[11px] text-[var(--text-body)]'>{props.filename}</span>
</div>
)
}
export function ChatMessageAttachments(props: {
attachments: ChatMessageAttachment[]
align?: 'start' | 'end'
className?: string
}) {
const { attachments, align = 'end', className } = props
if (!attachments.length) return null
return (
<div
className={cn(
'flex flex-wrap gap-[6px]',
align === 'end' ? 'justify-end' : 'justify-start',
className
)}
>
{attachments.map((att) => {
const isImage = att.media_type.startsWith('image/')
return isImage && att.previewUrl ? (
<div key={att.id} className='h-[56px] w-[56px] overflow-hidden rounded-[8px]'>
<img src={att.previewUrl} alt={att.filename} className='h-full w-full object-cover' />
</div>
) : (
<FileAttachmentPill key={att.id} mediaType={att.media_type} filename={att.filename} />
)
})}
</div>
)
}

View File

@@ -1,4 +1,8 @@
export { MessageContent } from './message-content'
export { ChatMessageAttachments } from './chat-message-attachments'
export {
assistantMessageHasRenderableContent,
MessageContent,
} from './message-content'
export { MothershipView } from './mothership-view'
export { QueuedMessages } from './queued-messages'
export { TemplatePrompts } from './template-prompts'

View File

@@ -98,7 +98,6 @@ export function AgentGroup({
toolName={item.data.toolName}
displayTitle={item.data.displayTitle}
status={item.data.status}
result={item.data.result}
/>
) : (
<span

View File

@@ -1,29 +1,9 @@
'use client'
import { useMemo } from 'react'
import { PillsRing } from '@/components/emcn'
import type { ToolCallResult, ToolCallStatus } from '../../../../types'
import type { ToolCallStatus } from '../../../../types'
import { getToolIcon } from '../../utils'
/** Tools that render as cards with result data on success. */
const CARD_TOOLS = new Set<string>([
'function_execute',
'search_online',
'scrape_page',
'get_page_contents',
'search_library_docs',
'superagent',
'run',
'plan',
'debug',
'edit',
'fast_edit',
'custom_tool',
'research',
'agent',
'job',
])
function CircleCheck({ className }: { className?: string }) {
return (
<svg
@@ -76,15 +56,13 @@ function StatusIcon({ status, toolName }: { status: ToolCallStatus; toolName: st
return <CircleCheck className='h-[15px] w-[15px] text-[var(--text-tertiary)]' />
}
function FlatToolLine({
toolName,
displayTitle,
status,
}: {
interface ToolCallItemProps {
toolName: string
displayTitle: string
status: ToolCallStatus
}) {
}
export function ToolCallItem({ toolName, displayTitle, status }: ToolCallItemProps) {
return (
<div className='flex items-center gap-[8px] pl-[24px]'>
<div className='flex h-[16px] w-[16px] flex-shrink-0 items-center justify-center'>
@@ -94,68 +72,3 @@ function FlatToolLine({
</div>
)
}
function formatToolOutput(output: unknown): string {
if (output === null || output === undefined) return ''
if (typeof output === 'string') return output
try {
return JSON.stringify(output, null, 2)
} catch {
return String(output)
}
}
interface ToolCallItemProps {
toolName: string
displayTitle: string
status: ToolCallStatus
result?: ToolCallResult
}
export function ToolCallItem({ toolName, displayTitle, status, result }: ToolCallItemProps) {
const showCard =
CARD_TOOLS.has(toolName) &&
status === 'success' &&
result?.output !== undefined &&
result?.output !== null
if (showCard) {
return <ToolCallCard toolName={toolName} displayTitle={displayTitle} result={result!} />
}
return <FlatToolLine toolName={toolName} displayTitle={displayTitle} status={status} />
}
function ToolCallCard({
toolName,
displayTitle,
result,
}: {
toolName: string
displayTitle: string
result: ToolCallResult
}) {
const body = useMemo(() => formatToolOutput(result.output), [result.output])
const Icon = getToolIcon(toolName)
const ResolvedIcon = Icon ?? CircleCheck
return (
<div className='animate-stream-fade-in pl-[24px]'>
<div className='overflow-hidden rounded-[8px] border border-[var(--border)] bg-[var(--surface-3)]'>
<div className='flex items-center gap-[8px] px-[10px] py-[6px]'>
<div className='flex h-[16px] w-[16px] flex-shrink-0 items-center justify-center'>
<ResolvedIcon className='h-[15px] w-[15px] text-[var(--text-tertiary)]' />
</div>
<span className='font-base text-[13px] text-[var(--text-secondary)]'>{displayTitle}</span>
</div>
{body && (
<div className='border-[var(--border)] border-t px-[10px] py-[6px]'>
<pre className='max-h-[200px] overflow-y-auto whitespace-pre-wrap break-all font-mono text-[12px] text-[var(--text-body)] leading-[1.5]'>
{body}
</pre>
</div>
)}
</div>
</div>
)
}

View File

@@ -16,7 +16,7 @@ import {
SpecialTags,
} from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags'
import { useStreamingReveal } from '@/app/workspace/[workspaceId]/home/hooks/use-streaming-reveal'
import { useThrottledValue } from '@/hooks/use-throttled-value'
import { useStreamingText } from '@/hooks/use-streaming-text'
const REMARK_PLUGINS = [remarkGfm]
@@ -187,11 +187,8 @@ interface ChatContentProps {
onOptionSelect?: (id: string) => void
}
const STREAMING_THROTTLE_MS = 50
export function ChatContent({ content, isStreaming = false, onOptionSelect }: ChatContentProps) {
const throttled = useThrottledValue(content, isStreaming ? STREAMING_THROTTLE_MS : undefined)
const rendered = isStreaming ? throttled : content
const rendered = useStreamingText(content, isStreaming)
const parsed = useMemo(() => parseSpecialTags(rendered, isStreaming), [rendered, isStreaming])
const hasSpecialContent = parsed.hasPendingTag || parsed.segments.some((s) => s.type !== 'text')

View File

@@ -1,8 +1,23 @@
export type {
ContentSegment,
CredentialTagData,
CredentialTagType,
FileTagData,
MothershipErrorTagData,
OptionsTagData,
ParsedSpecialContent,
RuntimeSpecialTagName,
UsageUpgradeAction,
UsageUpgradeTagData,
} from './special-tags'
export { PendingTagIndicator, parseSpecialTags, SpecialTags } from './special-tags'
export {
CREDENTIAL_TAG_TYPES,
PendingTagIndicator,
parseFileTag,
parseJsonTagBody,
parseSpecialTags,
parseTagAttributes,
parseTextTagBody,
SpecialTags,
USAGE_UPGRADE_ACTIONS,
} from './special-tags'

View File

@@ -8,36 +8,207 @@ import { OAUTH_PROVIDERS } from '@/lib/oauth/oauth'
export interface OptionsItemData {
title: string
description?: string
description: string
}
export type OptionsTagData = Record<string, OptionsItemData | string>
export type OptionsTagData = Record<string, OptionsItemData>
export const USAGE_UPGRADE_ACTIONS = ['upgrade_plan', 'increase_limit'] as const
export type UsageUpgradeAction = (typeof USAGE_UPGRADE_ACTIONS)[number]
/**
* Synthetic inline tag payload derived from request-layer HTTP upgrade/quota
* failures and rendered through the same special-tag abstraction as streamed tags.
*/
export interface UsageUpgradeTagData {
reason: string
action: 'upgrade_plan' | 'increase_limit'
action: UsageUpgradeAction
message: string
}
export const CREDENTIAL_TAG_TYPES = [
'env_key',
'oauth_key',
'sim_key',
'credential_id',
'link',
] as const
export type CredentialTagType = (typeof CREDENTIAL_TAG_TYPES)[number]
export interface CredentialTagData {
value: string
type: 'env_key' | 'oauth_key' | 'sim_key' | 'credential_id' | 'link'
type: CredentialTagType
provider?: string
}
export interface MothershipErrorTagData {
message: string
code?: string
provider?: string
}
export interface FileTagData {
name: string
type: string
content: string
}
export type ContentSegment =
| { type: 'text'; content: string }
| { type: 'thinking'; content: string }
| { type: 'options'; data: OptionsTagData }
| { type: 'usage_upgrade'; data: UsageUpgradeTagData }
| { type: 'credential'; data: CredentialTagData }
| { type: 'mothership-error'; data: MothershipErrorTagData }
export type RuntimeSpecialTagName =
| 'thinking'
| 'options'
| 'credential'
| 'mothership-error'
| 'file'
export interface ParsedSpecialContent {
segments: ContentSegment[]
hasPendingTag: boolean
}
const SPECIAL_TAG_NAMES = ['thinking', 'options', 'usage_upgrade', 'credential'] as const
const RUNTIME_SPECIAL_TAG_NAMES = [
'thinking',
'options',
'credential',
'mothership-error',
'file',
] as const
const SPECIAL_TAG_NAMES = [
'thinking',
'options',
'usage_upgrade',
'credential',
'mothership-error',
] as const
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null
}
function isOptionsItemData(value: unknown): value is OptionsItemData {
if (!isRecord(value)) return false
return typeof value.title === 'string' && typeof value.description === 'string'
}
function isOptionsTagData(value: unknown): value is OptionsTagData {
if (!isRecord(value)) return false
return Object.values(value).every(isOptionsItemData)
}
function isUsageUpgradeTagData(value: unknown): value is UsageUpgradeTagData {
if (!isRecord(value)) return false
return (
typeof value.reason === 'string' &&
typeof value.message === 'string' &&
typeof value.action === 'string' &&
(USAGE_UPGRADE_ACTIONS as readonly string[]).includes(value.action)
)
}
function isCredentialTagData(value: unknown): value is CredentialTagData {
if (!isRecord(value)) return false
return (
typeof value.value === 'string' &&
typeof value.type === 'string' &&
(CREDENTIAL_TAG_TYPES as readonly string[]).includes(value.type) &&
(value.provider === undefined || typeof value.provider === 'string')
)
}
function isMothershipErrorTagData(value: unknown): value is MothershipErrorTagData {
if (!isRecord(value)) return false
return (
typeof value.message === 'string' &&
(value.code === undefined || typeof value.code === 'string') &&
(value.provider === undefined || typeof value.provider === 'string')
)
}
export function parseJsonTagBody<T>(
body: string,
isExpectedShape: (value: unknown) => value is T
): T | null {
try {
const parsed = JSON.parse(body) as unknown
return isExpectedShape(parsed) ? parsed : null
} catch {
return null
}
}
export function parseTextTagBody(body: string): string | null {
return body.trim() ? body : null
}
export function parseTagAttributes(openTag: string): Record<string, string> {
const attributes: Record<string, string> = {}
const attributePattern = /([A-Za-z_:][A-Za-z0-9_:-]*)="([^"]*)"/g
let match: RegExpExecArray | null = null
while ((match = attributePattern.exec(openTag)) !== null) {
attributes[match[1]] = match[2]
}
return attributes
}
export function parseFileTag(openTag: string, body: string): FileTagData | null {
const attributes = parseTagAttributes(openTag)
if (!attributes.name || !attributes.type) return null
return {
name: attributes.name,
type: attributes.type,
content: body,
}
}
function parseSpecialTagData(
tagName: (typeof SPECIAL_TAG_NAMES)[number],
body: string
):
| { type: 'thinking'; content: string }
| { type: 'options'; data: OptionsTagData }
| { type: 'usage_upgrade'; data: UsageUpgradeTagData }
| { type: 'credential'; data: CredentialTagData }
| { type: 'mothership-error'; data: MothershipErrorTagData }
| null {
if (tagName === 'thinking') {
const content = parseTextTagBody(body)
return content ? { type: 'thinking', content } : null
}
if (tagName === 'options') {
const data = parseJsonTagBody(body, isOptionsTagData)
return data ? { type: 'options', data } : null
}
if (tagName === 'usage_upgrade') {
const data = parseJsonTagBody(body, isUsageUpgradeTagData)
return data ? { type: 'usage_upgrade', data } : null
}
if (tagName === 'credential') {
const data = parseJsonTagBody(body, isCredentialTagData)
return data ? { type: 'credential', data } : null
}
if (tagName === 'mothership-error') {
const data = parseJsonTagBody(body, isMothershipErrorTagData)
return data ? { type: 'mothership-error', data } : null
}
return null
}
/**
* Parses inline special tags (`<options>`, `<usage_upgrade>`) from streamed
@@ -55,7 +226,7 @@ export function parseSpecialTags(content: string, isStreaming: boolean): ParsedS
while (cursor < content.length) {
let nearestStart = -1
let nearestTagName = ''
let nearestTagName: (typeof SPECIAL_TAG_NAMES)[number] | '' = ''
for (const name of SPECIAL_TAG_NAMES) {
const idx = content.indexOf(`<${name}>`, cursor)
@@ -69,10 +240,13 @@ export function parseSpecialTags(content: string, isStreaming: boolean): ParsedS
let remaining = content.slice(cursor)
if (isStreaming) {
const partial = remaining.match(/<[a-z_]*$/i)
const partial = remaining.match(/<[a-z_-]*$/i)
if (partial) {
const fragment = partial[0].slice(1)
if (fragment.length > 0 && SPECIAL_TAG_NAMES.some((t) => t.startsWith(fragment))) {
if (
fragment.length > 0 &&
[...SPECIAL_TAG_NAMES, ...RUNTIME_SPECIAL_TAG_NAMES].some((t) => t.startsWith(fragment))
) {
remaining = remaining.slice(0, -partial[0].length)
hasPendingTag = true
}
@@ -104,17 +278,13 @@ export function parseSpecialTags(content: string, isStreaming: boolean): ParsedS
}
const body = content.slice(bodyStart, closeIdx)
if (nearestTagName === 'thinking') {
if (body.trim()) {
segments.push({ type: 'thinking', content: body })
}
} else {
try {
const data = JSON.parse(body)
segments.push({ type: nearestTagName as 'options' | 'usage_upgrade' | 'credential', data })
} catch {
/* malformed JSON — drop the tag silently */
}
if (!nearestTagName) {
cursor = closeIdx + closeTag.length
continue
}
const parsedTag = parseSpecialTagData(nearestTagName, body)
if (parsedTag) {
segments.push(parsedTag)
}
cursor = closeIdx + closeTag.length
@@ -152,6 +322,8 @@ export function SpecialTags({ segment, onOptionSelect }: SpecialTagsProps) {
return <UsageUpgradeDisplay data={segment.data} />
case 'credential':
return <CredentialDisplay data={segment.data} />
case 'mothership-error':
return <MothershipErrorDisplay data={segment.data} />
default:
return null
}
@@ -193,7 +365,7 @@ function OptionsDisplay({ data, onSelect }: OptionsDisplayProps) {
<span className='font-base text-[14px] text-[var(--text-body)]'>Suggested follow-ups</span>
<div className='mt-1.5 flex flex-col'>
{entries.map(([key, value], i) => {
const title = typeof value === 'string' ? value : value.title
const title = value.title
return (
<button
@@ -276,6 +448,37 @@ function CredentialDisplay({ data }: { data: CredentialTagData }) {
)
}
function MothershipErrorDisplay({ data }: { data: MothershipErrorTagData }) {
return (
<div className='animate-stream-fade-in rounded-xl border border-red-300/40 bg-red-50/50 px-4 py-3 dark:border-red-500/20 dark:bg-red-950/20'>
<div className='flex items-center gap-2'>
<svg
className='h-4 w-4 shrink-0 text-red-600 dark:text-red-400'
viewBox='0 0 16 16'
fill='none'
xmlns='http://www.w3.org/2000/svg'
>
<circle cx='8' cy='8' r='6.5' stroke='currentColor' strokeWidth='1.3' />
<path d='M8 4.5v4' stroke='currentColor' strokeWidth='1.3' strokeLinecap='round' />
<circle cx='8' cy='11' r='0.75' fill='currentColor' />
</svg>
<span className='font-[500] text-[14px] text-red-800 leading-5 dark:text-red-300'>
Something went wrong
</span>
</div>
<p className='mt-1.5 text-[13px] text-red-700/90 leading-[20px] dark:text-red-400/80'>
{data.message}
</p>
{data.code && (
<span className='mt-1 inline-block text-[11px] text-red-500/70 dark:text-red-500/50'>
{data.provider ? `${data.provider}:` : ''}
{data.code}
</span>
)}
</div>
)
}
function UsageUpgradeDisplay({ data }: { data: UsageUpgradeTagData }) {
const { workspaceId } = useParams<{ workspaceId: string }>()
const settingsPath = `/workspace/${workspaceId}/settings/subscription`

View File

@@ -1 +1,4 @@
export { MessageContent } from './message-content'
export {
assistantMessageHasRenderableContent,
MessageContent,
} from './message-content'

View File

@@ -31,6 +31,16 @@ type MessageSegment = TextSegment | AgentGroupSegment | OptionsSegment | Stopped
const SUBAGENT_KEYS = new Set(Object.keys(SUBAGENT_LABELS))
/**
* Maps subagent names to the Mothership tool that dispatches them when the
* tool name differs from the subagent name (e.g. `workspace_file` → `file_write`).
* When a `subagent` block arrives, any trailing dispatch tool in the previous
* group is absorbed so it doesn't render as a separate Mothership entry.
*/
const SUBAGENT_DISPATCH_TOOLS: Record<string, string> = {
file_write: 'workspace_file',
}
function formatToolName(name: string): string {
return name
.replace(/_v\d+$/, '')
@@ -53,6 +63,7 @@ function toToolData(tc: NonNullable<ContentBlock['toolCall']>): ToolCallData {
formatToolName(tc.name),
status: tc.status,
result: tc.result,
streamingArgs: tc.streamingArgs,
}
}
@@ -108,10 +119,22 @@ function parseBlocks(blocks: ContentBlock[]): MessageSegment[] {
if (!block.content) continue
const key = block.content
if (group && group.agentName === key) continue
if (group) {
const dispatchToolName = SUBAGENT_DISPATCH_TOOLS[key]
if (group && dispatchToolName) {
const last = group.items[group.items.length - 1]
if (last?.type === 'tool' && last.data.toolName === dispatchToolName) {
group.items.pop()
}
if (group.items.length > 0) {
segments.push(group)
}
group = null
} else if (group) {
segments.push(group)
group = null
}
group = {
type: 'agent_group',
id: `agent-${key}-${i}`,
@@ -211,6 +234,26 @@ function parseBlocks(blocks: ContentBlock[]): MessageSegment[] {
return segments
}
/**
* Mirrors the segment resolution inside {@link MessageContent} so list renderers
* can tell whether an assistant message has anything visible yet. Avoids treating
* `contentBlocks: [{ type: 'text', content: '' }]` as "has content" — that briefly
* made MessageContent return null while streaming and caused a double Thinking flash.
*/
export function assistantMessageHasRenderableContent(
blocks: ContentBlock[],
fallbackContent: string
): boolean {
const parsed = blocks.length > 0 ? parseBlocks(blocks) : []
const segments: MessageSegment[] =
parsed.length > 0
? parsed
: fallbackContent.trim()
? [{ type: 'text' as const, content: fallbackContent }]
: []
return segments.length > 0
}
interface MessageContentProps {
blocks: ContentBlock[]
fallbackContent: string
@@ -233,7 +276,16 @@ export function MessageContent({
? [{ type: 'text' as const, content: fallbackContent }]
: []
if (segments.length === 0) return null
if (segments.length === 0) {
if (isStreaming) {
return (
<div className='space-y-[10px]'>
<PendingTagIndicator />
</div>
)
}
return null
}
const lastSegment = segments[segments.length - 1]
const hasTrailingContent = lastSegment.type === 'text' || lastSegment.type === 'stopped'

View File

@@ -60,7 +60,9 @@ const TOOL_ICONS: Record<MothershipToolName | SubagentName | 'mothership', IconC
debug: Bug,
edit: Pencil,
fast_edit: Pencil,
context_compaction: Asterisk,
open_resource: Eye,
file_write: File,
}
export function getAgentIcon(name: string): IconComponent {

View File

@@ -21,7 +21,10 @@ import {
} from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tab-controls'
import type { MothershipResource } from '@/app/workspace/[workspaceId]/home/types'
import { KnowledgeBase } from '@/app/workspace/[workspaceId]/knowledge/[id]/base'
import { useWorkspacePermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import {
useUserPermissionsContext,
useWorkspacePermissionsContext,
} from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { Table } from '@/app/workspace/[workspaceId]/tables/[tableId]/components'
import { useUsageLimits } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/hooks'
import { useWorkflowExecution } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution'
@@ -44,6 +47,7 @@ interface ResourceContentProps {
workspaceId: string
resource: MothershipResource
previewMode?: PreviewMode
streamingFile?: { fileName: string; content: string } | null
}
/**
@@ -51,11 +55,48 @@ interface ResourceContentProps {
* Handles table, file, and workflow resource types with appropriate
* embedded rendering for each.
*/
const STREAMING_EPOCH = new Date(0)
export const ResourceContent = memo(function ResourceContent({
workspaceId,
resource,
previewMode,
streamingFile,
}: ResourceContentProps) {
const streamFileName = streamingFile?.fileName || 'file.md'
const streamingExtractedContent = useMemo(
() => (streamingFile ? extractFileContent(streamingFile.content) : ''),
[streamingFile]
)
const syntheticFile = useMemo(
() => ({
id: 'streaming-file',
workspaceId,
name: streamFileName,
key: '',
path: '',
size: 0,
type: 'text/plain',
uploadedBy: '',
uploadedAt: STREAMING_EPOCH,
}),
[workspaceId, streamFileName]
)
if (streamingFile && resource.id === 'streaming-file') {
return (
<div className='flex h-full flex-col overflow-hidden'>
<FileViewer
file={syntheticFile}
workspaceId={workspaceId}
canEdit={false}
previewMode={previewMode ?? 'preview'}
streamingContent={streamingExtractedContent}
/>
</div>
)
}
switch (resource.type) {
case 'table':
return <Table key={resource.id} workspaceId={workspaceId} tableId={resource.id} embedded />
@@ -67,6 +108,7 @@ export const ResourceContent = memo(function ResourceContent({
workspaceId={workspaceId}
fileId={resource.id}
previewMode={previewMode}
streamingContent={streamingFile ? extractFileContent(streamingFile.content) : undefined}
/>
)
@@ -260,7 +302,7 @@ function EmbeddedFileActions({ workspaceId, fileId }: EmbeddedFileActionsProps)
}, [file])
const handleOpenInFiles = useCallback(() => {
router.push(`/workspace/${workspaceId}/files?fileId=${encodeURIComponent(fileId)}`)
router.push(`/workspace/${workspaceId}/files/${encodeURIComponent(fileId)}`)
}, [router, workspaceId, fileId])
return (
@@ -341,9 +383,11 @@ interface EmbeddedFileProps {
workspaceId: string
fileId: string
previewMode?: PreviewMode
streamingContent?: string
}
function EmbeddedFile({ workspaceId, fileId, previewMode }: EmbeddedFileProps) {
function EmbeddedFile({ workspaceId, fileId, previewMode, streamingContent }: EmbeddedFileProps) {
const { canEdit } = useUserPermissionsContext()
const { data: files = [], isLoading, isFetching } = useWorkspaceFiles(workspaceId)
const file = useMemo(() => files.find((f) => f.id === fileId), [files, fileId])
@@ -369,9 +413,23 @@ function EmbeddedFile({ workspaceId, fileId, previewMode }: EmbeddedFileProps) {
key={file.id}
file={file}
workspaceId={workspaceId}
canEdit={true}
canEdit={canEdit}
previewMode={previewMode}
streamingContent={streamingContent}
/>
</div>
)
}
function extractFileContent(raw: string): string {
const marker = '"content":'
const idx = raw.indexOf(marker)
if (idx === -1) return ''
let rest = raw.slice(idx + marker.length).trimStart()
if (rest.startsWith('"')) rest = rest.slice(1)
return rest
.replace(/\\n/g, '\n')
.replace(/\\t/g, '\t')
.replace(/\\"/g, '"')
.replace(/\\\\/g, '\\')
}

View File

@@ -9,6 +9,7 @@ import type {
MothershipResource,
MothershipResourceType,
} from '@/app/workspace/[workspaceId]/home/types'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { ResourceActions, ResourceContent, ResourceTabs } from './components'
const PREVIEW_CYCLE: Record<PreviewMode, PreviewMode> = {
@@ -17,6 +18,40 @@ const PREVIEW_CYCLE: Record<PreviewMode, PreviewMode> = {
preview: 'editor',
} as const
function streamFileBasename(name: string): string {
const n = name.replace(/\\/g, '/').trim()
const parts = n.split('/').filter(Boolean)
return parts.length ? parts[parts.length - 1]! : n
}
function fileTitlesEquivalent(streamFileName: string, resourceTitle: string): boolean {
return streamFileBasename(streamFileName) === streamFileBasename(resourceTitle)
}
/**
* Whether the active resource should show the in-progress file_write stream.
* The synthetic `streaming-file` tab always shows it; a real file tab shows it when
* the streamed `fileName` matches that resource (so users who stay on the open file see live text).
*/
function streamReferencesFileId(raw: string, fileId: string): boolean {
if (!fileId) return false
const escaped = fileId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
return new RegExp(`"fileId"\\s*:\\s*"${escaped}"`).test(raw)
}
function shouldShowStreamingFilePanel(
streamingFile: { fileName: string; content: string } | null | undefined,
active: MothershipResource | null
): boolean {
if (!streamingFile || !active) return false
if (active.id === 'streaming-file') return true
if (active.type !== 'file') return false
const fn = streamingFile.fileName.trim()
if (fn && fileTitlesEquivalent(fn, active.title)) return true
if (active.id && streamReferencesFileId(streamingFile.content, active.id)) return true
return false
}
interface MothershipViewProps {
workspaceId: string
chatId?: string
@@ -29,6 +64,7 @@ interface MothershipViewProps {
onCollapse: () => void
isCollapsed: boolean
className?: string
streamingFile?: { fileName: string; content: string } | null
}
export const MothershipView = memo(
@@ -45,10 +81,17 @@ export const MothershipView = memo(
onCollapse,
isCollapsed,
className,
streamingFile,
}: MothershipViewProps,
ref
) {
const active = resources.find((r) => r.id === activeResourceId) ?? resources[0] ?? null
const { canEdit } = useUserPermissionsContext()
const streamingForActive =
streamingFile && active && shouldShowStreamingFilePanel(streamingFile, active)
? streamingFile
: undefined
const [previewMode, setPreviewMode] = useState<PreviewMode>('preview')
const [prevActiveId, setPrevActiveId] = useState<string | null | undefined>(active?.id)
@@ -61,7 +104,9 @@ export const MothershipView = memo(
}
const isActivePreviewable =
active?.type === 'file' && RICH_PREVIEWABLE_EXTENSIONS.has(getFileExtension(active.title))
canEdit &&
active?.type === 'file' &&
RICH_PREVIEWABLE_EXTENSIONS.has(getFileExtension(active.title))
return (
<div
@@ -95,6 +140,7 @@ export const MothershipView = memo(
workspaceId={workspaceId}
resource={active}
previewMode={isActivePreviewable ? previewMode : undefined}
streamingFile={streamingForActive}
/>
) : (
<div className='flex h-full items-center justify-center text-[14px] text-[var(--text-muted)]'>

View File

@@ -4,7 +4,6 @@ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react
import { createLogger } from '@sim/logger'
import { useParams, useRouter } from 'next/navigation'
import { PanelLeft } from '@/components/emcn/icons'
import { getDocumentIcon } from '@/components/icons/document-icons'
import { useSession } from '@/lib/auth/auth-client'
import {
LandingPromptStorage,
@@ -16,6 +15,8 @@ import { MessageActions } from '@/app/workspace/[workspaceId]/components'
import { useChatHistory, useMarkTaskRead } from '@/hooks/queries/tasks'
import type { ChatContext } from '@/stores/panel'
import {
assistantMessageHasRenderableContent,
ChatMessageAttachments,
MessageContent,
MothershipView,
QueuedMessages,
@@ -29,21 +30,6 @@ import type { FileAttachmentForApi, MothershipResource, MothershipResourceType }
const logger = createLogger('Home')
interface FileAttachmentPillProps {
mediaType: string
filename: string
}
function FileAttachmentPill({ mediaType, filename }: FileAttachmentPillProps) {
const Icon = getDocumentIcon(mediaType, filename)
return (
<div className='flex max-w-[140px] items-center gap-[5px] rounded-[10px] bg-[var(--surface-5)] px-[6px] py-[3px]'>
<Icon className='h-[14px] w-[14px] flex-shrink-0 text-[var(--text-icon)]' />
<span className='truncate text-[11px] text-[var(--text-body)]'>{filename}</span>
</div>
)
}
interface HomeProps {
chatId?: string
}
@@ -186,6 +172,7 @@ export function Home({ chatId }: HomeProps = {}) {
removeFromQueue,
sendNow,
editQueuedMessage,
streamingFile,
} = useChat(workspaceId, chatId, { onResourceEvent: handleResourceEvent })
const [editingInputValue, setEditingInputValue] = useState('')
@@ -374,29 +361,11 @@ export function Home({ chatId }: HomeProps = {}) {
return (
<div key={msg.id} className='flex flex-col items-end gap-[6px] pt-3'>
{hasAttachments && (
<div className='flex max-w-[70%] flex-wrap justify-end gap-[6px]'>
{msg.attachments!.map((att) => {
const isImage = att.media_type.startsWith('image/')
return isImage && att.previewUrl ? (
<div
key={att.id}
className='h-[56px] w-[56px] overflow-hidden rounded-[8px]'
>
<img
src={att.previewUrl}
alt={att.filename}
className='h-full w-full object-cover'
/>
</div>
) : (
<FileAttachmentPill
key={att.id}
mediaType={att.media_type}
filename={att.filename}
/>
)
})}
</div>
<ChatMessageAttachments
attachments={msg.attachments!}
align='end'
className='max-w-[70%]'
/>
)}
<div className='max-w-[70%] overflow-hidden rounded-[16px] bg-[var(--surface-5)] px-3.5 py-2'>
<UserMessageContent content={msg.content} contexts={msg.contexts} />
@@ -405,15 +374,21 @@ export function Home({ chatId }: HomeProps = {}) {
)
}
const hasBlocks = msg.contentBlocks && msg.contentBlocks.length > 0
const hasAnyBlocks = Boolean(msg.contentBlocks?.length)
const hasRenderableAssistant = assistantMessageHasRenderableContent(
msg.contentBlocks ?? [],
msg.content ?? ''
)
const isLastAssistant = msg.role === 'assistant' && index === messages.length - 1
const isThisStreaming = isSending && isLastAssistant
if (!hasBlocks && !msg.content && isThisStreaming) {
if (!hasAnyBlocks && !msg.content?.trim() && isThisStreaming) {
return <PendingTagIndicator key={msg.id} />
}
if (!hasBlocks && !msg.content) return null
if (!hasRenderableAssistant && !msg.content?.trim() && !isThisStreaming) {
return null
}
const isLastMessage = index === messages.length - 1
@@ -486,6 +461,7 @@ export function Home({ chatId }: HomeProps = {}) {
onReorderResources={reorderResources}
onCollapse={collapseResource}
isCollapsed={isResourceCollapsed}
streamingFile={streamingFile}
className={
isResourceAnimatingIn
? 'animate-slide-in-right'

File diff suppressed because it is too large Load Diff

View File

@@ -74,6 +74,10 @@ export function useStreamingReveal(content: string, isStreaming: boolean): Strea
}
}, [content, isStreaming])
if (!isStreaming) {
return { committed: content, incoming: '', generation }
}
if (committedEnd > 0 && committedEnd < content.length) {
return {
committed: content.slice(0, committedEnd),
@@ -82,5 +86,12 @@ export function useStreamingReveal(content: string, isStreaming: boolean): Strea
}
}
// No paragraph split yet: keep the growing markdown in `incoming` only so ReactMarkdown
// re-parses one tail block (same as the paragraph-tail path). Putting everything in
// `committed` would re-render the full document every tick and makes tables jump.
if (committedEnd === 0 && content.length > 0) {
return { committed: '', incoming: content, generation }
}
return { committed: content, incoming: '', generation }
}

View File

@@ -49,6 +49,8 @@ export type SSEEventType =
| 'structured_result' // structured result from a tool call
| 'subagent_result' // result from a subagent
| 'done' // end of the chat
| 'context_compaction_start' // context compaction started
| 'context_compaction' // conversation context was compacted
| 'error' // error in the chat
| 'start' // start of the chat
@@ -94,6 +96,7 @@ export type MothershipToolName =
| 'edit'
| 'fast_edit'
| 'open_resource'
| 'context_compaction'
/**
* Subagent identifiers dispatched via `subagent_start` SSE events.
@@ -119,6 +122,7 @@ export type SubagentName =
| 'run'
| 'agent'
| 'job'
| 'file_write'
export type ToolPhase =
| 'workspace'
@@ -142,6 +146,7 @@ export interface ToolCallData {
displayTitle: string
status: ToolCallStatus
result?: ToolCallResult
streamingArgs?: string
}
export interface ToolCallInfo {
@@ -152,6 +157,7 @@ export interface ToolCallInfo {
phaseLabel?: string
calledBy?: string
result?: { success: boolean; output?: unknown; error?: string }
streamingArgs?: string
}
export interface OptionItem {
@@ -219,6 +225,7 @@ export const SUBAGENT_LABELS: Record<SubagentName, string> = {
run: 'Run agent',
agent: 'Agent manager',
job: 'Job agent',
file_write: 'File Write',
} as const
export interface ToolUIMetadata {
@@ -349,6 +356,11 @@ export const TOOL_UI_METADATA: Partial<Record<MothershipToolName, ToolUIMetadata
phaseLabel: 'Resource',
phase: 'resource',
},
context_compaction: {
title: 'Compacted context',
phaseLabel: 'Context',
phase: 'management',
},
}
export interface SSEPayloadUI {

View File

@@ -208,6 +208,9 @@ export function Table({
})
const userPermissions = useUserPermissionsContext()
const canEditRef = useRef(userPermissions.canEdit)
canEditRef.current = userPermissions.canEdit
const {
contextMenu,
handleRowContextMenu: baseHandleRowContextMenu,
@@ -633,6 +636,7 @@ export function Table({
const handleCellClick = useCallback((rowId: string, columnName: string) => {
const column = columnsRef.current.find((c) => c.name === columnName)
if (column?.type === 'boolean') {
if (!canEditRef.current) return
const row = rowsRef.current.find((r) => r.id === rowId)
if (row) {
toggleBooleanCell(rowId, columnName, row.data[columnName])
@@ -647,6 +651,7 @@ export function Table({
}, [])
const handleCellDoubleClick = useCallback((rowId: string, columnName: string) => {
if (!canEditRef.current) return
const column = columnsRef.current.find((c) => c.name === columnName)
if (!column || column.type === 'boolean') return
@@ -739,6 +744,7 @@ export function Table({
if ((e.key === 'Delete' || e.key === 'Backspace') && checkedRowsRef.current.size > 0) {
if (editingCellRef.current) return
if (!canEditRef.current) return
e.preventDefault()
const checked = checkedRowsRef.current
const pMap = positionMapRef.current
@@ -770,6 +776,7 @@ export function Table({
const totalRows = mp + 1
if (e.shiftKey && e.key === 'Enter') {
if (!canEditRef.current) return
const row = positionMapRef.current.get(anchor.rowIndex)
if (!row) return
e.preventDefault()
@@ -792,6 +799,7 @@ export function Table({
}
if (e.key === 'Enter' || e.key === 'F2') {
if (!canEditRef.current) return
e.preventDefault()
const col = cols[anchor.colIndex]
if (!col) return
@@ -809,6 +817,7 @@ export function Table({
}
if (e.key === ' ' && !e.shiftKey) {
if (!canEditRef.current) return
e.preventDefault()
const row = positionMapRef.current.get(anchor.rowIndex)
if (row) {
@@ -861,6 +870,7 @@ export function Table({
}
if (e.key === 'Delete' || e.key === 'Backspace') {
if (!canEditRef.current) return
e.preventDefault()
const sel = computeNormalizedSelection(anchor, selectionFocusRef.current)
if (!sel) return
@@ -888,6 +898,7 @@ export function Table({
}
if (e.key.length === 1 && !e.metaKey && !e.ctrlKey && !e.altKey) {
if (!canEditRef.current) return
const col = cols[anchor.colIndex]
if (!col || col.type === 'boolean') return
if (col.type === 'number' && !/[\d.-]/.test(e.key)) return
@@ -957,6 +968,7 @@ export function Table({
const tag = (e.target as HTMLElement).tagName
if (tag === 'INPUT' || tag === 'TEXTAREA') return
if (editingCellRef.current) return
if (!canEditRef.current) return
const checked = checkedRowsRef.current
const cols = columnsRef.current
@@ -1029,6 +1041,7 @@ export function Table({
const handlePaste = (e: ClipboardEvent) => {
const tag = (e.target as HTMLElement).tagName
if (tag === 'INPUT' || tag === 'TEXTAREA') return
if (!canEditRef.current) return
const currentAnchor = selectionAnchorRef.current
if (!currentAnchor || editingCellRef.current) return
@@ -1523,6 +1536,7 @@ export function Table({
<ColumnHeaderMenu
key={column.name}
column={column}
readOnly={!userPermissions.canEdit}
isRenaming={columnRename.editingId === column.name}
renameValue={
columnRename.editingId === column.name ? columnRename.editValue : ''
@@ -1541,10 +1555,12 @@ export function Table({
onResizeEnd={handleColumnResizeEnd}
/>
))}
<AddColumnButton
onClick={handleAddColumn}
disabled={addColumnMutation.isPending}
/>
{userPermissions.canEdit && (
<AddColumnButton
onClick={handleAddColumn}
disabled={addColumnMutation.isPending}
/>
)}
</tr>
)}
</thead>
@@ -2414,6 +2430,7 @@ const COLUMN_TYPE_OPTIONS: { type: string; label: string; icon: React.ElementTyp
const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({
column,
readOnly,
isRenaming,
renameValue,
onRenameValueChange,
@@ -2430,6 +2447,7 @@ const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({
onResizeEnd,
}: {
column: ColumnDefinition
readOnly?: boolean
isRenaming: boolean
renameValue: string
onRenameValueChange: (value: string) => void
@@ -2503,6 +2521,13 @@ const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({
className='ml-[6px] min-w-0 flex-1 border-0 bg-transparent p-0 font-medium text-[13px] text-[var(--text-primary)] outline-none focus:outline-none focus:ring-0'
/>
</div>
) : readOnly ? (
<div className='flex h-full w-full min-w-0 items-center px-[8px] py-[7px]'>
<ColumnTypeIcon type={column.type} />
<span className='ml-[6px] min-w-0 truncate font-medium text-[13px] text-[var(--text-primary)]'>
{column.name}
</span>
</div>
) : (
<DropdownMenu>
<DropdownMenuTrigger asChild>

View File

@@ -34,6 +34,7 @@ import { CHAT_ACCEPT_ATTRIBUTE } from '@/lib/uploads/utils/validation'
import { normalizeInputFormatValue } from '@/lib/workflows/input-format'
import { StartBlockPath, TriggerUtils } from '@/lib/workflows/triggers/triggers'
import { START_BLOCK_RESERVED_FIELDS } from '@/lib/workflows/types'
import type { ChatMessageAttachment } from '@/app/workspace/[workspaceId]/home/types'
import {
ChatMessage,
OutputSelect,
@@ -84,17 +85,6 @@ interface ChatFile {
file: File
}
/**
* Represents a processed file attachment with data URL for display
*/
interface ProcessedAttachment {
id: string
name: string
type: string
size: number
dataUrl: string
}
/** Timeout for FileReader operations in milliseconds */
const FILE_READ_TIMEOUT_MS = 60000
@@ -103,13 +93,13 @@ const FILE_READ_TIMEOUT_MS = 60000
* @param chatFiles - Array of chat files to process
* @returns Promise resolving to array of files with data URLs for images
*/
const processFileAttachments = async (chatFiles: ChatFile[]): Promise<ProcessedAttachment[]> => {
const processFileAttachments = async (chatFiles: ChatFile[]): Promise<ChatMessageAttachment[]> => {
return Promise.all(
chatFiles.map(async (file) => {
let dataUrl = ''
let previewUrl: string | undefined
if (file.type.startsWith('image/')) {
try {
dataUrl = await new Promise<string>((resolve, reject) => {
previewUrl = await new Promise<string>((resolve, reject) => {
const reader = new FileReader()
let settled = false
@@ -150,10 +140,10 @@ const processFileAttachments = async (chatFiles: ChatFile[]): Promise<ProcessedA
}
return {
id: file.id,
name: file.name,
type: file.type,
filename: file.name,
media_type: file.type,
size: file.size,
dataUrl,
previewUrl,
}
})
)

View File

@@ -1,16 +1,8 @@
import { useMemo } from 'react'
import { FileText } from 'lucide-react'
import { StreamingIndicator } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/smooth-streaming'
import { ChatMessageAttachments } from '@/app/workspace/[workspaceId]/home/components'
import type { ChatMessageAttachment } from '@/app/workspace/[workspaceId]/home/types'
import { useThrottledValue } from '@/hooks/use-throttled-value'
interface ChatAttachment {
id: string
name: string
type: string
dataUrl: string
size?: number
}
interface ChatMessageProps {
message: {
id: string
@@ -18,45 +10,14 @@ interface ChatMessageProps {
timestamp: string | Date
type: 'user' | 'workflow'
isStreaming?: boolean
attachments?: ChatAttachment[]
attachments?: ChatMessageAttachment[]
}
}
const MAX_WORD_LENGTH = 25
/**
* Formats file size in human-readable format
*/
const formatFileSize = (bytes?: number): string => {
if (!bytes || bytes === 0) return ''
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(1024))
return `${Math.round((bytes / 1024 ** i) * 10) / 10} ${sizes[i]}`
}
/**
* Opens image attachment in new window
*/
const openImageInNewWindow = (dataUrl: string, fileName: string) => {
const newWindow = window.open('', '_blank')
if (!newWindow) return
newWindow.document.write(`
<!DOCTYPE html>
<html>
<head>
<title>${fileName}</title>
<style>
body { margin: 0; display: flex; justify-content: center; align-items: center; min-height: 100vh; background: #000; }
img { max-width: 100%; max-height: 100vh; object-fit: contain; }
</style>
</head>
<body>
<img src="${dataUrl}" alt="${fileName}" />
</body>
</html>
`)
newWindow.document.close()
function StreamingIndicator() {
return <span className='inline-block h-[14px] w-[6px] animate-pulse bg-current opacity-70' />
}
/**
@@ -105,54 +66,16 @@ export function ChatMessage({ message }: ChatMessageProps) {
const throttled = useThrottledValue(rawContent)
const formattedContent = message.type === 'user' ? rawContent : throttled
const handleAttachmentClick = (attachment: ChatAttachment) => {
const validDataUrl = attachment.dataUrl?.trim()
if (validDataUrl?.startsWith('data:')) {
openImageInNewWindow(validDataUrl, attachment.name)
}
}
if (message.type === 'user') {
const hasAttachments = message.attachments && message.attachments.length > 0
return (
<div className='w-full max-w-full overflow-hidden opacity-100 transition-opacity duration-200'>
{hasAttachments && (
<div className='mb-[4px] flex flex-wrap gap-[4px]'>
{message.attachments!.map((attachment) => {
const hasValidDataUrl =
attachment.dataUrl?.trim() && attachment.dataUrl.startsWith('data:')
const canDisplayAsImage = attachment.type.startsWith('image/') && hasValidDataUrl
return (
<div
key={attachment.id}
className={`flex max-w-[150px] items-center gap-[5px] rounded-[6px] bg-[var(--surface-2)] px-[5px] py-[3px] ${
hasValidDataUrl ? 'cursor-pointer' : ''
}`}
onClick={(e) => {
if (hasValidDataUrl) {
e.preventDefault()
e.stopPropagation()
handleAttachmentClick(attachment)
}
}}
>
{canDisplayAsImage ? (
<img
src={attachment.dataUrl}
alt={attachment.name}
className='h-[20px] w-[20px] flex-shrink-0 rounded-[3px] object-cover'
/>
) : (
<FileText className='h-[12px] w-[12px] flex-shrink-0 text-[var(--text-tertiary)]' />
)}
<span className='truncate text-[10px] text-[var(--text-secondary)]'>
{attachment.name}
</span>
</div>
)
})}
</div>
<ChatMessageAttachments
attachments={message.attachments!}
align='start'
className='mb-[4px]'
/>
)}
{formattedContent && !formattedContent.startsWith('Uploaded') && (

View File

@@ -4,7 +4,6 @@ import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/provide
import { createCommand } from '@/app/workspace/[workspaceId]/utils/commands-utils'
import { usePreventZoom } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
import { useNotificationStore } from '@/stores/notifications'
import { useCopilotStore } from '@/stores/panel'
import { useWorkflowDiffStore } from '@/stores/workflow-diff'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
@@ -25,15 +24,6 @@ export const DiffControls = memo(function DiffControls() {
)
)
const { updatePreviewToolCallState } = useCopilotStore(
useCallback(
(state) => ({
updatePreviewToolCallState: state.updatePreviewToolCallState,
}),
[]
)
)
const { activeWorkflowId } = useWorkflowRegistry(
useCallback((state) => ({ activeWorkflowId: state.activeWorkflowId }), [])
)
@@ -46,81 +36,21 @@ export const DiffControls = memo(function DiffControls() {
const handleAccept = useCallback(() => {
logger.info('Accepting proposed changes with backup protection')
// Resolve target toolCallId for build/edit and update to terminal success state in the copilot store
// This happens synchronously first for instant UI feedback
try {
const { toolCallsById, messages } = useCopilotStore.getState()
let id: string | undefined
outer: for (let mi = messages.length - 1; mi >= 0; mi--) {
const m = messages[mi]
if (m.role !== 'assistant' || !m.contentBlocks) continue
const blocks = m.contentBlocks as any[]
for (let bi = blocks.length - 1; bi >= 0; bi--) {
const b = blocks[bi]
if (b?.type === 'tool_call') {
const tn = b.toolCall?.name
if (tn === 'edit_workflow') {
id = b.toolCall?.id
break outer
}
}
}
}
if (!id) {
const candidates = Object.values(toolCallsById).filter((t) => t.name === 'edit_workflow')
id = candidates.length ? candidates[candidates.length - 1].id : undefined
}
if (id) updatePreviewToolCallState('accepted', id)
} catch {}
// Accept changes without blocking the UI; errors will be logged by the store handler
acceptChanges().catch((error) => {
logger.error('Failed to accept changes (background):', error)
})
// Create checkpoint in the background (fire-and-forget) so it doesn't block UI
logger.info('Accept triggered; UI will update optimistically')
}, [updatePreviewToolCallState, acceptChanges])
}, [acceptChanges])
const handleReject = useCallback(() => {
logger.info('Rejecting proposed changes (optimistic)')
// Resolve target toolCallId for build/edit and update to terminal rejected state in the copilot store
try {
const { toolCallsById, messages } = useCopilotStore.getState()
let id: string | undefined
outer: for (let mi = messages.length - 1; mi >= 0; mi--) {
const m = messages[mi]
if (m.role !== 'assistant' || !m.contentBlocks) continue
const blocks = m.contentBlocks as any[]
for (let bi = blocks.length - 1; bi >= 0; bi--) {
const b = blocks[bi]
if (b?.type === 'tool_call') {
const tn = b.toolCall?.name
if (tn === 'edit_workflow') {
id = b.toolCall?.id
break outer
}
}
}
}
if (!id) {
const candidates = Object.values(toolCallsById).filter((t) => t.name === 'edit_workflow')
id = candidates.length ? candidates[candidates.length - 1].id : undefined
}
if (id) updatePreviewToolCallState('rejected', id)
} catch {}
// Reject changes optimistically
rejectChanges().catch((error) => {
logger.error('Failed to reject changes (background):', error)
})
}, [updatePreviewToolCallState, rejectChanges])
}, [rejectChanges])
const preventZoomRef = usePreventZoom()
// Register global command to accept changes (Cmd/Ctrl + Shift + Enter)
const acceptCommand = useMemo(
() =>
createCommand({
@@ -135,7 +65,6 @@ export const DiffControls = memo(function DiffControls() {
)
useRegisterGlobalCommands([acceptCommand])
// Don't show anything if no diff is available or diff is not ready
if (!hasActiveDiff || !isDiffReady) {
return null
}

View File

@@ -1,14 +1,13 @@
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { X } from 'lucide-react'
import { Button, Tooltip } from '@/components/emcn'
import { Button, CountdownRing, Tooltip } from '@/components/emcn'
import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider'
import { createCommands } from '@/app/workspace/[workspaceId]/utils/commands-utils'
import { usePreventZoom } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
import {
type Notification,
type NotificationAction,
openCopilotWithMessage,
sendMothershipMessage,
useNotificationStore,
} from '@/stores/notifications'
@@ -20,9 +19,6 @@ const STACK_OFFSET_PX = 3
const AUTO_DISMISS_MS = 10000
const EXIT_ANIMATION_MS = 200
const RING_RADIUS = 5.5
const RING_CIRCUMFERENCE = 2 * Math.PI * RING_RADIUS
const ACTION_LABELS: Record<NotificationAction['type'], string> = {
copilot: 'Fix in Copilot',
refresh: 'Refresh',
@@ -33,7 +29,7 @@ function isAutoDismissable(n: Notification): boolean {
return n.level === 'error' && !!n.workflowId
}
function CountdownRing({ onPause }: { onPause: () => void }) {
function NotificationCountdownRing({ onPause }: { onPause: () => void }) {
return (
<Tooltip.Root>
<Tooltip.Trigger asChild>
@@ -41,30 +37,9 @@ function CountdownRing({ onPause }: { onPause: () => void }) {
variant='ghost'
onClick={onPause}
aria-label='Keep notifications visible'
className='!p-[4px] -m-[2px] shrink-0 rounded-[5px] hover:bg-[var(--surface-active)]'
className='!p-[4px] -m-[2px] shrink-0 rounded-[5px] text-[var(--text-icon)] hover:bg-[var(--surface-active)]'
>
<svg
width='14'
height='14'
viewBox='0 0 16 16'
fill='none'
xmlns='http://www.w3.org/2000/svg'
style={{ transform: 'rotate(-90deg) scaleX(-1)' }}
>
<circle cx='8' cy='8' r={RING_RADIUS} stroke='var(--border)' strokeWidth='1.5' />
<circle
cx='8'
cy='8'
r={RING_RADIUS}
stroke='var(--text-icon)'
strokeWidth='1.5'
strokeLinecap='round'
strokeDasharray={RING_CIRCUMFERENCE}
style={{
animation: `notification-countdown ${AUTO_DISMISS_MS}ms linear forwards`,
}}
/>
</svg>
<CountdownRing duration={AUTO_DISMISS_MS} />
</Button>
</Tooltip.Trigger>
<Tooltip.Content>
@@ -117,11 +92,7 @@ export const Notifications = memo(function Notifications({ embedded }: Notificat
switch (action.type) {
case 'copilot':
if (embedded) {
sendMothershipMessage(action.message)
} else {
openCopilotWithMessage(action.message)
}
sendMothershipMessage(action.message)
break
case 'refresh':
window.location.reload()
@@ -266,7 +237,7 @@ export const Notifications = memo(function Notifications({ embedded }: Notificat
{notification.message}
</div>
<div className='flex shrink-0 items-start gap-[2px]'>
{showCountdown && <CountdownRing onPause={pauseAll} />}
{showCountdown && <NotificationCountdownRing onPause={pauseAll} />}
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button

View File

@@ -1,79 +0,0 @@
import { Button } from '@/components/emcn'
type CheckpointConfirmationVariant = 'restore' | 'discard'
interface CheckpointConfirmationProps {
/** Confirmation variant - 'restore' for reverting, 'discard' for edit with checkpoint options */
variant: CheckpointConfirmationVariant
/** Whether an action is currently processing */
isProcessing: boolean
/** Callback when cancel is clicked */
onCancel: () => void
/** Callback when revert is clicked */
onRevert: () => void
/** Callback when continue is clicked (only for 'discard' variant) */
onContinue?: () => void
}
/**
* Inline confirmation for checkpoint operations
* Supports two variants:
* - 'restore': Simple revert confirmation with warning
* - 'discard': Edit with checkpoint options (revert or continue without revert)
*/
export function CheckpointConfirmation({
variant,
isProcessing,
onCancel,
onRevert,
onContinue,
}: CheckpointConfirmationProps) {
const isRestoreVariant = variant === 'restore'
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)]'>
{isRestoreVariant ? (
<>
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>
</>
) : (
'Continue from a previous message?'
)}
</p>
<div className='flex gap-[8px]'>
<Button
onClick={onCancel}
variant='active'
size='sm'
className='flex-1'
disabled={isProcessing}
>
Cancel
</Button>
<Button
onClick={onRevert}
variant='destructive'
size='sm'
className='flex-1'
disabled={isProcessing}
>
{isProcessing ? 'Reverting...' : 'Revert'}
</Button>
{!isRestoreVariant && onContinue && (
<Button
onClick={onContinue}
variant='primary'
size='sm'
className='flex-1'
disabled={isProcessing}
>
Continue
</Button>
)}
</div>
</div>
)
}

View File

@@ -1,135 +0,0 @@
import { memo, useState } from 'react'
import { FileText, Image } from 'lucide-react'
import type { MessageFileAttachment } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-file-attachments'
/**
* File size units for formatting
*/
const FILE_SIZE_UNITS = ['B', 'KB', 'MB', 'GB'] as const
/**
* Kilobyte multiplier
*/
const KILOBYTE = 1024
/**
* Props for the FileAttachmentDisplay component
*/
interface FileAttachmentDisplayProps {
/** Array of file attachments to display */
fileAttachments: MessageFileAttachment[]
}
/**
* FileAttachmentDisplay shows thumbnails or icons for attached files
* Displays image previews or appropriate icons based on file type
*
* @param props - Component props
* @returns Grid of file attachment thumbnails
*/
export const FileAttachmentDisplay = memo(({ fileAttachments }: FileAttachmentDisplayProps) => {
const [fileUrls, setFileUrls] = useState<Record<string, string>>({})
const [failedImages, setFailedImages] = useState<Set<string>>(() => new Set())
/**
* Formats file size in bytes to human-readable format
* @param bytes - File size in bytes
* @returns Formatted string (e.g., "2.5 MB")
*/
const formatFileSize = (bytes: number) => {
if (bytes === 0) return '0 B'
const i = Math.floor(Math.log(bytes) / Math.log(KILOBYTE))
return `${Math.round((bytes / KILOBYTE ** i) * 10) / 10} ${FILE_SIZE_UNITS[i]}`
}
/**
* Returns appropriate icon based on file media type
* @param mediaType - MIME type of the file
* @returns Icon component
*/
const getFileIcon = (mediaType: string) => {
if (mediaType.startsWith('image/')) {
return <Image className='h-5 w-5 text-muted-foreground' />
}
if (mediaType.includes('pdf')) {
return <FileText className='h-5 w-5 text-red-500' />
}
if (mediaType.includes('text') || mediaType.includes('json') || mediaType.includes('xml')) {
return <FileText className='h-5 w-5 text-blue-500' />
}
return <FileText className='h-5 w-5 text-muted-foreground' />
}
/**
* Gets or generates the file URL from cache
* @param file - File attachment object
* @returns URL to serve the file
*/
const getFileUrl = (file: MessageFileAttachment) => {
const cacheKey = file.key
if (fileUrls[cacheKey]) {
return fileUrls[cacheKey]
}
const url = `/api/files/serve/${encodeURIComponent(file.key)}?context=copilot`
setFileUrls((prev) => ({ ...prev, [cacheKey]: url }))
return url
}
/**
* Handles click on a file attachment - opens in new tab
* @param file - File attachment object
*/
const handleFileClick = (file: MessageFileAttachment) => {
const serveUrl = getFileUrl(file)
window.open(serveUrl, '_blank')
}
/**
* Checks if a file is an image based on media type
* @param mediaType - MIME type of the file
* @returns True if file is an image
*/
const isImageFile = (mediaType: string) => {
return mediaType.startsWith('image/')
}
/**
* Handles image loading errors
* @param fileId - ID of the file that failed to load
*/
const handleImageError = (fileId: string) => {
setFailedImages((prev) => new Set(prev).add(fileId))
}
return (
<>
{fileAttachments.map((file) => (
<div
key={file.id}
className='group relative h-16 w-16 cursor-pointer overflow-hidden rounded-md border border-[var(--border-1)] bg-muted/20 transition-all hover:bg-muted/40'
onClick={() => handleFileClick(file)}
title={`${file.filename} (${formatFileSize(file.size)})`}
>
{isImageFile(file.media_type) && !failedImages.has(file.id) ? (
<img
src={getFileUrl(file)}
alt={file.filename}
className='h-full w-full object-cover'
onError={() => handleImageError(file.id)}
/>
) : (
<div className='flex h-full w-full items-center justify-center bg-background/50'>
{getFileIcon(file.media_type)}
</div>
)}
{/* Hover overlay effect */}
<div className='pointer-events-none absolute inset-0 bg-black/10 opacity-0 transition-opacity group-hover:opacity-100' />
</div>
))}
</>
)
})
FileAttachmentDisplay.displayName = 'FileAttachmentDisplay'

View File

@@ -1,6 +0,0 @@
export * from './checkpoint-confirmation'
export * from './file-display'
export { CopilotMarkdownRenderer } from './markdown-renderer'
export * from './smooth-streaming'
export * from './thinking-block'
export * from './usage-limit-actions'

View File

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

View File

@@ -1,331 +0,0 @@
'use client'
import React, { memo, useCallback, useState } from 'react'
import { Check, Copy } from 'lucide-react'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import { Code, Tooltip } from '@/components/emcn'
const REMARK_PLUGINS = [remarkGfm]
/**
* Recursively extracts text content from React elements
* @param element - React node to extract text from
* @returns Concatenated text content
*/
const getTextContent = (element: React.ReactNode): string => {
if (typeof element === 'string') {
return element
}
if (typeof element === 'number') {
return String(element)
}
if (React.isValidElement(element)) {
const elementProps = element.props as { children?: React.ReactNode }
return getTextContent(elementProps.children)
}
if (Array.isArray(element)) {
return element.map(getTextContent).join('')
}
return ''
}
/**
* Maps common language aliases to supported viewer languages
*/
const LANGUAGE_MAP: Record<string, 'javascript' | 'json' | 'python'> = {
js: 'javascript',
javascript: 'javascript',
jsx: 'javascript',
ts: 'javascript',
typescript: 'javascript',
tsx: 'javascript',
json: 'json',
python: 'python',
py: 'python',
code: 'javascript',
}
/**
* Normalizes a language string to a supported viewer language
*/
function normalizeLanguage(lang: string): 'javascript' | 'json' | 'python' {
const normalized = (lang || '').toLowerCase()
return LANGUAGE_MAP[normalized] || 'javascript'
}
/**
* Props for the CodeBlock component
*/
interface CodeBlockProps {
/** Code content to display */
code: string
/** Language identifier from markdown */
language: string
}
/**
* CodeBlock component with isolated copy state
* Prevents full markdown re-renders when copy button is clicked
*/
const CodeBlock = memo(function CodeBlock({ code, language }: CodeBlockProps) {
const [copied, setCopied] = useState(false)
const handleCopy = useCallback(() => {
if (code) {
navigator.clipboard.writeText(code)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
}, [code])
const viewerLanguage = normalizeLanguage(language)
const displayLanguage = language === 'code' ? viewerLanguage : language
return (
<div className='mt-2.5 mb-2.5 w-0 min-w-full overflow-hidden rounded-md border border-[var(--border-1)] bg-[var(--surface-1)] text-sm'>
<div className='flex items-center justify-between border-[var(--border-1)] border-b px-3 py-1'>
<span className='font-season text-[var(--text-muted)] text-xs'>{displayLanguage}</span>
<button
onClick={handleCopy}
className='text-[var(--text-muted)] transition-colors hover:text-[var(--text-tertiary)]'
title='Copy'
type='button'
>
{copied ? (
<Check className='h-3 w-3' strokeWidth={2} />
) : (
<Copy className='h-3 w-3' strokeWidth={2} />
)}
</button>
</div>
<Code.Viewer
code={code.replace(/\n+$/, '')}
showGutter
language={viewerLanguage}
className='m-0 min-h-0 rounded-none border-0 bg-transparent'
/>
</div>
)
})
/**
* Link component with hover preview tooltip
*/
const LinkWithPreview = memo(function LinkWithPreview({
href,
children,
}: {
href: string
children: React.ReactNode
}) {
return (
<Tooltip.Root delayDuration={300}>
<Tooltip.Trigger asChild>
<a
href={href}
className='inline break-all text-blue-600 hover:underline dark:text-blue-400'
target='_blank'
rel='noopener noreferrer'
>
{children}
</a>
</Tooltip.Trigger>
<Tooltip.Content side='top' align='center' sideOffset={5} className='max-w-sm'>
<span className='text-sm'>{href}</span>
</Tooltip.Content>
</Tooltip.Root>
)
})
/**
* Props for the CopilotMarkdownRenderer component
*/
interface CopilotMarkdownRendererProps {
/** Markdown content to render */
content: string
}
/**
* Static markdown component definitions - optimized for LLM chat spacing
* Tighter spacing compared to traditional prose for better chat UX
*/
const markdownComponents = {
p: ({ children }: React.HTMLAttributes<HTMLParagraphElement>) => (
<p className='mb-1.5 font-base font-season text-[var(--text-primary)] text-sm leading-[1.4] last:mb-0 dark:font-[470]'>
{children}
</p>
),
h1: ({ children }: React.HTMLAttributes<HTMLHeadingElement>) => (
<h1 className='mt-2 mb-1 font-season font-semibold text-[var(--text-primary)] text-base first:mt-0'>
{children}
</h1>
),
h2: ({ children }: React.HTMLAttributes<HTMLHeadingElement>) => (
<h2 className='mt-2 mb-1 font-season font-semibold text-[15px] text-[var(--text-primary)] first:mt-0'>
{children}
</h2>
),
h3: ({ children }: React.HTMLAttributes<HTMLHeadingElement>) => (
<h3 className='mt-1.5 mb-0.5 font-season font-semibold text-[var(--text-primary)] text-sm first:mt-0'>
{children}
</h3>
),
h4: ({ children }: React.HTMLAttributes<HTMLHeadingElement>) => (
<h4 className='mt-1.5 mb-0.5 font-season font-semibold text-[var(--text-primary)] text-sm first:mt-0'>
{children}
</h4>
),
ul: ({ children }: React.HTMLAttributes<HTMLUListElement>) => (
<ul
className='my-1 space-y-0.5 pl-5 font-base font-season text-[var(--text-primary)] dark:font-[470]'
style={{ listStyleType: 'disc' }}
>
{children}
</ul>
),
ol: ({ children }: React.HTMLAttributes<HTMLOListElement>) => (
<ol
className='my-1 space-y-0.5 pl-5 font-base font-season text-[var(--text-primary)] dark:font-[470]'
style={{ listStyleType: 'decimal' }}
>
{children}
</ol>
),
li: ({ children }: React.LiHTMLAttributes<HTMLLIElement>) => (
<li
className='font-base font-season text-[var(--text-primary)] text-sm leading-[1.4] dark:font-[470]'
style={{ display: 'list-item' }}
>
{children}
</li>
),
pre: ({ children }: React.HTMLAttributes<HTMLPreElement>) => {
let codeContent: React.ReactNode = children
let language = 'code'
if (
React.isValidElement<{ className?: string; children?: React.ReactNode }>(children) &&
children.type === 'code'
) {
const childElement = children as React.ReactElement<{
className?: string
children?: React.ReactNode
}>
codeContent = childElement.props.children
language = childElement.props.className?.replace('language-', '') || 'code'
}
let actualCodeText = ''
if (typeof codeContent === 'string') {
actualCodeText = codeContent
} else if (React.isValidElement(codeContent)) {
actualCodeText = getTextContent(codeContent)
} else if (Array.isArray(codeContent)) {
actualCodeText = codeContent
.map((child) =>
typeof child === 'string'
? child
: React.isValidElement(child)
? getTextContent(child)
: ''
)
.join('')
} else {
actualCodeText = String(codeContent || '')
}
return <CodeBlock code={actualCodeText} language={language} />
},
code: ({
className,
children,
...props
}: React.HTMLAttributes<HTMLElement> & { className?: string }) => (
<code
className='whitespace-normal break-all rounded border border-[var(--border-1)] bg-[var(--surface-1)] px-1 py-0.5 font-mono text-[0.85em] text-[var(--text-primary)]'
{...props}
>
{children}
</code>
),
strong: ({ children }: React.HTMLAttributes<HTMLElement>) => (
<strong className='font-semibold text-[var(--text-primary)]'>{children}</strong>
),
b: ({ children }: React.HTMLAttributes<HTMLElement>) => (
<b className='font-semibold text-[var(--text-primary)]'>{children}</b>
),
em: ({ children }: React.HTMLAttributes<HTMLElement>) => (
<em className='text-[var(--text-primary)] italic'>{children}</em>
),
i: ({ children }: React.HTMLAttributes<HTMLElement>) => (
<i className='text-[var(--text-primary)] italic'>{children}</i>
),
blockquote: ({ children }: React.HTMLAttributes<HTMLQuoteElement>) => (
<blockquote className='my-1.5 border-[var(--border-1)] border-l-2 py-0.5 pl-3 font-season text-[var(--text-secondary)] text-sm italic'>
{children}
</blockquote>
),
hr: () => <hr className='my-3 border-[var(--divider)] border-t' />,
a: ({ href, children }: React.AnchorHTMLAttributes<HTMLAnchorElement>) => (
<LinkWithPreview href={href || '#'}>{children}</LinkWithPreview>
),
table: ({ children }: React.TableHTMLAttributes<HTMLTableElement>) => (
<div className='my-2 max-w-full overflow-x-auto'>
<table className='min-w-full table-auto border border-[var(--border-1)] font-season text-xs'>
{children}
</table>
</div>
),
thead: ({ children }: React.HTMLAttributes<HTMLTableSectionElement>) => (
<thead className='bg-[var(--surface-5)] text-left dark:bg-[var(--surface-4)]'>{children}</thead>
),
tbody: ({ children }: React.HTMLAttributes<HTMLTableSectionElement>) => (
<tbody className='divide-y divide-[var(--border-1)]'>{children}</tbody>
),
tr: ({ children }: React.HTMLAttributes<HTMLTableRowElement>) => (
<tr className='border-[var(--border-1)] border-b'>{children}</tr>
),
th: ({ children }: React.ThHTMLAttributes<HTMLTableCellElement>) => (
<th className='border-[var(--border-1)] border-r px-2 py-1 align-top font-base text-[var(--text-secondary)] last:border-r-0 dark:font-[470]'>
{children}
</th>
),
td: ({ children }: React.TdHTMLAttributes<HTMLTableCellElement>) => (
<td className='break-words border-[var(--border-1)] border-r px-2 py-1 align-top font-base text-[var(--text-primary)] last:border-r-0 dark:font-[470]'>
{children}
</td>
),
img: ({ src, alt, ...props }: React.ImgHTMLAttributes<HTMLImageElement>) => (
<img src={src} alt={alt || 'Image'} className='my-2 h-auto max-w-full rounded-md' {...props} />
),
}
/**
* CopilotMarkdownRenderer renders markdown content with custom styling
* Optimized for LLM chat: tight spacing, memoized components, isolated state
*
* @param props - Component props
* @returns Rendered markdown content
*/
function CopilotMarkdownRenderer({ content }: CopilotMarkdownRendererProps) {
return (
<div className='max-w-full break-words font-base font-season text-[var(--text-primary)] text-sm leading-[1.4] dark:font-[470] [&_*]:max-w-full [&_a]:break-all [&_code:not(pre_code)]:break-words [&_li]:break-words [&_p]:break-words'>
<ReactMarkdown remarkPlugins={REMARK_PLUGINS} components={markdownComponents}>
{content}
</ReactMarkdown>
</div>
)
}
export default memo(CopilotMarkdownRenderer)

View File

@@ -1,107 +0,0 @@
import { memo, useEffect, useRef, useState } from 'react'
import { cn } from '@/lib/core/utils/cn'
import { CopilotMarkdownRenderer } from '../markdown-renderer'
/** Character animation delay in milliseconds */
const CHARACTER_DELAY = 3
/** Props for the StreamingIndicator component */
interface StreamingIndicatorProps {
/** Optional class name for layout adjustments */
className?: string
}
/** Shows animated dots during message streaming when no content has arrived */
export const StreamingIndicator = memo(({ className }: StreamingIndicatorProps) => (
<div className={cn('flex h-[1.25rem] items-center text-muted-foreground', className)}>
<div className='flex space-x-0.5'>
<div className='h-1 w-1 animate-bounce rounded-full bg-muted-foreground [animation-delay:0ms] [animation-duration:1.2s]' />
<div className='h-1 w-1 animate-bounce rounded-full bg-muted-foreground [animation-delay:150ms] [animation-duration:1.2s]' />
<div className='h-1 w-1 animate-bounce rounded-full bg-muted-foreground [animation-delay:300ms] [animation-duration:1.2s]' />
</div>
</div>
))
StreamingIndicator.displayName = 'StreamingIndicator'
/** Props for the SmoothStreamingText component */
interface SmoothStreamingTextProps {
/** Content to display with streaming animation */
content: string
/** Whether the content is actively streaming */
isStreaming: boolean
}
/** Displays text with character-by-character animation for smooth streaming */
export const SmoothStreamingText = memo(
({ content, isStreaming }: SmoothStreamingTextProps) => {
const [displayedContent, setDisplayedContent] = useState(() => (isStreaming ? '' : content))
const contentRef = useRef(content)
const timeoutRef = useRef<NodeJS.Timeout | null>(null)
const indexRef = useRef(isStreaming ? 0 : content.length)
const isAnimatingRef = useRef(false)
useEffect(() => {
contentRef.current = content
if (content.length === 0) {
setDisplayedContent('')
indexRef.current = 0
return
}
if (isStreaming) {
if (indexRef.current < content.length) {
const animateText = () => {
const currentContent = contentRef.current
const currentIndex = indexRef.current
if (currentIndex < currentContent.length) {
const newDisplayed = currentContent.slice(0, currentIndex + 1)
setDisplayedContent(newDisplayed)
indexRef.current = currentIndex + 1
timeoutRef.current = setTimeout(animateText, CHARACTER_DELAY)
} else {
isAnimatingRef.current = false
}
}
if (!isAnimatingRef.current) {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
}
isAnimatingRef.current = true
animateText()
}
}
} else {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
}
setDisplayedContent(content)
indexRef.current = content.length
isAnimatingRef.current = false
}
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
}
isAnimatingRef.current = false
}
}, [content, isStreaming])
return (
<div className='min-h-[1.25rem] max-w-full'>
<CopilotMarkdownRenderer content={displayedContent} />
</div>
)
},
(prevProps, nextProps) => {
return (
prevProps.content === nextProps.content && prevProps.isStreaming === nextProps.isStreaming
)
}
)
SmoothStreamingText.displayName = 'SmoothStreamingText'

View File

@@ -1,364 +0,0 @@
'use client'
import { memo, useEffect, useMemo, useRef, useState } from 'react'
import clsx from 'clsx'
import { ChevronUp } from 'lucide-react'
import { formatDuration } from '@/lib/core/utils/formatting'
import { CopilotMarkdownRenderer } from '../markdown-renderer'
/** Removes thinking tags (raw or escaped) and special tags from streamed content */
function stripThinkingTags(text: string): string {
return text
.replace(/<\/?thinking[^>]*>/gi, '')
.replace(/&lt;\/?thinking[^&]*&gt;/gi, '')
.replace(/<options>[\s\S]*?<\/options>/gi, '')
.replace(/<options>[\s\S]*$/gi, '')
.replace(/<plan>[\s\S]*?<\/plan>/gi, '')
.replace(/<plan>[\s\S]*$/gi, '')
.trim()
}
/** Interval for auto-scroll during streaming (ms) */
const SCROLL_INTERVAL = 50
/** Timer update interval in milliseconds */
const TIMER_UPDATE_INTERVAL = 100
/** Thinking text streaming delay - faster than main text */
const THINKING_DELAY = 0.5
const THINKING_CHARS_PER_FRAME = 3
/** Props for the SmoothThinkingText component */
interface SmoothThinkingTextProps {
content: string
isStreaming: boolean
}
/**
* Renders thinking content with fast streaming animation.
*/
const SmoothThinkingText = memo(
({ content, isStreaming }: SmoothThinkingTextProps) => {
const [displayedContent, setDisplayedContent] = useState(() => (isStreaming ? '' : content))
const contentRef = useRef(content)
const textRef = useRef<HTMLDivElement>(null)
const rafRef = useRef<number | null>(null)
const indexRef = useRef(isStreaming ? 0 : content.length)
const lastFrameTimeRef = useRef<number>(0)
const isAnimatingRef = useRef(false)
useEffect(() => {
contentRef.current = content
if (content.length === 0) {
setDisplayedContent('')
indexRef.current = 0
return
}
if (isStreaming) {
if (indexRef.current < content.length && !isAnimatingRef.current) {
isAnimatingRef.current = true
lastFrameTimeRef.current = performance.now()
const animateText = (timestamp: number) => {
const currentContent = contentRef.current
const currentIndex = indexRef.current
const elapsed = timestamp - lastFrameTimeRef.current
if (elapsed >= THINKING_DELAY) {
if (currentIndex < currentContent.length) {
const newIndex = Math.min(
currentIndex + THINKING_CHARS_PER_FRAME,
currentContent.length
)
const newDisplayed = currentContent.slice(0, newIndex)
setDisplayedContent(newDisplayed)
indexRef.current = newIndex
lastFrameTimeRef.current = timestamp
}
}
if (indexRef.current < currentContent.length) {
rafRef.current = requestAnimationFrame(animateText)
} else {
isAnimatingRef.current = false
}
}
rafRef.current = requestAnimationFrame(animateText)
}
} else {
if (rafRef.current) {
cancelAnimationFrame(rafRef.current)
}
setDisplayedContent(content)
indexRef.current = content.length
isAnimatingRef.current = false
}
return () => {
if (rafRef.current) {
cancelAnimationFrame(rafRef.current)
}
isAnimatingRef.current = false
}
}, [content, isStreaming])
return (
<div
ref={textRef}
className='[&_*]:!text-[var(--text-muted)] [&_*]:!text-[12px] [&_*]:!leading-[1.4] [&_p]:!m-0 [&_p]:!mb-1 [&_h1]:!text-[12px] [&_h1]:!font-semibold [&_h1]:!m-0 [&_h1]:!mb-1 [&_h2]:!text-[12px] [&_h2]:!font-semibold [&_h2]:!m-0 [&_h2]:!mb-1 [&_h3]:!text-[12px] [&_h3]:!font-semibold [&_h3]:!m-0 [&_h3]:!mb-1 [&_code]:!text-[11px] [&_ul]:!pl-5 [&_ul]:!my-1 [&_ol]:!pl-6 [&_ol]:!my-1 [&_li]:!my-0.5 [&_li]:!py-0 font-season text-[12px] text-[var(--text-muted)]'
>
<CopilotMarkdownRenderer content={displayedContent} />
</div>
)
},
(prevProps, nextProps) => {
return (
prevProps.content === nextProps.content && prevProps.isStreaming === nextProps.isStreaming
)
}
)
SmoothThinkingText.displayName = 'SmoothThinkingText'
/** Props for the ThinkingBlock component */
interface ThinkingBlockProps {
/** Content of the thinking block */
content: string
/** Whether the block is currently streaming */
isStreaming?: boolean
/** Whether there are more content blocks after this one (e.g., tool calls) */
hasFollowingContent?: boolean
/** Custom label for the thinking block (e.g., "Thinking", "Exploring"). Defaults to "Thought" */
label?: string
/** Whether special tags (plan, options) are present - triggers collapse */
hasSpecialTags?: boolean
}
/**
* Displays AI reasoning/thinking process with collapsible content and duration timer.
* Auto-expands during streaming and collapses when complete.
*/
export function ThinkingBlock({
content,
isStreaming = false,
hasFollowingContent = false,
label = 'Thought',
hasSpecialTags = false,
}: ThinkingBlockProps) {
const cleanContent = useMemo(() => stripThinkingTags(content || ''), [content])
const [isExpanded, setIsExpanded] = useState(false)
const [duration, setDuration] = useState(0)
const [userHasScrolledAway, setUserHasScrolledAway] = useState(false)
const userCollapsedRef = useRef<boolean>(false)
const scrollContainerRef = useRef<HTMLDivElement>(null)
const startTimeRef = useRef<number>(Date.now())
const lastScrollTopRef = useRef(0)
const programmaticScrollRef = useRef(false)
/** Auto-expands during streaming, auto-collapses when streaming ends or following content arrives */
useEffect(() => {
if (!isStreaming || hasFollowingContent || hasSpecialTags) {
setIsExpanded(false)
userCollapsedRef.current = false
setUserHasScrolledAway(false)
return
}
if (!userCollapsedRef.current && cleanContent && cleanContent.length > 0) {
setIsExpanded(true)
}
}, [isStreaming, cleanContent, hasFollowingContent, hasSpecialTags])
useEffect(() => {
if (isStreaming && !hasFollowingContent) {
startTimeRef.current = Date.now()
setDuration(0)
setUserHasScrolledAway(false)
}
}, [isStreaming, hasFollowingContent])
useEffect(() => {
if (!isStreaming || hasFollowingContent) return
const interval = setInterval(() => {
setDuration(Date.now() - startTimeRef.current)
}, TIMER_UPDATE_INTERVAL)
return () => clearInterval(interval)
}, [isStreaming, hasFollowingContent])
useEffect(() => {
const container = scrollContainerRef.current
if (!container || !isExpanded) return
const handleScroll = () => {
if (programmaticScrollRef.current) return
const { scrollTop, scrollHeight, clientHeight } = container
const distanceFromBottom = scrollHeight - scrollTop - clientHeight
const isNearBottom = distanceFromBottom <= 20
const delta = scrollTop - lastScrollTopRef.current
const movedUp = delta < -1
if (movedUp && !isNearBottom) {
setUserHasScrolledAway(true)
}
if (userHasScrolledAway && isNearBottom && delta > 10) {
setUserHasScrolledAway(false)
}
lastScrollTopRef.current = scrollTop
}
container.addEventListener('scroll', handleScroll, { passive: true })
lastScrollTopRef.current = container.scrollTop
return () => container.removeEventListener('scroll', handleScroll)
}, [isExpanded, userHasScrolledAway])
useEffect(() => {
if (!isStreaming || !isExpanded || userHasScrolledAway) return
const intervalId = window.setInterval(() => {
const container = scrollContainerRef.current
if (!container) return
programmaticScrollRef.current = true
container.scrollTo({
top: container.scrollHeight,
behavior: 'auto',
})
window.setTimeout(() => {
programmaticScrollRef.current = false
}, 16)
}, SCROLL_INTERVAL)
return () => window.clearInterval(intervalId)
}, [isStreaming, isExpanded, userHasScrolledAway])
const hasContent = cleanContent.length > 0
const isThinkingDone = !isStreaming || hasFollowingContent || hasSpecialTags
// Round to nearest second (minimum 1s) to match original behavior
const roundedMs = Math.max(1000, Math.round(duration / 1000) * 1000)
const durationText = `${label} for ${formatDuration(roundedMs)}`
const getStreamingLabel = (lbl: string) => {
if (lbl === 'Thought') return 'Thinking'
if (lbl.endsWith('ed')) return `${lbl.slice(0, -2)}ing`
return lbl
}
const streamingLabel = getStreamingLabel(label)
if (!isThinkingDone) {
return (
<div>
<style>{`
@keyframes thinking-shimmer {
0% { background-position: 150% 0; }
50% { background-position: 0% 0; }
100% { background-position: -150% 0; }
}
`}</style>
<button
onClick={() => {
setIsExpanded((v) => {
const next = !v
if (!next) userCollapsedRef.current = true
return next
})
}}
className='group inline-flex items-center gap-1 text-left font-[470] font-season text-[var(--text-secondary)] text-sm transition-colors hover:text-[var(--text-primary)]'
type='button'
>
<span className='relative inline-block'>
<span className='text-[var(--text-tertiary)]'>{streamingLabel}</span>
<span
aria-hidden='true'
className='pointer-events-none absolute inset-0 select-none overflow-hidden'
>
<span
className='block text-transparent'
style={{
backgroundImage:
'linear-gradient(90deg, rgba(255,255,255,0) 0%, rgba(255,255,255,0.85) 50%, rgba(255,255,255,0) 100%)',
backgroundSize: '200% 100%',
backgroundRepeat: 'no-repeat',
WebkitBackgroundClip: 'text',
backgroundClip: 'text',
animation: 'thinking-shimmer 1.4s ease-in-out infinite',
mixBlendMode: 'screen',
}}
>
{streamingLabel}
</span>
</span>
</span>
{hasContent && (
<ChevronUp
className={clsx(
'h-3 w-3 transition-all group-hover:opacity-100',
isExpanded ? 'rotate-180 opacity-100' : 'rotate-90 opacity-0'
)}
aria-hidden='true'
/>
)}
</button>
<div
ref={scrollContainerRef}
className={clsx(
'overflow-y-auto transition-all duration-150 ease-out',
isExpanded ? 'mt-1.5 max-h-[150px] opacity-100' : 'max-h-0 opacity-0'
)}
>
<SmoothThinkingText
content={cleanContent}
isStreaming={isStreaming && !hasFollowingContent}
/>
</div>
</div>
)
}
return (
<div>
<button
onClick={() => {
setIsExpanded((v) => !v)
}}
className='group inline-flex items-center gap-1 text-left font-[470] font-season text-[var(--text-secondary)] text-sm transition-colors hover:text-[var(--text-primary)]'
type='button'
disabled={!hasContent}
>
<span className='text-[var(--text-tertiary)]'>{durationText}</span>
{hasContent && (
<ChevronUp
className={clsx(
'h-3 w-3 transition-all group-hover:opacity-100',
isExpanded ? 'rotate-180 opacity-100' : 'rotate-90 opacity-0'
)}
aria-hidden='true'
/>
)}
</button>
<div
ref={scrollContainerRef}
className={clsx(
'overflow-y-auto transition-all duration-150 ease-out',
isExpanded ? 'mt-1.5 max-h-[150px] opacity-100' : 'max-h-0 opacity-0'
)}
>
<div className='[&_*]:!text-[var(--text-muted)] [&_*]:!text-[12px] [&_*]:!leading-[1.4] [&_p]:!m-0 [&_p]:!mb-1 [&_h1]:!text-[12px] [&_h1]:!font-semibold [&_h1]:!m-0 [&_h1]:!mb-1 [&_h2]:!text-[12px] [&_h2]:!font-semibold [&_h2]:!m-0 [&_h2]:!mb-1 [&_h3]:!text-[12px] [&_h3]:!font-semibold [&_h3]:!m-0 [&_h3]:!mb-1 [&_code]:!text-[11px] [&_ul]:!pl-5 [&_ul]:!my-1 [&_ol]:!pl-6 [&_ol]:!my-1 [&_li]:!my-0.5 [&_li]:!py-0 font-season text-[12px] text-[var(--text-muted)]'>
<CopilotMarkdownRenderer content={cleanContent} />
</div>
</div>
</div>
)
}

View File

@@ -1,105 +0,0 @@
'use client'
import { useState } from 'react'
import { Loader2 } from 'lucide-react'
import { Button } from '@/components/emcn'
import { formatCredits } from '@/lib/billing/credits/conversion'
import { canEditUsageLimit } from '@/lib/billing/subscriptions/utils'
import { getEnv, isTruthy } from '@/lib/core/config/env'
import { isHosted } from '@/lib/core/config/feature-flags'
import { useSubscriptionData, useUpdateUsageLimit } from '@/hooks/queries/subscription'
import { useSettingsNavigation } from '@/hooks/use-settings-navigation'
import { useCopilotStore } from '@/stores/panel'
const isBillingEnabled = isTruthy(getEnv('NEXT_PUBLIC_BILLING_ENABLED'))
const LIMIT_INCREMENTS = [0, 50, 100] as const
function roundUpToNearest50(value: number): number {
return Math.ceil(value / 50) * 50
}
export function UsageLimitActions() {
const { navigateToSettings } = useSettingsNavigation()
const { data: subscriptionData } = useSubscriptionData({ enabled: isBillingEnabled })
const updateUsageLimitMutation = useUpdateUsageLimit()
const subscription = subscriptionData?.data
const canEdit = subscription ? canEditUsageLimit(subscription) : false
const [selectedAmount, setSelectedAmount] = useState<number | null>(null)
const [isHidden, setIsHidden] = useState(false)
const currentLimit = subscription?.usageLimit ?? 0
const baseLimit = roundUpToNearest50(currentLimit) || 50
const limitOptions = LIMIT_INCREMENTS.map((increment) => baseLimit + increment)
const handleUpdateLimit = async (newLimit: number) => {
setSelectedAmount(newLimit)
try {
await updateUsageLimitMutation.mutateAsync({ limit: newLimit })
setIsHidden(true)
const { messages, sendMessage } = useCopilotStore.getState()
const lastUserMessage = [...messages].reverse().find((m) => m.role === 'user')
if (lastUserMessage) {
const filteredMessages = messages.filter(
(m) => !(m.role === 'assistant' && m.errorType === 'usage_limit')
)
useCopilotStore.setState({ messages: filteredMessages })
await sendMessage(lastUserMessage.content, {
fileAttachments: lastUserMessage.fileAttachments,
contexts: lastUserMessage.contexts,
messageId: lastUserMessage.id,
})
}
} catch {
setIsHidden(false)
} finally {
setSelectedAmount(null)
}
}
const handleNavigateToUpgrade = () => {
if (isHosted) {
navigateToSettings({ section: 'subscription' })
} else {
window.open('https://www.sim.ai', '_blank')
}
}
if (isHidden) {
return null
}
if (!isHosted || !canEdit) {
return (
<Button onClick={handleNavigateToUpgrade} variant='default'>
Upgrade
</Button>
)
}
return (
<>
{limitOptions.map((limit) => {
const isLoading = updateUsageLimitMutation.isPending && selectedAmount === limit
const isDisabled = updateUsageLimitMutation.isPending
return (
<Button
key={limit}
onClick={() => handleUpdateLimit(limit)}
disabled={isDisabled}
variant='default'
>
{isLoading ? <Loader2 className='mr-1 h-3 w-3 animate-spin' /> : null}
{formatCredits(limit)}
</Button>
)
})}
</>
)
}

View File

@@ -1,568 +0,0 @@
'use client'
import { type FC, memo, useCallback, useMemo, useRef, useState } from 'react'
import { RotateCcw } from 'lucide-react'
import { Button } from '@/components/emcn'
import { MessageActions } from '@/app/workspace/[workspaceId]/components'
import {
OptionsSelector,
parseSpecialTags,
ToolCall,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components'
import {
CheckpointConfirmation,
FileAttachmentDisplay,
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 {
useCheckpointManagement,
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'
/**
* Props for the CopilotMessage component
*/
interface CopilotMessageProps {
/** Message object containing content and metadata */
message: CopilotMessageType
/** Whether the message is currently streaming */
isStreaming?: boolean
/** Width of the panel in pixels */
panelWidth?: number
/** Whether the message should appear dimmed */
isDimmed?: boolean
/** Number of checkpoints for this message */
checkpointCount?: number
/** Callback when edit mode changes */
onEditModeChange?: (isEditing: boolean, cancelCallback?: () => void) => void
/** Callback when revert mode changes */
onRevertModeChange?: (isReverting: boolean) => void
/** Whether this is the last message in the conversation */
isLastMessage?: boolean
}
/**
* CopilotMessage component displays individual chat messages
* Handles both user and assistant messages with different rendering and interactions
* Supports editing, checkpoints, feedback, and file attachments
*
* @param props - Component props
* @returns Message component with appropriate role-based rendering
*/
const CopilotMessage: FC<CopilotMessageProps> = memo(
({
message,
isStreaming,
panelWidth = 308,
isDimmed = false,
checkpointCount = 0,
onEditModeChange,
onRevertModeChange,
isLastMessage = false,
}) => {
const isUser = message.role === 'user'
const isAssistant = message.role === 'assistant'
const {
messageCheckpoints: allMessageCheckpoints,
messages,
isSendingMessage,
abortMessage,
mode,
setMode,
isAborting,
} = useCopilotStore()
const maskCredentialValue = useCopilotStore((s) => s.maskCredentialValue)
const messageCheckpoints = isUser ? allMessageCheckpoints[message.id] || [] : []
const hasCheckpoints = messageCheckpoints.length > 0 && messageCheckpoints.some((cp) => cp?.id)
const isLastUserMessage = useMemo(() => {
if (!isUser) return false
const userMessages = messages.filter((m) => m.role === 'user')
return userMessages.length > 0 && userMessages[userMessages.length - 1]?.id === message.id
}, [isUser, messages, message.id])
const [isHoveringMessage, setIsHoveringMessage] = useState(false)
const cancelEditRef = useRef<(() => void) | null>(null)
const {
showRestoreConfirmation,
showCheckpointDiscardModal,
isReverting,
isProcessingDiscard,
pendingEditRef,
setShowCheckpointDiscardModal,
handleRevertToCheckpoint,
handleConfirmRevert,
handleCancelRevert,
handleCancelCheckpointDiscard,
handleContinueWithoutRevert,
handleContinueAndRevert,
} = useCheckpointManagement(
message,
messages,
messageCheckpoints,
onRevertModeChange,
onEditModeChange,
() => cancelEditRef.current?.()
)
const {
isEditMode,
isExpanded,
editedContent,
needsExpansion,
editContainerRef,
messageContentRef,
userInputRef,
setEditedContent,
handleCancelEdit,
handleMessageClick,
handleSubmitEdit,
} = useMessageEditing({
message,
messages,
isLastUserMessage,
hasCheckpoints,
onEditModeChange: (isEditing) => {
onEditModeChange?.(isEditing, handleCancelEdit)
},
disableDocumentClickOutside: true,
showCheckpointDiscardModal,
setShowCheckpointDiscardModal,
pendingEditRef,
})
cancelEditRef.current = handleCancelEdit
const cleanTextContent = useMemo(() => {
if (!message.content) return ''
return message.content.replace(/\n{3,}/g, '\n\n')
}, [message.content])
const parsedTags = useMemo(() => {
if (isUser) return null
if (message.content) {
const parsed = parseSpecialTags(message.content)
if (parsed.options || parsed.plan) return parsed
}
if (message.contentBlocks && message.contentBlocks.length > 0) {
for (const block of message.contentBlocks) {
if (block.type === 'text' && block.content) {
const parsed = parseSpecialTags(block.content)
if (parsed.options || parsed.plan) return parsed
}
}
}
return null
}, [message.content, message.contentBlocks, isUser])
const selectedOptionKey = useMemo(() => {
if (!parsedTags?.options || isStreaming) return null
const currentIndex = messages.findIndex((m) => m.id === message.id)
if (currentIndex === -1 || currentIndex >= messages.length - 1) return null
const nextMessage = messages[currentIndex + 1]
if (!nextMessage || nextMessage.role !== 'user') return null
const nextContent = nextMessage.content?.trim()
if (!nextContent) return null
for (const [key, option] of Object.entries(parsedTags.options)) {
const optionTitle = typeof option === 'string' ? option : option.title
if (nextContent === optionTitle) {
return key
}
}
return null
}, [parsedTags?.options, messages, message.id, isStreaming])
const sendMessage = useCopilotStore((s) => s.sendMessage)
const handleOptionSelect = useCallback(
(_optionKey: string, optionText: string) => {
sendMessage(optionText)
},
[sendMessage]
)
const isActivelyStreaming = isLastMessage && isStreaming
const memoizedContentBlocks = useMemo(() => {
if (!message.contentBlocks || message.contentBlocks.length === 0) {
return null
}
return message.contentBlocks.map((block, index) => {
if (block.type === 'text') {
const isLastTextBlock =
index === message.contentBlocks!.length - 1 && block.type === 'text'
const parsed = parseSpecialTags(block.content ?? '')
// Mask credential IDs in the displayed content
const cleanBlockContent = maskCredentialValue(
parsed.cleanContent.replace(/\n{3,}/g, '\n\n')
)
if (!cleanBlockContent.trim()) return null
const shouldUseSmoothing = isActivelyStreaming && isLastTextBlock
const blockKey = `text-${index}-${block.timestamp || index}`
return (
<div key={blockKey} className='w-full max-w-full'>
{shouldUseSmoothing ? (
<SmoothStreamingText
content={cleanBlockContent}
isStreaming={isActivelyStreaming}
/>
) : (
<CopilotMarkdownRenderer content={cleanBlockContent} />
)}
</div>
)
}
if (block.type === 'thinking') {
const hasFollowingContent = index < message.contentBlocks!.length - 1
const hasSpecialTags = !!(parsedTags?.options || parsedTags?.plan)
const blockKey = `thinking-${index}-${block.timestamp || index}`
return (
<div key={blockKey} className='w-full'>
<ThinkingBlock
content={maskCredentialValue(block.content ?? '')}
isStreaming={isActivelyStreaming}
hasFollowingContent={hasFollowingContent}
hasSpecialTags={hasSpecialTags}
/>
</div>
)
}
if (block.type === 'tool_call' && block.toolCall) {
const blockKey = `tool-${block.toolCall.id}`
return (
<div key={blockKey}>
<ToolCall
toolCallId={block.toolCall.id}
toolCall={block.toolCall}
isCurrentMessage={isLastMessage}
/>
</div>
)
}
return null
})
}, [message.contentBlocks, isActivelyStreaming, parsedTags, isLastMessage])
if (isUser) {
return (
<div
className={`w-full max-w-full flex-none overflow-hidden transition-opacity duration-200 [max-width:var(--panel-max-width)] ${isDimmed ? 'opacity-40' : 'opacity-100'}`}
style={{ '--panel-max-width': `${panelWidth - 16}px` } as React.CSSProperties}
>
{isEditMode ? (
<div
ref={editContainerRef}
data-edit-container
data-message-id={message.id}
className='relative w-full'
>
<UserInput
ref={userInputRef}
onSubmit={handleSubmitEdit}
onAbort={() => {
if (isSendingMessage && isLastUserMessage) {
abortMessage()
}
}}
isLoading={isSendingMessage && isLastUserMessage}
isAborting={isAborting}
disabled={showCheckpointDiscardModal}
value={editedContent}
onChange={setEditedContent}
placeholder='Edit your message...'
mode={mode}
onModeChange={setMode}
panelWidth={panelWidth}
clearOnSubmit={false}
initialContexts={message.contexts}
/>
{/* Inline checkpoint confirmation - shown below input in edit mode */}
{showCheckpointDiscardModal && (
<CheckpointConfirmation
variant='discard'
isProcessing={isProcessingDiscard}
onCancel={handleCancelCheckpointDiscard}
onRevert={handleContinueAndRevert}
onContinue={handleContinueWithoutRevert}
/>
)}
</div>
) : (
<div className='w-full'>
{/* File attachments displayed above the message box */}
{message.fileAttachments && message.fileAttachments.length > 0 && (
<div className='mb-1.5 flex flex-wrap gap-1.5'>
<FileAttachmentDisplay fileAttachments={message.fileAttachments} />
</div>
)}
{/* Message box - styled like input, clickable to edit */}
<div
data-message-box
data-message-id={message.id}
onClick={handleMessageClick}
onMouseEnter={() => setIsHoveringMessage(true)}
onMouseLeave={() => setIsHoveringMessage(false)}
className='group relative w-full cursor-pointer rounded-[4px] border border-[var(--border-1)] bg-[var(--surface-4)] px-[6px] py-[6px] transition-all duration-200 hover:border-[var(--surface-7)] hover:bg-[var(--surface-4)] dark:bg-[var(--surface-4)] dark:hover:border-[var(--surface-7)] dark:hover:bg-[var(--border-1)]'
>
<div
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'}`}
>
{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 */}
{!isExpanded && needsExpansion && (
<div className='pointer-events-none absolute right-0 bottom-0 left-0 h-6 bg-gradient-to-t from-0% from-[var(--surface-4)] via-25% via-[var(--surface-4)] to-100% to-transparent opacity-40 group-hover:opacity-30 dark:from-[var(--surface-4)] dark:via-[var(--surface-4)] dark:group-hover:from-[var(--border-1)] dark:group-hover:via-[var(--border-1)]' />
)}
{/* Abort button when hovering and response is generating (only on last user message) */}
{isSendingMessage && isHoveringMessage && isLastUserMessage && (
<div className='pointer-events-auto absolute right-[6px] bottom-[6px]'>
<Button
onClick={(e) => {
e.stopPropagation()
abortMessage()
}}
className='h-[20px] w-[20px] rounded-full border-0 bg-[var(--c-383838)] p-0 transition-colors hover:bg-[var(--c-575757)] dark:bg-[var(--c-E0E0E0)] dark:hover:bg-[var(--c-CFCFCF)]'
title='Stop generation'
>
<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>
</div>
)}
{/* Revert button on hover (only when has checkpoints and not generating) */}
{!isSendingMessage && hasCheckpoints && isHoveringMessage && (
<div className='pointer-events-auto absolute right-[6px] bottom-[6px]'>
<Button
onClick={(e) => {
e.stopPropagation()
handleRevertToCheckpoint()
}}
variant='ghost'
className='h-[22px] w-[22px] rounded-full p-0'
title='Revert to checkpoint'
>
<RotateCcw className='h-3.5 w-3.5' />
</Button>
</div>
)}
</div>
</div>
)}
{/* Inline restore checkpoint confirmation */}
{showRestoreConfirmation && (
<CheckpointConfirmation
variant='restore'
isProcessing={isReverting}
onCancel={handleCancelRevert}
onRevert={handleConfirmRevert}
/>
)}
</div>
)
}
if (isAssistant) {
return (
<div
className={`group/msg relative w-full max-w-full flex-none overflow-hidden [max-width:var(--panel-max-width)] ${isDimmed ? 'opacity-40' : 'opacity-100'}`}
style={{ '--panel-max-width': `${panelWidth - 16}px` } as React.CSSProperties}
>
{!isStreaming && (message.content || message.contentBlocks?.length) && (
<div className='absolute right-0 bottom-0 z-10'>
<MessageActions content={message.content} requestId={message.requestId} />
</div>
)}
<div className='max-w-full space-y-[4px] px-[2px] pb-5'>
{/* Content blocks in chronological order */}
{memoizedContentBlocks || (isStreaming && <div className='min-h-0' />)}
{isStreaming && <StreamingIndicator />}
{message.errorType === 'usage_limit' && (
<div className='flex gap-1.5'>
<UsageLimitActions />
</div>
)}
{/* Citations if available */}
{message.citations && message.citations.length > 0 && (
<div className='pt-1'>
<div className='font-medium text-muted-foreground text-xs'>Sources:</div>
<div className='flex flex-wrap gap-2'>
{message.citations.map((citation) => (
<a
key={citation.id}
href={citation.url}
target='_blank'
rel='noopener noreferrer'
className='inline-flex max-w-full items-center rounded-md border bg-muted/50 px-2 py-1 text-muted-foreground text-xs transition-colors hover:bg-muted hover:text-foreground'
>
<span className='truncate'>{citation.title}</span>
</a>
))}
</div>
</div>
)}
{/* Options selector when agent presents choices - streams in but disabled until complete */}
{/* Disabled for previous messages (not isLastMessage) so only the latest options are interactive */}
{parsedTags?.options && Object.keys(parsedTags.options).length > 0 && (
<OptionsSelector
options={parsedTags.options}
onSelect={handleOptionSelect}
disabled={!isLastMessage || isSendingMessage || isStreaming}
enableKeyboardNav={
isLastMessage && !isStreaming && parsedTags.optionsComplete === true
}
streaming={isStreaming || !parsedTags.optionsComplete}
selectedOptionKey={selectedOptionKey}
/>
)}
</div>
</div>
)
}
return null
},
(prevProps, nextProps) => {
const prevMessage = prevProps.message
const nextMessage = nextProps.message
if (prevMessage.id !== nextMessage.id) return false
if (prevProps.isStreaming !== nextProps.isStreaming) return false
if (prevProps.isDimmed !== nextProps.isDimmed) return false
if (prevProps.panelWidth !== nextProps.panelWidth) return false
if (prevProps.checkpointCount !== nextProps.checkpointCount) return false
if (prevProps.isLastMessage !== nextProps.isLastMessage) return false
if (nextProps.isStreaming) {
const prevBlocks = prevMessage.contentBlocks || []
const nextBlocks = nextMessage.contentBlocks || []
if (prevBlocks.length !== nextBlocks.length) return false
const getLastBlockContent = (blocks: any[], type: 'text' | 'thinking'): string | null => {
for (let i = blocks.length - 1; i >= 0; i--) {
const block = blocks[i]
if (block && block.type === type) {
return (block as any).content ?? ''
}
}
return null
}
const prevLastTextContent = getLastBlockContent(prevBlocks as any[], 'text')
const nextLastTextContent = getLastBlockContent(nextBlocks as any[], 'text')
if (
prevLastTextContent !== null &&
nextLastTextContent !== null &&
prevLastTextContent !== nextLastTextContent
) {
return false
}
const prevLastThinkingContent = getLastBlockContent(prevBlocks as any[], 'thinking')
const nextLastThinkingContent = getLastBlockContent(nextBlocks as any[], 'thinking')
if (
prevLastThinkingContent !== null &&
nextLastThinkingContent !== null &&
prevLastThinkingContent !== nextLastThinkingContent
) {
return false
}
const prevToolCalls = prevMessage.toolCalls || []
const nextToolCalls = nextMessage.toolCalls || []
if (prevToolCalls.length !== nextToolCalls.length) return false
for (let i = 0; i < nextToolCalls.length; i++) {
if (prevToolCalls[i]?.state !== nextToolCalls[i]?.state) return false
}
return true
}
if (
prevMessage.content !== nextMessage.content ||
prevMessage.role !== nextMessage.role ||
(prevMessage.toolCalls?.length || 0) !== (nextMessage.toolCalls?.length || 0) ||
(prevMessage.contentBlocks?.length || 0) !== (nextMessage.contentBlocks?.length || 0)
) {
return false
}
const prevToolCalls = prevMessage.toolCalls || []
const nextToolCalls = nextMessage.toolCalls || []
for (let i = 0; i < nextToolCalls.length; i++) {
if (prevToolCalls[i]?.state !== nextToolCalls[i]?.state) return false
}
const prevContentBlocks = prevMessage.contentBlocks || []
const nextContentBlocks = nextMessage.contentBlocks || []
for (let i = 0; i < nextContentBlocks.length; i++) {
const prevBlock = prevContentBlocks[i]
const nextBlock = nextContentBlocks[i]
if (
prevBlock?.type === 'tool_call' &&
nextBlock?.type === 'tool_call' &&
prevBlock.toolCall?.state !== nextBlock.toolCall?.state
) {
return false
}
}
return true
}
)
CopilotMessage.displayName = 'CopilotMessage'
export { CopilotMessage }

View File

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

View File

@@ -1,264 +0,0 @@
'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import type { CopilotMessage } from '@/stores/panel'
import { useCopilotStore } from '@/stores/panel'
const logger = createLogger('useCheckpointManagement')
/**
* Custom hook to handle checkpoint-related operations for messages
*
* @param message - The copilot message
* @param messages - Array of all messages in the chat
* @param messageCheckpoints - Checkpoints for this message
* @param onRevertModeChange - Callback for revert mode changes
* @param onEditModeChange - Callback for edit mode changes
* @param onCancelEdit - Callback when edit is cancelled
* @returns Checkpoint management utilities
*/
export function useCheckpointManagement(
message: CopilotMessage,
messages: CopilotMessage[],
messageCheckpoints: any[],
onRevertModeChange?: (isReverting: boolean) => void,
onEditModeChange?: (isEditing: boolean) => void,
onCancelEdit?: () => void
) {
const [showRestoreConfirmation, setShowRestoreConfirmation] = useState(false)
const [showCheckpointDiscardModal, setShowCheckpointDiscardModal] = useState(false)
const [isReverting, setIsReverting] = useState(false)
const [isProcessingDiscard, setIsProcessingDiscard] = useState(false)
const pendingEditRef = useRef<{
message: string
fileAttachments?: any[]
contexts?: any[]
} | null>(null)
const { revertToCheckpoint, currentChat } = useCopilotStore()
/** Initiates checkpoint revert confirmation */
const handleRevertToCheckpoint = useCallback(() => {
setShowRestoreConfirmation(true)
onRevertModeChange?.(true)
}, [onRevertModeChange])
/** Confirms and executes checkpoint revert */
const handleConfirmRevert = useCallback(async () => {
if (messageCheckpoints.length > 0) {
const latestCheckpoint = messageCheckpoints[0]
setIsReverting(true)
try {
await revertToCheckpoint(latestCheckpoint.id)
const { messageCheckpoints: currentCheckpoints } = useCopilotStore.getState()
const updatedCheckpoints = {
...currentCheckpoints,
[message.id]: [],
}
useCopilotStore.setState({ messageCheckpoints: updatedCheckpoints })
const currentMessages = messages
const revertIndex = currentMessages.findIndex((m) => m.id === message.id)
if (revertIndex !== -1) {
const truncatedMessages = currentMessages.slice(0, revertIndex + 1)
useCopilotStore.setState({ messages: truncatedMessages })
if (currentChat?.id) {
try {
await fetch('/api/copilot/chat/update-messages', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
chatId: currentChat.id,
messages: truncatedMessages.map((m) => ({
id: m.id,
role: m.role,
content: m.content,
timestamp: m.timestamp,
...(m.contentBlocks && { contentBlocks: m.contentBlocks }),
...(m.fileAttachments && { fileAttachments: m.fileAttachments }),
...((m as any).contexts && { contexts: (m as any).contexts }),
})),
}),
})
} catch (error) {
logger.error('Failed to update messages in DB after revert:', error)
}
}
}
setShowRestoreConfirmation(false)
onRevertModeChange?.(false)
logger.info('Checkpoint reverted and removed from message', {
messageId: message.id,
checkpointId: latestCheckpoint.id,
})
} catch (error) {
logger.error('Failed to revert to checkpoint:', error)
setShowRestoreConfirmation(false)
onRevertModeChange?.(false)
} finally {
setIsReverting(false)
}
}
}, [
messageCheckpoints,
revertToCheckpoint,
message.id,
messages,
currentChat,
onRevertModeChange,
])
/** Cancels checkpoint revert */
const handleCancelRevert = useCallback(() => {
setShowRestoreConfirmation(false)
onRevertModeChange?.(false)
}, [onRevertModeChange])
/** Reverts to checkpoint then proceeds with pending edit */
const handleContinueAndRevert = useCallback(async () => {
setIsProcessingDiscard(true)
try {
if (messageCheckpoints.length > 0) {
const latestCheckpoint = messageCheckpoints[0]
try {
await revertToCheckpoint(latestCheckpoint.id)
const { messageCheckpoints: currentCheckpoints } = useCopilotStore.getState()
const updatedCheckpoints = {
...currentCheckpoints,
[message.id]: [],
}
useCopilotStore.setState({ messageCheckpoints: updatedCheckpoints })
logger.info('Reverted to checkpoint before editing message', {
messageId: message.id,
checkpointId: latestCheckpoint.id,
})
} catch (error) {
logger.error('Failed to revert to checkpoint:', error)
}
}
setShowCheckpointDiscardModal(false)
onEditModeChange?.(false)
onCancelEdit?.()
const { sendMessage } = useCopilotStore.getState()
if (pendingEditRef.current) {
const { message: msg, fileAttachments, contexts } = pendingEditRef.current
const editIndex = messages.findIndex((m) => m.id === message.id)
if (editIndex !== -1) {
const truncatedMessages = messages.slice(0, editIndex)
const updatedMessage = {
...message,
content: msg,
fileAttachments: fileAttachments || message.fileAttachments,
contexts: contexts || (message as any).contexts,
}
useCopilotStore.setState({ messages: [...truncatedMessages, updatedMessage] })
await sendMessage(msg, {
fileAttachments: fileAttachments || message.fileAttachments,
contexts: contexts || (message as any).contexts,
messageId: message.id,
queueIfBusy: false,
})
}
pendingEditRef.current = null
}
} finally {
setIsProcessingDiscard(false)
}
}, [messageCheckpoints, revertToCheckpoint, message, messages, onEditModeChange, onCancelEdit])
/** Cancels checkpoint discard and clears pending edit */
const handleCancelCheckpointDiscard = useCallback(() => {
setShowCheckpointDiscardModal(false)
onEditModeChange?.(false)
onCancelEdit?.()
pendingEditRef.current = null
}, [onEditModeChange, onCancelEdit])
/** Continues with edit without reverting checkpoint */
const handleContinueWithoutRevert = useCallback(async () => {
setShowCheckpointDiscardModal(false)
onEditModeChange?.(false)
onCancelEdit?.()
if (pendingEditRef.current) {
const { message: msg, fileAttachments, contexts } = pendingEditRef.current
const { sendMessage } = useCopilotStore.getState()
const editIndex = messages.findIndex((m) => m.id === message.id)
if (editIndex !== -1) {
const truncatedMessages = messages.slice(0, editIndex)
const updatedMessage = {
...message,
content: msg,
fileAttachments: fileAttachments || message.fileAttachments,
contexts: contexts || (message as any).contexts,
}
useCopilotStore.setState({ messages: [...truncatedMessages, updatedMessage] })
await sendMessage(msg, {
fileAttachments: fileAttachments || message.fileAttachments,
contexts: contexts || (message as any).contexts,
messageId: message.id,
queueIfBusy: false,
})
}
pendingEditRef.current = null
}
}, [message, messages, onEditModeChange, onCancelEdit])
/** Handles keyboard events for confirmation dialogs */
useEffect(() => {
const isActive = showRestoreConfirmation || showCheckpointDiscardModal
if (!isActive) return
const handleKeyDown = (event: KeyboardEvent) => {
if (event.defaultPrevented) return
if (event.key === 'Escape') {
if (showRestoreConfirmation) handleCancelRevert()
else handleCancelCheckpointDiscard()
} else if (event.key === 'Enter') {
event.preventDefault()
if (showRestoreConfirmation) handleConfirmRevert()
else handleContinueAndRevert()
}
}
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [
showRestoreConfirmation,
showCheckpointDiscardModal,
handleCancelRevert,
handleConfirmRevert,
handleCancelCheckpointDiscard,
handleContinueAndRevert,
])
return {
// State
showRestoreConfirmation,
showCheckpointDiscardModal,
isReverting,
isProcessingDiscard,
pendingEditRef,
// Operations
setShowCheckpointDiscardModal,
handleRevertToCheckpoint,
handleConfirmRevert,
handleCancelRevert,
handleCancelCheckpointDiscard,
handleContinueWithoutRevert,
handleContinueAndRevert,
}
}

View File

@@ -1,256 +0,0 @@
'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
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 */
const MESSAGE_TRUNCATION_HEIGHT = 60
/** Delay before attaching click-outside listener to avoid immediate trigger */
const CLICK_OUTSIDE_DELAY = 100
/** Delay before aborting when editing during stream */
const ABORT_DELAY = 100
interface UseMessageEditingProps {
message: CopilotMessage
messages: CopilotMessage[]
isLastUserMessage: boolean
hasCheckpoints: boolean
onEditModeChange?: (isEditing: boolean) => void
showCheckpointDiscardModal: boolean
setShowCheckpointDiscardModal: (show: boolean) => void
pendingEditRef: React.MutableRefObject<{
message: string
fileAttachments?: MessageFileAttachment[]
contexts?: ChatContext[]
} | null>
/**
* When true, disables the internal document click-outside handler.
* Use when a parent component provides its own click-outside handling.
*/
disableDocumentClickOutside?: boolean
}
/**
* Custom hook to manage message editing functionality
* Handles edit mode state, expansion, click handlers, and edit submission
*
* @param props - Message editing configuration
* @returns Message editing state and handlers
*/
export function useMessageEditing(props: UseMessageEditingProps) {
const {
message,
messages,
isLastUserMessage,
hasCheckpoints,
onEditModeChange,
showCheckpointDiscardModal,
setShowCheckpointDiscardModal,
pendingEditRef,
disableDocumentClickOutside = false,
} = props
const [isEditMode, setIsEditMode] = useState(false)
const [isExpanded, setIsExpanded] = useState(false)
const [editedContent, setEditedContent] = useState(message.content)
const [needsExpansion, setNeedsExpansion] = useState(false)
const editContainerRef = useRef<HTMLDivElement>(null)
const messageContentRef = useRef<HTMLDivElement>(null)
const userInputRef = useRef<UserInputRef>(null)
const { sendMessage, isSendingMessage, abortMessage, currentChat } = useCopilotStore()
/** Checks if message content needs expansion based on height */
useEffect(() => {
if (messageContentRef.current && message.role === 'user') {
const scrollHeight = messageContentRef.current.scrollHeight
setNeedsExpansion(scrollHeight > MESSAGE_TRUNCATION_HEIGHT)
}
}, [message.content, message.role])
/** Enters edit mode */
const handleEditMessage = useCallback(() => {
setIsEditMode(true)
setIsExpanded(false)
setEditedContent(message.content)
onEditModeChange?.(true)
setTimeout(() => {
userInputRef.current?.focus()
}, 0)
}, [message.content, onEditModeChange])
/** Cancels edit mode */
const handleCancelEdit = useCallback(() => {
setIsEditMode(false)
setEditedContent(message.content)
onEditModeChange?.(false)
}, [message.content, onEditModeChange])
/** Handles message click to enter edit mode */
const handleMessageClick = useCallback(() => {
if (needsExpansion && !isExpanded) {
setIsExpanded(true)
}
handleEditMessage()
}, [needsExpansion, isExpanded, handleEditMessage])
/** Performs the edit operation - truncates messages after edited message and resends */
const performEdit = useCallback(
async (
editedMessage: string,
fileAttachments?: MessageFileAttachment[],
contexts?: ChatContext[]
) => {
const currentMessages = messages
const editIndex = currentMessages.findIndex((m) => m.id === message.id)
if (editIndex !== -1) {
setIsEditMode(false)
onEditModeChange?.(false)
const truncatedMessages = currentMessages.slice(0, editIndex)
const updatedMessage = {
...message,
content: editedMessage,
fileAttachments: fileAttachments || message.fileAttachments,
contexts: contexts || message.contexts,
}
useCopilotStore.setState({ messages: [...truncatedMessages, updatedMessage] })
if (currentChat?.id) {
try {
await fetch('/api/copilot/chat/update-messages', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
chatId: currentChat.id,
messages: truncatedMessages.map((m) => ({
id: m.id,
role: m.role,
content: m.content,
timestamp: m.timestamp,
...(m.contentBlocks && { contentBlocks: m.contentBlocks }),
...(m.fileAttachments && { fileAttachments: m.fileAttachments }),
...(m.contexts && { contexts: m.contexts }),
})),
}),
})
} catch (error) {
logger.error('Failed to update messages in DB after edit:', error)
}
}
await sendMessage(editedMessage, {
fileAttachments: fileAttachments || message.fileAttachments,
contexts: contexts || message.contexts,
messageId: message.id,
queueIfBusy: false,
})
}
},
[messages, message, currentChat, sendMessage, onEditModeChange]
)
/** Submits edited message, checking for checkpoints first */
const handleSubmitEdit = useCallback(
async (
editedMessage: string,
fileAttachments?: MessageFileAttachment[],
contexts?: ChatContext[]
) => {
if (!editedMessage.trim()) return
if (isSendingMessage) {
abortMessage()
await new Promise((resolve) => setTimeout(resolve, ABORT_DELAY))
}
if (hasCheckpoints) {
pendingEditRef.current = { message: editedMessage, fileAttachments, contexts }
setShowCheckpointDiscardModal(true)
return
}
await performEdit(editedMessage, fileAttachments, contexts)
},
[
isSendingMessage,
hasCheckpoints,
abortMessage,
performEdit,
pendingEditRef,
setShowCheckpointDiscardModal,
]
)
/** Keyboard-only exit (Esc) */
useEffect(() => {
if (!isEditMode) return
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
handleCancelEdit()
}
}
document.addEventListener('keydown', handleKeyDown)
return () => {
document.removeEventListener('keydown', handleKeyDown)
}
}, [isEditMode, handleCancelEdit])
/** Optional document-level click-outside handler */
useEffect(() => {
if (!isEditMode || disableDocumentClickOutside) return
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as HTMLElement
if (editContainerRef.current?.contains(target)) {
return
}
handleCancelEdit()
}
const timeoutId = setTimeout(() => {
document.addEventListener('click', handleClickOutside, true)
}, CLICK_OUTSIDE_DELAY)
return () => {
clearTimeout(timeoutId)
document.removeEventListener('click', handleClickOutside, true)
}
}, [isEditMode, disableDocumentClickOutside, handleCancelEdit])
return {
// State
isEditMode,
isExpanded,
editedContent,
needsExpansion,
// Refs
editContainerRef,
messageContentRef,
userInputRef,
// Operations
setEditedContent,
handleCancelEdit,
handleMessageClick,
handleSubmitEdit,
}
}

View File

@@ -1,8 +0,0 @@
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

@@ -1,281 +0,0 @@
/**
* Plan Mode Section component with resizable markdown content display.
* Displays markdown content in a separate section at the top of the copilot panel.
* Follows emcn design principles with consistent spacing, typography, and color scheme.
*
* @example
* ```tsx
* import { PlanModeSection } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components'
*
* function CopilotPanel() {
* const plan = "# My Plan\n\nThis is a plan description..."
*
* return (
* <PlanModeSection
* content={plan}
* initialHeight={200}
* minHeight={100}
* maxHeight={600}
* />
* )
* }
* ```
*/
'use client'
import * as React from 'react'
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'
/**
* Shared border and background styles
*/
const SURFACE_5 = 'bg-[var(--surface-4)]'
const SURFACE_9 = 'bg-[var(--surface-5)]'
const BORDER_STRONG = 'border-[var(--border-1)]'
export interface PlanModeSectionProps {
/**
* Markdown content to display
*/
content: string
/**
* Optional class name for additional styling
*/
className?: string
/**
* Initial height of the section in pixels
* @default 180
*/
initialHeight?: number
/**
* Minimum height in pixels
* @default 80
*/
minHeight?: number
/**
* Maximum height in pixels
* @default 600
*/
maxHeight?: number
/**
* Callback function when clear button is clicked
*/
onClear?: () => void
/**
* Callback function when save button is clicked
* Receives the current content as parameter
*/
onSave?: (content: string) => void
/**
* Callback when Build Plan button is clicked
*/
onBuildPlan?: () => void
}
/**
* Plan Mode Section component for displaying markdown content with resizable height.
* Features: pinned position, resizable height with drag handle, internal scrolling.
*/
const PlanModeSection: React.FC<PlanModeSectionProps> = ({
content,
className,
initialHeight,
minHeight = 80,
maxHeight = 600,
onClear,
onSave,
onBuildPlan,
}) => {
// Default to 75% of max height
const defaultHeight = initialHeight ?? Math.floor(maxHeight * 0.75)
const [height, setHeight] = React.useState(defaultHeight)
const [isResizing, setIsResizing] = React.useState(false)
const [isEditing, setIsEditing] = React.useState(false)
const [editedContent, setEditedContent] = React.useState(content)
const [prevContent, setPrevContent] = React.useState(content)
if (!isEditing && content !== prevContent) {
setPrevContent(content)
setEditedContent(content)
}
const resizeStartRef = React.useRef({ y: 0, startHeight: 0 })
const textareaRef = React.useRef<HTMLTextAreaElement>(null)
const handleResizeStart = React.useCallback(
(e: React.MouseEvent) => {
e.preventDefault()
setIsResizing(true)
resizeStartRef.current = {
y: e.clientY,
startHeight: height,
}
},
[height]
)
const handleResizeMove = React.useCallback(
(e: MouseEvent) => {
if (!isResizing) return
const deltaY = e.clientY - resizeStartRef.current.y
const newHeight = Math.max(
minHeight,
Math.min(maxHeight, resizeStartRef.current.startHeight + deltaY)
)
setHeight(newHeight)
},
[isResizing, minHeight, maxHeight]
)
const handleResizeEnd = React.useCallback(() => {
setIsResizing(false)
}, [])
React.useEffect(() => {
if (isResizing) {
document.addEventListener('mousemove', handleResizeMove)
document.addEventListener('mouseup', handleResizeEnd)
document.body.style.cursor = 'ns-resize'
document.body.style.userSelect = 'none'
return () => {
document.removeEventListener('mousemove', handleResizeMove)
document.removeEventListener('mouseup', handleResizeEnd)
document.body.style.cursor = ''
document.body.style.userSelect = ''
}
}
}, [isResizing, handleResizeMove, handleResizeEnd])
const handleEdit = React.useCallback(() => {
setIsEditing(true)
setEditedContent(content)
setTimeout(() => {
textareaRef.current?.focus()
}, 50)
}, [content])
const handleSave = React.useCallback(() => {
if (onSave && editedContent.trim() !== content.trim()) {
onSave(editedContent.trim())
}
setIsEditing(false)
}, [editedContent, content, onSave])
const handleCancel = React.useCallback(() => {
setEditedContent(content)
setIsEditing(false)
}, [content])
if (!content || !content.trim()) {
return null
}
return (
<div
className={cn('relative flex flex-col rounded-[4px]', SURFACE_5, className)}
style={{ height: `${height}px` }}
>
{/* Header with build/edit/save/clear buttons */}
<div className='flex flex-shrink-0 items-center justify-between border-[var(--border-1)] border-b py-[6px] pr-[2px] pl-[12px]'>
<span className='font-[500] text-[11px] text-[var(--text-secondary)] uppercase tracking-wide'>
Workflow Plan
</span>
<div className='ml-auto flex items-center gap-[4px]'>
{isEditing ? (
<>
<Button
variant='ghost'
className='h-[18px] w-[18px] p-0 hover:text-[var(--text-primary)]'
onClick={handleCancel}
aria-label='Cancel editing'
>
<X className='h-[11px] w-[11px]' />
</Button>
<Button
variant='ghost'
className='h-[18px] w-[18px] p-0 hover:text-[var(--text-primary)]'
onClick={handleSave}
aria-label='Save changes'
>
<Check className='h-[12px] w-[12px]' />
</Button>
</>
) : (
<>
{onBuildPlan && (
<Button
variant='default'
onClick={onBuildPlan}
className='h-[22px] px-[10px] text-[11px]'
title='Build workflow from plan'
>
Build Plan
</Button>
)}
{onSave && (
<Button
variant='ghost'
className='h-[18px] w-[18px] p-0 hover:text-[var(--text-primary)]'
onClick={handleEdit}
aria-label='Edit workflow plan'
>
<Pencil className='h-[10px] w-[10px]' />
</Button>
)}
{onClear && (
<Button
variant='ghost'
className='h-[18px] w-[18px] p-0 hover:text-[var(--text-primary)]'
onClick={onClear}
aria-label='Clear workflow plan'
>
<Trash className='h-[11px] w-[11px]' />
</Button>
)}
</>
)}
</div>
</div>
{/* Scrollable content area */}
<div className='flex-1 overflow-y-auto overflow-x-hidden px-[12px] py-[10px]'>
{isEditing ? (
<Textarea
ref={textareaRef}
value={editedContent}
onChange={(e) => setEditedContent(e.target.value)}
className='h-full min-h-full w-full resize-none border-0 bg-transparent p-0 font-[470] font-season text-[13px] text-[var(--text-primary)] leading-[1.4rem] outline-none ring-0 focus-visible:ring-0 focus-visible:ring-offset-0'
placeholder='Enter your workflow plan...'
/>
) : (
<CopilotMarkdownRenderer content={content.trim()} />
)}
</div>
{/* Resize handle */}
<div
className={cn(
'group flex h-[20px] w-full cursor-ns-resize items-center justify-center border-t',
BORDER_STRONG,
'transition-colors hover:bg-[var(--surface-5)]',
isResizing && SURFACE_9
)}
onMouseDown={handleResizeStart}
role='separator'
aria-orientation='horizontal'
aria-label='Resize plan section'
>
<GripHorizontal className='h-3 w-3 text-[var(--text-secondary)] transition-colors group-hover:text-[var(--text-primary)]' />
</div>
</div>
)
}
PlanModeSection.displayName = 'PlanModeSection'
export { PlanModeSection }

View File

@@ -1,103 +0,0 @@
'use client'
import { useCallback, useState } from 'react'
import { ArrowUp, ChevronDown, ChevronRight, Trash2 } from 'lucide-react'
import { useCopilotStore } from '@/stores/panel/copilot/store'
/**
* Displays queued messages in a Cursor-style collapsible panel above the input box.
*/
export function QueuedMessages() {
const messageQueue = useCopilotStore((s) => s.messageQueue)
const removeFromQueue = useCopilotStore((s) => s.removeFromQueue)
const sendNow = useCopilotStore((s) => s.sendNow)
const [isExpanded, setIsExpanded] = useState(true)
const handleRemove = useCallback(
(id: string) => {
removeFromQueue(id)
},
[removeFromQueue]
)
const handleSendNow = useCallback(
async (id: string) => {
await sendNow(id)
},
[sendNow]
)
if (messageQueue.length === 0) return null
return (
<div className='mx-[14px] overflow-hidden rounded-t-[4px] border border-[var(--border)] border-b-0 bg-[var(--bg-secondary)]'>
{/* Header */}
<button
type='button'
onClick={() => setIsExpanded(!isExpanded)}
className='flex w-full items-center justify-between px-[10px] py-[6px] transition-colors hover:bg-[var(--surface-3)]'
>
<div className='flex items-center gap-[6px]'>
{isExpanded ? (
<ChevronDown className='h-[14px] w-[14px] text-[var(--text-tertiary)]' />
) : (
<ChevronRight className='h-[14px] w-[14px] text-[var(--text-tertiary)]' />
)}
<span className='font-medium text-[12px] text-[var(--text-primary)]'>Queued</span>
<span className='flex-shrink-0 font-medium text-[12px] text-[var(--text-tertiary)]'>
{messageQueue.length}
</span>
</div>
</button>
{/* Message list */}
{isExpanded && (
<div>
{messageQueue.map((msg) => (
<div
key={msg.id}
className='group flex items-center gap-[8px] border-[var(--border)] border-t px-[10px] py-[6px] hover:bg-[var(--surface-3)]'
>
{/* Radio indicator */}
<div className='flex h-[14px] w-[14px] shrink-0 items-center justify-center'>
<div className='h-[10px] w-[10px] rounded-full border border-[var(--text-tertiary)]/50' />
</div>
{/* Message content */}
<div className='min-w-0 flex-1'>
<p className='truncate text-[13px] text-[var(--text-primary)]'>{msg.content}</p>
</div>
{/* Actions - always visible */}
<div className='flex shrink-0 items-center gap-[4px]'>
<button
type='button'
onClick={(e) => {
e.stopPropagation()
handleSendNow(msg.id)
}}
className='rounded p-[3px] text-[var(--text-tertiary)] transition-colors hover:bg-[var(--bg-quaternary)] hover:text-[var(--text-primary)]'
title='Send now (aborts current stream)'
>
<ArrowUp className='h-[14px] w-[14px]' />
</button>
<button
type='button'
onClick={(e) => {
e.stopPropagation()
handleRemove(msg.id)
}}
className='rounded p-[3px] text-[var(--text-tertiary)] transition-colors hover:bg-red-500/10 hover:text-red-400'
title='Remove from queue'
>
<Trash2 className='h-[14px] w-[14px]' />
</button>
</div>
</div>
))}
</div>
)}
</div>
)
}

View File

@@ -1,157 +0,0 @@
'use client'
import { memo, useState } from 'react'
import { Check, ChevronDown, ChevronRight, Loader2, X } from 'lucide-react'
import { Button } from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
/**
* Represents a single todo item
*/
export interface TodoItem {
/** Unique identifier for the todo */
id: string
/** Todo content/description */
content: string
/** Whether the todo is completed */
completed?: boolean
/** Whether the todo is currently being executed */
executing?: boolean
}
/**
* Props for the TodoList component
*/
interface TodoListProps {
/** Array of todo items to display */
todos: TodoItem[]
/** Callback when close button is clicked */
onClose?: () => void
/** Whether the list should be collapsed */
collapsed?: boolean
/** Additional CSS classes */
className?: string
}
/**
* Todo list component for displaying agent plan tasks
* Shows progress bar and allows collapsing/expanding
*
* @param props - Component props
* @returns Todo list UI with progress indicator
*/
export const TodoList = memo(function TodoList({
todos,
onClose,
collapsed = false,
className,
}: TodoListProps) {
const [isCollapsed, setIsCollapsed] = useState(collapsed)
const [prevCollapsed, setPrevCollapsed] = useState(collapsed)
if (collapsed !== prevCollapsed) {
setPrevCollapsed(collapsed)
setIsCollapsed(collapsed)
}
if (!todos || todos.length === 0) {
return null
}
const completedCount = todos.filter((todo) => todo.completed).length
const totalCount = todos.length
const progress = totalCount > 0 ? (completedCount / totalCount) * 100 : 0
return (
<div
className={cn(
'w-full rounded-t-[4px] rounded-b-none border-[var(--border-1)] border-x border-t bg-[var(--surface-5)] dark:bg-[var(--surface-5)]',
className
)}
>
{/* Header - always visible */}
<div className='flex items-center justify-between px-[5.5px] py-[5px]'>
<div className='flex items-center gap-[8px]'>
<Button
variant='ghost'
onClick={() => setIsCollapsed(!isCollapsed)}
className='!h-[14px] !w-[14px] !p-0'
>
{isCollapsed ? (
<ChevronRight className='h-[14px] w-[14px]' />
) : (
<ChevronDown className='h-[14px] w-[14px]' />
)}
</Button>
<span className='font-medium text-[var(--text-primary)] text-xs'>Todo:</span>
<span className='font-medium text-[var(--text-primary)] text-xs'>
{completedCount}/{totalCount}
</span>
</div>
<div className='flex flex-1 items-center gap-[8px] pl-[10px]'>
{/* Progress bar */}
<div className='h-1.5 flex-1 overflow-hidden rounded-full bg-[var(--border-1)]'>
<div
className='h-full bg-[var(--brand-400)] transition-all duration-300 ease-out'
style={{ width: `${progress}%` }}
/>
</div>
{onClose && (
<Button
variant='ghost'
onClick={onClose}
className='!h-[14px] !w-[14px] !p-0'
aria-label='Close todo list'
>
<X className='h-[14px] w-[14px]' />
</Button>
)}
</div>
</div>
{/* Todo items - only show when not collapsed */}
{!isCollapsed && (
<div className='max-h-48 overflow-y-auto'>
{todos.map((todo, index) => (
<div
key={todo.id}
className={cn(
'flex items-start gap-2 px-3 py-1.5 transition-colors hover:bg-[var(--surface-5)]/50 dark:hover:bg-[var(--border-1)]/50',
index !== todos.length - 1 && 'border-[var(--border-1)] border-b'
)}
>
{todo.executing ? (
<div className='mt-0.5 flex h-4 w-4 flex-shrink-0 items-center justify-center'>
<Loader2 className='h-3 w-3 animate-spin text-[var(--text-primary)]' />
</div>
) : (
<div
className={cn(
'mt-0.5 flex h-4 w-4 flex-shrink-0 items-center justify-center rounded border transition-all',
todo.completed
? 'border-[var(--brand-400)] bg-[var(--brand-400)]'
: 'border-[var(--text-muted)]'
)}
>
{todo.completed ? <Check className='h-3 w-3 text-white' strokeWidth={3} /> : null}
</div>
)}
<span
className={cn(
'flex-1 font-base text-[12px] leading-relaxed',
todo.completed
? 'text-[var(--text-muted)] line-through'
: 'text-[var(--text-primary)]'
)}
>
{todo.content}
</span>
</div>
))}
</div>
)}
</div>
)
})

View File

@@ -1,122 +0,0 @@
'use client'
import { FileText, Image, Loader2, X } from 'lucide-react'
import { Button } from '@/components/emcn'
interface AttachedFile {
id: string
name: string
size: number
type: string
path: string
key?: string
uploading: boolean
previewUrl?: string
}
interface AttachedFilesDisplayProps {
/** Array of attached files to display */
files: AttachedFile[]
/** Callback when a file is clicked to open/preview */
onFileClick: (file: AttachedFile) => void
/** Callback when a file is removed */
onFileRemove: (fileId: string) => void
/** Format file size helper from hook */
formatFileSize: (bytes: number) => string
/** Get file icon type helper from hook */
getFileIconType: (mediaType: string) => 'image' | 'pdf' | 'text' | 'default'
}
/**
* Returns icon component for file type
*
* @param iconType - The file icon type
* @returns React icon component
*/
function getFileIconComponent(iconType: 'image' | 'pdf' | 'text' | 'default') {
switch (iconType) {
case 'image':
return <Image className='h-5 w-5 text-muted-foreground' />
case 'pdf':
return <FileText className='h-5 w-5 text-red-500' />
case 'text':
return <FileText className='h-5 w-5 text-blue-500' />
default:
return <FileText className='h-5 w-5 text-muted-foreground' />
}
}
/**
* Displays attached files with thumbnails, loading states, and remove buttons.
* Shows image previews for image files and icons for other file types.
*
* @param props - Component props
* @returns Rendered file attachments or null if no files
*/
export function AttachedFilesDisplay({
files,
onFileClick,
onFileRemove,
formatFileSize,
getFileIconType,
}: AttachedFilesDisplayProps) {
if (files.length === 0) {
return null
}
const isImageFile = (type: string) => type.startsWith('image/')
return (
<div className='mb-2 flex gap-1.5 overflow-x-auto [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden'>
{files.map((file) => (
<div
key={file.id}
className='group relative h-16 w-16 flex-shrink-0 cursor-pointer overflow-hidden rounded-md border bg-muted/20 transition-all hover:bg-muted/40'
title={`${file.name} (${formatFileSize(file.size)})`}
onClick={() => onFileClick(file)}
>
{isImageFile(file.type) && file.previewUrl ? (
/* For images, show actual thumbnail */
<img src={file.previewUrl} alt={file.name} className='h-full w-full object-cover' />
) : isImageFile(file.type) && file.key ? (
/* For uploaded images without preview URL, use storage URL */
<img
src={file.previewUrl || file.path}
alt={file.name}
className='h-full w-full object-cover'
/>
) : (
/* For other files, show icon centered */
<div className='flex h-full w-full items-center justify-center bg-background/50'>
{getFileIconComponent(getFileIconType(file.type))}
</div>
)}
{/* Loading overlay */}
{file.uploading && (
<div className='absolute inset-0 flex items-center justify-center bg-black/50'>
<Loader2 className='h-4 w-4 animate-spin text-white' />
</div>
)}
{/* Remove button */}
{!file.uploading && (
<Button
variant='ghost'
onClick={(e) => {
e.stopPropagation()
onFileRemove(file.id)
}}
className='absolute top-0.5 right-0.5 h-5 w-5 p-0 opacity-0 transition-opacity group-hover:opacity-100'
>
<X className='h-3 w-3' />
</Button>
)}
{/* Hover overlay effect */}
<div className='pointer-events-none absolute inset-0 bg-black/10 opacity-0 transition-opacity group-hover:opacity-100' />
</div>
))}
</div>
)
}

View File

@@ -1,127 +0,0 @@
'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,51 +0,0 @@
'use client'
import { X } from 'lucide-react'
import { Badge } from '@/components/emcn'
import type { ChatContext } from '@/stores/panel'
interface ContextPillsProps {
/** Selected contexts to display as pills */
contexts: ChatContext[]
/** Callback when a context pill is removed */
onRemoveContext: (context: ChatContext) => void
}
/**
* Displays selected contexts as dismissible pills matching the @ badge style.
* Filters out current_workflow contexts as they are always implied.
*
* @param props - Component props
* @returns Rendered context pills or null if no visible contexts
*/
export function ContextPills({ contexts, onRemoveContext }: ContextPillsProps) {
const visibleContexts = contexts.filter((c) => c.kind !== 'current_workflow')
if (visibleContexts.length === 0) {
return null
}
return (
<>
{visibleContexts.map((ctx, idx) => (
<Badge
key={`selctx-${idx}-${ctx.label}`}
variant='outline'
className='inline-flex items-center gap-1 rounded-[6px] px-2 py-[4.5px] text-xs leading-[12px]'
title={ctx.label}
>
<span className='max-w-[140px] truncate leading-[12px]'>{ctx.label}</span>
<button
type='button'
onClick={() => onRemoveContext(ctx)}
className='text-muted-foreground transition-colors hover:text-foreground'
title='Remove context'
aria-label='Remove context'
>
<X className='h-3 w-3' strokeWidth={1.75} />
</button>
</Badge>
))}
</>
)
}

View File

@@ -1,7 +0,0 @@
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

@@ -1,161 +0,0 @@
'use client'
import type { ComponentType, ReactNode, SVGProps } from 'react'
import { PopoverItem } from '@/components/emcn'
import { formatCompactTimestamp } from '@/lib/core/utils/formatting'
import {
FOLDER_CONFIGS,
MENU_STATE_TEXT_CLASSES,
type MentionFolderId,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/constants'
const ICON_CONTAINER =
'relative flex h-[16px] w-[16px] flex-shrink-0 items-center justify-center overflow-hidden rounded-[4px]'
export function BlockIcon({
bgColor,
Icon,
}: {
bgColor?: string
Icon?: ComponentType<SVGProps<SVGSVGElement>>
}) {
return (
<div className={ICON_CONTAINER} style={{ background: bgColor || '#6B7280' }}>
{Icon && <Icon className='!h-[10px] !w-[10px] !text-white' />}
</div>
)
}
export function WorkflowColorDot({ color }: { color?: string }) {
const c = color || '#3972F6'
return (
<div
className={`${ICON_CONTAINER} border-[2px]`}
style={{
backgroundColor: c,
borderColor: `${c}60`,
backgroundClip: 'padding-box',
}}
/>
)
}
interface FolderContentProps {
/** Folder ID to render content for */
folderId: MentionFolderId
/** Items to render (already filtered) */
items: any[]
/** Whether data is loading */
isLoading: boolean
/** Current search query (for determining empty vs no-match message) */
currentQuery: string
/** Currently active item index (for keyboard navigation) */
activeIndex: number
/** Callback when an item is clicked */
onItemClick: (item: any) => void
}
export function renderItemIcon(folderId: MentionFolderId, item: any): ReactNode {
switch (folderId) {
case 'workflows':
return <WorkflowColorDot color={item.color} />
case 'blocks':
case 'workflow-blocks':
return <BlockIcon bgColor={item.bgColor} Icon={item.iconComponent} />
default:
return null
}
}
function renderItemSuffix(folderId: MentionFolderId, item: any): ReactNode {
switch (folderId) {
case 'templates':
return <span className='text-[10px] text-[var(--text-muted)]'>{item.stars}</span>
case 'logs':
return (
<>
<span className='text-[10px] text-[var(--text-tertiary)]'>·</span>
<span className='whitespace-nowrap text-[10px]'>
{formatCompactTimestamp(item.createdAt)}
</span>
<span className='text-[10px] text-[var(--text-tertiary)]'>·</span>
<span className='text-[10px] capitalize'>{(item.trigger || 'manual').toLowerCase()}</span>
</>
)
default:
return null
}
}
export function FolderContent({
folderId,
items,
isLoading,
currentQuery,
activeIndex,
onItemClick,
}: FolderContentProps) {
const config = FOLDER_CONFIGS[folderId]
if (isLoading) {
return <div className={MENU_STATE_TEXT_CLASSES}>Loading...</div>
}
if (items.length === 0) {
return (
<div className={MENU_STATE_TEXT_CLASSES}>
{currentQuery ? config.noMatchMessage : config.emptyMessage}
</div>
)
}
return (
<>
{items.map((item, index) => (
<PopoverItem
key={config.getId(item)}
onClick={() => onItemClick(item)}
data-idx={index}
active={index === activeIndex}
>
{renderItemIcon(folderId, item)}
<span className={folderId === 'logs' ? 'min-w-0 flex-1 truncate' : 'truncate'}>
{config.getLabel(item)}
</span>
{renderItemSuffix(folderId, item)}
</PopoverItem>
))}
</>
)
}
export function FolderPreviewContent({
folderId,
items,
isLoading,
onItemClick,
}: Omit<FolderContentProps, 'currentQuery' | 'activeIndex'>) {
const config = FOLDER_CONFIGS[folderId]
if (isLoading) {
return <div className={MENU_STATE_TEXT_CLASSES}>Loading...</div>
}
if (items.length === 0) {
return <div className={MENU_STATE_TEXT_CLASSES}>{config.emptyMessage}</div>
}
return (
<>
{items.map((item) => (
<PopoverItem key={config.getId(item)} onClick={() => onItemClick(item)}>
{renderItemIcon(folderId, item)}
<span className={folderId === 'logs' ? 'min-w-0 flex-1 truncate' : 'truncate'}>
{config.getLabel(item)}
</span>
{renderItemSuffix(folderId, item)}
</PopoverItem>
))}
</>
)
}

View File

@@ -1,333 +0,0 @@
'use client'
import { useCallback, useEffect, useMemo } from 'react'
import {
Popover,
PopoverAnchor,
PopoverBackButton,
PopoverContent,
PopoverFolder,
PopoverItem,
PopoverScrollArea,
usePopoverContext,
} from '@/components/emcn'
import { formatCompactTimestamp } from '@/lib/core/utils/formatting'
import {
FOLDER_CONFIGS,
FOLDER_ORDER,
MENU_STATE_TEXT_CLASSES,
type MentionCategory,
type MentionFolderId,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/constants'
import {
useCaretViewport,
type useMentionData,
type useMentionMenu,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks'
import {
getFolderData as getFolderDataUtil,
getFolderEnsureLoaded as getFolderEnsureLoadedUtil,
getFolderLoading as getFolderLoadingUtil,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/utils'
import { FolderContent, FolderPreviewContent, renderItemIcon } from './folder-content'
interface AggregatedItem {
id: string
label: string
category: MentionCategory
data: any
icon?: React.ReactNode
}
export interface MentionFolderNav {
isInFolder: boolean
currentFolder: string | null
openFolder: (id: string, title: string) => void
closeFolder: () => void
}
interface MentionMenuProps {
mentionMenu: ReturnType<typeof useMentionMenu>
mentionData: ReturnType<typeof useMentionData>
message: string
insertHandlers: {
insertPastChatMention: (chat: any) => void
insertWorkflowMention: (wf: any) => void
insertKnowledgeMention: (kb: any) => void
insertBlockMention: (blk: any) => void
insertWorkflowBlockMention: (blk: any) => void
insertTemplateMention: (tpl: any) => void
insertLogMention: (log: any) => void
insertDocsMention: () => void
}
onFolderNavChange?: (nav: MentionFolderNav) => void
}
type InsertHandlerMap = Record<MentionFolderId, (item: any) => void>
function MentionMenuContent({
mentionMenu,
mentionData,
message,
insertHandlers,
onFolderNavChange,
}: MentionMenuProps) {
const { currentFolder, openFolder, closeFolder } = usePopoverContext()
const {
menuListRef,
getActiveMentionQueryAtPosition,
getCaretPos,
submenuActiveIndex,
mentionActiveIndex,
setSubmenuActiveIndex,
} = mentionMenu
const currentQuery = useMemo(() => {
const caretPos = getCaretPos()
const active = getActiveMentionQueryAtPosition(caretPos, message)
return active?.query.trim().toLowerCase() || ''
}, [message, getCaretPos, getActiveMentionQueryAtPosition])
const isInFolder = currentFolder !== null
const showAggregatedView = currentQuery.length > 0
const isInFolderNavigationMode = !isInFolder && !showAggregatedView
useEffect(() => {
setSubmenuActiveIndex(0)
}, [isInFolder, setSubmenuActiveIndex])
useEffect(() => {
if (onFolderNavChange) {
onFolderNavChange({
isInFolder,
currentFolder,
openFolder,
closeFolder,
})
}
}, [onFolderNavChange, isInFolder, currentFolder, openFolder, closeFolder])
const insertHandlerMap = useMemo(
(): InsertHandlerMap => ({
chats: insertHandlers.insertPastChatMention,
workflows: insertHandlers.insertWorkflowMention,
knowledge: insertHandlers.insertKnowledgeMention,
blocks: insertHandlers.insertBlockMention,
'workflow-blocks': insertHandlers.insertWorkflowBlockMention,
templates: insertHandlers.insertTemplateMention,
logs: insertHandlers.insertLogMention,
}),
[insertHandlers]
)
const getFolderData = useCallback(
(folderId: MentionFolderId) => getFolderDataUtil(mentionData, folderId),
[mentionData]
)
const getFolderLoading = useCallback(
(folderId: MentionFolderId) => getFolderLoadingUtil(mentionData, folderId),
[mentionData]
)
const getEnsureLoaded = useCallback(
(folderId: MentionFolderId) => getFolderEnsureLoadedUtil(mentionData, folderId),
[mentionData]
)
const filterFolderItems = useCallback(
(folderId: MentionFolderId, query: string): any[] => {
const config = FOLDER_CONFIGS[folderId]
const items = getFolderData(folderId)
if (!query) return items
const q = query.toLowerCase()
return items.filter((item) => config.filterFn(item, q))
},
[getFolderData]
)
const getFilteredFolderItems = useCallback(
(folderId: MentionFolderId): any[] => {
return isInFolder ? filterFolderItems(folderId, currentQuery) : getFolderData(folderId)
},
[isInFolder, currentQuery, filterFolderItems, getFolderData]
)
const filteredAggregatedItems = useMemo(() => {
if (!currentQuery) return []
const items: AggregatedItem[] = []
const q = currentQuery.toLowerCase()
for (const folderId of FOLDER_ORDER) {
const config = FOLDER_CONFIGS[folderId]
const folderData = getFolderData(folderId)
folderData.forEach((item) => {
if (config.filterFn(item, q)) {
items.push({
id: `${folderId}-${config.getId(item)}`,
label: config.getLabel(item),
category: folderId as MentionCategory,
data: item,
icon: renderItemIcon(folderId, item),
})
}
})
}
if ('docs'.includes(q)) {
items.push({
id: 'docs',
label: 'Docs',
category: 'docs',
data: null,
})
}
return items
}, [currentQuery, getFolderData])
const handleAggregatedItemClick = useCallback(
(item: AggregatedItem) => {
if (item.category === 'docs') {
insertHandlers.insertDocsMention()
return
}
const handler = insertHandlerMap[item.category as MentionFolderId]
if (handler) {
handler(item.data)
}
},
[insertHandlerMap, insertHandlers]
)
return (
<PopoverScrollArea ref={menuListRef} className='space-y-[2px]'>
{isInFolder ? (
<FolderContent
folderId={currentFolder as MentionFolderId}
items={getFilteredFolderItems(currentFolder as MentionFolderId)}
isLoading={getFolderLoading(currentFolder as MentionFolderId)}
currentQuery={currentQuery}
activeIndex={submenuActiveIndex}
onItemClick={insertHandlerMap[currentFolder as MentionFolderId]}
/>
) : showAggregatedView ? (
<>
{filteredAggregatedItems.length === 0 ? (
<div className={MENU_STATE_TEXT_CLASSES}>No results found</div>
) : (
filteredAggregatedItems.map((item, index) => (
<PopoverItem
key={item.id}
onClick={() => handleAggregatedItemClick(item)}
data-idx={index}
active={index === submenuActiveIndex}
>
{item.icon}
<span className='flex-1 truncate'>{item.label}</span>
{item.category === 'logs' && (
<>
<span className='text-[10px] text-[var(--text-tertiary)]'>·</span>
<span className='whitespace-nowrap text-[10px]'>
{formatCompactTimestamp(item.data.createdAt)}
</span>
</>
)}
</PopoverItem>
))
)}
</>
) : (
<>
{FOLDER_ORDER.map((folderId, folderIndex) => {
const config = FOLDER_CONFIGS[folderId]
const ensureLoaded = getEnsureLoaded(folderId)
return (
<PopoverFolder
key={folderId}
id={folderId}
title={config.title}
onOpen={() => ensureLoaded?.()}
active={isInFolderNavigationMode && mentionActiveIndex === folderIndex}
data-idx={folderIndex}
>
<FolderPreviewContent
folderId={folderId}
items={getFolderData(folderId)}
isLoading={getFolderLoading(folderId)}
onItemClick={insertHandlerMap[folderId]}
/>
</PopoverFolder>
)
})}
<PopoverItem
rootOnly
onClick={() => insertHandlers.insertDocsMention()}
active={isInFolderNavigationMode && mentionActiveIndex === FOLDER_ORDER.length}
data-idx={FOLDER_ORDER.length}
>
<span>Docs</span>
</PopoverItem>
</>
)}
</PopoverScrollArea>
)
}
export function MentionMenu({
mentionMenu,
mentionData,
message,
insertHandlers,
onFolderNavChange,
}: MentionMenuProps) {
const { mentionMenuRef, textareaRef, getCaretPos } = mentionMenu
const caretPos = getCaretPos()
const { caretViewport, side } = useCaretViewport({ textareaRef, message, caretPos })
if (!caretViewport) return null
return (
<Popover open={true} onOpenChange={() => {}}>
<PopoverAnchor asChild>
<div
style={{
position: 'fixed',
top: `${caretViewport.top}px`,
left: `${caretViewport.left}px`,
width: '1px',
height: '1px',
pointerEvents: 'none',
}}
/>
</PopoverAnchor>
<PopoverContent
ref={mentionMenuRef}
side={side}
align='start'
collisionPadding={6}
maxHeight={360}
className='pointer-events-auto'
style={{ width: '224px' }}
onOpenAutoFocus={(e) => e.preventDefault()}
onCloseAutoFocus={(e) => e.preventDefault()}
onMouseDown={(e) => e.preventDefault()}
>
<PopoverBackButton />
<MentionMenuContent
mentionMenu={mentionMenu}
mentionData={mentionData}
message={message}
insertHandlers={insertHandlers}
onFolderNavChange={onFolderNavChange}
/>
</PopoverContent>
</Popover>
)
}

View File

@@ -1,143 +0,0 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import { ListTree, MessageSquare, Package } from 'lucide-react'
import {
Badge,
Popover,
PopoverAnchor,
PopoverContent,
PopoverItem,
PopoverScrollArea,
} from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
interface ModeSelectorProps {
/** Current mode - 'ask', 'build', or 'plan' */
mode: 'ask' | 'build' | 'plan'
/** Callback when mode changes */
onModeChange?: (mode: 'ask' | 'build' | 'plan') => void
/** Whether the input is near the top of viewport (affects dropdown direction) */
isNearTop: boolean
/** Whether the selector is disabled */
disabled?: boolean
}
/**
* Mode selector dropdown for switching between Ask, Build, and Plan modes.
* Displays appropriate icon and label, with tooltips explaining each mode.
*
* @param props - Component props
* @returns Rendered mode selector dropdown
*/
export function ModeSelector({ mode, onModeChange, isNearTop, disabled }: ModeSelectorProps) {
const [open, setOpen] = useState(false)
const triggerRef = useRef<HTMLDivElement>(null)
const popoverRef = useRef<HTMLDivElement>(null)
const getModeIcon = () => {
if (mode === 'ask') {
return <MessageSquare className='h-3 w-3' />
}
if (mode === 'plan') {
return <ListTree className='h-3 w-3' />
}
return <Package className='h-3 w-3' />
}
const getModeText = () => {
if (mode === 'ask') {
return 'Ask'
}
if (mode === 'plan') {
return 'Plan'
}
return 'Build'
}
const handleSelect = (selectedMode: 'ask' | 'build' | 'plan') => {
onModeChange?.(selectedMode)
setOpen(false)
}
useEffect(() => {
if (!open) return
const handleClickOutside = (event: MouseEvent) => {
// Keep popover open while resizing the panel (mousedown on resize handle)
const target = event.target as Element | null
if (
target &&
(target.closest('[aria-label="Resize panel"]') ||
target.closest('[role="separator"]') ||
target.closest('.cursor-ew-resize'))
) {
return
}
if (
popoverRef.current &&
!popoverRef.current.contains(event.target as Node) &&
triggerRef.current &&
!triggerRef.current.contains(event.target as Node)
) {
setOpen(false)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [open])
return (
<Popover open={open} variant='default'>
<PopoverAnchor asChild>
<div ref={triggerRef}>
<Badge
variant='outline'
className={cn(
'cursor-pointer rounded-[6px]',
(disabled || !onModeChange) && 'cursor-not-allowed opacity-50'
)}
aria-expanded={open}
onMouseDown={(e) => {
if (disabled || !onModeChange) {
e.preventDefault()
return
}
e.stopPropagation()
setOpen((prev) => !prev)
}}
>
{getModeIcon()}
<span>{getModeText()}</span>
</Badge>
</div>
</PopoverAnchor>
<PopoverContent
ref={popoverRef}
side={isNearTop ? 'bottom' : 'top'}
align='start'
sideOffset={4}
style={{ width: '120px', minWidth: '120px' }}
onOpenAutoFocus={(e) => e.preventDefault()}
onCloseAutoFocus={(e) => e.preventDefault()}
>
<PopoverScrollArea className='space-y-[2px]'>
<PopoverItem active={mode === 'ask'} onClick={() => handleSelect('ask')}>
<MessageSquare className='h-3.5 w-3.5' />
<span>Ask</span>
</PopoverItem>
{/* <PopoverItem active={mode === 'plan'} onClick={() => handleSelect('plan')}>
<ListTree className='h-3.5 w-3.5' />
<span>Plan</span>
</PopoverItem> */}
<PopoverItem active={mode === 'build'} onClick={() => handleSelect('build')}>
<Package className='h-3.5 w-3.5' />
<span>Build</span>
</PopoverItem>
</PopoverScrollArea>
</PopoverContent>
</Popover>
)
}

View File

@@ -1,189 +0,0 @@
'use client'
import { useEffect, useMemo, useRef, useState } from 'react'
import {
Badge,
Popover,
PopoverAnchor,
PopoverContent,
PopoverItem,
PopoverScrollArea,
} from '@/components/emcn'
import { AnthropicIcon, AzureIcon, BedrockIcon, GeminiIcon, OpenAIIcon } from '@/components/icons'
import { useCopilotStore } from '@/stores/panel'
interface ModelSelectorProps {
/** Currently selected model */
selectedModel: string
/** Whether the input is near the top of viewport (affects dropdown direction) */
isNearTop: boolean
/** Callback when model is selected */
onModelSelect: (model: string) => void
}
/**
* Map a provider string (from the available-models API) to its icon component.
* Falls back to null when the provider is unrecognised.
*/
const PROVIDER_ICON_MAP: Record<string, React.ComponentType<{ className?: string }>> = {
anthropic: AnthropicIcon,
openai: OpenAIIcon,
gemini: GeminiIcon,
google: GeminiIcon,
bedrock: BedrockIcon,
azure: AzureIcon,
'azure-openai': AzureIcon,
'azure-anthropic': AzureIcon,
}
function getIconForProvider(provider: string): React.ComponentType<{ className?: string }> | null {
return PROVIDER_ICON_MAP[provider] ?? null
}
/**
* Model selector dropdown for choosing AI model.
* Displays model icon and label.
*
* @param props - Component props
* @returns Rendered model selector dropdown
*/
export function ModelSelector({ selectedModel, isNearTop, onModelSelect }: ModelSelectorProps) {
const [open, setOpen] = useState(false)
const triggerRef = useRef<HTMLDivElement>(null)
const popoverRef = useRef<HTMLDivElement>(null)
const availableModels = useCopilotStore((state) => state.availableModels)
const modelOptions = useMemo(() => {
return availableModels.map((model) => ({
value: model.id,
label: model.friendlyName || model.id,
provider: model.provider,
}))
}, [availableModels])
/**
* Extract the provider from a composite model key (e.g. "bedrock/claude-opus-4-6" → "bedrock").
* This mirrors the agent block pattern where model IDs are provider-prefixed.
*/
const getProviderForModel = (compositeKey: string): string | undefined => {
const slashIdx = compositeKey.indexOf('/')
if (slashIdx !== -1) return compositeKey.slice(0, slashIdx)
// Legacy migration path: allow old raw IDs (without provider prefix)
// by resolving against current available model options.
const exact = modelOptions.find((m) => m.value === compositeKey)
if (exact?.provider) return exact.provider
const byRawSuffix = modelOptions.find((m) => m.value.endsWith(`/${compositeKey}`))
return byRawSuffix?.provider
}
const getCollapsedModeLabel = () => {
const model =
modelOptions.find((m) => m.value === selectedModel) ??
modelOptions.find((m) => m.value.endsWith(`/${selectedModel}`))
return model?.label || selectedModel || 'No models available'
}
const getModelIcon = () => {
const provider = getProviderForModel(selectedModel)
if (!provider) return null
const IconComponent = getIconForProvider(provider)
if (!IconComponent) return null
return (
<span className='flex-shrink-0'>
<IconComponent className='h-3 w-3' />
</span>
)
}
const getModelIconComponent = (modelValue: string) => {
const provider = getProviderForModel(modelValue)
if (!provider) return null
const IconComponent = getIconForProvider(provider)
if (!IconComponent) return null
return <IconComponent className='h-3.5 w-3.5' />
}
const handleSelect = (modelValue: string) => {
onModelSelect(modelValue)
setOpen(false)
}
useEffect(() => {
if (!open) return
const handleClickOutside = (event: MouseEvent) => {
// Keep popover open while resizing the panel (mousedown on resize handle)
const target = event.target as Element | null
if (
target &&
(target.closest('[aria-label="Resize panel"]') ||
target.closest('[role="separator"]') ||
target.closest('.cursor-ew-resize'))
) {
return
}
if (
popoverRef.current &&
!popoverRef.current.contains(event.target as Node) &&
triggerRef.current &&
!triggerRef.current.contains(event.target as Node)
) {
setOpen(false)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [open])
return (
<Popover open={open} variant='default'>
<PopoverAnchor asChild>
<div ref={triggerRef} className='min-w-0 max-w-full'>
<Badge
variant='outline'
className='min-w-0 max-w-full cursor-pointer rounded-[6px]'
title='Choose model'
aria-expanded={open}
onMouseDown={(e) => {
e.stopPropagation()
setOpen((prev) => !prev)
}}
>
{getModelIcon()}
<span className='min-w-0 flex-1 truncate'>{getCollapsedModeLabel()}</span>
</Badge>
</div>
</PopoverAnchor>
<PopoverContent
ref={popoverRef}
side={isNearTop ? 'bottom' : 'top'}
align='start'
sideOffset={4}
maxHeight={280}
onOpenAutoFocus={(e) => e.preventDefault()}
onCloseAutoFocus={(e) => e.preventDefault()}
>
<PopoverScrollArea className='space-y-[2px]'>
{modelOptions.length > 0 ? (
modelOptions.map((option) => (
<PopoverItem
key={option.value}
active={selectedModel === option.value}
onClick={() => handleSelect(option.value)}
>
{getModelIconComponent(option.value)}
<span>{option.label}</span>
</PopoverItem>
))
) : (
<div className='px-2 py-2 text-[var(--text-muted)] text-xs'>No models available</div>
)}
</PopoverScrollArea>
</PopoverContent>
</Popover>
)
}

View File

@@ -1,207 +0,0 @@
'use client'
import { useEffect, useMemo } from 'react'
import {
Popover,
PopoverAnchor,
PopoverBackButton,
PopoverContent,
PopoverFolder,
PopoverItem,
PopoverScrollArea,
usePopoverContext,
} from '@/components/emcn'
import {
ALL_SLASH_COMMANDS,
MENU_STATE_TEXT_CLASSES,
TOP_LEVEL_COMMANDS,
WEB_COMMANDS,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/constants'
import { useCaretViewport } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks'
import type { useMentionMenu } from '../../hooks/use-mention-menu'
export interface SlashFolderNav {
isInFolder: boolean
openWebFolder: () => void
closeFolder: () => void
}
interface SlashMenuProps {
mentionMenu: ReturnType<typeof useMentionMenu>
message: string
onSelectCommand: (command: string) => void
onFolderNavChange?: (nav: SlashFolderNav) => void
}
function SlashMenuContent({
mentionMenu,
message,
onSelectCommand,
onFolderNavChange,
}: SlashMenuProps) {
const { currentFolder, openFolder, closeFolder } = usePopoverContext()
const {
menuListRef,
getActiveSlashQueryAtPosition,
getCaretPos,
submenuActiveIndex,
mentionActiveIndex,
setSubmenuActiveIndex,
} = mentionMenu
const caretPos = getCaretPos()
const currentQuery = useMemo(() => {
const active = getActiveSlashQueryAtPosition(caretPos, message)
return active?.query.trim().toLowerCase() || ''
}, [message, caretPos, getActiveSlashQueryAtPosition])
const filteredCommands = useMemo(() => {
if (!currentQuery) return null
return ALL_SLASH_COMMANDS.filter(
(cmd) =>
cmd.id.toLowerCase().includes(currentQuery) ||
cmd.label.toLowerCase().includes(currentQuery)
)
}, [currentQuery])
const showAggregatedView = currentQuery.length > 0
const isInFolder = currentFolder !== null
const isInFolderNavigationMode = !isInFolder && !showAggregatedView
useEffect(() => {
if (onFolderNavChange) {
onFolderNavChange({
isInFolder,
openWebFolder: () => {
openFolder('web', 'Web')
setSubmenuActiveIndex(0)
},
closeFolder: () => {
closeFolder()
setSubmenuActiveIndex(0)
},
})
}
}, [onFolderNavChange, isInFolder, openFolder, closeFolder, setSubmenuActiveIndex])
return (
<PopoverScrollArea ref={menuListRef} className='space-y-[2px]'>
{isInFolder ? (
<>
{WEB_COMMANDS.map((cmd, index) => (
<PopoverItem
key={cmd.id}
onClick={() => onSelectCommand(cmd.id)}
data-idx={index}
active={index === submenuActiveIndex}
>
<span className='truncate'>{cmd.label}</span>
</PopoverItem>
))}
</>
) : showAggregatedView ? (
<>
{filteredCommands && filteredCommands.length === 0 ? (
<div className={MENU_STATE_TEXT_CLASSES}>No commands found</div>
) : (
filteredCommands?.map((cmd, index) => (
<PopoverItem
key={cmd.id}
onClick={() => onSelectCommand(cmd.id)}
data-idx={index}
active={index === submenuActiveIndex}
>
<span className='truncate'>{cmd.label}</span>
</PopoverItem>
))
)}
</>
) : (
<>
{TOP_LEVEL_COMMANDS.map((cmd, index) => (
<PopoverItem
key={cmd.id}
onClick={() => onSelectCommand(cmd.id)}
data-idx={index}
active={isInFolderNavigationMode && index === mentionActiveIndex}
>
<span className='truncate'>{cmd.label}</span>
</PopoverItem>
))}
<PopoverFolder
id='web'
title='Web'
onOpen={() => setSubmenuActiveIndex(0)}
active={isInFolderNavigationMode && mentionActiveIndex === TOP_LEVEL_COMMANDS.length}
data-idx={TOP_LEVEL_COMMANDS.length}
>
{WEB_COMMANDS.map((cmd) => (
<PopoverItem key={cmd.id} onClick={() => onSelectCommand(cmd.id)}>
<span className='truncate'>{cmd.label}</span>
</PopoverItem>
))}
</PopoverFolder>
</>
)}
</PopoverScrollArea>
)
}
export function SlashMenu({
mentionMenu,
message,
onSelectCommand,
onFolderNavChange,
}: SlashMenuProps) {
const { mentionMenuRef, textareaRef, getCaretPos } = mentionMenu
const caretPos = getCaretPos()
const { caretViewport, side } = useCaretViewport({
textareaRef,
message,
caretPos,
})
if (!caretViewport) return null
return (
<Popover open={true} onOpenChange={() => {}}>
<PopoverAnchor asChild>
<div
style={{
position: 'fixed',
top: `${caretViewport.top}px`,
left: `${caretViewport.left}px`,
width: '1px',
height: '1px',
pointerEvents: 'none',
}}
/>
</PopoverAnchor>
<PopoverContent
ref={mentionMenuRef}
side={side}
align='start'
collisionPadding={6}
maxHeight={360}
className='pointer-events-auto'
style={{ width: '180px' }}
onOpenAutoFocus={(e) => e.preventDefault()}
onCloseAutoFocus={(e) => e.preventDefault()}
onMouseDown={(e) => e.preventDefault()}
>
<PopoverBackButton />
<SlashMenuContent
mentionMenu={mentionMenu}
message={message}
onSelectCommand={onSelectCommand}
onFolderNavChange={onFolderNavChange}
/>
</PopoverContent>
</Popover>
)
}

View File

@@ -1,11 +1,11 @@
import { useCallback, useMemo } from 'react'
import type { MentionFolderNav } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/components'
import {
DOCS_CONFIG,
FOLDER_CONFIGS,
type FolderConfig,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/constants'
import type { useMentionMenu } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-menu'
import type { MentionFolderNav } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/types'
import { isContextAlreadySelected } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/utils'
import type { ChatContext } from '@/stores/panel'

View File

@@ -1,5 +1,4 @@
import { type KeyboardEvent, useCallback, useMemo } from 'react'
import type { MentionFolderNav } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/components'
import {
FOLDER_CONFIGS,
FOLDER_ORDER,
@@ -10,6 +9,7 @@ import type {
useMentionData,
useMentionMenu,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks'
import type { MentionFolderNav } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/types'
import {
getFolderData as getFolderDataUtil,
getFolderEnsureLoaded as getFolderEnsureLoadedUtil,

View File

@@ -0,0 +1,11 @@
import type { MentionFolderId } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/constants'
/**
* Shared folder navigation state for the mention menu.
*/
export interface MentionFolderNav {
currentFolder: MentionFolderId | null
isInFolder: boolean
openFolder: (folderId: MentionFolderId, title: string) => void
closeFolder: () => void
}

View File

@@ -1,901 +0,0 @@
'use client'
import {
forwardRef,
type KeyboardEvent,
useCallback,
useEffect,
useImperativeHandle,
useState,
} from 'react'
import { createLogger } from '@sim/logger'
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 { CHAT_ACCEPT_ATTRIBUTE } from '@/lib/uploads/utils/validation'
import {
AttachedFilesDisplay,
BottomControls,
ContextPills,
type MentionFolderNav,
MentionMenu,
type SlashFolderNav,
SlashMenu,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/components'
import {
ALL_COMMAND_IDS,
getCommandDisplayLabel,
getNextIndex,
NEAR_TOP_THRESHOLD,
TOP_LEVEL_COMMANDS,
WEB_COMMANDS,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/constants'
import {
useContextManagement,
useFileAttachments,
useMentionData,
useMentionInsertHandlers,
useMentionKeyboard,
useMentionMenu,
useMentionTokens,
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'
const logger = createLogger('CopilotUserInput')
interface UserInputProps {
onSubmit: (
message: string,
fileAttachments?: MessageFileAttachment[],
contexts?: ChatContext[]
) => void
onAbort?: () => void
disabled?: boolean
isLoading?: boolean
isAborting?: boolean
placeholder?: string
className?: string
mode?: 'ask' | 'build' | 'plan'
onModeChange?: (mode: 'ask' | 'build' | 'plan') => void
value?: string
onChange?: (value: string) => void
panelWidth?: number
clearOnSubmit?: boolean
hasPlanArtifact?: boolean
/** Override workflowId from store (for use outside copilot context) */
workflowIdOverride?: string | null
/** Override selectedModel from store (for use outside copilot context) */
selectedModelOverride?: string
/** Override setSelectedModel from store (for use outside copilot context) */
onModelChangeOverride?: (model: string) => void
hideModeSelector?: boolean
/** Disable @mention functionality */
disableMentions?: boolean
/** Initial contexts for editing a message with existing context mentions */
initialContexts?: ChatContext[]
}
interface UserInputRef {
focus: () => void
}
/**
* User input component for the copilot chat interface.
* Supports file attachments, @mentions, mode selection, model selection, and rich text editing.
* Integrates with the copilot store and provides keyboard shortcuts for enhanced UX.
*
* @param props - Component props
* @returns Rendered user input component
*/
const UserInput = forwardRef<UserInputRef, UserInputProps>(
(
{
onSubmit,
onAbort,
disabled = false,
isLoading = false,
isAborting = false,
placeholder,
className,
mode = 'build',
onModeChange,
value: controlledValue,
onChange: onControlledChange,
panelWidth = 308,
clearOnSubmit = true,
hasPlanArtifact = false,
workflowIdOverride,
selectedModelOverride,
onModelChangeOverride,
hideModeSelector = false,
disableMentions = false,
initialContexts,
},
ref
) => {
const { data: session } = useSession()
const params = useParams()
const workspaceId = params.workspaceId as string
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 : storeSelectedModel
const setSelectedModel = onModelChangeOverride || storeSetSelectedModel
const [internalMessage, setInternalMessage] = useState('')
const [isNearTop, setIsNearTop] = useState(false)
const [containerRef, setContainerRef] = useState<HTMLDivElement | null>(null)
const [inputContainerRef, setInputContainerRef] = useState<HTMLDivElement | null>(null)
const [showSlashMenu, setShowSlashMenu] = useState(false)
const [slashFolderNav, setSlashFolderNav] = useState<SlashFolderNav | null>(null)
const [mentionFolderNav, setMentionFolderNav] = useState<MentionFolderNav | null>(null)
const message = controlledValue !== undefined ? controlledValue : internalMessage
const setMessage =
controlledValue !== undefined ? onControlledChange || (() => {}) : setInternalMessage
const effectivePlaceholder =
placeholder ||
(mode === 'ask'
? 'Ask about your workflow'
: mode === 'plan'
? 'Plan your workflow'
: 'Plan, search, build anything')
const contextManagement = useContextManagement({ message, initialContexts })
const mentionMenu = useMentionMenu({
message,
selectedContexts: contextManagement.selectedContexts,
onContextSelect: contextManagement.addContext,
onMessageChange: setMessage,
})
const mentionTokensWithContext = 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: workflowId || null,
workspaceId,
})
const fileAttachments = useFileAttachments({
userId: session?.user?.id,
workspaceId,
disabled,
isLoading,
})
const insertHandlers = useMentionInsertHandlers({
mentionMenu,
workflowId: workflowId || null,
selectedContexts: contextManagement.selectedContexts,
onContextAdd: contextManagement.addContext,
mentionFolderNav,
})
const mentionKeyboard = useMentionKeyboard({
mentionMenu,
mentionData,
insertHandlers,
mentionFolderNav,
})
useImperativeHandle(
ref,
() => ({
focus: () => {
const textarea = mentionMenu.textareaRef.current
if (textarea) {
textarea.focus()
const length = textarea.value.length
textarea.setSelectionRange(length, length)
textarea.scrollTop = textarea.scrollHeight
}
},
}),
[mentionMenu.textareaRef]
)
useEffect(() => {
const checkPosition = () => {
if (containerRef) {
const rect = containerRef.getBoundingClientRect()
setIsNearTop(rect.top < NEAR_TOP_THRESHOLD)
}
}
checkPosition()
const scrollContainer = containerRef?.closest('[data-radix-scroll-area-viewport]')
if (scrollContainer) {
scrollContainer.addEventListener('scroll', checkPosition, { passive: true })
}
window.addEventListener('scroll', checkPosition, { capture: true, passive: true })
window.addEventListener('resize', checkPosition)
return () => {
if (scrollContainer) {
scrollContainer.removeEventListener('scroll', checkPosition)
}
window.removeEventListener('scroll', checkPosition, true)
window.removeEventListener('resize', checkPosition)
}
}, [containerRef])
useEffect(() => {
if (mentionMenu.showMentionMenu && containerRef) {
const rect = containerRef.getBoundingClientRect()
setIsNearTop(rect.top < NEAR_TOP_THRESHOLD)
}
}, [mentionMenu.showMentionMenu, containerRef])
useEffect(() => {
if (!mentionMenu.showMentionMenu || mentionFolderNav?.isInFolder) {
return
}
const q = mentionMenu
.getActiveMentionQueryAtPosition(mentionMenu.getCaretPos())
?.query.trim()
.toLowerCase()
if (q && q.length > 0) {
void mentionData.ensurePastChatsLoaded()
void mentionData.ensureKnowledgeLoaded()
void mentionData.ensureBlocksLoaded()
void mentionData.ensureTemplatesLoaded()
void mentionData.ensureLogsLoaded()
mentionMenu.setSubmenuActiveIndex(0)
requestAnimationFrame(() => mentionMenu.scrollActiveItemIntoView(0))
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [mentionMenu.showMentionMenu, mentionFolderNav?.isInFolder, message])
useEffect(() => {
if (mentionFolderNav?.isInFolder) {
mentionMenu.setSubmenuActiveIndex(0)
requestAnimationFrame(() => mentionMenu.scrollActiveItemIntoView(0))
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [mentionFolderNav?.isInFolder])
const handleSubmit = useCallback(
async (overrideMessage?: string, options: { preserveInput?: boolean } = {}) => {
const targetMessage = overrideMessage ?? message
const trimmedMessage = targetMessage.trim()
if (!trimmedMessage || disabled) return
const failedUploads = fileAttachments.attachedFiles.filter((f) => !f.uploading && !f.key)
if (failedUploads.length > 0) {
logger.error(
`Some files failed to upload: ${failedUploads.map((f) => f.name).join(', ')}`
)
}
const fileAttachmentsForApi = fileAttachments.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(trimmedMessage, fileAttachmentsForApi, contextManagement.selectedContexts)
const shouldClearInput = clearOnSubmit && !options.preserveInput && !overrideMessage
if (shouldClearInput) {
fileAttachments.attachedFiles.forEach((f) => {
if (f.previewUrl) {
URL.revokeObjectURL(f.previewUrl)
}
})
setMessage('')
fileAttachments.clearAttachedFiles()
contextManagement.clearContexts()
mentionMenu.setOpenSubmenuFor(null)
} else {
mentionMenu.setOpenSubmenuFor(null)
}
mentionMenu.setShowMentionMenu(false)
},
[
message,
disabled,
isLoading,
fileAttachments,
onSubmit,
contextManagement,
clearOnSubmit,
setMessage,
mentionMenu,
]
)
const handleBuildWorkflow = useCallback(() => {
if (!hasPlanArtifact || !onModeChange) {
return
}
if (disabled || isLoading) {
return
}
onModeChange('build')
void handleSubmit('build the workflow according to the design plan', { preserveInput: true })
}, [hasPlanArtifact, onModeChange, disabled, isLoading, handleSubmit])
const handleAbort = useCallback(() => {
if (onAbort && isLoading) {
onAbort()
}
}, [onAbort, isLoading])
const handleSlashCommandSelect = useCallback(
(command: string) => {
const displayLabel = getCommandDisplayLabel(command)
mentionMenu.replaceActiveSlashWith(displayLabel)
contextManagement.addContext({
kind: 'slash_command',
command,
label: displayLabel,
})
setShowSlashMenu(false)
mentionMenu.textareaRef.current?.focus()
},
[mentionMenu, contextManagement]
)
const handleKeyDown = useCallback(
(e: KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Escape' && (mentionMenu.showMentionMenu || showSlashMenu)) {
e.preventDefault()
if (mentionFolderNav?.isInFolder) {
mentionFolderNav.closeFolder()
mentionMenu.setSubmenuQueryStart(null)
} else if (slashFolderNav?.isInFolder) {
slashFolderNav.closeFolder()
} else {
mentionMenu.closeMentionMenu()
setShowSlashMenu(false)
}
return
}
if (showSlashMenu) {
const caretPos = mentionMenu.getCaretPos()
const activeSlash = mentionMenu.getActiveSlashQueryAtPosition(caretPos, message)
const query = activeSlash?.query.trim().toLowerCase() || ''
const showAggregatedView = query.length > 0
const direction = e.key === 'ArrowDown' ? 'down' : 'up'
const isInFolder = slashFolderNav?.isInFolder ?? false
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault()
if (isInFolder) {
mentionMenu.setSubmenuActiveIndex((prev) => {
const next = getNextIndex(prev, direction, WEB_COMMANDS.length - 1)
requestAnimationFrame(() => mentionMenu.scrollActiveItemIntoView(next))
return next
})
} else if (showAggregatedView) {
const filtered = ALL_COMMAND_IDS.filter((cmd) => cmd.includes(query))
mentionMenu.setSubmenuActiveIndex((prev) => {
if (filtered.length === 0) return 0
const next = getNextIndex(prev, direction, filtered.length - 1)
requestAnimationFrame(() => mentionMenu.scrollActiveItemIntoView(next))
return next
})
} else {
mentionMenu.setMentionActiveIndex((prev) => {
const next = getNextIndex(prev, direction, TOP_LEVEL_COMMANDS.length)
requestAnimationFrame(() => mentionMenu.scrollActiveItemIntoView(next))
return next
})
}
return
}
if (e.key === 'ArrowRight') {
e.preventDefault()
if (!showAggregatedView && !isInFolder) {
if (mentionMenu.mentionActiveIndex === TOP_LEVEL_COMMANDS.length) {
slashFolderNav?.openWebFolder()
}
}
return
}
if (e.key === 'ArrowLeft') {
e.preventDefault()
if (isInFolder) {
slashFolderNav?.closeFolder()
}
return
}
}
if (mentionKeyboard.handleArrowNavigation(e)) return
if (mentionKeyboard.handleArrowRight(e)) return
if (mentionKeyboard.handleArrowLeft(e)) return
if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) {
e.preventDefault()
if (showSlashMenu) {
const caretPos = mentionMenu.getCaretPos()
const activeSlash = mentionMenu.getActiveSlashQueryAtPosition(caretPos, message)
const query = activeSlash?.query.trim().toLowerCase() || ''
const showAggregatedView = query.length > 0
const isInFolder = slashFolderNav?.isInFolder ?? false
if (isInFolder) {
const selectedCommand =
WEB_COMMANDS[mentionMenu.submenuActiveIndex]?.id || WEB_COMMANDS[0].id
handleSlashCommandSelect(selectedCommand)
} else if (showAggregatedView) {
const filtered = ALL_COMMAND_IDS.filter((cmd) => cmd.includes(query))
if (filtered.length > 0) {
const selectedCommand = filtered[mentionMenu.submenuActiveIndex] || filtered[0]
handleSlashCommandSelect(selectedCommand)
}
} else {
const selectedIndex = mentionMenu.mentionActiveIndex
if (selectedIndex < TOP_LEVEL_COMMANDS.length) {
handleSlashCommandSelect(TOP_LEVEL_COMMANDS[selectedIndex].id)
} else if (selectedIndex === TOP_LEVEL_COMMANDS.length) {
slashFolderNav?.openWebFolder()
}
}
return
}
if (!mentionMenu.showMentionMenu) {
handleSubmit()
} else {
mentionKeyboard.handleEnterSelection(e)
}
return
}
if (!mentionMenu.showMentionMenu) {
const textarea = mentionMenu.textareaRef.current
const selStart = textarea?.selectionStart ?? 0
const selEnd = textarea?.selectionEnd ?? selStart
const selectionLength = Math.abs(selEnd - selStart)
if (e.key === 'Backspace' || e.key === 'Delete') {
if (selectionLength > 0) {
mentionTokensWithContext.removeContextsInSelection(selStart, selEnd)
} else {
const ranges = mentionTokensWithContext.computeMentionRanges()
const target =
e.key === 'Backspace'
? ranges.find((r) => selStart > r.start && selStart <= r.end)
: ranges.find((r) => selStart >= r.start && selStart < r.end)
if (target) {
e.preventDefault()
mentionTokensWithContext.deleteRange(target)
return
}
}
}
if (selectionLength === 0 && (e.key === 'ArrowLeft' || e.key === 'ArrowRight')) {
if (textarea) {
if (e.key === 'ArrowLeft') {
const nextPos = Math.max(0, selStart - 1)
const r = mentionTokensWithContext.findRangeContaining(nextPos)
if (r) {
e.preventDefault()
const target = r.start
setTimeout(() => textarea.setSelectionRange(target, target), 0)
return
}
} else if (e.key === 'ArrowRight') {
const nextPos = Math.min(message.length, selStart + 1)
const r = mentionTokensWithContext.findRangeContaining(nextPos)
if (r) {
e.preventDefault()
const target = r.end
setTimeout(() => textarea.setSelectionRange(target, target), 0)
return
}
}
}
}
if (e.key.length === 1 || e.key === 'Space') {
const blocked =
selectionLength === 0 && !!mentionTokensWithContext.findRangeContaining(selStart)
if (blocked) {
e.preventDefault()
const r = mentionTokensWithContext.findRangeContaining(selStart)
if (r && textarea) {
setTimeout(() => {
textarea.setSelectionRange(r.end, r.end)
}, 0)
}
return
}
}
}
},
[
mentionMenu,
mentionKeyboard,
handleSubmit,
handleSlashCommandSelect,
message,
mentionTokensWithContext,
showSlashMenu,
slashFolderNav,
mentionFolderNav,
]
)
const handleInputChange = useCallback(
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
const newValue = e.target.value
setMessage(newValue)
if (disableMentions) return
const caret = e.target.selectionStart ?? newValue.length
const activeMention = mentionMenu.getActiveMentionQueryAtPosition(caret, newValue)
const activeSlash = mentionMenu.getActiveSlashQueryAtPosition(caret, newValue)
if (activeMention) {
setShowSlashMenu(false)
mentionMenu.setShowMentionMenu(true)
mentionMenu.setInAggregated(false)
if (mentionFolderNav?.isInFolder) {
mentionMenu.setSubmenuActiveIndex(0)
} else {
mentionMenu.setMentionActiveIndex(0)
mentionMenu.setSubmenuActiveIndex(0)
}
} else if (activeSlash) {
mentionMenu.setShowMentionMenu(false)
mentionMenu.setOpenSubmenuFor(null)
mentionMenu.setSubmenuQueryStart(null)
setShowSlashMenu(true)
mentionMenu.setSubmenuActiveIndex(0)
} else {
mentionMenu.setShowMentionMenu(false)
mentionMenu.setOpenSubmenuFor(null)
mentionMenu.setSubmenuQueryStart(null)
setShowSlashMenu(false)
}
},
[setMessage, mentionMenu, disableMentions, mentionFolderNav]
)
const handleSelectAdjust = useCallback(() => {
const textarea = mentionMenu.textareaRef.current
if (!textarea) return
const pos = textarea.selectionStart ?? 0
const r = mentionTokensWithContext.findRangeContaining(pos)
if (r) {
const snapPos = pos - r.start < r.end - pos ? r.start : r.end
setTimeout(() => {
textarea.setSelectionRange(snapPos, snapPos)
}, 0)
}
}, [mentionMenu.textareaRef, mentionTokensWithContext])
const insertTriggerAndOpenMenu = useCallback(
(trigger: '@' | '/') => {
if (disabled) return
const textarea = mentionMenu.textareaRef.current
if (!textarea) return
textarea.focus()
const start = textarea.selectionStart ?? message.length
const end = textarea.selectionEnd ?? message.length
const needsSpaceBefore = start > 0 && !/\s/.test(message.charAt(start - 1))
const insertText = needsSpaceBefore ? ` ${trigger}` : trigger
const before = message.slice(0, start)
const after = message.slice(end)
setMessage(`${before}${insertText}${after}`)
setTimeout(() => {
const newPos = before.length + insertText.length
textarea.setSelectionRange(newPos, newPos)
textarea.focus()
}, 0)
if (trigger === '@') {
mentionMenu.setShowMentionMenu(true)
mentionMenu.setOpenSubmenuFor(null)
mentionMenu.setMentionActiveIndex(0)
} else {
setShowSlashMenu(true)
}
mentionMenu.setSubmenuActiveIndex(0)
},
[disabled, mentionMenu, message, setMessage]
)
const handleOpenMentionMenuWithAt = useCallback(
() => insertTriggerAndOpenMenu('@'),
[insertTriggerAndOpenMenu]
)
const handleOpenSlashMenu = useCallback(
() => insertTriggerAndOpenMenu('/'),
[insertTriggerAndOpenMenu]
)
const handleModelSelect = useCallback(
(model: string) => {
setSelectedModel(model as CopilotModelId)
},
[setSelectedModel]
)
const canSubmit = message.trim().length > 0 && !disabled && !isLoading
const showAbortButton = isLoading && onAbort
const renderOverlayContent = useCallback(() => {
const contexts = contextManagement.selectedContexts
if (!message) {
return <span>{'\u00A0'}</span>
}
if (contexts.length === 0) {
const displayText = message.endsWith('\n') ? `${message}\u200B` : message
return <span>{displayText}</span>
}
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]
if (range.start > lastIndex) {
const before = message.slice(lastIndex, range.start)
elements.push(<span key={`text-${i}-${lastIndex}-${range.start}`}>{before}</span>)
}
elements.push(
<span
key={`mention-${i}-${range.start}-${range.end}`}
className='rounded-[4px] bg-[rgba(50,189,126,0.65)] py-[1px]'
>
{range.token}
</span>
)
lastIndex = range.end
}
const tail = message.slice(lastIndex)
if (tail) {
const displayTail = tail.endsWith('\n') ? `${tail}\u200B` : tail
elements.push(<span key={`tail-${lastIndex}`}>{displayTail}</span>)
}
return elements.length > 0 ? elements : <span>{'\u00A0'}</span>
}, [message, contextManagement.selectedContexts])
return (
<div
ref={setContainerRef}
data-user-input
className={cn('relative w-full flex-none [max-width:var(--panel-max-width)]', className)}
style={{ '--panel-max-width': `${panelWidth - 16}px` } as React.CSSProperties}
>
<div
ref={setInputContainerRef}
className={cn(
'relative w-full rounded-[4px] border border-[var(--border-1)] bg-[var(--surface-4)] px-[6px] py-[6px] transition-colors dark:bg-[var(--surface-4)]',
fileAttachments.isDragging && 'ring-[1.75px] ring-[var(--brand-secondary)]'
)}
onDragEnter={fileAttachments.handleDragEnter}
onDragLeave={fileAttachments.handleDragLeave}
onDragOver={fileAttachments.handleDragOver}
onDrop={fileAttachments.handleDrop}
>
{/* Top Row: Context controls + Build Workflow button */}
<div className='mb-[6px] flex flex-wrap items-center justify-between gap-[6px]'>
<div className='flex flex-wrap items-center gap-[6px]'>
{!disableMentions && (
<>
<Badge
variant='outline'
onClick={handleOpenMentionMenuWithAt}
title='Insert @'
className={cn(
'cursor-pointer rounded-[6px] p-[4.5px]',
disabled && 'cursor-not-allowed'
)}
>
<AtSign className='h-3 w-3' strokeWidth={1.75} />
</Badge>
<Badge
variant='outline'
onClick={handleOpenSlashMenu}
title='Insert /'
className={cn(
'cursor-pointer rounded-[6px] p-[4.5px]',
disabled && 'cursor-not-allowed'
)}
>
<span className='flex h-3 w-3 items-center justify-center font-medium text-[11px] leading-none'>
/
</span>
</Badge>
{/* Selected Context Pills */}
<ContextPills
contexts={contextManagement.selectedContexts}
onRemoveContext={contextManagement.removeContext}
/>
</>
)}
</div>
{hasPlanArtifact && (
<Button
type='button'
variant='outline'
onClick={handleBuildWorkflow}
disabled={disabled || isLoading}
>
Build Plan
</Button>
)}
</div>
{/* Attached Files Display */}
<AttachedFilesDisplay
files={fileAttachments.attachedFiles}
onFileClick={fileAttachments.handleFileClick}
onFileRemove={fileAttachments.removeFile}
formatFileSize={fileAttachments.formatFileSize}
getFileIconType={fileAttachments.getFileIconType}
/>
{/* Textarea Field with overlay */}
<div className='relative mb-[6px]'>
{/* Highlight overlay - must have identical flow as textarea */}
<div
ref={overlayRef}
className='pointer-events-none absolute top-0 left-0 z-[1] m-0 box-border h-auto max-h-[120px] min-h-[48px] w-full resize-none overflow-y-auto overflow-x-hidden whitespace-pre-wrap break-words border-0 bg-transparent px-[2px] py-1 font-medium font-sans text-[var(--text-primary)] text-sm leading-[1.25rem] outline-none [-ms-overflow-style:none] [scrollbar-width:none] [text-rendering:optimizeLegibility] [&::-webkit-scrollbar]:hidden'
aria-hidden='true'
>
{renderOverlayContent()}
</div>
<Textarea
ref={mentionMenu.textareaRef}
value={message}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
onCut={mentionTokensWithContext.handleCut}
onSelect={handleSelectAdjust}
onMouseUp={handleSelectAdjust}
onScroll={(e) => {
const overlay = overlayRef.current
if (overlay) {
overlay.scrollTop = e.currentTarget.scrollTop
overlay.scrollLeft = e.currentTarget.scrollLeft
}
}}
placeholder={fileAttachments.isDragging ? 'Drop files here...' : effectivePlaceholder}
disabled={disabled}
rows={2}
className='relative z-[2] m-0 box-border h-auto max-h-[120px] min-h-[48px] w-full resize-none overflow-y-auto overflow-x-hidden break-words border-0 bg-transparent px-[2px] py-1 font-medium font-sans text-sm text-transparent leading-[1.25rem] caret-foreground outline-none [-ms-overflow-style:none] [scrollbar-width:none] [text-rendering:auto] placeholder:text-[var(--text-muted)] focus-visible:ring-0 focus-visible:ring-offset-0 dark:placeholder:text-[var(--text-muted)] [&::-webkit-scrollbar]:hidden'
/>
{/* Mention Menu Portal */}
{!disableMentions &&
mentionMenu.showMentionMenu &&
createPortal(
<MentionMenu
mentionMenu={mentionMenu}
mentionData={mentionData}
message={message}
insertHandlers={insertHandlers}
onFolderNavChange={setMentionFolderNav}
/>,
document.body
)}
{/* Slash Menu Portal */}
{!disableMentions &&
showSlashMenu &&
createPortal(
<SlashMenu
mentionMenu={mentionMenu}
message={message}
onSelectCommand={handleSlashCommandSelect}
onFolderNavChange={setSlashFolderNav}
/>,
document.body
)}
</div>
{/* Bottom Row: Mode Selector + Model Selector + Attach Button + Send Button */}
<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
ref={fileAttachments.fileInputRef}
type='file'
onChange={fileAttachments.handleFileChange}
className='hidden'
accept={CHAT_ACCEPT_ATTRIBUTE}
multiple
disabled={disabled}
/>
</div>
</div>
)
}
)
UserInput.displayName = 'UserInput'
export { UserInput }
export type { UserInputRef }

Some files were not shown because too many files have changed in this diff Show More