mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-11 07:58:06 -05:00
Compare commits
6 Commits
fix/resize
...
fix/copilo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
077a902325 | ||
|
|
fd2c4b6a7c | ||
|
|
47209aee32 | ||
|
|
0350321d1b | ||
|
|
e3b849ad74 | ||
|
|
07433ccbb1 |
@@ -802,49 +802,29 @@ export async function POST(req: NextRequest) {
|
||||
toolNames: toolCalls.map((tc) => tc?.name).filter(Boolean),
|
||||
})
|
||||
|
||||
// Save messages to database after streaming completes (including aborted messages)
|
||||
// NOTE: Messages are saved by the client via update-messages endpoint with full contentBlocks.
|
||||
// Server only updates conversationId here to avoid overwriting client's richer save.
|
||||
if (currentChat) {
|
||||
const updatedMessages = [...conversationHistory, userMessage]
|
||||
|
||||
// Save assistant message if there's any content or tool calls (even partial from abort)
|
||||
if (assistantContent.trim() || toolCalls.length > 0) {
|
||||
const assistantMessage = {
|
||||
id: crypto.randomUUID(),
|
||||
role: 'assistant',
|
||||
content: assistantContent,
|
||||
timestamp: new Date().toISOString(),
|
||||
...(toolCalls.length > 0 && { toolCalls }),
|
||||
}
|
||||
updatedMessages.push(assistantMessage)
|
||||
logger.info(
|
||||
`[${tracker.requestId}] Saving assistant message with content (${assistantContent.length} chars) and ${toolCalls.length} tool calls`
|
||||
)
|
||||
} else {
|
||||
logger.info(
|
||||
`[${tracker.requestId}] No assistant content or tool calls to save (aborted before response)`
|
||||
)
|
||||
}
|
||||
|
||||
// Persist only a safe conversationId to avoid continuing from a state that expects tool outputs
|
||||
const previousConversationId = currentChat?.conversationId as string | undefined
|
||||
const responseId = lastSafeDoneResponseId || previousConversationId || undefined
|
||||
|
||||
// Update chat in database immediately (without title)
|
||||
await db
|
||||
.update(copilotChats)
|
||||
.set({
|
||||
messages: updatedMessages,
|
||||
updatedAt: new Date(),
|
||||
...(responseId ? { conversationId: responseId } : {}),
|
||||
})
|
||||
.where(eq(copilotChats.id, actualChatId!))
|
||||
if (responseId) {
|
||||
await db
|
||||
.update(copilotChats)
|
||||
.set({
|
||||
updatedAt: new Date(),
|
||||
conversationId: responseId,
|
||||
})
|
||||
.where(eq(copilotChats.id, actualChatId!))
|
||||
|
||||
logger.info(`[${tracker.requestId}] Updated chat ${actualChatId} with new messages`, {
|
||||
messageCount: updatedMessages.length,
|
||||
savedUserMessage: true,
|
||||
savedAssistantMessage: assistantContent.trim().length > 0,
|
||||
updatedConversationId: responseId || null,
|
||||
})
|
||||
logger.info(
|
||||
`[${tracker.requestId}] Updated conversationId for chat ${actualChatId}`,
|
||||
{
|
||||
updatedConversationId: responseId,
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`[${tracker.requestId}] Error processing stream:`, error)
|
||||
|
||||
@@ -77,6 +77,18 @@ export async function POST(req: NextRequest) {
|
||||
|
||||
const { chatId, messages, planArtifact, config } = UpdateMessagesSchema.parse(body)
|
||||
|
||||
// Debug: Log what we're about to save
|
||||
const lastMsgParsed = messages[messages.length - 1]
|
||||
if (lastMsgParsed?.role === 'assistant') {
|
||||
logger.info(`[${tracker.requestId}] Parsed messages to save`, {
|
||||
messageCount: messages.length,
|
||||
lastMsgId: lastMsgParsed.id,
|
||||
lastMsgContentLength: lastMsgParsed.content?.length || 0,
|
||||
lastMsgContentBlockCount: lastMsgParsed.contentBlocks?.length || 0,
|
||||
lastMsgContentBlockTypes: lastMsgParsed.contentBlocks?.map((b: any) => b?.type) || [],
|
||||
})
|
||||
}
|
||||
|
||||
// Verify that the chat belongs to the user
|
||||
const [chat] = await db
|
||||
.select()
|
||||
|
||||
@@ -303,8 +303,8 @@ export const DiffControls = memo(function DiffControls() {
|
||||
!isResizing && 'transition-[bottom,right] duration-100 ease-out'
|
||||
)}
|
||||
style={{
|
||||
bottom: 'calc(var(--terminal-height) + 8px)',
|
||||
right: 'calc(var(--panel-width) + 8px)',
|
||||
bottom: 'calc(var(--terminal-height) + 16px)',
|
||||
right: 'calc(var(--panel-width) + 16px)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
|
||||
@@ -470,17 +470,8 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
|
||||
{/* Content blocks in chronological order */}
|
||||
{memoizedContentBlocks}
|
||||
|
||||
{/* Show streaming indicator if streaming but no text content yet after tool calls */}
|
||||
{isStreaming &&
|
||||
!message.content &&
|
||||
message.contentBlocks?.every((block) => block.type === 'tool_call') && (
|
||||
<StreamingIndicator />
|
||||
)}
|
||||
|
||||
{/* Streaming indicator when no content yet */}
|
||||
{!cleanTextContent && !message.contentBlocks?.length && isStreaming && (
|
||||
<StreamingIndicator />
|
||||
)}
|
||||
{/* Always show streaming indicator at the end while streaming */}
|
||||
{isStreaming && <StreamingIndicator />}
|
||||
|
||||
{message.errorType === 'usage_limit' && (
|
||||
<div className='flex gap-1.5'>
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { ChevronUp, LayoutList } from 'lucide-react'
|
||||
import { Button, Code } from '@/components/emcn'
|
||||
import Editor from 'react-simple-code-editor'
|
||||
import { Button, Code, getCodeEditorProps, highlight, languages } from '@/components/emcn'
|
||||
import { ClientToolCallState } from '@/lib/copilot/tools/client/base-tool'
|
||||
import { getClientTool } from '@/lib/copilot/tools/client/manager'
|
||||
import { getRegisteredTools } from '@/lib/copilot/tools/client/registry'
|
||||
@@ -413,6 +414,8 @@ const ACTION_VERBS = [
|
||||
'Listed',
|
||||
'Editing',
|
||||
'Edited',
|
||||
'Executing',
|
||||
'Executed',
|
||||
'Running',
|
||||
'Ran',
|
||||
'Designing',
|
||||
@@ -751,36 +754,70 @@ function SubAgentToolCall({ toolCall: toolCallProp }: { toolCall: CopilotToolCal
|
||||
const safeInputs = inputs && typeof inputs === 'object' ? inputs : {}
|
||||
const inputEntries = Object.entries(safeInputs)
|
||||
if (inputEntries.length === 0) return null
|
||||
|
||||
/**
|
||||
* Format a value for display - handles objects, arrays, and primitives
|
||||
*/
|
||||
const formatValue = (value: unknown): string => {
|
||||
if (value === null || value === undefined) return '-'
|
||||
if (typeof value === 'string') return value || '-'
|
||||
if (typeof value === 'number' || typeof value === 'boolean') return String(value)
|
||||
try {
|
||||
return JSON.stringify(value, null, 2)
|
||||
} catch {
|
||||
return String(value)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a value is a complex type (object or array)
|
||||
*/
|
||||
const isComplex = (value: unknown): boolean => {
|
||||
return typeof value === 'object' && value !== null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='mt-1.5 w-full overflow-hidden rounded-[4px] border border-[var(--border-1)] bg-[var(--surface-1)]'>
|
||||
<table className='w-full table-fixed bg-transparent'>
|
||||
<thead className='bg-transparent'>
|
||||
<tr className='border-[var(--border-1)] border-b bg-transparent'>
|
||||
<th className='w-[36%] border-[var(--border-1)] border-r bg-transparent px-[10px] py-[5px] text-left font-medium text-[12px] text-[var(--text-tertiary)]'>
|
||||
Input
|
||||
</th>
|
||||
<th className='w-[64%] bg-transparent px-[10px] py-[5px] text-left font-medium text-[12px] text-[var(--text-tertiary)]'>
|
||||
Value
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className='bg-transparent'>
|
||||
{inputEntries.map(([key, value]) => (
|
||||
<tr key={key} className='border-[var(--border-1)] border-t bg-transparent'>
|
||||
<td className='w-[36%] border-[var(--border-1)] border-r bg-transparent px-[10px] py-[6px]'>
|
||||
<span className='truncate font-medium text-[var(--text-primary)] text-xs'>
|
||||
{key}
|
||||
<div className='mt-1.5 w-full overflow-hidden rounded-md border border-[var(--border-1)] bg-[var(--surface-1)]'>
|
||||
{/* Header */}
|
||||
<div className='flex items-center gap-[8px] border-[var(--border-1)] border-b bg-[var(--surface-2)] p-[8px]'>
|
||||
<span className='font-medium text-[12px] text-[var(--text-primary)]'>Input</span>
|
||||
<span className='flex-shrink-0 font-medium text-[12px] text-[var(--text-tertiary)]'>
|
||||
{inputEntries.length}
|
||||
</span>
|
||||
</div>
|
||||
{/* Input entries */}
|
||||
<div className='flex flex-col'>
|
||||
{inputEntries.map(([key, value], index) => {
|
||||
const formattedValue = formatValue(value)
|
||||
const needsCodeViewer = isComplex(value)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className={clsx(
|
||||
'flex flex-col gap-1 px-[10px] py-[6px]',
|
||||
index > 0 && 'border-[var(--border-1)] border-t'
|
||||
)}
|
||||
>
|
||||
{/* Input key */}
|
||||
<span className='font-medium text-[11px] text-[var(--text-primary)]'>{key}</span>
|
||||
{/* Value display */}
|
||||
{needsCodeViewer ? (
|
||||
<Code.Viewer
|
||||
code={formattedValue}
|
||||
language='json'
|
||||
showGutter={false}
|
||||
className='max-h-[80px] min-h-0'
|
||||
/>
|
||||
) : (
|
||||
<span className='font-mono text-[11px] text-[var(--text-muted)] leading-[1.3]'>
|
||||
{formattedValue}
|
||||
</span>
|
||||
</td>
|
||||
<td className='w-[64%] bg-transparent px-[10px] py-[6px]'>
|
||||
<span className='font-mono text-[var(--text-muted)] text-xs'>
|
||||
{String(value)}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -2290,74 +2327,136 @@ export function ToolCall({ toolCall: toolCallProp, toolCallId, onStateChange }:
|
||||
const safeInputs = inputs && typeof inputs === 'object' ? inputs : {}
|
||||
const inputEntries = Object.entries(safeInputs)
|
||||
|
||||
// Don't show the table if there are no inputs
|
||||
// Don't show the section if there are no inputs
|
||||
if (inputEntries.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='w-full overflow-hidden rounded-[4px] border border-[var(--border-1)] bg-[var(--surface-1)]'>
|
||||
<table className='w-full table-fixed bg-transparent'>
|
||||
<thead className='bg-transparent'>
|
||||
<tr className='border-[var(--border-1)] border-b bg-transparent'>
|
||||
<th className='w-[36%] border-[var(--border-1)] border-r bg-transparent px-[10px] py-[5px] text-left font-medium text-[14px] text-[var(--text-tertiary)]'>
|
||||
Input
|
||||
</th>
|
||||
<th className='w-[64%] bg-transparent px-[10px] py-[5px] text-left font-medium text-[14px] text-[var(--text-tertiary)]'>
|
||||
Value
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className='bg-transparent'>
|
||||
{inputEntries.map(([key, value]) => (
|
||||
<tr
|
||||
key={key}
|
||||
className='group relative border-[var(--border-1)] border-t bg-transparent'
|
||||
>
|
||||
<td className='relative w-[36%] border-[var(--border-1)] border-r bg-transparent p-0'>
|
||||
<div className='px-[10px] py-[8px]'>
|
||||
<span className='truncate font-medium text-[var(--text-primary)] text-xs'>
|
||||
{key}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className='relative w-[64%] bg-transparent p-0'>
|
||||
<div className='min-w-0 px-[10px] py-[8px]'>
|
||||
<input
|
||||
type='text'
|
||||
value={String(value)}
|
||||
onChange={(e) => {
|
||||
const newInputs = { ...safeInputs, [key]: e.target.value }
|
||||
/**
|
||||
* Format a value for display - handles objects, arrays, and primitives
|
||||
*/
|
||||
const formatValueForDisplay = (value: unknown): string => {
|
||||
if (value === null || value === undefined) return ''
|
||||
if (typeof value === 'string') return value
|
||||
if (typeof value === 'number' || typeof value === 'boolean') return String(value)
|
||||
// For objects and arrays, use JSON.stringify with formatting
|
||||
try {
|
||||
return JSON.stringify(value, null, 2)
|
||||
} catch {
|
||||
return String(value)
|
||||
}
|
||||
}
|
||||
|
||||
// Determine how to update based on original structure
|
||||
if (isNestedInWorkflowInput) {
|
||||
// Update workflow_input
|
||||
setEditedParams({ ...editedParams, workflow_input: newInputs })
|
||||
} else if (typeof editedParams.input === 'string') {
|
||||
// Input was a JSON string, serialize back
|
||||
setEditedParams({ ...editedParams, input: JSON.stringify(newInputs) })
|
||||
} else if (editedParams.input && typeof editedParams.input === 'object') {
|
||||
// Input is an object
|
||||
setEditedParams({ ...editedParams, input: newInputs })
|
||||
} else if (
|
||||
editedParams.inputs &&
|
||||
typeof editedParams.inputs === 'object'
|
||||
) {
|
||||
// Inputs is an object
|
||||
setEditedParams({ ...editedParams, inputs: newInputs })
|
||||
} else {
|
||||
// Flat structure - update at base level
|
||||
setEditedParams({ ...editedParams, [key]: e.target.value })
|
||||
}
|
||||
}}
|
||||
className='w-full bg-transparent font-mono text-[var(--text-muted)] text-xs outline-none focus:text-[var(--text-primary)]'
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
/**
|
||||
* Parse a string value back to its original type if possible
|
||||
*/
|
||||
const parseInputValue = (value: string, originalValue: unknown): unknown => {
|
||||
// If original was a primitive, keep as string
|
||||
if (typeof originalValue !== 'object' || originalValue === null) {
|
||||
return value
|
||||
}
|
||||
// Try to parse as JSON for objects/arrays
|
||||
try {
|
||||
return JSON.parse(value)
|
||||
} catch {
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a value is a complex type (object or array)
|
||||
*/
|
||||
const isComplexValue = (value: unknown): boolean => {
|
||||
return typeof value === 'object' && value !== null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='w-full overflow-hidden rounded-md border border-[var(--border-1)] bg-[var(--surface-1)]'>
|
||||
{/* Header */}
|
||||
<div className='flex items-center gap-[8px] border-[var(--border-1)] border-b bg-[var(--surface-2)] p-[8px]'>
|
||||
<span className='font-medium text-[12px] text-[var(--text-primary)]'>Edit Input</span>
|
||||
<span className='flex-shrink-0 font-medium text-[12px] text-[var(--text-tertiary)]'>
|
||||
{inputEntries.length}
|
||||
</span>
|
||||
</div>
|
||||
{/* Input entries */}
|
||||
<div className='flex flex-col'>
|
||||
{inputEntries.map(([key, value], index) => {
|
||||
const isComplex = isComplexValue(value)
|
||||
const displayValue = formatValueForDisplay(value)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className={clsx(
|
||||
'flex flex-col gap-1.5 px-[10px] py-[8px]',
|
||||
index > 0 && 'border-[var(--border-1)] border-t'
|
||||
)}
|
||||
>
|
||||
{/* Input key */}
|
||||
<span className='font-medium text-[11px] text-[var(--text-primary)]'>{key}</span>
|
||||
{/* Value editor */}
|
||||
{isComplex ? (
|
||||
<Code.Container className='max-h-[168px] min-h-[60px]'>
|
||||
<Code.Content>
|
||||
<Editor
|
||||
value={displayValue}
|
||||
onValueChange={(newCode) => {
|
||||
const parsedValue = parseInputValue(newCode, value)
|
||||
const newInputs = { ...safeInputs, [key]: parsedValue }
|
||||
|
||||
if (isNestedInWorkflowInput) {
|
||||
setEditedParams({ ...editedParams, workflow_input: newInputs })
|
||||
} else if (typeof editedParams.input === 'string') {
|
||||
setEditedParams({ ...editedParams, input: JSON.stringify(newInputs) })
|
||||
} else if (
|
||||
editedParams.input &&
|
||||
typeof editedParams.input === 'object'
|
||||
) {
|
||||
setEditedParams({ ...editedParams, input: newInputs })
|
||||
} else if (
|
||||
editedParams.inputs &&
|
||||
typeof editedParams.inputs === 'object'
|
||||
) {
|
||||
setEditedParams({ ...editedParams, inputs: newInputs })
|
||||
} else {
|
||||
setEditedParams({ ...editedParams, [key]: parsedValue })
|
||||
}
|
||||
}}
|
||||
highlight={(code) => highlight(code, languages.json, 'json')}
|
||||
{...getCodeEditorProps()}
|
||||
className={clsx(getCodeEditorProps().className, 'min-h-[40px]')}
|
||||
style={{ minHeight: '40px' }}
|
||||
/>
|
||||
</Code.Content>
|
||||
</Code.Container>
|
||||
) : (
|
||||
<input
|
||||
type='text'
|
||||
value={displayValue}
|
||||
onChange={(e) => {
|
||||
const parsedValue = parseInputValue(e.target.value, value)
|
||||
const newInputs = { ...safeInputs, [key]: parsedValue }
|
||||
|
||||
if (isNestedInWorkflowInput) {
|
||||
setEditedParams({ ...editedParams, workflow_input: newInputs })
|
||||
} else if (typeof editedParams.input === 'string') {
|
||||
setEditedParams({ ...editedParams, input: JSON.stringify(newInputs) })
|
||||
} else if (editedParams.input && typeof editedParams.input === 'object') {
|
||||
setEditedParams({ ...editedParams, input: newInputs })
|
||||
} else if (editedParams.inputs && typeof editedParams.inputs === 'object') {
|
||||
setEditedParams({ ...editedParams, inputs: newInputs })
|
||||
} else {
|
||||
setEditedParams({ ...editedParams, [key]: parsedValue })
|
||||
}
|
||||
}}
|
||||
className='w-full rounded-[4px] border border-[var(--border-1)] bg-[var(--surface-1)] px-[8px] py-[6px] font-medium font-mono text-[13px] text-[var(--text-primary)] outline-none transition-colors placeholder:text-[var(--text-muted)] focus:outline-none'
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -2443,8 +2542,8 @@ export function ToolCall({ toolCall: toolCallProp, toolCallId, onStateChange }:
|
||||
<ShimmerOverlayText
|
||||
text={displayName}
|
||||
active={isLoadingState}
|
||||
isSpecial={false}
|
||||
className='font-[470] font-season text-[var(--text-muted)] text-sm'
|
||||
isSpecial={isSpecial}
|
||||
className='font-[470] font-season text-[var(--text-secondary)] text-sm dark:text-[var(--text-muted)]'
|
||||
/>
|
||||
</div>
|
||||
{code && (
|
||||
|
||||
@@ -124,8 +124,10 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
|
||||
isSendingMessage,
|
||||
})
|
||||
|
||||
// Handle scroll management
|
||||
const { scrollAreaRef, scrollToBottom } = useScrollManagement(messages, isSendingMessage)
|
||||
// Handle scroll management (80px stickiness for copilot)
|
||||
const { scrollAreaRef, scrollToBottom } = useScrollManagement(messages, isSendingMessage, {
|
||||
stickinessThreshold: 80,
|
||||
})
|
||||
|
||||
// Handle chat history grouping
|
||||
const { groupedChats, handleHistoryDropdownOpen: handleHistoryDropdownOpenHook } = useChatHistory(
|
||||
|
||||
@@ -12,6 +12,12 @@ interface UseScrollManagementOptions {
|
||||
* - `auto`: immediate scroll to bottom (used by floating chat to avoid jitter).
|
||||
*/
|
||||
behavior?: 'auto' | 'smooth'
|
||||
/**
|
||||
* Distance from bottom (in pixels) within which auto-scroll stays active.
|
||||
* Lower values = less sticky (user can scroll away easier).
|
||||
* Default is 100px.
|
||||
*/
|
||||
stickinessThreshold?: number
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -34,6 +40,7 @@ export function useScrollManagement(
|
||||
const programmaticScrollInProgressRef = useRef(false)
|
||||
const lastScrollTopRef = useRef(0)
|
||||
const scrollBehavior: 'auto' | 'smooth' = options?.behavior ?? 'smooth'
|
||||
const stickinessThreshold = options?.stickinessThreshold ?? 100
|
||||
|
||||
/**
|
||||
* Scrolls the container to the bottom with smooth animation
|
||||
@@ -74,7 +81,7 @@ export function useScrollManagement(
|
||||
const { scrollTop, scrollHeight, clientHeight } = scrollContainer
|
||||
const distanceFromBottom = scrollHeight - scrollTop - clientHeight
|
||||
|
||||
const nearBottom = distanceFromBottom <= 100
|
||||
const nearBottom = distanceFromBottom <= stickinessThreshold
|
||||
setIsNearBottom(nearBottom)
|
||||
|
||||
if (isSendingMessage) {
|
||||
@@ -95,7 +102,7 @@ export function useScrollManagement(
|
||||
|
||||
// Track last scrollTop for direction detection
|
||||
lastScrollTopRef.current = scrollTop
|
||||
}, [getScrollContainer, isSendingMessage, userHasScrolledDuringStream])
|
||||
}, [getScrollContainer, isSendingMessage, userHasScrolledDuringStream, stickinessThreshold])
|
||||
|
||||
// Attach scroll listener
|
||||
useEffect(() => {
|
||||
@@ -174,14 +181,20 @@ export function useScrollManagement(
|
||||
|
||||
const { scrollTop, scrollHeight, clientHeight } = scrollContainer
|
||||
const distanceFromBottom = scrollHeight - scrollTop - clientHeight
|
||||
const nearBottom = distanceFromBottom <= 120
|
||||
const nearBottom = distanceFromBottom <= stickinessThreshold
|
||||
if (nearBottom) {
|
||||
scrollToBottom()
|
||||
}
|
||||
}, 100)
|
||||
|
||||
return () => window.clearInterval(intervalId)
|
||||
}, [isSendingMessage, userHasScrolledDuringStream, getScrollContainer, scrollToBottom])
|
||||
}, [
|
||||
isSendingMessage,
|
||||
userHasScrolledDuringStream,
|
||||
getScrollContainer,
|
||||
scrollToBottom,
|
||||
stickinessThreshold,
|
||||
])
|
||||
|
||||
return {
|
||||
scrollAreaRef,
|
||||
|
||||
@@ -271,11 +271,31 @@ function resolveToolDisplay(
|
||||
if (cand?.text || cand?.icon) return { text: cand.text, icon: cand.icon }
|
||||
}
|
||||
} catch {}
|
||||
// Humanized fallback as last resort
|
||||
// Humanized fallback as last resort - include state verb for proper verb-noun styling
|
||||
try {
|
||||
if (toolName) {
|
||||
const text = toolName.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase())
|
||||
return { text, icon: undefined as any }
|
||||
const formattedName = toolName.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase())
|
||||
// Add state verb prefix for verb-noun rendering in tool-call component
|
||||
let stateVerb: string
|
||||
switch (state) {
|
||||
case ClientToolCallState.pending:
|
||||
case ClientToolCallState.executing:
|
||||
stateVerb = 'Executing'
|
||||
break
|
||||
case ClientToolCallState.success:
|
||||
stateVerb = 'Executed'
|
||||
break
|
||||
case ClientToolCallState.error:
|
||||
stateVerb = 'Failed'
|
||||
break
|
||||
case ClientToolCallState.rejected:
|
||||
case ClientToolCallState.aborted:
|
||||
stateVerb = 'Skipped'
|
||||
break
|
||||
default:
|
||||
stateVerb = 'Executing'
|
||||
}
|
||||
return { text: `${stateVerb} ${formattedName}`, icon: undefined as any }
|
||||
}
|
||||
} catch {}
|
||||
return undefined
|
||||
@@ -572,8 +592,30 @@ function stripTodoTags(text: string): string {
|
||||
*/
|
||||
function deepClone<T>(obj: T): T {
|
||||
try {
|
||||
return JSON.parse(JSON.stringify(obj))
|
||||
} catch {
|
||||
const json = JSON.stringify(obj)
|
||||
if (!json || json === 'undefined') {
|
||||
logger.warn('[deepClone] JSON.stringify returned empty for object', {
|
||||
type: typeof obj,
|
||||
isArray: Array.isArray(obj),
|
||||
length: Array.isArray(obj) ? obj.length : undefined,
|
||||
})
|
||||
return obj
|
||||
}
|
||||
const parsed = JSON.parse(json)
|
||||
// Verify the clone worked
|
||||
if (Array.isArray(obj) && (!Array.isArray(parsed) || parsed.length !== obj.length)) {
|
||||
logger.warn('[deepClone] Array clone mismatch', {
|
||||
originalLength: obj.length,
|
||||
clonedLength: Array.isArray(parsed) ? parsed.length : 'not array',
|
||||
})
|
||||
}
|
||||
return parsed
|
||||
} catch (err) {
|
||||
logger.error('[deepClone] Failed to clone object', {
|
||||
error: String(err),
|
||||
type: typeof obj,
|
||||
isArray: Array.isArray(obj),
|
||||
})
|
||||
return obj
|
||||
}
|
||||
}
|
||||
@@ -587,11 +629,18 @@ function serializeMessagesForDB(messages: CopilotMessage[]): any[] {
|
||||
const result = messages
|
||||
.map((msg) => {
|
||||
// Deep clone the entire message to ensure all nested data is serializable
|
||||
// Ensure timestamp is always a string (Zod schema requires it)
|
||||
let timestamp: string = msg.timestamp
|
||||
if (typeof timestamp !== 'string') {
|
||||
const ts = timestamp as any
|
||||
timestamp = ts instanceof Date ? ts.toISOString() : new Date().toISOString()
|
||||
}
|
||||
|
||||
const serialized: any = {
|
||||
id: msg.id,
|
||||
role: msg.role,
|
||||
content: msg.content || '',
|
||||
timestamp: msg.timestamp,
|
||||
timestamp,
|
||||
}
|
||||
|
||||
// Deep clone contentBlocks (the main rendering data)
|
||||
@@ -3151,7 +3200,7 @@ export const useCopilotStore = create<CopilotStore>()(
|
||||
model: selectedModel,
|
||||
}
|
||||
|
||||
await fetch('/api/copilot/chat/update-messages', {
|
||||
const saveResponse = await fetch('/api/copilot/chat/update-messages', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
@@ -3162,6 +3211,18 @@ export const useCopilotStore = create<CopilotStore>()(
|
||||
}),
|
||||
})
|
||||
|
||||
if (!saveResponse.ok) {
|
||||
const errorText = await saveResponse.text().catch(() => '')
|
||||
logger.error('[Stream Done] Failed to save messages to DB', {
|
||||
status: saveResponse.status,
|
||||
error: errorText,
|
||||
})
|
||||
} else {
|
||||
logger.info('[Stream Done] Successfully saved messages to DB', {
|
||||
messageCount: dbMessages.length,
|
||||
})
|
||||
}
|
||||
|
||||
// Update local chat object with plan artifact and config
|
||||
set({
|
||||
currentChat: {
|
||||
@@ -3170,7 +3231,9 @@ export const useCopilotStore = create<CopilotStore>()(
|
||||
config,
|
||||
},
|
||||
})
|
||||
} catch {}
|
||||
} catch (err) {
|
||||
logger.error('[Stream Done] Exception saving messages', { error: String(err) })
|
||||
}
|
||||
}
|
||||
|
||||
// Post copilot_stats record (input/output tokens can be null for now)
|
||||
|
||||
Reference in New Issue
Block a user