mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-06 03:00:16 -04:00
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:
committed by
GitHub
parent
506d3821bd
commit
d6bf12da24
@@ -11,7 +11,7 @@ function makeQueryClient() {
|
||||
retryOnMount: false,
|
||||
},
|
||||
mutations: {
|
||||
retry: 1,
|
||||
retry: false,
|
||||
},
|
||||
dehydrate: {
|
||||
shouldDehydrateQuery: (query) =>
|
||||
|
||||
@@ -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}`)
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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`
|
||||
)
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
getUserEntityPermissions,
|
||||
} from '@/lib/workspaces/permissions/utils'
|
||||
|
||||
export const maxDuration = 3600
|
||||
|
||||
const logger = createLogger('MothershipExecuteAPI')
|
||||
|
||||
const MessageSchema = z.object({
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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}`)
|
||||
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
39
apps/sim/app/workspace/[workspaceId]/files/[fileId]/page.tsx
Normal file
39
apps/sim/app/workspace/[workspaceId]/files/[fileId]/page.tsx
Normal 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 />
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -98,7 +98,6 @@ export function AgentGroup({
|
||||
toolName={item.data.toolName}
|
||||
displayTitle={item.data.displayTitle}
|
||||
status={item.data.status}
|
||||
result={item.data.result}
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -1 +1,4 @@
|
||||
export { MessageContent } from './message-content'
|
||||
export {
|
||||
assistantMessageHasRenderableContent,
|
||||
MessageContent,
|
||||
} from './message-content'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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, '\\')
|
||||
}
|
||||
|
||||
@@ -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)]'>
|
||||
|
||||
@@ -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
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
@@ -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') && (
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export * from './checkpoint-confirmation'
|
||||
@@ -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'
|
||||
@@ -1 +0,0 @@
|
||||
export * from './file-display'
|
||||
@@ -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'
|
||||
@@ -1 +0,0 @@
|
||||
export { default as CopilotMarkdownRenderer } from './markdown-renderer'
|
||||
@@ -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)
|
||||
@@ -1 +0,0 @@
|
||||
export * from './smooth-streaming'
|
||||
@@ -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'
|
||||
@@ -1 +0,0 @@
|
||||
export * from './thinking-block'
|
||||
@@ -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(/<\/?thinking[^&]*>/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>
|
||||
)
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export * from './usage-limit-actions'
|
||||
@@ -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>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -1,2 +0,0 @@
|
||||
export { useCheckpointManagement } from './use-checkpoint-management'
|
||||
export { useMessageEditing } from './use-message-editing'
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export * from './copilot-message'
|
||||
@@ -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'
|
||||
@@ -1 +0,0 @@
|
||||
export * from './plan-mode-section'
|
||||
@@ -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 }
|
||||
@@ -1 +0,0 @@
|
||||
export * from './queued-messages'
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export * from './todo-list'
|
||||
@@ -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>
|
||||
)
|
||||
})
|
||||
@@ -1 +0,0 @@
|
||||
export * from './tool-call'
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export * from './attached-files-display'
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export * from './bottom-controls'
|
||||
@@ -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>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export * from './context-pills'
|
||||
@@ -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'
|
||||
@@ -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>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export * from './mention-menu'
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export * from './mode-selector'
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export * from './model-selector'
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export * from './slash-menu'
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export * from './user-input'
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -1 +0,0 @@
|
||||
export * from './welcome'
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user