Skip to content

Commit 003f52d

Browse files
committed
fix(copilot): scrolling, tool-call truncation, thinking ui
1 parent 81059dc commit 003f52d

File tree

18 files changed

+133
-166
lines changed

18 files changed

+133
-166
lines changed

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/thinking-block/thinking-block.tsx

Lines changed: 0 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,6 @@ function stripThinkingTags(text: string): string {
1717
.trim()
1818
}
1919

20-
/** Max height for thinking content before internal scrolling */
21-
const THINKING_MAX_HEIGHT = 150
22-
23-
/** Height threshold before gradient fade kicks in */
24-
const GRADIENT_THRESHOLD = 100
25-
2620
/** Interval for auto-scroll during streaming (ms) */
2721
const SCROLL_INTERVAL = 50
2822

@@ -41,12 +35,10 @@ interface SmoothThinkingTextProps {
4135

4236
/**
4337
* Renders thinking content with fast streaming animation.
44-
* Uses gradient fade at top when content is tall enough.
4538
*/
4639
const SmoothThinkingText = memo(
4740
({ content, isStreaming }: SmoothThinkingTextProps) => {
4841
const [displayedContent, setDisplayedContent] = useState(() => (isStreaming ? '' : content))
49-
const [showGradient, setShowGradient] = useState(false)
5042
const contentRef = useRef(content)
5143
const textRef = useRef<HTMLDivElement>(null)
5244
const rafRef = useRef<number | null>(null)
@@ -112,28 +104,10 @@ const SmoothThinkingText = memo(
112104
}
113105
}, [content, isStreaming])
114106

115-
useEffect(() => {
116-
if (textRef.current && isStreaming) {
117-
const height = textRef.current.scrollHeight
118-
setShowGradient(height > GRADIENT_THRESHOLD)
119-
} else {
120-
setShowGradient(false)
121-
}
122-
}, [displayedContent, isStreaming])
123-
124-
const gradientStyle =
125-
isStreaming && showGradient
126-
? {
127-
maskImage: 'linear-gradient(to bottom, transparent 0%, black 30%, black 100%)',
128-
WebkitMaskImage: 'linear-gradient(to bottom, transparent 0%, black 30%, black 100%)',
129-
}
130-
: undefined
131-
132107
return (
133108
<div
134109
ref={textRef}
135110
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)]'
136-
style={gradientStyle}
137111
>
138112
<CopilotMarkdownRenderer content={displayedContent} />
139113
</div>

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/copilot-message.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -401,7 +401,7 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
401401
className={`w-full max-w-full flex-none overflow-hidden [max-width:var(--panel-max-width)] ${isDimmed ? 'opacity-40' : 'opacity-100'}`}
402402
style={{ '--panel-max-width': `${panelWidth - 16}px` } as React.CSSProperties}
403403
>
404-
<div className='max-w-full space-y-[4px] px-[2px]'>
404+
<div className='max-w-full space-y-[4px] px-[2px] pb-[4px]'>
405405
{/* Content blocks in chronological order */}
406406
{memoizedContentBlocks || (isStreaming && <div className='min-h-0' />)}
407407

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/tool-call/tool-call.tsx

Lines changed: 26 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -337,7 +337,7 @@ export function OptionsSelector({
337337
})
338338
}, [options])
339339

340-
const [hoveredIndex, setHoveredIndex] = useState(0)
340+
const [hoveredIndex, setHoveredIndex] = useState(-1)
341341
const [chosenKey, setChosenKey] = useState<string | null>(selectedOptionKey)
342342
const containerRef = useRef<HTMLDivElement>(null)
343343

