Compare commits

..

1 Commits

Author SHA1 Message Date
Cursor Agent
170367ce13 fix: prevent copilot keyboard shortcuts from triggering when panel is inactive
The OptionsSelector component was capturing keyboard events (1-9 number keys and Enter)
globally on the document, causing accidental option selections when users were
interacting with other parts of the application.

This fix adds a check to only handle keyboard shortcuts when the copilot panel
is the active tab, preventing the shortcuts from interfering with other workflows.

Co-authored-by: Emir Karabeg <emir-karabeg@users.noreply.github.com>
2026-02-14 01:10:33 +00:00
15 changed files with 282 additions and 576 deletions

View File

@@ -1,2 +1,2 @@
export type { StatusBarSegment } from './status-bar' export type { StatusBarSegment } from './status-bar'
export { StatusBar } from './status-bar' export { default, StatusBar } from './status-bar'

View File

@@ -8,7 +8,7 @@ export interface StatusBarSegment {
timestamp: string timestamp: string
} }
function StatusBarInner({ export function StatusBar({
segments, segments,
selectedSegmentIndices, selectedSegmentIndices,
onSegmentClick, onSegmentClick,
@@ -127,45 +127,4 @@ function StatusBarInner({
) )
} }
/** export default memo(StatusBar)
* Custom equality function for StatusBar memo.
* Performs structural comparison of segments array to avoid re-renders
* when poll data returns new object references with identical content.
*/
function areStatusBarPropsEqual(
prev: Parameters<typeof StatusBarInner>[0],
next: Parameters<typeof StatusBarInner>[0]
): boolean {
if (prev.workflowId !== next.workflowId) return false
if (prev.segmentDurationMs !== next.segmentDurationMs) return false
if (prev.preferBelow !== next.preferBelow) return false
if (prev.selectedSegmentIndices !== next.selectedSegmentIndices) {
if (!prev.selectedSegmentIndices || !next.selectedSegmentIndices) return false
if (prev.selectedSegmentIndices.length !== next.selectedSegmentIndices.length) return false
for (let i = 0; i < prev.selectedSegmentIndices.length; i++) {
if (prev.selectedSegmentIndices[i] !== next.selectedSegmentIndices[i]) return false
}
}
if (prev.segments !== next.segments) {
if (prev.segments.length !== next.segments.length) return false
for (let i = 0; i < prev.segments.length; i++) {
const ps = prev.segments[i]
const ns = next.segments[i]
if (
ps.successRate !== ns.successRate ||
ps.hasExecutions !== ns.hasExecutions ||
ps.totalExecutions !== ns.totalExecutions ||
ps.successfulExecutions !== ns.successfulExecutions ||
ps.timestamp !== ns.timestamp
) {
return false
}
}
}
return true
}
export const StatusBar = memo(StatusBarInner, areStatusBarPropsEqual)

View File

@@ -1,2 +1,2 @@
export type { WorkflowExecutionItem } from './workflows-list' export type { WorkflowExecutionItem } from './workflows-list'
export { WorkflowsList } from './workflows-list' export { default, WorkflowsList } from './workflows-list'

View File

@@ -14,7 +14,7 @@ export interface WorkflowExecutionItem {
overallSuccessRate: number overallSuccessRate: number
} }
function WorkflowsListInner({ export function WorkflowsList({
filteredExecutions, filteredExecutions,
expandedWorkflowId, expandedWorkflowId,
onToggleWorkflow, onToggleWorkflow,
@@ -103,7 +103,7 @@ function WorkflowsListInner({
<StatusBar <StatusBar
segments={workflow.segments} segments={workflow.segments}
selectedSegmentIndices={selectedSegments[workflow.workflowId] || null} selectedSegmentIndices={selectedSegments[workflow.workflowId] || null}
onSegmentClick={onSegmentClick} onSegmentClick={onSegmentClick as any}
workflowId={workflow.workflowId} workflowId={workflow.workflowId}
segmentDurationMs={segmentDurationMs} segmentDurationMs={segmentDurationMs}
preferBelow={idx < 2} preferBelow={idx < 2}
@@ -124,4 +124,4 @@ function WorkflowsListInner({
) )
} }
export const WorkflowsList = memo(WorkflowsListInner) export default memo(WorkflowsList)

View File

@@ -1,6 +1,6 @@
'use client' 'use client'
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Loader2 } from 'lucide-react' import { Loader2 } from 'lucide-react'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { formatLatency } from '@/app/workspace/[workspaceId]/logs/utils' import { formatLatency } from '@/app/workspace/[workspaceId]/logs/utils'
@@ -141,10 +141,10 @@ function toWorkflowExecution(wf: WorkflowStats): WorkflowExecution {
} }
} }
function DashboardInner({ stats, isLoading, error }: DashboardProps) { export default function Dashboard({ stats, isLoading, error }: DashboardProps) {
const [selectedSegments, setSelectedSegments] = useState<Record<string, number[]>>({}) const [selectedSegments, setSelectedSegments] = useState<Record<string, number[]>>({})
const [lastAnchorIndices, setLastAnchorIndices] = useState<Record<string, number>>({}) const [lastAnchorIndices, setLastAnchorIndices] = useState<Record<string, number>>({})
const lastAnchorIndicesRef = useRef<Record<string, number>>({}) const barsAreaRef = useRef<HTMLDivElement | null>(null)
const { workflowIds, searchQuery, toggleWorkflowId, timeRange } = useFilterStore() const { workflowIds, searchQuery, toggleWorkflowId, timeRange } = useFilterStore()
@@ -152,79 +152,20 @@ function DashboardInner({ stats, isLoading, error }: DashboardProps) {
const expandedWorkflowId = workflowIds.length === 1 ? workflowIds[0] : null const expandedWorkflowId = workflowIds.length === 1 ? workflowIds[0] : null
const { rawExecutions, aggregateSegments, segmentMs } = useMemo(() => { const { executions, aggregateSegments, segmentMs } = useMemo(() => {
if (!stats) { if (!stats) {
return { rawExecutions: [], aggregateSegments: [], segmentMs: 0 } return { executions: [], aggregateSegments: [], segmentMs: 0 }
} }
const workflowExecutions = stats.workflows.map(toWorkflowExecution)
return { return {
rawExecutions: stats.workflows.map(toWorkflowExecution), executions: workflowExecutions,
aggregateSegments: stats.aggregateSegments, aggregateSegments: stats.aggregateSegments,
segmentMs: stats.segmentMs, segmentMs: stats.segmentMs,
} }
}, [stats]) }, [stats])
/**
* Stabilize execution objects: reuse previous references for workflows
* whose segment data hasn't structurally changed between polls.
* This prevents cascading re-renders through WorkflowsList → StatusBar.
*/
const prevExecutionsRef = useRef<WorkflowExecution[]>([])
const executions = useMemo(() => {
const prevMap = new Map(prevExecutionsRef.current.map((e) => [e.workflowId, e]))
let anyChanged = false
const result = rawExecutions.map((exec) => {
const prev = prevMap.get(exec.workflowId)
if (!prev) {
anyChanged = true
return exec
}
if (
prev.overallSuccessRate !== exec.overallSuccessRate ||
prev.workflowName !== exec.workflowName ||
prev.segments.length !== exec.segments.length
) {
anyChanged = true
return exec
}
for (let i = 0; i < prev.segments.length; i++) {
const ps = prev.segments[i]
const ns = exec.segments[i]
if (
ps.totalExecutions !== ns.totalExecutions ||
ps.successfulExecutions !== ns.successfulExecutions ||
ps.timestamp !== ns.timestamp ||
ps.avgDurationMs !== ns.avgDurationMs ||
ps.p50Ms !== ns.p50Ms ||
ps.p90Ms !== ns.p90Ms ||
ps.p99Ms !== ns.p99Ms
) {
anyChanged = true
return exec
}
}
return prev
})
if (
!anyChanged &&
result.length === prevExecutionsRef.current.length &&
result.every((r, i) => r === prevExecutionsRef.current[i])
) {
return prevExecutionsRef.current
}
return result
}, [rawExecutions])
useEffect(() => {
prevExecutionsRef.current = executions
}, [executions])
const lastExecutionByWorkflow = useMemo(() => { const lastExecutionByWorkflow = useMemo(() => {
const map = new Map<string, number>() const map = new Map<string, number>()
for (const wf of executions) { for (const wf of executions) {
@@ -371,10 +312,6 @@ function DashboardInner({ stats, isLoading, error }: DashboardProps) {
[toggleWorkflowId] [toggleWorkflowId]
) )
useEffect(() => {
lastAnchorIndicesRef.current = lastAnchorIndices
}, [lastAnchorIndices])
/** /**
* Handles segment click for selecting time segments. * Handles segment click for selecting time segments.
* @param workflowId - The workflow containing the segment * @param workflowId - The workflow containing the segment
@@ -424,7 +361,7 @@ function DashboardInner({ stats, isLoading, error }: DashboardProps) {
} else if (mode === 'range') { } else if (mode === 'range') {
setSelectedSegments((prev) => { setSelectedSegments((prev) => {
const currentSegments = prev[workflowId] || [] const currentSegments = prev[workflowId] || []
const anchor = lastAnchorIndicesRef.current[workflowId] ?? segmentIndex const anchor = lastAnchorIndices[workflowId] ?? segmentIndex
const [start, end] = const [start, end] =
anchor < segmentIndex ? [anchor, segmentIndex] : [segmentIndex, anchor] anchor < segmentIndex ? [anchor, segmentIndex] : [segmentIndex, anchor]
const range = Array.from({ length: end - start + 1 }, (_, i) => start + i) const range = Array.from({ length: end - start + 1 }, (_, i) => start + i)
@@ -433,12 +370,12 @@ function DashboardInner({ stats, isLoading, error }: DashboardProps) {
}) })
} }
}, },
[] [lastAnchorIndices]
) )
useEffect(() => { useEffect(() => {
setSelectedSegments((prev) => (Object.keys(prev).length > 0 ? {} : prev)) setSelectedSegments({})
setLastAnchorIndices((prev) => (Object.keys(prev).length > 0 ? {} : prev)) setLastAnchorIndices({})
}, [stats, timeRange, workflowIds, searchQuery]) }, [stats, timeRange, workflowIds, searchQuery])
if (isLoading) { if (isLoading) {
@@ -556,7 +493,7 @@ function DashboardInner({ stats, isLoading, error }: DashboardProps) {
</div> </div>
</div> </div>
<div className='min-h-0 flex-1 overflow-hidden'> <div className='min-h-0 flex-1 overflow-hidden' ref={barsAreaRef}>
<WorkflowsList <WorkflowsList
filteredExecutions={filteredExecutions as WorkflowExecution[]} filteredExecutions={filteredExecutions as WorkflowExecution[]}
expandedWorkflowId={expandedWorkflowId} expandedWorkflowId={expandedWorkflowId}
@@ -570,5 +507,3 @@ function DashboardInner({ stats, isLoading, error }: DashboardProps) {
</div> </div>
) )
} }
export default memo(DashboardInner)

View File

@@ -43,200 +43,184 @@ import { useLogDetailsUIStore } from '@/stores/logs/store'
/** /**
* Workflow Output section with code viewer, copy, search, and context menu functionality * Workflow Output section with code viewer, copy, search, and context menu functionality
*/ */
const WorkflowOutputSection = memo( function WorkflowOutputSection({ output }: { output: Record<string, unknown> }) {
function WorkflowOutputSection({ output }: { output: Record<string, unknown> }) { const contentRef = useRef<HTMLDivElement>(null)
const contentRef = useRef<HTMLDivElement>(null) const [copied, setCopied] = useState(false)
const [copied, setCopied] = useState(false)
const copyTimerRef = useRef<number | null>(null)
const [isContextMenuOpen, setIsContextMenuOpen] = useState(false) // Context menu state
const [contextMenuPosition, setContextMenuPosition] = useState({ x: 0, y: 0 }) const [isContextMenuOpen, setIsContextMenuOpen] = useState(false)
const [contextMenuPosition, setContextMenuPosition] = useState({ x: 0, y: 0 })
const { const {
isSearchActive, isSearchActive,
searchQuery, searchQuery,
setSearchQuery, setSearchQuery,
matchCount, matchCount,
currentMatchIndex, currentMatchIndex,
activateSearch, activateSearch,
closeSearch, closeSearch,
goToNextMatch, goToNextMatch,
goToPreviousMatch, goToPreviousMatch,
handleMatchCountChange, handleMatchCountChange,
searchInputRef, searchInputRef,
} = useCodeViewerFeatures({ contentRef }) } = useCodeViewerFeatures({ contentRef })
const jsonString = useMemo(() => JSON.stringify(output, null, 2), [output]) const jsonString = useMemo(() => JSON.stringify(output, null, 2), [output])
const handleContextMenu = useCallback((e: React.MouseEvent) => { const handleContextMenu = useCallback((e: React.MouseEvent) => {
e.preventDefault() e.preventDefault()
e.stopPropagation() e.stopPropagation()
setContextMenuPosition({ x: e.clientX, y: e.clientY }) setContextMenuPosition({ x: e.clientX, y: e.clientY })
setIsContextMenuOpen(true) setIsContextMenuOpen(true)
}, []) }, [])
const closeContextMenu = useCallback(() => { const closeContextMenu = useCallback(() => {
setIsContextMenuOpen(false) setIsContextMenuOpen(false)
}, []) }, [])
const handleCopy = useCallback(() => { const handleCopy = useCallback(() => {
navigator.clipboard.writeText(jsonString) navigator.clipboard.writeText(jsonString)
setCopied(true) setCopied(true)
if (copyTimerRef.current !== null) window.clearTimeout(copyTimerRef.current) setTimeout(() => setCopied(false), 1500)
copyTimerRef.current = window.setTimeout(() => setCopied(false), 1500) closeContextMenu()
closeContextMenu() }, [jsonString, closeContextMenu])
}, [jsonString, closeContextMenu])
useEffect(() => { const handleSearch = useCallback(() => {
return () => { activateSearch()
if (copyTimerRef.current !== null) window.clearTimeout(copyTimerRef.current) closeContextMenu()
} }, [activateSearch, closeContextMenu])
}, [])
const handleSearch = useCallback(() => { return (
activateSearch() <div className='relative flex min-w-0 flex-col overflow-hidden'>
closeContextMenu() <div ref={contentRef} onContextMenu={handleContextMenu} className='relative'>
}, [activateSearch, closeContextMenu]) <Code.Viewer
code={jsonString}
return ( language='json'
<div className='relative flex min-w-0 flex-col overflow-hidden'> className='!bg-[var(--surface-4)] dark:!bg-[var(--surface-3)] max-h-[300px] min-h-0 max-w-full rounded-[6px] border-0 [word-break:break-all]'
<div ref={contentRef} onContextMenu={handleContextMenu} className='relative'> wrapText
<Code.Viewer searchQuery={isSearchActive ? searchQuery : undefined}
code={jsonString} currentMatchIndex={currentMatchIndex}
language='json' onMatchCountChange={handleMatchCountChange}
className='!bg-[var(--surface-4)] dark:!bg-[var(--surface-3)] max-h-[300px] min-h-0 max-w-full rounded-[6px] border-0 [word-break:break-all]' />
wrapText {/* Glass action buttons overlay */}
searchQuery={isSearchActive ? searchQuery : undefined} {!isSearchActive && (
currentMatchIndex={currentMatchIndex} <div className='absolute top-[7px] right-[6px] z-10 flex gap-[4px]'>
onMatchCountChange={handleMatchCountChange} <Tooltip.Root>
/> <Tooltip.Trigger asChild>
{/* Glass action buttons overlay */} <Button
{!isSearchActive && ( type='button'
<div className='absolute top-[7px] right-[6px] z-10 flex gap-[4px]'> variant='default'
<Tooltip.Root> onClick={(e) => {
<Tooltip.Trigger asChild> e.stopPropagation()
<Button handleCopy()
type='button' }}
variant='default' className='h-[20px] w-[20px] cursor-pointer border border-[var(--border-1)] bg-transparent p-0 backdrop-blur-sm hover:bg-[var(--surface-3)]'
onClick={(e) => { >
e.stopPropagation() {copied ? (
handleCopy() <Check className='h-[10px] w-[10px] text-[var(--text-success)]' />
}} ) : (
className='h-[20px] w-[20px] cursor-pointer border border-[var(--border-1)] bg-transparent p-0 backdrop-blur-sm hover:bg-[var(--surface-3)]' <Clipboard className='h-[10px] w-[10px]' />
> )}
{copied ? ( </Button>
<Check className='h-[10px] w-[10px] text-[var(--text-success)]' /> </Tooltip.Trigger>
) : ( <Tooltip.Content side='top'>{copied ? 'Copied' : 'Copy'}</Tooltip.Content>
<Clipboard className='h-[10px] w-[10px]' /> </Tooltip.Root>
)} <Tooltip.Root>
</Button> <Tooltip.Trigger asChild>
</Tooltip.Trigger> <Button
<Tooltip.Content side='top'>{copied ? 'Copied' : 'Copy'}</Tooltip.Content> type='button'
</Tooltip.Root> variant='default'
<Tooltip.Root> onClick={(e) => {
<Tooltip.Trigger asChild> e.stopPropagation()
<Button activateSearch()
type='button' }}
variant='default' className='h-[20px] w-[20px] cursor-pointer border border-[var(--border-1)] bg-transparent p-0 backdrop-blur-sm hover:bg-[var(--surface-3)]'
onClick={(e) => { >
e.stopPropagation() <Search className='h-[10px] w-[10px]' />
activateSearch() </Button>
}} </Tooltip.Trigger>
className='h-[20px] w-[20px] cursor-pointer border border-[var(--border-1)] bg-transparent p-0 backdrop-blur-sm hover:bg-[var(--surface-3)]' <Tooltip.Content side='top'>Search</Tooltip.Content>
> </Tooltip.Root>
<Search className='h-[10px] w-[10px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='top'>Search</Tooltip.Content>
</Tooltip.Root>
</div>
)}
</div>
{/* Search Overlay */}
{isSearchActive && (
<div
className='absolute top-0 right-0 z-30 flex h-[34px] items-center gap-[6px] rounded-[4px] border border-[var(--border)] bg-[var(--surface-1)] px-[6px] shadow-sm'
onClick={(e) => e.stopPropagation()}
>
<Input
ref={searchInputRef}
type='text'
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder='Search...'
className='mr-[2px] h-[23px] w-[94px] text-[12px]'
/>
<span
className={cn(
'min-w-[45px] text-center text-[11px]',
matchCount > 0 ? 'text-[var(--text-secondary)]' : 'text-[var(--text-tertiary)]'
)}
>
{matchCount > 0 ? `${currentMatchIndex + 1}/${matchCount}` : '0/0'}
</span>
<Button
variant='ghost'
className='!p-1'
onClick={goToPreviousMatch}
disabled={matchCount === 0}
aria-label='Previous match'
>
<ArrowUp className='h-[12px] w-[12px]' />
</Button>
<Button
variant='ghost'
className='!p-1'
onClick={goToNextMatch}
disabled={matchCount === 0}
aria-label='Next match'
>
<ArrowDown className='h-[12px] w-[12px]' />
</Button>
<Button
variant='ghost'
className='!p-1'
onClick={closeSearch}
aria-label='Close search'
>
<X className='h-[12px] w-[12px]' />
</Button>
</div> </div>
)} )}
{/* Context Menu - rendered in portal to avoid transform/overflow clipping */}
{typeof document !== 'undefined' &&
createPortal(
<Popover
open={isContextMenuOpen}
onOpenChange={closeContextMenu}
variant='secondary'
size='sm'
colorScheme='inverted'
>
<PopoverAnchor
style={{
position: 'fixed',
left: `${contextMenuPosition.x}px`,
top: `${contextMenuPosition.y}px`,
width: '1px',
height: '1px',
}}
/>
<PopoverContent align='start' side='bottom' sideOffset={4}>
<PopoverItem onClick={handleCopy}>Copy</PopoverItem>
<PopoverDivider />
<PopoverItem onClick={handleSearch}>Search</PopoverItem>
</PopoverContent>
</Popover>,
document.body
)}
</div> </div>
)
}, {/* Search Overlay */}
(prev, next) => {isSearchActive && (
prev.output === next.output || JSON.stringify(prev.output) === JSON.stringify(next.output) <div
) className='absolute top-0 right-0 z-30 flex h-[34px] items-center gap-[6px] rounded-[4px] border border-[var(--border)] bg-[var(--surface-1)] px-[6px] shadow-sm'
onClick={(e) => e.stopPropagation()}
>
<Input
ref={searchInputRef}
type='text'
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder='Search...'
className='mr-[2px] h-[23px] w-[94px] text-[12px]'
/>
<span
className={cn(
'min-w-[45px] text-center text-[11px]',
matchCount > 0 ? 'text-[var(--text-secondary)]' : 'text-[var(--text-tertiary)]'
)}
>
{matchCount > 0 ? `${currentMatchIndex + 1}/${matchCount}` : '0/0'}
</span>
<Button
variant='ghost'
className='!p-1'
onClick={goToPreviousMatch}
disabled={matchCount === 0}
aria-label='Previous match'
>
<ArrowUp className='h-[12px] w-[12px]' />
</Button>
<Button
variant='ghost'
className='!p-1'
onClick={goToNextMatch}
disabled={matchCount === 0}
aria-label='Next match'
>
<ArrowDown className='h-[12px] w-[12px]' />
</Button>
<Button variant='ghost' className='!p-1' onClick={closeSearch} aria-label='Close search'>
<X className='h-[12px] w-[12px]' />
</Button>
</div>
)}
{/* Context Menu - rendered in portal to avoid transform/overflow clipping */}
{typeof document !== 'undefined' &&
createPortal(
<Popover
open={isContextMenuOpen}
onOpenChange={closeContextMenu}
variant='secondary'
size='sm'
colorScheme='inverted'
>
<PopoverAnchor
style={{
position: 'fixed',
left: `${contextMenuPosition.x}px`,
top: `${contextMenuPosition.y}px`,
width: '1px',
height: '1px',
}}
/>
<PopoverContent align='start' side='bottom' sideOffset={4}>
<PopoverItem onClick={handleCopy}>Copy</PopoverItem>
<PopoverDivider />
<PopoverItem onClick={handleSearch}>Search</PopoverItem>
</PopoverContent>
</Popover>,
document.body
)}
</div>
)
}
interface LogDetailsProps { interface LogDetailsProps {
/** The log to display details for */ /** The log to display details for */
@@ -294,6 +278,7 @@ export const LogDetails = memo(function LogDetails({
return isWorkflowExecutionLog && log?.cost return isWorkflowExecutionLog && log?.cost
}, [log, isWorkflowExecutionLog]) }, [log, isWorkflowExecutionLog])
// Extract and clean the workflow final output (recursively remove hidden keys for cleaner display)
const workflowOutput = useMemo(() => { const workflowOutput = useMemo(() => {
const executionData = log?.executionData as const executionData = log?.executionData as
| { finalOutput?: Record<string, unknown> } | { finalOutput?: Record<string, unknown> }

View File

@@ -1,7 +1,6 @@
'use client' 'use client'
import type { RefObject } from 'react' import type { RefObject } from 'react'
import { memo } from 'react'
import { import {
Popover, Popover,
PopoverAnchor, PopoverAnchor,
@@ -30,7 +29,7 @@ interface LogRowContextMenuProps {
* Context menu for log rows. * Context menu for log rows.
* Provides quick actions for copying data, navigation, and filtering. * Provides quick actions for copying data, navigation, and filtering.
*/ */
export const LogRowContextMenu = memo(function LogRowContextMenu({ export function LogRowContextMenu({
isOpen, isOpen,
position, position,
menuRef, menuRef,
@@ -122,4 +121,4 @@ export const LogRowContextMenu = memo(function LogRowContextMenu({
</PopoverContent> </PopoverContent>
</Popover> </Popover>
) )
}) }

View File

@@ -24,7 +24,6 @@ interface LogRowProps {
log: WorkflowLog log: WorkflowLog
isSelected: boolean isSelected: boolean
onClick: (log: WorkflowLog) => void onClick: (log: WorkflowLog) => void
onHover?: (log: WorkflowLog) => void
onContextMenu?: (e: React.MouseEvent, log: WorkflowLog) => void onContextMenu?: (e: React.MouseEvent, log: WorkflowLog) => void
selectedRowRef: React.RefObject<HTMLTableRowElement | null> | null selectedRowRef: React.RefObject<HTMLTableRowElement | null> | null
} }
@@ -34,14 +33,7 @@ interface LogRowProps {
* Uses shallow comparison for the log object. * Uses shallow comparison for the log object.
*/ */
const LogRow = memo( const LogRow = memo(
function LogRow({ function LogRow({ log, isSelected, onClick, onContextMenu, selectedRowRef }: LogRowProps) {
log,
isSelected,
onClick,
onHover,
onContextMenu,
selectedRowRef,
}: LogRowProps) {
const formattedDate = useMemo(() => formatDate(log.createdAt), [log.createdAt]) const formattedDate = useMemo(() => formatDate(log.createdAt), [log.createdAt])
const isDeletedWorkflow = !log.workflow?.id && !log.workflowId const isDeletedWorkflow = !log.workflow?.id && !log.workflowId
const workflowName = isDeletedWorkflow const workflowName = isDeletedWorkflow
@@ -51,8 +43,6 @@ const LogRow = memo(
const handleClick = useCallback(() => onClick(log), [onClick, log]) const handleClick = useCallback(() => onClick(log), [onClick, log])
const handleMouseEnter = useCallback(() => onHover?.(log), [onHover, log])
const handleContextMenu = useCallback( const handleContextMenu = useCallback(
(e: React.MouseEvent) => { (e: React.MouseEvent) => {
if (onContextMenu) { if (onContextMenu) {
@@ -71,7 +61,6 @@ const LogRow = memo(
isSelected && 'bg-[var(--surface-3)] dark:bg-[var(--surface-4)]' isSelected && 'bg-[var(--surface-3)] dark:bg-[var(--surface-4)]'
)} )}
onClick={handleClick} onClick={handleClick}
onMouseEnter={handleMouseEnter}
onContextMenu={handleContextMenu} onContextMenu={handleContextMenu}
> >
<div className='flex flex-1 items-center'> <div className='flex flex-1 items-center'>
@@ -153,8 +142,7 @@ const LogRow = memo(
prevProps.log.id === nextProps.log.id && prevProps.log.id === nextProps.log.id &&
prevProps.log.duration === nextProps.log.duration && prevProps.log.duration === nextProps.log.duration &&
prevProps.log.status === nextProps.log.status && prevProps.log.status === nextProps.log.status &&
prevProps.isSelected === nextProps.isSelected && prevProps.isSelected === nextProps.isSelected
prevProps.onHover === nextProps.onHover
) )
} }
) )
@@ -163,7 +151,6 @@ interface RowProps {
logs: WorkflowLog[] logs: WorkflowLog[]
selectedLogId: string | null selectedLogId: string | null
onLogClick: (log: WorkflowLog) => void onLogClick: (log: WorkflowLog) => void
onLogHover?: (log: WorkflowLog) => void
onLogContextMenu?: (e: React.MouseEvent, log: WorkflowLog) => void onLogContextMenu?: (e: React.MouseEvent, log: WorkflowLog) => void
selectedRowRef: React.RefObject<HTMLTableRowElement | null> selectedRowRef: React.RefObject<HTMLTableRowElement | null>
isFetchingNextPage: boolean isFetchingNextPage: boolean
@@ -180,7 +167,6 @@ function Row({
logs, logs,
selectedLogId, selectedLogId,
onLogClick, onLogClick,
onLogHover,
onLogContextMenu, onLogContextMenu,
selectedRowRef, selectedRowRef,
isFetchingNextPage, isFetchingNextPage,
@@ -212,7 +198,6 @@ function Row({
log={log} log={log}
isSelected={isSelected} isSelected={isSelected}
onClick={onLogClick} onClick={onLogClick}
onHover={onLogHover}
onContextMenu={onLogContextMenu} onContextMenu={onLogContextMenu}
selectedRowRef={isSelected ? selectedRowRef : null} selectedRowRef={isSelected ? selectedRowRef : null}
/> />
@@ -224,7 +209,6 @@ export interface LogsListProps {
logs: WorkflowLog[] logs: WorkflowLog[]
selectedLogId: string | null selectedLogId: string | null
onLogClick: (log: WorkflowLog) => void onLogClick: (log: WorkflowLog) => void
onLogHover?: (log: WorkflowLog) => void
onLogContextMenu?: (e: React.MouseEvent, log: WorkflowLog) => void onLogContextMenu?: (e: React.MouseEvent, log: WorkflowLog) => void
selectedRowRef: React.RefObject<HTMLTableRowElement | null> selectedRowRef: React.RefObject<HTMLTableRowElement | null>
hasNextPage: boolean hasNextPage: boolean
@@ -243,7 +227,6 @@ export function LogsList({
logs, logs,
selectedLogId, selectedLogId,
onLogClick, onLogClick,
onLogHover,
onLogContextMenu, onLogContextMenu,
selectedRowRef, selectedRowRef,
hasNextPage, hasNextPage,
@@ -289,7 +272,6 @@ export function LogsList({
logs, logs,
selectedLogId, selectedLogId,
onLogClick, onLogClick,
onLogHover,
onLogContextMenu, onLogContextMenu,
selectedRowRef, selectedRowRef,
isFetchingNextPage, isFetchingNextPage,
@@ -299,7 +281,6 @@ export function LogsList({
logs, logs,
selectedLogId, selectedLogId,
onLogClick, onLogClick,
onLogHover,
onLogContextMenu, onLogContextMenu,
selectedRowRef, selectedRowRef,
isFetchingNextPage, isFetchingNextPage,

View File

@@ -1,6 +1,6 @@
'use client' 'use client'
import { memo, useCallback, useEffect, useMemo, useState } from 'react' import { useCallback, useEffect, useMemo, useState } from 'react'
import { createLogger } from '@sim/logger' import { createLogger } from '@sim/logger'
import { Plus, X } from 'lucide-react' import { Plus, X } from 'lucide-react'
import { import {
@@ -113,7 +113,7 @@ function formatAlertConfigLabel(config: {
} }
} }
export const NotificationSettings = memo(function NotificationSettings({ export function NotificationSettings({
workspaceId, workspaceId,
open, open,
onOpenChange, onOpenChange,
@@ -144,7 +144,7 @@ export const NotificationSettings = memo(function NotificationSettings({
slackChannelId: '', slackChannelId: '',
slackChannelName: '', slackChannelName: '',
slackAccountId: '', slackAccountId: '',
useAlertRule: false,
alertRule: 'none' as AlertRule, alertRule: 'none' as AlertRule,
consecutiveFailures: 3, consecutiveFailures: 3,
failureRatePercent: 50, failureRatePercent: 50,
@@ -212,7 +212,7 @@ export const NotificationSettings = memo(function NotificationSettings({
slackChannelId: '', slackChannelId: '',
slackChannelName: '', slackChannelName: '',
slackAccountId: '', slackAccountId: '',
useAlertRule: false,
alertRule: 'none', alertRule: 'none',
consecutiveFailures: 3, consecutiveFailures: 3,
failureRatePercent: 50, failureRatePercent: 50,
@@ -484,6 +484,7 @@ export const NotificationSettings = memo(function NotificationSettings({
slackChannelId: subscription.slackConfig?.channelId || '', slackChannelId: subscription.slackConfig?.channelId || '',
slackChannelName: subscription.slackConfig?.channelName || '', slackChannelName: subscription.slackConfig?.channelName || '',
slackAccountId: subscription.slackConfig?.accountId || '', slackAccountId: subscription.slackConfig?.accountId || '',
useAlertRule: !!subscription.alertConfig,
alertRule: subscription.alertConfig?.rule || 'none', alertRule: subscription.alertConfig?.rule || 'none',
consecutiveFailures: subscription.alertConfig?.consecutiveFailures || 3, consecutiveFailures: subscription.alertConfig?.consecutiveFailures || 3,
failureRatePercent: subscription.alertConfig?.failureRatePercent || 50, failureRatePercent: subscription.alertConfig?.failureRatePercent || 50,
@@ -1288,4 +1289,4 @@ export const NotificationSettings = memo(function NotificationSettings({
</Modal> </Modal>
</> </>
) )
}) }

View File

@@ -1,6 +1,6 @@
'use client' 'use client'
import { memo, useCallback, useMemo, useState } from 'react' import { useCallback, useMemo, useState } from 'react'
import { ArrowUp, Bell, Library, MoreHorizontal, RefreshCw } from 'lucide-react' import { ArrowUp, Bell, Library, MoreHorizontal, RefreshCw } from 'lucide-react'
import { useParams } from 'next/navigation' import { useParams } from 'next/navigation'
import { import {
@@ -149,7 +149,7 @@ function getTriggerIcon(
* @param props - The component props * @param props - The component props
* @returns The complete logs toolbar * @returns The complete logs toolbar
*/ */
export const LogsToolbar = memo(function LogsToolbar({ export function LogsToolbar({
viewMode, viewMode,
onViewModeChange, onViewModeChange,
isRefreshing, isRefreshing,
@@ -749,4 +749,4 @@ export const LogsToolbar = memo(function LogsToolbar({
</div> </div>
</div> </div>
) )
}) }

View File

@@ -1,7 +1,6 @@
'use client' 'use client'
import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useQueryClient } from '@tanstack/react-query'
import { Loader2 } from 'lucide-react' import { Loader2 } from 'lucide-react'
import { useParams } from 'next/navigation' import { useParams } from 'next/navigation'
import { cn } from '@/lib/core/utils/cn' import { cn } from '@/lib/core/utils/cn'
@@ -11,17 +10,12 @@ import {
hasActiveFilters, hasActiveFilters,
} from '@/lib/logs/filters' } from '@/lib/logs/filters'
import { parseQuery, queryToApiParams } from '@/lib/logs/query-parser' import { parseQuery, queryToApiParams } from '@/lib/logs/query-parser'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { useFolders } from '@/hooks/queries/folders' import { useFolders } from '@/hooks/queries/folders'
import { import { useDashboardStats, useLogDetail, useLogsList } from '@/hooks/queries/logs'
prefetchLogDetail,
useDashboardStats,
useLogDetail,
useLogsList,
} from '@/hooks/queries/logs'
import { useDebounce } from '@/hooks/use-debounce' import { useDebounce } from '@/hooks/use-debounce'
import { useFilterStore } from '@/stores/logs/filters/store' import { useFilterStore } from '@/stores/logs/filters/store'
import type { WorkflowLog } from '@/stores/logs/filters/types' import type { WorkflowLog } from '@/stores/logs/filters/types'
import { useUserPermissionsContext } from '../providers/workspace-permissions-provider'
import { import {
Dashboard, Dashboard,
ExecutionSnapshot, ExecutionSnapshot,
@@ -36,38 +30,6 @@ import { LOG_COLUMN_ORDER, LOG_COLUMNS } from './utils'
const LOGS_PER_PAGE = 50 as const const LOGS_PER_PAGE = 50 as const
const REFRESH_SPINNER_DURATION_MS = 1000 as const const REFRESH_SPINNER_DURATION_MS = 1000 as const
interface LogSelectionState {
selectedLogId: string | null
isSidebarOpen: boolean
}
type LogSelectionAction =
| { type: 'TOGGLE_LOG'; logId: string }
| { type: 'SELECT_LOG'; logId: string }
| { type: 'CLOSE_SIDEBAR' }
| { type: 'TOGGLE_SIDEBAR' }
function logSelectionReducer(
state: LogSelectionState,
action: LogSelectionAction
): LogSelectionState {
switch (action.type) {
case 'TOGGLE_LOG':
if (state.selectedLogId === action.logId && state.isSidebarOpen) {
return { selectedLogId: null, isSidebarOpen: false }
}
return { selectedLogId: action.logId, isSidebarOpen: true }
case 'SELECT_LOG':
return { ...state, selectedLogId: action.logId }
case 'CLOSE_SIDEBAR':
return { selectedLogId: null, isSidebarOpen: false }
case 'TOGGLE_SIDEBAR':
return state.selectedLogId ? { ...state, isSidebarOpen: !state.isSidebarOpen } : state
default:
return state
}
}
/** /**
* Logs page component displaying workflow execution history. * Logs page component displaying workflow execution history.
* Supports filtering, search, live updates, and detailed log inspection. * Supports filtering, search, live updates, and detailed log inspection.
@@ -98,13 +60,11 @@ export default function Logs() {
setWorkspaceId(workspaceId) setWorkspaceId(workspaceId)
}, [workspaceId, setWorkspaceId]) }, [workspaceId, setWorkspaceId])
const [{ selectedLogId, isSidebarOpen }, dispatch] = useReducer(logSelectionReducer, { const [selectedLogId, setSelectedLogId] = useState<string | null>(null)
selectedLogId: null, const [isSidebarOpen, setIsSidebarOpen] = useState(false)
isSidebarOpen: false,
})
const selectedRowRef = useRef<HTMLTableRowElement | null>(null) const selectedRowRef = useRef<HTMLTableRowElement | null>(null)
const loaderRef = useRef<HTMLDivElement>(null) const loaderRef = useRef<HTMLDivElement>(null)
const scrollContainerRef = useRef<HTMLDivElement>(null)
const isInitialized = useRef<boolean>(false) const isInitialized = useRef<boolean>(false)
const [searchQuery, setSearchQuery] = useState('') const [searchQuery, setSearchQuery] = useState('')
@@ -122,13 +82,6 @@ export default function Logs() {
const [isVisuallyRefreshing, setIsVisuallyRefreshing] = useState(false) const [isVisuallyRefreshing, setIsVisuallyRefreshing] = useState(false)
const [isExporting, setIsExporting] = useState(false) const [isExporting, setIsExporting] = useState(false)
const isSearchOpenRef = useRef<boolean>(false) const isSearchOpenRef = useRef<boolean>(false)
const refreshTimersRef = useRef(new Set<number>())
const logsRef = useRef<WorkflowLog[]>([])
const selectedLogIndexRef = useRef(-1)
const selectedLogIdRef = useRef<string | null>(null)
const logsRefetchRef = useRef<() => void>(() => {})
const activeLogRefetchRef = useRef<() => void>(() => {})
const logsQueryRef = useRef({ isFetching: false, hasNextPage: false, fetchNextPage: () => {} })
const [isNotificationSettingsOpen, setIsNotificationSettingsOpen] = useState(false) const [isNotificationSettingsOpen, setIsNotificationSettingsOpen] = useState(false)
const userPermissions = useUserPermissionsContext() const userPermissions = useUserPermissionsContext()
@@ -141,19 +94,8 @@ export default function Logs() {
const [previewLogId, setPreviewLogId] = useState<string | null>(null) const [previewLogId, setPreviewLogId] = useState<string | null>(null)
const activeLogId = isPreviewOpen ? previewLogId : selectedLogId const activeLogId = isPreviewOpen ? previewLogId : selectedLogId
const queryClient = useQueryClient()
const detailRefetchInterval = useCallback(
(query: { state: { data?: WorkflowLog } }) => {
if (!isLive) return false
const status = query.state.data?.status
return status === 'running' || status === 'pending' ? 3000 : false
},
[isLive]
)
const activeLogQuery = useLogDetail(activeLogId ?? undefined, { const activeLogQuery = useLogDetail(activeLogId ?? undefined, {
refetchInterval: detailRefetchInterval, refetchInterval: isLive ? 3000 : false,
}) })
const logFilters = useMemo( const logFilters = useMemo(
@@ -212,67 +154,42 @@ export default function Logs() {
return { ...selectedLogFromList, ...activeLogQuery.data } return { ...selectedLogFromList, ...activeLogQuery.data }
}, [selectedLogFromList, activeLogQuery.data, isPreviewOpen]) }, [selectedLogFromList, activeLogQuery.data, isPreviewOpen])
const handleLogHover = useCallback(
(log: WorkflowLog) => {
prefetchLogDetail(queryClient, log.id)
},
[queryClient]
)
useFolders(workspaceId) useFolders(workspaceId)
useEffect(() => {
logsRef.current = logs
}, [logs])
useEffect(() => {
selectedLogIndexRef.current = selectedLogIndex
}, [selectedLogIndex])
useEffect(() => {
selectedLogIdRef.current = selectedLogId
}, [selectedLogId])
logsRefetchRef.current = logsQuery.refetch
activeLogRefetchRef.current = activeLogQuery.refetch
logsQueryRef.current = {
isFetching: logsQuery.isFetching,
hasNextPage: logsQuery.hasNextPage ?? false,
fetchNextPage: logsQuery.fetchNextPage,
}
useEffect(() => {
const timers = refreshTimersRef.current
return () => {
timers.forEach((id) => window.clearTimeout(id))
timers.clear()
}
}, [])
useEffect(() => { useEffect(() => {
if (isInitialized.current) { if (isInitialized.current) {
setStoreSearchQuery(debouncedSearchQuery) setStoreSearchQuery(debouncedSearchQuery)
} }
}, [debouncedSearchQuery, setStoreSearchQuery]) }, [debouncedSearchQuery, setStoreSearchQuery])
const handleLogClick = useCallback((log: WorkflowLog) => { const handleLogClick = useCallback(
dispatch({ type: 'TOGGLE_LOG', logId: log.id }) (log: WorkflowLog) => {
}, []) if (selectedLogId === log.id && isSidebarOpen) {
setIsSidebarOpen(false)
setSelectedLogId(null)
return
}
setSelectedLogId(log.id)
setIsSidebarOpen(true)
},
[selectedLogId, isSidebarOpen]
)
const handleNavigateNext = useCallback(() => { const handleNavigateNext = useCallback(() => {
const idx = selectedLogIndexRef.current if (selectedLogIndex < logs.length - 1) {
const currentLogs = logsRef.current setSelectedLogId(logs[selectedLogIndex + 1].id)
if (idx < currentLogs.length - 1) {
dispatch({ type: 'SELECT_LOG', logId: currentLogs[idx + 1].id })
} }
}, []) }, [selectedLogIndex, logs])
const handleNavigatePrev = useCallback(() => { const handleNavigatePrev = useCallback(() => {
const idx = selectedLogIndexRef.current if (selectedLogIndex > 0) {
if (idx > 0) { setSelectedLogId(logs[selectedLogIndex - 1].id)
dispatch({ type: 'SELECT_LOG', logId: logsRef.current[idx - 1].id })
} }
}, []) }, [selectedLogIndex, logs])
const handleCloseSidebar = useCallback(() => { const handleCloseSidebar = useCallback(() => {
dispatch({ type: 'CLOSE_SIDEBAR' }) setIsSidebarOpen(false)
setSelectedLogId(null)
}, []) }, [])
const handleLogContextMenu = useCallback((e: React.MouseEvent, log: WorkflowLog) => { const handleLogContextMenu = useCallback((e: React.MouseEvent, log: WorkflowLog) => {
@@ -343,34 +260,26 @@ export default function Logs() {
const handleRefresh = useCallback(() => { const handleRefresh = useCallback(() => {
setIsVisuallyRefreshing(true) setIsVisuallyRefreshing(true)
const timerId = window.setTimeout(() => { setTimeout(() => setIsVisuallyRefreshing(false), REFRESH_SPINNER_DURATION_MS)
setIsVisuallyRefreshing(false) logsQuery.refetch()
refreshTimersRef.current.delete(timerId) if (selectedLogId) {
}, REFRESH_SPINNER_DURATION_MS) activeLogQuery.refetch()
refreshTimersRef.current.add(timerId)
logsRefetchRef.current()
if (selectedLogIdRef.current) {
activeLogRefetchRef.current()
} }
}, []) }, [logsQuery, activeLogQuery, selectedLogId])
const handleToggleLive = useCallback(() => { const handleToggleLive = useCallback(() => {
setIsLive((prev) => { const newIsLive = !isLive
if (!prev) { setIsLive(newIsLive)
setIsVisuallyRefreshing(true)
const timerId = window.setTimeout(() => { if (newIsLive) {
setIsVisuallyRefreshing(false) setIsVisuallyRefreshing(true)
refreshTimersRef.current.delete(timerId) setTimeout(() => setIsVisuallyRefreshing(false), REFRESH_SPINNER_DURATION_MS)
}, REFRESH_SPINNER_DURATION_MS) logsQuery.refetch()
refreshTimersRef.current.add(timerId) if (selectedLogId) {
logsRefetchRef.current() activeLogQuery.refetch()
if (selectedLogIdRef.current) {
activeLogRefetchRef.current()
}
} }
return !prev }
}) }, [isLive, logsQuery, activeLogQuery, selectedLogId])
}, [])
const prevIsFetchingRef = useRef(logsQuery.isFetching) const prevIsFetchingRef = useRef(logsQuery.isFetching)
useEffect(() => { useEffect(() => {
@@ -380,15 +289,11 @@ export default function Logs() {
if (isLive && !wasFetching && isFetching) { if (isLive && !wasFetching && isFetching) {
setIsVisuallyRefreshing(true) setIsVisuallyRefreshing(true)
const timerId = window.setTimeout(() => { setTimeout(() => setIsVisuallyRefreshing(false), REFRESH_SPINNER_DURATION_MS)
setIsVisuallyRefreshing(false)
refreshTimersRef.current.delete(timerId)
}, REFRESH_SPINNER_DURATION_MS)
refreshTimersRef.current.add(timerId)
} }
}, [logsQuery.isFetching, isLive]) }, [logsQuery.isFetching, isLive])
const handleExport = useCallback(async () => { const handleExport = async () => {
setIsExporting(true) setIsExporting(true)
try { try {
const params = new URLSearchParams() const params = new URLSearchParams()
@@ -422,17 +327,7 @@ export default function Logs() {
} finally { } finally {
setIsExporting(false) setIsExporting(false)
} }
}, [ }
workspaceId,
level,
triggers,
workflowIds,
folderIds,
timeRange,
startDate,
endDate,
debouncedSearchQuery,
])
useEffect(() => { useEffect(() => {
if (!isInitialized.current) { if (!isInitialized.current) {
@@ -453,59 +348,41 @@ export default function Logs() {
}, [initializeFromURL]) }, [initializeFromURL])
const loadMoreLogs = useCallback(() => { const loadMoreLogs = useCallback(() => {
const { isFetching, hasNextPage, fetchNextPage } = logsQueryRef.current if (!logsQuery.isFetching && logsQuery.hasNextPage) {
if (!isFetching && hasNextPage) { logsQuery.fetchNextPage()
fetchNextPage()
} }
}, []) }, [logsQuery])
useEffect(() => { useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
if (isSearchOpenRef.current) return if (isSearchOpenRef.current) return
const currentLogs = logsRef.current if (logs.length === 0) return
const currentIndex = selectedLogIndexRef.current
if (currentLogs.length === 0) return
if (currentIndex === -1 && (e.key === 'ArrowUp' || e.key === 'ArrowDown')) { if (selectedLogIndex === -1 && (e.key === 'ArrowUp' || e.key === 'ArrowDown')) {
e.preventDefault() e.preventDefault()
dispatch({ type: 'SELECT_LOG', logId: currentLogs[0].id }) setSelectedLogId(logs[0].id)
return return
} }
if (e.key === 'ArrowUp' && !e.metaKey && !e.ctrlKey && currentIndex > 0) { if (e.key === 'ArrowUp' && !e.metaKey && !e.ctrlKey && selectedLogIndex > 0) {
e.preventDefault() e.preventDefault()
handleNavigatePrev() handleNavigatePrev()
} }
if ( if (e.key === 'ArrowDown' && !e.metaKey && !e.ctrlKey && selectedLogIndex < logs.length - 1) {
e.key === 'ArrowDown' &&
!e.metaKey &&
!e.ctrlKey &&
currentIndex < currentLogs.length - 1
) {
e.preventDefault() e.preventDefault()
handleNavigateNext() handleNavigateNext()
} }
if (e.key === 'Enter' && selectedLogIdRef.current) { if (e.key === 'Enter' && selectedLogId) {
e.preventDefault() e.preventDefault()
dispatch({ type: 'TOGGLE_SIDEBAR' }) setIsSidebarOpen(!isSidebarOpen)
} }
} }
window.addEventListener('keydown', handleKeyDown) window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown) return () => window.removeEventListener('keydown', handleKeyDown)
}, [handleNavigateNext, handleNavigatePrev]) }, [logs, selectedLogIndex, isSidebarOpen, selectedLogId, handleNavigateNext, handleNavigatePrev])
const handleCloseContextMenu = useCallback(() => setContextMenuOpen(false), [])
const handleOpenNotificationSettings = useCallback(() => setIsNotificationSettingsOpen(true), [])
const handleSearchOpenChange = useCallback((open: boolean) => {
isSearchOpenRef.current = open
}, [])
const handleClosePreview = useCallback(() => {
setIsPreviewOpen(false)
setPreviewLogId(null)
}, [])
const isDashboardView = viewMode === 'dashboard' const isDashboardView = viewMode === 'dashboard'
@@ -525,10 +402,12 @@ export default function Logs() {
onExport={handleExport} onExport={handleExport}
canEdit={userPermissions.canEdit} canEdit={userPermissions.canEdit}
hasLogs={logs.length > 0} hasLogs={logs.length > 0}
onOpenNotificationSettings={handleOpenNotificationSettings} onOpenNotificationSettings={() => setIsNotificationSettingsOpen(true)}
searchQuery={searchQuery} searchQuery={searchQuery}
onSearchQueryChange={setSearchQuery} onSearchQueryChange={setSearchQuery}
onSearchOpenChange={handleSearchOpenChange} onSearchOpenChange={(open: boolean) => {
isSearchOpenRef.current = open
}}
/> />
</div> </div>
@@ -570,7 +449,7 @@ export default function Logs() {
</div> </div>
{/* Table body - virtualized */} {/* Table body - virtualized */}
<div className='min-h-0 flex-1 overflow-hidden'> <div className='min-h-0 flex-1 overflow-hidden' ref={scrollContainerRef}>
{logsQuery.isLoading && !logsQuery.data ? ( {logsQuery.isLoading && !logsQuery.data ? (
<div className='flex h-full items-center justify-center'> <div className='flex h-full items-center justify-center'>
<div className='flex items-center gap-[8px] text-[var(--text-secondary)]'> <div className='flex items-center gap-[8px] text-[var(--text-secondary)]'>
@@ -597,7 +476,6 @@ export default function Logs() {
logs={logs} logs={logs}
selectedLogId={selectedLogId} selectedLogId={selectedLogId}
onLogClick={handleLogClick} onLogClick={handleLogClick}
onLogHover={handleLogHover}
onLogContextMenu={handleLogContextMenu} onLogContextMenu={handleLogContextMenu}
selectedRowRef={selectedRowRef} selectedRowRef={selectedRowRef}
hasNextPage={logsQuery.hasNextPage ?? false} hasNextPage={logsQuery.hasNextPage ?? false}
@@ -633,7 +511,7 @@ export default function Logs() {
isOpen={contextMenuOpen} isOpen={contextMenuOpen}
position={contextMenuPosition} position={contextMenuPosition}
menuRef={contextMenuRef} menuRef={contextMenuRef}
onClose={handleCloseContextMenu} onClose={() => setContextMenuOpen(false)}
log={contextMenuLog} log={contextMenuLog}
onCopyExecutionId={handleCopyExecutionId} onCopyExecutionId={handleCopyExecutionId}
onOpenWorkflow={handleOpenWorkflow} onOpenWorkflow={handleOpenWorkflow}
@@ -650,7 +528,10 @@ export default function Logs() {
traceSpans={activeLogQuery.data.executionData?.traceSpans} traceSpans={activeLogQuery.data.executionData?.traceSpans}
isModal isModal
isOpen={isPreviewOpen} isOpen={isPreviewOpen}
onClose={handleClosePreview} onClose={() => {
setIsPreviewOpen(false)
setPreviewLogId(null)
}}
/> />
)} )}
</div> </div>

View File

@@ -23,7 +23,7 @@ import { ParallelTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/compo
import { getDisplayValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block' import { getDisplayValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block'
import { getBlock } from '@/blocks/registry' import { getBlock } from '@/blocks/registry'
import type { CopilotToolCall } from '@/stores/panel' import type { CopilotToolCall } from '@/stores/panel'
import { useCopilotStore } from '@/stores/panel' import { useCopilotStore, usePanelStore } from '@/stores/panel'
import type { SubAgentContentBlock } from '@/stores/panel/copilot/types' import type { SubAgentContentBlock } from '@/stores/panel/copilot/types'
import { useWorkflowStore } from '@/stores/workflows/workflow/store' import { useWorkflowStore } from '@/stores/workflows/workflow/store'
@@ -341,16 +341,20 @@ export function OptionsSelector({
const [hoveredIndex, setHoveredIndex] = useState(-1) const [hoveredIndex, setHoveredIndex] = useState(-1)
const [chosenKey, setChosenKey] = useState<string | null>(selectedOptionKey) const [chosenKey, setChosenKey] = useState<string | null>(selectedOptionKey)
const containerRef = useRef<HTMLDivElement>(null) const containerRef = useRef<HTMLDivElement>(null)
const activeTab = usePanelStore((s) => s.activeTab)
const isLocked = chosenKey !== null const isLocked = chosenKey !== null
// Handle keyboard navigation - only for the active options selector // Handle keyboard navigation - only for the active options selector when copilot is active
useEffect(() => { useEffect(() => {
if (isInteractionDisabled || !enableKeyboardNav || isLocked) return if (isInteractionDisabled || !enableKeyboardNav || isLocked) return
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
if (e.defaultPrevented) return if (e.defaultPrevented) return
// Only handle keyboard shortcuts when the copilot panel is active
if (activeTab !== 'copilot') return
const activeElement = document.activeElement const activeElement = document.activeElement
const isInputFocused = const isInputFocused =
activeElement?.tagName === 'INPUT' || activeElement?.tagName === 'INPUT' ||
@@ -387,7 +391,7 @@ export function OptionsSelector({
document.addEventListener('keydown', handleKeyDown) document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown) return () => document.removeEventListener('keydown', handleKeyDown)
}, [isInteractionDisabled, enableKeyboardNav, isLocked, sortedOptions, hoveredIndex, onSelect]) }, [isInteractionDisabled, enableKeyboardNav, isLocked, sortedOptions, hoveredIndex, onSelect, activeTab])
if (sortedOptions.length === 0) return null if (sortedOptions.length === 0) return null

View File

@@ -223,12 +223,7 @@ function resolveToolsDisplay(
* - Resolves tool names from block registry * - Resolves tool names from block registry
* - Shows '-' for other selector types that need hydration * - Shows '-' for other selector types that need hydration
*/ */
const SubBlockRow = memo(function SubBlockRow({ function SubBlockRow({ title, value, subBlock, rawValue }: SubBlockRowProps) {
title,
value,
subBlock,
rawValue,
}: SubBlockRowProps) {
const isPasswordField = subBlock?.password === true const isPasswordField = subBlock?.password === true
const maskedValue = isPasswordField && value && value !== '-' ? '•••' : null const maskedValue = isPasswordField && value && value !== '-' ? '•••' : null
@@ -260,7 +255,7 @@ const SubBlockRow = memo(function SubBlockRow({
)} )}
</div> </div>
) )
}) }
/** /**
* Preview block component for workflow visualization. * Preview block component for workflow visualization.

View File

@@ -1008,7 +1008,7 @@ const VirtualizedViewerInner = memo(function VirtualizedViewerInner({
* Non-virtualized code viewer implementation. * Non-virtualized code viewer implementation.
* Renders all lines directly without windowing. * Renders all lines directly without windowing.
*/ */
const ViewerInner = memo(function ViewerInner({ function ViewerInner({
code, code,
showGutter, showGutter,
language, language,
@@ -1181,7 +1181,7 @@ const ViewerInner = memo(function ViewerInner({
</Content> </Content>
</Container> </Container>
) )
}) }
/** /**
* Readonly code viewer with optional gutter and syntax highlighting. * Readonly code viewer with optional gutter and syntax highlighting.

View File

@@ -1,10 +1,4 @@
import { import { keepPreviousData, useInfiniteQuery, useQuery } from '@tanstack/react-query'
keepPreviousData,
type QueryClient,
useInfiniteQuery,
useQuery,
useQueryClient,
} from '@tanstack/react-query'
import { getEndDateFromTimeRange, getStartDateFromTimeRange } from '@/lib/logs/filters' import { getEndDateFromTimeRange, getStartDateFromTimeRange } from '@/lib/logs/filters'
import { parseQuery, queryToApiParams } from '@/lib/logs/query-parser' import { parseQuery, queryToApiParams } from '@/lib/logs/query-parser'
import type { import type {
@@ -152,45 +146,17 @@ export function useLogsList(
interface UseLogDetailOptions { interface UseLogDetailOptions {
enabled?: boolean enabled?: boolean
refetchInterval?: refetchInterval?: number | false
| number
| false
| ((query: { state: { data?: WorkflowLog } }) => number | false | undefined)
} }
export function useLogDetail(logId: string | undefined, options?: UseLogDetailOptions) { export function useLogDetail(logId: string | undefined, options?: UseLogDetailOptions) {
const queryClient = useQueryClient()
return useQuery({ return useQuery({
queryKey: logKeys.detail(logId), queryKey: logKeys.detail(logId),
queryFn: () => fetchLogDetail(logId as string), queryFn: () => fetchLogDetail(logId as string),
enabled: Boolean(logId) && (options?.enabled ?? true), enabled: Boolean(logId) && (options?.enabled ?? true),
refetchInterval: options?.refetchInterval ?? false, refetchInterval: options?.refetchInterval ?? false,
staleTime: 30 * 1000, staleTime: 30 * 1000,
initialData: () => { placeholderData: keepPreviousData,
if (!logId) return undefined
const listQueries = queryClient.getQueriesData<{
pages: { logs: WorkflowLog[] }[]
}>({
queryKey: logKeys.lists(),
})
for (const [, data] of listQueries) {
const match = data?.pages?.flatMap((p) => p.logs).find((l) => l.id === logId)
if (match) return match
}
return undefined
},
initialDataUpdatedAt: 0,
})
}
/**
* Prefetches log detail data on hover for instant panel rendering on click.
*/
export function prefetchLogDetail(queryClient: QueryClient, logId: string) {
queryClient.prefetchQuery({
queryKey: logKeys.detail(logId),
queryFn: () => fetchLogDetail(logId),
staleTime: 30 * 1000,
}) })
} }