Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 37 additions & 2 deletions components/task-chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { Button } from '@/components/ui/button'
import { Textarea } from '@/components/ui/textarea'
import {
ArrowUp,
ArrowDown,
Loader2,
Copy,
Check,
Expand Down Expand Up @@ -86,6 +87,7 @@ export function TaskChat({ taskId, task }: TaskChatProps) {
const messageRefs = useRef<Record<string, HTMLDivElement | null>>({})
const [overflowingMessages, setOverflowingMessages] = useState<Set<string>>(new Set())
const contentRefs = useRef<Record<string, HTMLDivElement | null>>({})
const [showScrollButton, setShowScrollButton] = useState(false)

// Track if each tab has been loaded to avoid refetching on tab switch
const commentsLoadedRef = useRef(false)
Expand Down Expand Up @@ -350,12 +352,30 @@ export function TaskChat({ taskId, task }: TaskChatProps) {

const handleScroll = () => {
wasAtBottomRef.current = isNearBottom()
setShowScrollButton(!isNearBottom())
}

// Check initial scroll position
handleScroll()

container.addEventListener('scroll', handleScroll)
return () => container.removeEventListener('scroll', handleScroll)
}, [])

// Check scroll position when messages change or active tab changes
useEffect(() => {
if (activeTab === 'chat') {
// Small delay to ensure DOM is updated
setTimeout(() => {
const container = scrollContainerRef.current
if (container) {
const nearBottom = isNearBottom()
setShowScrollButton(!nearBottom)
}
}, 100)
Comment on lines +369 to +375
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
setTimeout(() => {
const container = scrollContainerRef.current
if (container) {
const nearBottom = isNearBottom()
setShowScrollButton(!nearBottom)
}
}, 100)
const timeout = setTimeout(() => {
const container = scrollContainerRef.current
if (container) {
const nearBottom = isNearBottom()
setShowScrollButton(!nearBottom)
}
}, 100)
return () => clearTimeout(timeout)

The second useEffect schedules a timeout to update scroll button visibility but doesn't provide a cleanup function to cancel it, which can cause state update warnings on component unmount and potential memory issues if messages change frequently.

View Details

Analysis

Missing cleanup function in useEffect with setTimeout schedules duplicate scroll button updates

What fails: The useEffect hook starting at line 366 in components/task-chat.tsx schedules a setTimeout without returning a cleanup function to cancel it. When dependencies (messages or activeTab) change, previous timeouts remain pending and can accumulate, causing redundant setState calls and violating React's official guidance on synchronizing with effects.

How to reproduce:

  1. Open a task in the chat interface
  2. Switch to another tab and back to 'chat' (triggers activeTab dependency change)
  3. Receive new messages frequently (triggers messages dependency change)
  4. Without cleanup, multiple setTimeout callbacks accumulate and each fires its own setShowScrollButton call within a short timeframe

Result: Multiple timeouts execute independently, each calling setShowScrollButton redundantly. According to React documentation, the cleanup function should mirror the Effect's setup—when a timeout is scheduled, it must be cancelled by the cleanup function.

Expected: Each useEffect invocation should return a cleanup function that calls clearTimeout() on the scheduled timeout, matching the pattern shown in React's recommended approach and the existing pattern in the same codebase at repo-selector.tsx line 203.

}
}, [messages, activeTab])

// Calculate heights for user messages to create proper sticky stacking
useEffect(() => {
const displayMessages = messages.slice(-10)
Expand Down Expand Up @@ -1180,7 +1200,7 @@ export function TaskChat({ taskId, task }: TaskChatProps) {
return (
<div className="flex flex-col h-full">
{/* Header Tabs */}
<div className="py-2 flex items-center justify-between gap-1 flex-shrink-0 h-[46px] overflow-x-auto [&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]">
<div className="py-2 flex items-center justify-between gap-1 flex-shrink-0 h-[46px] overflow-x-auto [&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none] relative">
<div className="flex items-center gap-1">
<button
onClick={() => setActiveTab('chat')}
Expand Down Expand Up @@ -1221,7 +1241,22 @@ export function TaskChat({ taskId, task }: TaskChatProps) {
</div>

{/* Tab Content */}
{renderTabContent()}
<div className="flex-1 min-h-0 relative">
{renderTabContent()}

{/* Scroll to bottom button - positioned within content area */}
{activeTab === 'chat' && showScrollButton && (
<div className="absolute bottom-2 left-0 right-0 flex justify-center pointer-events-none z-20">
<button
onClick={scrollToBottom}
className="rounded-full h-10 w-10 bg-primary text-primary-foreground hover:bg-primary/90 shadow-lg flex items-center justify-center transition-all pointer-events-auto"
aria-label="Scroll to bottom"
>
<ArrowDown className="h-5 w-5" />
</button>
</div>
)}
</div>

{/* Input Area (only for chat tab) */}
{activeTab === 'chat' && (
Expand Down
2 changes: 1 addition & 1 deletion components/task-details.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2086,7 +2086,7 @@ export function TaskDetails({ task, maxSandboxDuration = 300 }: TaskDetailsProps
</div>

{/* Chat Tab */}
<div className={cn('h-full px-3 pb-3', activeTab !== 'chat' && 'hidden')}>
<div className={cn('h-full px-3 pb-3 relative', activeTab !== 'chat' && 'hidden')}>
<TaskChat taskId={task.id} task={task} />
</div>

Expand Down
Loading