@@ -360,13 +360,14 @@ export function OptionsSelector({
360360

361361
if (e.key === 'ArrowDown') {
362362
e.preventDefault()
363-
setHoveredIndex((prev) => Math.min(prev + 1, sortedOptions.length - 1))
363+
setHoveredIndex((prev) => (prev < 0 ? 0 : Math.min(prev + 1, sortedOptions.length - 1)))
364364
} else if (e.key === 'ArrowUp') {
365365
e.preventDefault()
366-
setHoveredIndex((prev) => Math.max(prev - 1, 0))
366+
setHoveredIndex((prev) => (prev < 0 ? sortedOptions.length - 1 : Math.max(prev - 1, 0)))
367367
} else if (e.key === 'Enter') {
368368
e.preventDefault()
369-
const selected = sortedOptions[hoveredIndex]
369+
const indexToSelect = hoveredIndex < 0 ? 0 : hoveredIndex
370+
const selected = sortedOptions[indexToSelect]
370371
if (selected) {
371372
setChosenKey(selected.key)
372373
onSelect(selected.key, selected.title)
@@ -390,7 +391,7 @@ export function OptionsSelector({
390391
if (sortedOptions.length === 0) return null
391392

392393
return (
393-
<div ref={containerRef} className='mt-0 flex flex-col gap-[4px]'>
394+
<div ref={containerRef} className='flex flex-col gap-[4px] pt-[4px]'>
394395
{sortedOptions.map((option, index) => {
395396
const isHovered = index === hoveredIndex && !isLocked
396397
const isChosen = option.key === chosenKey
@@ -408,6 +409,9 @@ export function OptionsSelector({
408409
onMouseEnter={() => {
409410
if (!isLocked && !streaming) setHoveredIndex(index)
410411
}}
412+
onMouseLeave={() => {
413+
if (!isLocked && !streaming && sortedOptions.length === 1) setHoveredIndex(-1)
414+
}}
411415
className={clsx(
412416
'group flex cursor-pointer items-start gap-2 rounded-[6px] p-1',
413417
'hover:bg-[var(--surface-4)]',
@@ -596,6 +600,7 @@ function splitActionVerb(text: string): [string | null, string] {
596600
/**
597601
* Renders text with a shimmer overlay animation when active.
598602
* Special tools use a gradient color; normal tools highlight action verbs.
603+
* Uses CSS truncation to clamp to one line with ellipsis.
599604
*/
600605
const ShimmerOverlayText = memo(function ShimmerOverlayText({
601606
text,
@@ -605,18 +610,21 @@ const ShimmerOverlayText = memo(function ShimmerOverlayText({
605610
}: ShimmerOverlayTextProps) {
606611
const [actionVerb, remainder] = splitActionVerb(text)
607612

613+
// Base classes for single-line truncation with ellipsis
614+
const truncateClasses = 'block w-full overflow-hidden text-ellipsis whitespace-nowrap'
615+
608616
// Special tools: use tertiary-2 color for entire text with shimmer
609617
if (isSpecial) {
610618
return (
611-
<span className={`relative inline-block ${className || ''}`}>
619+
<span className={`relative ${truncateClasses} ${className || ''}`}>
612620
<span className='text-[var(--brand-tertiary-2)]'>{text}</span>
613621
{active ? (
614622
<span
615623
aria-hidden='true'
616624
className='pointer-events-none absolute inset-0 select-none overflow-hidden'
617625
>
618626
<span
619-
className='block text-transparent'
627+
className='block overflow-hidden text-ellipsis whitespace-nowrap text-transparent'
620628
style={{
621629
backgroundImage:
622630
'linear-gradient(90deg, rgba(51,196,129,0) 0%, rgba(255,255,255,0.6) 50%, rgba(51,196,129,0) 100%)',
@@ -647,7 +655,7 @@ const ShimmerOverlayText = memo(function ShimmerOverlayText({
647655
// Light mode: primary (#2d2d2d) vs muted (#737373) for good contrast
648656
// Dark mode: tertiary (#b3b3b3) vs muted (#787878) for good contrast
649657
return (
650-
<span className={`relative inline-block ${className || ''}`}>
658+
<span className={`relative ${truncateClasses} ${className || ''}`}>
651659
{actionVerb ? (
652660
<>
653661
<span className='text-[var(--text-primary)] dark:text-[var(--text-tertiary)]'>
@@ -664,7 +672,7 @@ const ShimmerOverlayText = memo(function ShimmerOverlayText({
664672
className='pointer-events-none absolute inset-0 select-none overflow-hidden'
665673
>
666674
<span
667-
className='block text-transparent'
675+
className='block overflow-hidden text-ellipsis whitespace-nowrap text-transparent'
668676
style={{
669677
backgroundImage:
670678
'linear-gradient(90deg, rgba(255,255,255,0) 0%, rgba(255,255,255,0.85) 50%, rgba(255,255,255,0) 100%)',
@@ -1423,7 +1431,7 @@ function RunSkipButtons({
14231431

14241432
// Standardized buttons for all interrupt tools: Allow, (Always Allow for client tools only), Skip
14251433
return (
1426-
<div className='mt-[6px] flex gap-[6px]'>
1434+
<div className='mt-[10px] flex gap-[6px]'>
14271435
<Button onClick={onRun} disabled={isProcessing} variant='tertiary'>
14281436
{isProcessing ? 'Allowing...' : 'Allow'}
14291437
</Button>
@@ -2016,9 +2024,9 @@ export function ToolCall({ toolCall: toolCallProp, toolCallId, onStateChange }:
20162024
className='font-[470] font-season text-[var(--text-secondary)] text-sm dark:text-[var(--text-muted)]'
20172025
/>
20182026
</div>
2019-
<div className='mt-[6px]'>{renderPendingDetails()}</div>
2027+
<div className='mt-[10px]'>{renderPendingDetails()}</div>
20202028
{showRemoveAutoAllow && isAutoAllowed && (
2021-
<div className='mt-[6px]'>
2029+
<div className='mt-[10px]'>
20222030
<Button
20232031
onClick={async () => {
20242032
await removeAutoAllowedTool(toolCall.name)
@@ -2074,12 +2082,12 @@ export function ToolCall({ toolCall: toolCallProp, toolCallId, onStateChange }:
20742082
/>
20752083
</div>
20762084
{code && (
2077-
<div className='mt-1.5'>
2085+
<div className='mt-[10px]'>
20782086
<Code.Viewer code={code} language='javascript' showGutter className='min-h-0' />
20792087
</div>
20802088
)}
20812089
{showRemoveAutoAllow && isAutoAllowed && (
2082-
<div className='mt-1.5'>
2090+
<div className='mt-[10px]'>
20832091
<Button
20842092
onClick={async () => {
20852093
await removeAutoAllowedTool(toolCall.name)
@@ -2138,9 +2146,9 @@ export function ToolCall({ toolCall: toolCallProp, toolCallId, onStateChange }:
21382146
/>
21392147
</div>
21402148
)}
2141-
{shouldShowDetails && <div className='mt-1.5'>{renderPendingDetails()}</div>}
2149+
{shouldShowDetails && <div className='mt-[10px]'>{renderPendingDetails()}</div>}
21422150
{showRemoveAutoAllow && isAutoAllowed && (
2143-
<div className='mt-1.5'>
2151+
<div className='mt-[10px]'>
21442152
<Button
21452153
onClick={async () => {
21462154
await removeAutoAllowedTool(toolCall.name)
@@ -2161,7 +2169,7 @@ export function ToolCall({ toolCall: toolCallProp, toolCallId, onStateChange }:
21612169
editedParams={editedParams}
21622170
/>
21632171
) : showMoveToBackground ? (
2164-
<div className='mt-1.5'>
2172+
<div className='mt-[10px]'>
21652173
<Button
21662174
onClick={async () => {
21672175
try {
@@ -2182,7 +2190,7 @@ export function ToolCall({ toolCall: toolCallProp, toolCallId, onStateChange }:
21822190
</Button>
21832191
</div>
21842192
) : showWake ? (
2185-
<div className='mt-1.5'>
2193+
<div className='mt-[10px]'>
21862194
<Button
21872195
onClick={async () => {
21882196
try {

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/copilot.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,7 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
130130

131131
// Handle scroll management (80px stickiness for copilot)
132132
const { scrollAreaRef, scrollToBottom } = useScrollManagement(messages, isSendingMessage, {
133-
stickinessThreshold: 80,
133+
stickinessThreshold: 40,
134134
})
135135

136136
// Handle chat history grouping
@@ -475,7 +475,7 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
475475
className='h-full overflow-y-auto overflow-x-hidden px-[8px]'
476476
>
477477
<div
478-
className={`w-full max-w-full space-y-[12px] overflow-hidden py-[8px] ${
478+
className={`w-full max-w-full space-y-[8px] overflow-hidden py-[8px] ${
479479
showPlanTodos && planTodos.length > 0 ? 'pb-14' : 'pb-10'
480480
}`}
481481
>

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/hooks/use-chat-history.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,14 @@ export function useChatHistory(props: UseChatHistoryProps) {
6666
}
6767
})
6868

69+
for (const groupName of Object.keys(groups)) {
70+
groups[groupName].sort((a, b) => {
71+
const dateA = new Date(a.updatedAt).getTime()
72+
const dateB = new Date(b.updatedAt).getTime()
73+
return dateB - dateA
74+
})
75+
}
76+
6977
return Object.entries(groups).filter(([, chats]) => chats.length > 0)
7078
}, [chats, activeWorkflowId, copilotWorkflowId])
7179

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-scroll-management.ts

Lines changed: 35 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
'use client'
22

3-
import { useCallback, useEffect, useRef } from 'react'
3+
import { useCallback, useEffect, useRef, useState } from 'react'
44

55
/**
66
* Options for configuring scroll behavior
@@ -36,11 +36,9 @@ export function useScrollManagement(
3636
options?: UseScrollManagementOptions
3737
) {
3838
const scrollAreaRef = useRef<HTMLDivElement>(null)
39-
const userHasScrolledRef = useRef(false)
39+
const [userHasScrolledAway, setUserHasScrolledAway] = useState(false)
4040
const programmaticScrollRef = useRef(false)
4141
const lastScrollTopRef = useRef(0)
42-
const lastMessageCountRef = useRef(0)
43-
const rafIdRef = useRef<number | null>(null)
4442

4543
const scrollBehavior = options?.behavior ?? 'smooth'
4644
const stickinessThreshold = options?.stickinessThreshold ?? 100
@@ -68,14 +66,19 @@ export function useScrollManagement(
6866
const nearBottom = distanceFromBottom <= stickinessThreshold
6967
const delta = scrollTop - lastScrollTopRef.current
7068

71-
if (delta < -2) {
72-
userHasScrolledRef.current = true
73-
} else if (userHasScrolledRef.current && delta > 2 && nearBottom) {
74-
userHasScrolledRef.current = false
69+
if (isSendingMessage) {
70+
// User scrolled up during streaming - break away
71+
if (delta < -2) {
72+
setUserHasScrolledAway(true)
73+
}
74+
// User scrolled back down to bottom - re-stick
75+
if (userHasScrolledAway && delta > 2 && nearBottom) {
76+
setUserHasScrolledAway(false)
77+
}
7578
}
7679

7780
lastScrollTopRef.current = scrollTop
78-
}, [stickinessThreshold])
81+
}, [isSendingMessage, userHasScrolledAway, stickinessThreshold])
7982

8083
/** Attaches scroll listener to container */
8184
useEffect(() => {
@@ -92,58 +95,46 @@ export function useScrollManagement(
9295
useEffect(() => {
9396
if (messages.length === 0) return
9497

95-
const messageAdded = messages.length > lastMessageCountRef.current
96-
lastMessageCountRef.current = messages.length
98+
const lastMessage = messages[messages.length - 1]
99+
const isUserMessage = lastMessage?.role === 'user'
97100

98-
if (messageAdded) {
99-
const lastMessage = messages[messages.length - 1]
100-
if (lastMessage?.role === 'user') {
101-
userHasScrolledRef.current = false
102-
}
101+
// Always scroll for user messages, respect scroll state for assistant messages
102+
if (isUserMessage) {
103+
setUserHasScrolledAway(false)
104+
scrollToBottom()
105+
} else if (!userHasScrolledAway) {
103106
scrollToBottom()
104107
}
105-
}, [messages, scrollToBottom])
108+
}, [messages, userHasScrolledAway, scrollToBottom])
106109

107110
/** Resets scroll state when streaming completes */
108-
const prevIsSendingRef = useRef(false)
109111
useEffect(() => {
110-
if (prevIsSendingRef.current && !isSendingMessage) {
111-
userHasScrolledRef.current = false
112+
if (!isSendingMessage) {
113+
setUserHasScrolledAway(false)
112114
}
113-
prevIsSendingRef.current = isSendingMessage
114115
}, [isSendingMessage])
115116

116-
/** Keeps scroll pinned during streaming using requestAnimationFrame */
117+
/** Keeps scroll pinned during streaming - uses interval, stops when user scrolls away */
117118
useEffect(() => {
118-
if (!isSendingMessage || userHasScrolledRef.current) {
119-
if (rafIdRef.current) {
120-
cancelAnimationFrame(rafIdRef.current)
121-
rafIdRef.current = null
122-
}
119+
// Early return stops the interval when user scrolls away (state change re-runs effect)
120+
if (!isSendingMessage || userHasScrolledAway) {
123121
return
124122
}
125123

126-
const tick = () => {
124+
const intervalId = window.setInterval(() => {
127125
const container = scrollAreaRef.current
128-
if (container && !userHasScrolledRef.current) {
129-
const { scrollTop, scrollHeight, clientHeight } = container
130-
const distanceFromBottom = scrollHeight - scrollTop - clientHeight
131-
if (distanceFromBottom <= stickinessThreshold) {
132-
scrollToBottom()
133-
}
134-
}
135-
rafIdRef.current = requestAnimationFrame(tick)
136-
}
126+
if (!container) return
137127

138-
rafIdRef.current = requestAnimationFrame(tick)
128+
const { scrollTop, scrollHeight, clientHeight } = container
129+
const distanceFromBottom = scrollHeight - scrollTop - clientHeight
139130

140-
return () => {
141-
if (rafIdRef.current) {
142-
cancelAnimationFrame(rafIdRef.current)
143-
rafIdRef.current = null
131+
if (distanceFromBottom > 1) {
132+
scrollToBottom()
144133
}
145-
}
146-
}, [isSendingMessage, scrollToBottom, stickinessThreshold])
134+
}, 100)
135+
136+
return () => window.clearInterval(intervalId)
137+
}, [isSendingMessage, userHasScrolledAway, scrollToBottom])
147138

148139
return {
149140
scrollAreaRef,

0 commit comments

Comments
 (0)