Compare commits

...

6 Commits

Author SHA1 Message Date
Emir Karabeg
077a902325 feat(copilot): editable input component 2026-01-10 17:59:49 -08:00
Emir Karabeg
fd2c4b6a7c improvement: diff controls and notifications positioning 2026-01-10 17:35:17 -08:00
Siddharth Ganesan
47209aee32 Scroll stickiness 2026-01-10 15:49:15 -08:00
Siddharth Ganesan
0350321d1b Scroll stickiness 2026-01-10 15:36:24 -08:00
Siddharth Ganesan
e3b849ad74 Fix Lint 2026-01-10 15:29:52 -08:00
Siddharth Ganesan
07433ccbb1 Fix loading 2026-01-10 15:17:05 -08:00
8 changed files with 318 additions and 158 deletions

View File

@@ -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)

View File

@@ -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()

View File

@@ -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

View File

@@ -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'>

View File

@@ -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 && (

View File

@@ -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(

View File

@@ -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,

View File

@@ -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)