mirror of
https://github.com/simstudioai/sim.git
synced 2026-02-16 01:15:26 -05:00
Compare commits
1 Commits
staging
...
cursor/cop
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ccb15dae09 |
@@ -7,7 +7,7 @@ import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
|
||||
<BlockInfoCard
|
||||
type="google_books"
|
||||
color="#E0E0E0"
|
||||
color="#FFFFFF"
|
||||
/>
|
||||
|
||||
## Usage Instructions
|
||||
|
||||
@@ -71,7 +71,6 @@ Retrieve an object from an AWS S3 bucket
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `accessKeyId` | string | Yes | Your AWS Access Key ID |
|
||||
| `secretAccessKey` | string | Yes | Your AWS Secret Access Key |
|
||||
| `region` | string | No | Optional region override when URL does not include region \(e.g., us-east-1, eu-west-1\) |
|
||||
| `s3Uri` | string | Yes | S3 Object URL \(e.g., https://bucket.s3.region.amazonaws.com/path/to/file\) |
|
||||
|
||||
#### Output
|
||||
|
||||
@@ -79,7 +79,7 @@ Send messages to Slack channels or direct messages. Supports Slack mrkdwn format
|
||||
| `channel` | string | No | Slack channel ID \(e.g., C1234567890\) |
|
||||
| `dmUserId` | string | No | Slack user ID for direct messages \(e.g., U1234567890\) |
|
||||
| `text` | string | Yes | Message text to send \(supports Slack mrkdwn formatting\) |
|
||||
| `threadTs` | string | No | Thread timestamp to reply to \(creates thread reply\) |
|
||||
| `thread_ts` | string | No | Thread timestamp to reply to \(creates thread reply\) |
|
||||
| `files` | file[] | No | Files to attach to the message |
|
||||
|
||||
#### Output
|
||||
|
||||
@@ -238,11 +238,6 @@ Use this context to calculate relative dates like "yesterday", "last week", "beg
|
||||
finalSystemPrompt += currentTimeContext
|
||||
}
|
||||
|
||||
if (generationType === 'cron-expression') {
|
||||
finalSystemPrompt +=
|
||||
'\n\nIMPORTANT: Return ONLY the raw cron expression (e.g., "0 9 * * 1-5"). Do NOT wrap it in markdown code blocks, backticks, or quotes. Do NOT include any explanation or text before or after the expression.'
|
||||
}
|
||||
|
||||
if (generationType === 'json-object') {
|
||||
finalSystemPrompt +=
|
||||
'\n\nIMPORTANT: Return ONLY the raw JSON object. Do NOT wrap it in markdown code blocks (no ```json or ```). Do NOT include any explanation or text before or after the JSON. The response must start with { and end with }.'
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
export type { StatusBarSegment } from './status-bar'
|
||||
export { StatusBar } from './status-bar'
|
||||
export { default, StatusBar } from './status-bar'
|
||||
|
||||
@@ -8,7 +8,7 @@ export interface StatusBarSegment {
|
||||
timestamp: string
|
||||
}
|
||||
|
||||
function StatusBarInner({
|
||||
export function StatusBar({
|
||||
segments,
|
||||
selectedSegmentIndices,
|
||||
onSegmentClick,
|
||||
@@ -127,45 +127,4 @@ function StatusBarInner({
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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)
|
||||
export default memo(StatusBar)
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
export type { WorkflowExecutionItem } from './workflows-list'
|
||||
export { WorkflowsList } from './workflows-list'
|
||||
export { default, WorkflowsList } from './workflows-list'
|
||||
|
||||
@@ -14,7 +14,7 @@ export interface WorkflowExecutionItem {
|
||||
overallSuccessRate: number
|
||||
}
|
||||
|
||||
function WorkflowsListInner({
|
||||
export function WorkflowsList({
|
||||
filteredExecutions,
|
||||
expandedWorkflowId,
|
||||
onToggleWorkflow,
|
||||
@@ -103,7 +103,7 @@ function WorkflowsListInner({
|
||||
<StatusBar
|
||||
segments={workflow.segments}
|
||||
selectedSegmentIndices={selectedSegments[workflow.workflowId] || null}
|
||||
onSegmentClick={onSegmentClick}
|
||||
onSegmentClick={onSegmentClick as any}
|
||||
workflowId={workflow.workflowId}
|
||||
segmentDurationMs={segmentDurationMs}
|
||||
preferBelow={idx < 2}
|
||||
@@ -124,4 +124,4 @@ function WorkflowsListInner({
|
||||
)
|
||||
}
|
||||
|
||||
export const WorkflowsList = memo(WorkflowsListInner)
|
||||
export default memo(WorkflowsList)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'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 { Skeleton } from '@/components/ui/skeleton'
|
||||
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 [lastAnchorIndices, setLastAnchorIndices] = useState<Record<string, number>>({})
|
||||
const lastAnchorIndicesRef = useRef<Record<string, number>>({})
|
||||
const barsAreaRef = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
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 { rawExecutions, aggregateSegments, segmentMs } = useMemo(() => {
|
||||
const { executions, aggregateSegments, segmentMs } = useMemo(() => {
|
||||
if (!stats) {
|
||||
return { rawExecutions: [], aggregateSegments: [], segmentMs: 0 }
|
||||
return { executions: [], aggregateSegments: [], segmentMs: 0 }
|
||||
}
|
||||
|
||||
const workflowExecutions = stats.workflows.map(toWorkflowExecution)
|
||||
|
||||
return {
|
||||
rawExecutions: stats.workflows.map(toWorkflowExecution),
|
||||
executions: workflowExecutions,
|
||||
aggregateSegments: stats.aggregateSegments,
|
||||
segmentMs: stats.segmentMs,
|
||||
}
|
||||
}, [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 map = new Map<string, number>()
|
||||
for (const wf of executions) {
|
||||
@@ -371,8 +312,6 @@ function DashboardInner({ stats, isLoading, error }: DashboardProps) {
|
||||
[toggleWorkflowId]
|
||||
)
|
||||
|
||||
lastAnchorIndicesRef.current = lastAnchorIndices
|
||||
|
||||
/**
|
||||
* Handles segment click for selecting time segments.
|
||||
* @param workflowId - The workflow containing the segment
|
||||
@@ -422,7 +361,7 @@ function DashboardInner({ stats, isLoading, error }: DashboardProps) {
|
||||
} else if (mode === 'range') {
|
||||
setSelectedSegments((prev) => {
|
||||
const currentSegments = prev[workflowId] || []
|
||||
const anchor = lastAnchorIndicesRef.current[workflowId] ?? segmentIndex
|
||||
const anchor = lastAnchorIndices[workflowId] ?? segmentIndex
|
||||
const [start, end] =
|
||||
anchor < segmentIndex ? [anchor, segmentIndex] : [segmentIndex, anchor]
|
||||
const range = Array.from({ length: end - start + 1 }, (_, i) => start + i)
|
||||
@@ -431,12 +370,12 @@ function DashboardInner({ stats, isLoading, error }: DashboardProps) {
|
||||
})
|
||||
}
|
||||
},
|
||||
[]
|
||||
[lastAnchorIndices]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedSegments((prev) => (Object.keys(prev).length > 0 ? {} : prev))
|
||||
setLastAnchorIndices((prev) => (Object.keys(prev).length > 0 ? {} : prev))
|
||||
setSelectedSegments({})
|
||||
setLastAnchorIndices({})
|
||||
}, [stats, timeRange, workflowIds, searchQuery])
|
||||
|
||||
if (isLoading) {
|
||||
@@ -554,7 +493,7 @@ function DashboardInner({ stats, isLoading, error }: DashboardProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='min-h-0 flex-1 overflow-hidden'>
|
||||
<div className='min-h-0 flex-1 overflow-hidden' ref={barsAreaRef}>
|
||||
<WorkflowsList
|
||||
filteredExecutions={filteredExecutions as WorkflowExecution[]}
|
||||
expandedWorkflowId={expandedWorkflowId}
|
||||
@@ -568,5 +507,3 @@ function DashboardInner({ stats, isLoading, error }: DashboardProps) {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(DashboardInner)
|
||||
|
||||
@@ -43,199 +43,184 @@ import { useLogDetailsUIStore } from '@/stores/logs/store'
|
||||
/**
|
||||
* Workflow Output section with code viewer, copy, search, and context menu functionality
|
||||
*/
|
||||
const WorkflowOutputSection = memo(
|
||||
function WorkflowOutputSection({ output }: { output: Record<string, unknown> }) {
|
||||
const contentRef = useRef<HTMLDivElement>(null)
|
||||
const [copied, setCopied] = useState(false)
|
||||
const copyTimerRef = useRef<number | null>(null)
|
||||
function WorkflowOutputSection({ output }: { output: Record<string, unknown> }) {
|
||||
const contentRef = useRef<HTMLDivElement>(null)
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
const [isContextMenuOpen, setIsContextMenuOpen] = useState(false)
|
||||
const [contextMenuPosition, setContextMenuPosition] = useState({ x: 0, y: 0 })
|
||||
// Context menu state
|
||||
const [isContextMenuOpen, setIsContextMenuOpen] = useState(false)
|
||||
const [contextMenuPosition, setContextMenuPosition] = useState({ x: 0, y: 0 })
|
||||
|
||||
const {
|
||||
isSearchActive,
|
||||
searchQuery,
|
||||
setSearchQuery,
|
||||
matchCount,
|
||||
currentMatchIndex,
|
||||
activateSearch,
|
||||
closeSearch,
|
||||
goToNextMatch,
|
||||
goToPreviousMatch,
|
||||
handleMatchCountChange,
|
||||
searchInputRef,
|
||||
} = useCodeViewerFeatures({ contentRef })
|
||||
const {
|
||||
isSearchActive,
|
||||
searchQuery,
|
||||
setSearchQuery,
|
||||
matchCount,
|
||||
currentMatchIndex,
|
||||
activateSearch,
|
||||
closeSearch,
|
||||
goToNextMatch,
|
||||
goToPreviousMatch,
|
||||
handleMatchCountChange,
|
||||
searchInputRef,
|
||||
} = 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) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setContextMenuPosition({ x: e.clientX, y: e.clientY })
|
||||
setIsContextMenuOpen(true)
|
||||
}, [])
|
||||
const handleContextMenu = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setContextMenuPosition({ x: e.clientX, y: e.clientY })
|
||||
setIsContextMenuOpen(true)
|
||||
}, [])
|
||||
|
||||
const closeContextMenu = useCallback(() => {
|
||||
setIsContextMenuOpen(false)
|
||||
}, [])
|
||||
const closeContextMenu = useCallback(() => {
|
||||
setIsContextMenuOpen(false)
|
||||
}, [])
|
||||
|
||||
const handleCopy = useCallback(() => {
|
||||
navigator.clipboard.writeText(jsonString)
|
||||
setCopied(true)
|
||||
if (copyTimerRef.current !== null) window.clearTimeout(copyTimerRef.current)
|
||||
copyTimerRef.current = window.setTimeout(() => setCopied(false), 1500)
|
||||
closeContextMenu()
|
||||
}, [jsonString, closeContextMenu])
|
||||
const handleCopy = useCallback(() => {
|
||||
navigator.clipboard.writeText(jsonString)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 1500)
|
||||
closeContextMenu()
|
||||
}, [jsonString, closeContextMenu])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (copyTimerRef.current !== null) window.clearTimeout(copyTimerRef.current)
|
||||
}
|
||||
}, [])
|
||||
const handleSearch = useCallback(() => {
|
||||
activateSearch()
|
||||
closeContextMenu()
|
||||
}, [activateSearch, closeContextMenu])
|
||||
|
||||
const handleSearch = useCallback(() => {
|
||||
activateSearch()
|
||||
closeContextMenu()
|
||||
}, [activateSearch, closeContextMenu])
|
||||
|
||||
return (
|
||||
<div className='relative flex min-w-0 flex-col overflow-hidden'>
|
||||
<div ref={contentRef} onContextMenu={handleContextMenu} className='relative'>
|
||||
<Code.Viewer
|
||||
code={jsonString}
|
||||
language='json'
|
||||
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
|
||||
searchQuery={isSearchActive ? searchQuery : undefined}
|
||||
currentMatchIndex={currentMatchIndex}
|
||||
onMatchCountChange={handleMatchCountChange}
|
||||
/>
|
||||
{/* Glass action buttons overlay */}
|
||||
{!isSearchActive && (
|
||||
<div className='absolute top-[7px] right-[6px] z-10 flex gap-[4px]'>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
type='button'
|
||||
variant='default'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleCopy()
|
||||
}}
|
||||
className='h-[20px] w-[20px] cursor-pointer border border-[var(--border-1)] bg-transparent p-0 backdrop-blur-sm hover:bg-[var(--surface-3)]'
|
||||
>
|
||||
{copied ? (
|
||||
<Check className='h-[10px] w-[10px] text-[var(--text-success)]' />
|
||||
) : (
|
||||
<Clipboard className='h-[10px] w-[10px]' />
|
||||
)}
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side='top'>{copied ? 'Copied' : 'Copy'}</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
type='button'
|
||||
variant='default'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
activateSearch()
|
||||
}}
|
||||
className='h-[20px] w-[20px] cursor-pointer border border-[var(--border-1)] bg-transparent p-0 backdrop-blur-sm hover:bg-[var(--surface-3)]'
|
||||
>
|
||||
<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>
|
||||
return (
|
||||
<div className='relative flex min-w-0 flex-col overflow-hidden'>
|
||||
<div ref={contentRef} onContextMenu={handleContextMenu} className='relative'>
|
||||
<Code.Viewer
|
||||
code={jsonString}
|
||||
language='json'
|
||||
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
|
||||
searchQuery={isSearchActive ? searchQuery : undefined}
|
||||
currentMatchIndex={currentMatchIndex}
|
||||
onMatchCountChange={handleMatchCountChange}
|
||||
/>
|
||||
{/* Glass action buttons overlay */}
|
||||
{!isSearchActive && (
|
||||
<div className='absolute top-[7px] right-[6px] z-10 flex gap-[4px]'>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
type='button'
|
||||
variant='default'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleCopy()
|
||||
}}
|
||||
className='h-[20px] w-[20px] cursor-pointer border border-[var(--border-1)] bg-transparent p-0 backdrop-blur-sm hover:bg-[var(--surface-3)]'
|
||||
>
|
||||
{copied ? (
|
||||
<Check className='h-[10px] w-[10px] text-[var(--text-success)]' />
|
||||
) : (
|
||||
<Clipboard className='h-[10px] w-[10px]' />
|
||||
)}
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side='top'>{copied ? 'Copied' : 'Copy'}</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
type='button'
|
||||
variant='default'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
activateSearch()
|
||||
}}
|
||||
className='h-[20px] w-[20px] cursor-pointer border border-[var(--border-1)] bg-transparent p-0 backdrop-blur-sm hover:bg-[var(--surface-3)]'
|
||||
>
|
||||
<Search className='h-[10px] w-[10px]' />
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side='top'>Search</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</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>
|
||||
)
|
||||
},
|
||||
(prev, next) => prev.output === next.output
|
||||
)
|
||||
|
||||
{/* 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>
|
||||
)}
|
||||
|
||||
{/* 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 {
|
||||
/** The log to display details for */
|
||||
@@ -293,6 +278,7 @@ export const LogDetails = memo(function LogDetails({
|
||||
return isWorkflowExecutionLog && log?.cost
|
||||
}, [log, isWorkflowExecutionLog])
|
||||
|
||||
// Extract and clean the workflow final output (recursively remove hidden keys for cleaner display)
|
||||
const workflowOutput = useMemo(() => {
|
||||
const executionData = log?.executionData as
|
||||
| { finalOutput?: Record<string, unknown> }
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import type { RefObject } from 'react'
|
||||
import { memo } from 'react'
|
||||
import {
|
||||
Popover,
|
||||
PopoverAnchor,
|
||||
@@ -30,7 +29,7 @@ interface LogRowContextMenuProps {
|
||||
* Context menu for log rows.
|
||||
* Provides quick actions for copying data, navigation, and filtering.
|
||||
*/
|
||||
export const LogRowContextMenu = memo(function LogRowContextMenu({
|
||||
export function LogRowContextMenu({
|
||||
isOpen,
|
||||
position,
|
||||
menuRef,
|
||||
@@ -122,4 +121,4 @@ export const LogRowContextMenu = memo(function LogRowContextMenu({
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -24,7 +24,6 @@ interface LogRowProps {
|
||||
log: WorkflowLog
|
||||
isSelected: boolean
|
||||
onClick: (log: WorkflowLog) => void
|
||||
onHover?: (log: WorkflowLog) => void
|
||||
onContextMenu?: (e: React.MouseEvent, log: WorkflowLog) => void
|
||||
selectedRowRef: React.RefObject<HTMLTableRowElement | null> | null
|
||||
}
|
||||
@@ -34,14 +33,7 @@ interface LogRowProps {
|
||||
* Uses shallow comparison for the log object.
|
||||
*/
|
||||
const LogRow = memo(
|
||||
function LogRow({
|
||||
log,
|
||||
isSelected,
|
||||
onClick,
|
||||
onHover,
|
||||
onContextMenu,
|
||||
selectedRowRef,
|
||||
}: LogRowProps) {
|
||||
function LogRow({ log, isSelected, onClick, onContextMenu, selectedRowRef }: LogRowProps) {
|
||||
const formattedDate = useMemo(() => formatDate(log.createdAt), [log.createdAt])
|
||||
const isDeletedWorkflow = !log.workflow?.id && !log.workflowId
|
||||
const workflowName = isDeletedWorkflow
|
||||
@@ -51,8 +43,6 @@ const LogRow = memo(
|
||||
|
||||
const handleClick = useCallback(() => onClick(log), [onClick, log])
|
||||
|
||||
const handleMouseEnter = useCallback(() => onHover?.(log), [onHover, log])
|
||||
|
||||
const handleContextMenu = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (onContextMenu) {
|
||||
@@ -71,7 +61,6 @@ const LogRow = memo(
|
||||
isSelected && 'bg-[var(--surface-3)] dark:bg-[var(--surface-4)]'
|
||||
)}
|
||||
onClick={handleClick}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onContextMenu={handleContextMenu}
|
||||
>
|
||||
<div className='flex flex-1 items-center'>
|
||||
@@ -153,8 +142,7 @@ const LogRow = memo(
|
||||
prevProps.log.id === nextProps.log.id &&
|
||||
prevProps.log.duration === nextProps.log.duration &&
|
||||
prevProps.log.status === nextProps.log.status &&
|
||||
prevProps.isSelected === nextProps.isSelected &&
|
||||
prevProps.onHover === nextProps.onHover
|
||||
prevProps.isSelected === nextProps.isSelected
|
||||
)
|
||||
}
|
||||
)
|
||||
@@ -163,7 +151,6 @@ interface RowProps {
|
||||
logs: WorkflowLog[]
|
||||
selectedLogId: string | null
|
||||
onLogClick: (log: WorkflowLog) => void
|
||||
onLogHover?: (log: WorkflowLog) => void
|
||||
onLogContextMenu?: (e: React.MouseEvent, log: WorkflowLog) => void
|
||||
selectedRowRef: React.RefObject<HTMLTableRowElement | null>
|
||||
isFetchingNextPage: boolean
|
||||
@@ -180,7 +167,6 @@ function Row({
|
||||
logs,
|
||||
selectedLogId,
|
||||
onLogClick,
|
||||
onLogHover,
|
||||
onLogContextMenu,
|
||||
selectedRowRef,
|
||||
isFetchingNextPage,
|
||||
@@ -212,7 +198,6 @@ function Row({
|
||||
log={log}
|
||||
isSelected={isSelected}
|
||||
onClick={onLogClick}
|
||||
onHover={onLogHover}
|
||||
onContextMenu={onLogContextMenu}
|
||||
selectedRowRef={isSelected ? selectedRowRef : null}
|
||||
/>
|
||||
@@ -224,7 +209,6 @@ export interface LogsListProps {
|
||||
logs: WorkflowLog[]
|
||||
selectedLogId: string | null
|
||||
onLogClick: (log: WorkflowLog) => void
|
||||
onLogHover?: (log: WorkflowLog) => void
|
||||
onLogContextMenu?: (e: React.MouseEvent, log: WorkflowLog) => void
|
||||
selectedRowRef: React.RefObject<HTMLTableRowElement | null>
|
||||
hasNextPage: boolean
|
||||
@@ -243,7 +227,6 @@ export function LogsList({
|
||||
logs,
|
||||
selectedLogId,
|
||||
onLogClick,
|
||||
onLogHover,
|
||||
onLogContextMenu,
|
||||
selectedRowRef,
|
||||
hasNextPage,
|
||||
@@ -289,7 +272,6 @@ export function LogsList({
|
||||
logs,
|
||||
selectedLogId,
|
||||
onLogClick,
|
||||
onLogHover,
|
||||
onLogContextMenu,
|
||||
selectedRowRef,
|
||||
isFetchingNextPage,
|
||||
@@ -299,7 +281,6 @@ export function LogsList({
|
||||
logs,
|
||||
selectedLogId,
|
||||
onLogClick,
|
||||
onLogHover,
|
||||
onLogContextMenu,
|
||||
selectedRowRef,
|
||||
isFetchingNextPage,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { memo, useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { Plus, X } from 'lucide-react'
|
||||
import {
|
||||
@@ -113,7 +113,7 @@ function formatAlertConfigLabel(config: {
|
||||
}
|
||||
}
|
||||
|
||||
export const NotificationSettings = memo(function NotificationSettings({
|
||||
export function NotificationSettings({
|
||||
workspaceId,
|
||||
open,
|
||||
onOpenChange,
|
||||
@@ -144,7 +144,7 @@ export const NotificationSettings = memo(function NotificationSettings({
|
||||
slackChannelId: '',
|
||||
slackChannelName: '',
|
||||
slackAccountId: '',
|
||||
|
||||
useAlertRule: false,
|
||||
alertRule: 'none' as AlertRule,
|
||||
consecutiveFailures: 3,
|
||||
failureRatePercent: 50,
|
||||
@@ -212,7 +212,7 @@ export const NotificationSettings = memo(function NotificationSettings({
|
||||
slackChannelId: '',
|
||||
slackChannelName: '',
|
||||
slackAccountId: '',
|
||||
|
||||
useAlertRule: false,
|
||||
alertRule: 'none',
|
||||
consecutiveFailures: 3,
|
||||
failureRatePercent: 50,
|
||||
@@ -484,6 +484,7 @@ export const NotificationSettings = memo(function NotificationSettings({
|
||||
slackChannelId: subscription.slackConfig?.channelId || '',
|
||||
slackChannelName: subscription.slackConfig?.channelName || '',
|
||||
slackAccountId: subscription.slackConfig?.accountId || '',
|
||||
useAlertRule: !!subscription.alertConfig,
|
||||
alertRule: subscription.alertConfig?.rule || 'none',
|
||||
consecutiveFailures: subscription.alertConfig?.consecutiveFailures || 3,
|
||||
failureRatePercent: subscription.alertConfig?.failureRatePercent || 50,
|
||||
@@ -1288,4 +1289,4 @@ export const NotificationSettings = memo(function NotificationSettings({
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'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 { useParams } from 'next/navigation'
|
||||
import {
|
||||
@@ -149,7 +149,7 @@ function getTriggerIcon(
|
||||
* @param props - The component props
|
||||
* @returns The complete logs toolbar
|
||||
*/
|
||||
export const LogsToolbar = memo(function LogsToolbar({
|
||||
export function LogsToolbar({
|
||||
viewMode,
|
||||
onViewModeChange,
|
||||
isRefreshing,
|
||||
@@ -749,4 +749,4 @@ export const LogsToolbar = memo(function LogsToolbar({
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
@@ -11,17 +10,12 @@ import {
|
||||
hasActiveFilters,
|
||||
} from '@/lib/logs/filters'
|
||||
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 {
|
||||
prefetchLogDetail,
|
||||
useDashboardStats,
|
||||
useLogDetail,
|
||||
useLogsList,
|
||||
} from '@/hooks/queries/logs'
|
||||
import { useDashboardStats, useLogDetail, useLogsList } from '@/hooks/queries/logs'
|
||||
import { useDebounce } from '@/hooks/use-debounce'
|
||||
import { useFilterStore } from '@/stores/logs/filters/store'
|
||||
import type { WorkflowLog } from '@/stores/logs/filters/types'
|
||||
import { useUserPermissionsContext } from '../providers/workspace-permissions-provider'
|
||||
import {
|
||||
Dashboard,
|
||||
ExecutionSnapshot,
|
||||
@@ -36,38 +30,6 @@ import { LOG_COLUMN_ORDER, LOG_COLUMNS } from './utils'
|
||||
const LOGS_PER_PAGE = 50 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.
|
||||
* Supports filtering, search, live updates, and detailed log inspection.
|
||||
@@ -98,13 +60,11 @@ export default function Logs() {
|
||||
setWorkspaceId(workspaceId)
|
||||
}, [workspaceId, setWorkspaceId])
|
||||
|
||||
const [{ selectedLogId, isSidebarOpen }, dispatch] = useReducer(logSelectionReducer, {
|
||||
selectedLogId: null,
|
||||
isSidebarOpen: false,
|
||||
})
|
||||
const [selectedLogId, setSelectedLogId] = useState<string | null>(null)
|
||||
const [isSidebarOpen, setIsSidebarOpen] = useState(false)
|
||||
const selectedRowRef = useRef<HTMLTableRowElement | null>(null)
|
||||
const loaderRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null)
|
||||
const isInitialized = useRef<boolean>(false)
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
@@ -122,13 +82,6 @@ export default function Logs() {
|
||||
const [isVisuallyRefreshing, setIsVisuallyRefreshing] = useState(false)
|
||||
const [isExporting, setIsExporting] = useState(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 userPermissions = useUserPermissionsContext()
|
||||
|
||||
@@ -141,19 +94,8 @@ export default function Logs() {
|
||||
const [previewLogId, setPreviewLogId] = useState<string | null>(null)
|
||||
|
||||
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, {
|
||||
refetchInterval: detailRefetchInterval,
|
||||
refetchInterval: isLive ? 3000 : false,
|
||||
})
|
||||
|
||||
const logFilters = useMemo(
|
||||
@@ -212,73 +154,42 @@ export default function Logs() {
|
||||
return { ...selectedLogFromList, ...activeLogQuery.data }
|
||||
}, [selectedLogFromList, activeLogQuery.data, isPreviewOpen])
|
||||
|
||||
const handleLogHover = useCallback(
|
||||
(log: WorkflowLog) => {
|
||||
prefetchLogDetail(queryClient, log.id)
|
||||
},
|
||||
[queryClient]
|
||||
)
|
||||
|
||||
useFolders(workspaceId)
|
||||
|
||||
useEffect(() => {
|
||||
logsRef.current = logs
|
||||
}, [logs])
|
||||
useEffect(() => {
|
||||
selectedLogIndexRef.current = selectedLogIndex
|
||||
}, [selectedLogIndex])
|
||||
useEffect(() => {
|
||||
selectedLogIdRef.current = selectedLogId
|
||||
}, [selectedLogId])
|
||||
useEffect(() => {
|
||||
logsRefetchRef.current = logsQuery.refetch
|
||||
}, [logsQuery.refetch])
|
||||
useEffect(() => {
|
||||
activeLogRefetchRef.current = activeLogQuery.refetch
|
||||
}, [activeLogQuery.refetch])
|
||||
useEffect(() => {
|
||||
logsQueryRef.current = {
|
||||
isFetching: logsQuery.isFetching,
|
||||
hasNextPage: logsQuery.hasNextPage ?? false,
|
||||
fetchNextPage: logsQuery.fetchNextPage,
|
||||
}
|
||||
}, [logsQuery.isFetching, logsQuery.hasNextPage, logsQuery.fetchNextPage])
|
||||
|
||||
useEffect(() => {
|
||||
const timers = refreshTimersRef.current
|
||||
return () => {
|
||||
timers.forEach((id) => window.clearTimeout(id))
|
||||
timers.clear()
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (isInitialized.current) {
|
||||
setStoreSearchQuery(debouncedSearchQuery)
|
||||
}
|
||||
}, [debouncedSearchQuery, setStoreSearchQuery])
|
||||
|
||||
const handleLogClick = useCallback((log: WorkflowLog) => {
|
||||
dispatch({ type: 'TOGGLE_LOG', logId: log.id })
|
||||
}, [])
|
||||
const handleLogClick = useCallback(
|
||||
(log: WorkflowLog) => {
|
||||
if (selectedLogId === log.id && isSidebarOpen) {
|
||||
setIsSidebarOpen(false)
|
||||
setSelectedLogId(null)
|
||||
return
|
||||
}
|
||||
setSelectedLogId(log.id)
|
||||
setIsSidebarOpen(true)
|
||||
},
|
||||
[selectedLogId, isSidebarOpen]
|
||||
)
|
||||
|
||||
const handleNavigateNext = useCallback(() => {
|
||||
const idx = selectedLogIndexRef.current
|
||||
const currentLogs = logsRef.current
|
||||
if (idx < currentLogs.length - 1) {
|
||||
dispatch({ type: 'SELECT_LOG', logId: currentLogs[idx + 1].id })
|
||||
if (selectedLogIndex < logs.length - 1) {
|
||||
setSelectedLogId(logs[selectedLogIndex + 1].id)
|
||||
}
|
||||
}, [])
|
||||
}, [selectedLogIndex, logs])
|
||||
|
||||
const handleNavigatePrev = useCallback(() => {
|
||||
const idx = selectedLogIndexRef.current
|
||||
if (idx > 0) {
|
||||
dispatch({ type: 'SELECT_LOG', logId: logsRef.current[idx - 1].id })
|
||||
if (selectedLogIndex > 0) {
|
||||
setSelectedLogId(logs[selectedLogIndex - 1].id)
|
||||
}
|
||||
}, [])
|
||||
}, [selectedLogIndex, logs])
|
||||
|
||||
const handleCloseSidebar = useCallback(() => {
|
||||
dispatch({ type: 'CLOSE_SIDEBAR' })
|
||||
setIsSidebarOpen(false)
|
||||
setSelectedLogId(null)
|
||||
}, [])
|
||||
|
||||
const handleLogContextMenu = useCallback((e: React.MouseEvent, log: WorkflowLog) => {
|
||||
@@ -349,34 +260,26 @@ export default function Logs() {
|
||||
|
||||
const handleRefresh = useCallback(() => {
|
||||
setIsVisuallyRefreshing(true)
|
||||
const timerId = window.setTimeout(() => {
|
||||
setIsVisuallyRefreshing(false)
|
||||
refreshTimersRef.current.delete(timerId)
|
||||
}, REFRESH_SPINNER_DURATION_MS)
|
||||
refreshTimersRef.current.add(timerId)
|
||||
logsRefetchRef.current()
|
||||
if (selectedLogIdRef.current) {
|
||||
activeLogRefetchRef.current()
|
||||
setTimeout(() => setIsVisuallyRefreshing(false), REFRESH_SPINNER_DURATION_MS)
|
||||
logsQuery.refetch()
|
||||
if (selectedLogId) {
|
||||
activeLogQuery.refetch()
|
||||
}
|
||||
}, [])
|
||||
}, [logsQuery, activeLogQuery, selectedLogId])
|
||||
|
||||
const handleToggleLive = useCallback(() => {
|
||||
setIsLive((prev) => {
|
||||
if (!prev) {
|
||||
setIsVisuallyRefreshing(true)
|
||||
const timerId = window.setTimeout(() => {
|
||||
setIsVisuallyRefreshing(false)
|
||||
refreshTimersRef.current.delete(timerId)
|
||||
}, REFRESH_SPINNER_DURATION_MS)
|
||||
refreshTimersRef.current.add(timerId)
|
||||
logsRefetchRef.current()
|
||||
if (selectedLogIdRef.current) {
|
||||
activeLogRefetchRef.current()
|
||||
}
|
||||
const newIsLive = !isLive
|
||||
setIsLive(newIsLive)
|
||||
|
||||
if (newIsLive) {
|
||||
setIsVisuallyRefreshing(true)
|
||||
setTimeout(() => setIsVisuallyRefreshing(false), REFRESH_SPINNER_DURATION_MS)
|
||||
logsQuery.refetch()
|
||||
if (selectedLogId) {
|
||||
activeLogQuery.refetch()
|
||||
}
|
||||
return !prev
|
||||
})
|
||||
}, [])
|
||||
}
|
||||
}, [isLive, logsQuery, activeLogQuery, selectedLogId])
|
||||
|
||||
const prevIsFetchingRef = useRef(logsQuery.isFetching)
|
||||
useEffect(() => {
|
||||
@@ -386,15 +289,11 @@ export default function Logs() {
|
||||
|
||||
if (isLive && !wasFetching && isFetching) {
|
||||
setIsVisuallyRefreshing(true)
|
||||
const timerId = window.setTimeout(() => {
|
||||
setIsVisuallyRefreshing(false)
|
||||
refreshTimersRef.current.delete(timerId)
|
||||
}, REFRESH_SPINNER_DURATION_MS)
|
||||
refreshTimersRef.current.add(timerId)
|
||||
setTimeout(() => setIsVisuallyRefreshing(false), REFRESH_SPINNER_DURATION_MS)
|
||||
}
|
||||
}, [logsQuery.isFetching, isLive])
|
||||
|
||||
const handleExport = useCallback(async () => {
|
||||
const handleExport = async () => {
|
||||
setIsExporting(true)
|
||||
try {
|
||||
const params = new URLSearchParams()
|
||||
@@ -428,17 +327,7 @@ export default function Logs() {
|
||||
} finally {
|
||||
setIsExporting(false)
|
||||
}
|
||||
}, [
|
||||
workspaceId,
|
||||
level,
|
||||
triggers,
|
||||
workflowIds,
|
||||
folderIds,
|
||||
timeRange,
|
||||
startDate,
|
||||
endDate,
|
||||
debouncedSearchQuery,
|
||||
])
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!isInitialized.current) {
|
||||
@@ -459,59 +348,41 @@ export default function Logs() {
|
||||
}, [initializeFromURL])
|
||||
|
||||
const loadMoreLogs = useCallback(() => {
|
||||
const { isFetching, hasNextPage, fetchNextPage } = logsQueryRef.current
|
||||
if (!isFetching && hasNextPage) {
|
||||
fetchNextPage()
|
||||
if (!logsQuery.isFetching && logsQuery.hasNextPage) {
|
||||
logsQuery.fetchNextPage()
|
||||
}
|
||||
}, [])
|
||||
}, [logsQuery])
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (isSearchOpenRef.current) return
|
||||
const currentLogs = logsRef.current
|
||||
const currentIndex = selectedLogIndexRef.current
|
||||
if (currentLogs.length === 0) return
|
||||
if (logs.length === 0) return
|
||||
|
||||
if (currentIndex === -1 && (e.key === 'ArrowUp' || e.key === 'ArrowDown')) {
|
||||
if (selectedLogIndex === -1 && (e.key === 'ArrowUp' || e.key === 'ArrowDown')) {
|
||||
e.preventDefault()
|
||||
dispatch({ type: 'SELECT_LOG', logId: currentLogs[0].id })
|
||||
setSelectedLogId(logs[0].id)
|
||||
return
|
||||
}
|
||||
|
||||
if (e.key === 'ArrowUp' && !e.metaKey && !e.ctrlKey && currentIndex > 0) {
|
||||
if (e.key === 'ArrowUp' && !e.metaKey && !e.ctrlKey && selectedLogIndex > 0) {
|
||||
e.preventDefault()
|
||||
handleNavigatePrev()
|
||||
}
|
||||
|
||||
if (
|
||||
e.key === 'ArrowDown' &&
|
||||
!e.metaKey &&
|
||||
!e.ctrlKey &&
|
||||
currentIndex < currentLogs.length - 1
|
||||
) {
|
||||
if (e.key === 'ArrowDown' && !e.metaKey && !e.ctrlKey && selectedLogIndex < logs.length - 1) {
|
||||
e.preventDefault()
|
||||
handleNavigateNext()
|
||||
}
|
||||
|
||||
if (e.key === 'Enter' && selectedLogIdRef.current) {
|
||||
if (e.key === 'Enter' && selectedLogId) {
|
||||
e.preventDefault()
|
||||
dispatch({ type: 'TOGGLE_SIDEBAR' })
|
||||
setIsSidebarOpen(!isSidebarOpen)
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [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)
|
||||
}, [])
|
||||
}, [logs, selectedLogIndex, isSidebarOpen, selectedLogId, handleNavigateNext, handleNavigatePrev])
|
||||
|
||||
const isDashboardView = viewMode === 'dashboard'
|
||||
|
||||
@@ -531,10 +402,12 @@ export default function Logs() {
|
||||
onExport={handleExport}
|
||||
canEdit={userPermissions.canEdit}
|
||||
hasLogs={logs.length > 0}
|
||||
onOpenNotificationSettings={handleOpenNotificationSettings}
|
||||
onOpenNotificationSettings={() => setIsNotificationSettingsOpen(true)}
|
||||
searchQuery={searchQuery}
|
||||
onSearchQueryChange={setSearchQuery}
|
||||
onSearchOpenChange={handleSearchOpenChange}
|
||||
onSearchOpenChange={(open: boolean) => {
|
||||
isSearchOpenRef.current = open
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -576,7 +449,7 @@ export default function Logs() {
|
||||
</div>
|
||||
|
||||
{/* 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 ? (
|
||||
<div className='flex h-full items-center justify-center'>
|
||||
<div className='flex items-center gap-[8px] text-[var(--text-secondary)]'>
|
||||
@@ -603,7 +476,6 @@ export default function Logs() {
|
||||
logs={logs}
|
||||
selectedLogId={selectedLogId}
|
||||
onLogClick={handleLogClick}
|
||||
onLogHover={handleLogHover}
|
||||
onLogContextMenu={handleLogContextMenu}
|
||||
selectedRowRef={selectedRowRef}
|
||||
hasNextPage={logsQuery.hasNextPage ?? false}
|
||||
@@ -639,7 +511,7 @@ export default function Logs() {
|
||||
isOpen={contextMenuOpen}
|
||||
position={contextMenuPosition}
|
||||
menuRef={contextMenuRef}
|
||||
onClose={handleCloseContextMenu}
|
||||
onClose={() => setContextMenuOpen(false)}
|
||||
log={contextMenuLog}
|
||||
onCopyExecutionId={handleCopyExecutionId}
|
||||
onOpenWorkflow={handleOpenWorkflow}
|
||||
@@ -656,7 +528,10 @@ export default function Logs() {
|
||||
traceSpans={activeLogQuery.data.executionData?.traceSpans}
|
||||
isModal
|
||||
isOpen={isPreviewOpen}
|
||||
onClose={handleClosePreview}
|
||||
onClose={() => {
|
||||
setIsPreviewOpen(false)
|
||||
setPreviewLogId(null)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -131,10 +131,8 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
|
||||
resumeActiveStream,
|
||||
})
|
||||
|
||||
// Handle scroll management (80px stickiness for copilot)
|
||||
const { scrollAreaRef, scrollToBottom } = useScrollManagement(messages, isSendingMessage, {
|
||||
stickinessThreshold: 40,
|
||||
})
|
||||
// Handle scroll management
|
||||
const { scrollAreaRef, scrollToBottom } = useScrollManagement(messages, isSendingMessage)
|
||||
|
||||
// Handle chat history grouping
|
||||
const { groupedChats, handleHistoryDropdownOpen: handleHistoryDropdownOpenHook } = useChatHistory(
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
|
||||
import { SubBlock } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block'
|
||||
import type { SubBlockConfig as BlockSubBlockConfig } from '@/blocks/types'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
||||
|
||||
interface ToolSubBlockRendererProps {
|
||||
blockId: string
|
||||
@@ -45,43 +44,53 @@ export function ToolSubBlockRenderer({
|
||||
canonicalToggle,
|
||||
}: ToolSubBlockRendererProps) {
|
||||
const syntheticId = `${subBlockId}-tool-${toolIndex}-${effectiveParamId}`
|
||||
const [storeValue, setStoreValue] = useSubBlockValue(blockId, syntheticId)
|
||||
|
||||
const toolParamValue = toolParams?.[effectiveParamId] ?? ''
|
||||
const isObjectType = OBJECT_SUBBLOCK_TYPES.has(subBlock.type)
|
||||
|
||||
const syncedRef = useRef<string | null>(null)
|
||||
const onParamChangeRef = useRef(onParamChange)
|
||||
onParamChangeRef.current = onParamChange
|
||||
const lastPushedToStoreRef = useRef<string | null>(null)
|
||||
const lastPushedToParamsRef = useRef<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const unsub = useSubBlockStore.subscribe((state, prevState) => {
|
||||
const wfId = useWorkflowRegistry.getState().activeWorkflowId
|
||||
if (!wfId) return
|
||||
const newVal = state.workflowValues[wfId]?.[blockId]?.[syntheticId]
|
||||
const oldVal = prevState.workflowValues[wfId]?.[blockId]?.[syntheticId]
|
||||
if (newVal === oldVal) return
|
||||
const stringified =
|
||||
newVal == null ? '' : typeof newVal === 'string' ? newVal : JSON.stringify(newVal)
|
||||
if (stringified === syncedRef.current) return
|
||||
syncedRef.current = stringified
|
||||
onParamChangeRef.current(toolIndex, effectiveParamId, stringified)
|
||||
})
|
||||
return unsub
|
||||
}, [blockId, syntheticId, toolIndex, effectiveParamId])
|
||||
|
||||
useEffect(() => {
|
||||
if (toolParamValue === syncedRef.current) return
|
||||
syncedRef.current = toolParamValue
|
||||
if (isObjectType && toolParamValue) {
|
||||
try {
|
||||
const parsed = JSON.parse(toolParamValue)
|
||||
if (typeof parsed === 'object' && parsed !== null) {
|
||||
useSubBlockStore.getState().setValue(blockId, syntheticId, parsed)
|
||||
return
|
||||
}
|
||||
} catch {}
|
||||
if (!toolParamValue && lastPushedToStoreRef.current === null) {
|
||||
lastPushedToStoreRef.current = toolParamValue
|
||||
lastPushedToParamsRef.current = toolParamValue
|
||||
return
|
||||
}
|
||||
useSubBlockStore.getState().setValue(blockId, syntheticId, toolParamValue)
|
||||
}, [toolParamValue, blockId, syntheticId, isObjectType])
|
||||
if (toolParamValue !== lastPushedToStoreRef.current) {
|
||||
lastPushedToStoreRef.current = toolParamValue
|
||||
lastPushedToParamsRef.current = toolParamValue
|
||||
|
||||
if (isObjectType && typeof toolParamValue === 'string' && toolParamValue) {
|
||||
try {
|
||||
const parsed = JSON.parse(toolParamValue)
|
||||
if (typeof parsed === 'object' && parsed !== null) {
|
||||
setStoreValue(parsed)
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
// Not valid JSON — fall through to set as string
|
||||
}
|
||||
}
|
||||
setStoreValue(toolParamValue)
|
||||
}
|
||||
}, [toolParamValue, setStoreValue, isObjectType])
|
||||
|
||||
useEffect(() => {
|
||||
if (storeValue == null && lastPushedToParamsRef.current === null) return
|
||||
const stringValue =
|
||||
storeValue == null
|
||||
? ''
|
||||
: typeof storeValue === 'string'
|
||||
? storeValue
|
||||
: JSON.stringify(storeValue)
|
||||
if (stringValue !== lastPushedToParamsRef.current) {
|
||||
lastPushedToParamsRef.current = stringValue
|
||||
lastPushedToStoreRef.current = stringValue
|
||||
onParamChange(toolIndex, effectiveParamId, stringValue)
|
||||
}
|
||||
}, [storeValue, toolIndex, effectiveParamId, onParamChange])
|
||||
|
||||
const visibility = subBlock.paramVisibility ?? 'user-or-llm'
|
||||
const isOptionalForUser = visibility !== 'user-only'
|
||||
|
||||
@@ -1741,97 +1741,36 @@ export const ToolInput = memo(function ToolInput({
|
||||
) : null
|
||||
})()}
|
||||
|
||||
{requiresOAuth && oauthConfig && (
|
||||
<div className='relative min-w-0 space-y-[6px]'>
|
||||
<div className='font-medium text-[13px] text-[var(--text-primary)]'>
|
||||
Account <span className='ml-0.5'>*</span>
|
||||
</div>
|
||||
<div className='w-full min-w-0'>
|
||||
<ToolCredentialSelector
|
||||
value={tool.params?.credential || ''}
|
||||
onChange={(value: string) =>
|
||||
handleParamChange(toolIndex, 'credential', value)
|
||||
}
|
||||
provider={oauthConfig.provider as OAuthProvider}
|
||||
requiredScopes={
|
||||
toolBlock?.subBlocks?.find((sb) => sb.id === 'credential')
|
||||
?.requiredScopes ||
|
||||
getCanonicalScopesForProvider(oauthConfig.provider)
|
||||
}
|
||||
serviceId={oauthConfig.provider}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(() => {
|
||||
const renderedElements: React.ReactNode[] = []
|
||||
|
||||
const showOAuth =
|
||||
requiresOAuth && oauthConfig && tool.params?.authMethod !== 'bot_token'
|
||||
|
||||
const renderOAuthAccount = (): React.ReactNode => {
|
||||
if (!showOAuth || !oauthConfig) return null
|
||||
const credentialSubBlock = toolBlock?.subBlocks?.find(
|
||||
(s) => s.type === 'oauth-input'
|
||||
)
|
||||
return (
|
||||
<div key='oauth-account' className='relative min-w-0 space-y-[6px]'>
|
||||
<div className='font-medium text-[13px] text-[var(--text-primary)]'>
|
||||
{credentialSubBlock?.title || 'Account'}{' '}
|
||||
<span className='ml-0.5'>*</span>
|
||||
</div>
|
||||
<div className='w-full min-w-0'>
|
||||
<ToolCredentialSelector
|
||||
value={tool.params?.credential || ''}
|
||||
onChange={(value: string) =>
|
||||
handleParamChange(toolIndex, 'credential', value)
|
||||
}
|
||||
provider={oauthConfig.provider as OAuthProvider}
|
||||
requiredScopes={
|
||||
credentialSubBlock?.requiredScopes ||
|
||||
getCanonicalScopesForProvider(oauthConfig.provider)
|
||||
}
|
||||
serviceId={oauthConfig.provider}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const renderSubBlock = (sb: BlockSubBlockConfig): React.ReactNode => {
|
||||
const effectiveParamId = sb.id
|
||||
const canonicalId = toolCanonicalIndex?.canonicalIdBySubBlockId[sb.id]
|
||||
const canonicalGroup = canonicalId
|
||||
? toolCanonicalIndex?.groupsById[canonicalId]
|
||||
: undefined
|
||||
const hasCanonicalPair = isCanonicalPair(canonicalGroup)
|
||||
const canonicalMode =
|
||||
canonicalGroup && hasCanonicalPair
|
||||
? resolveCanonicalMode(
|
||||
canonicalGroup,
|
||||
{ operation: tool.operation, ...tool.params },
|
||||
toolScopedOverrides
|
||||
)
|
||||
: undefined
|
||||
|
||||
const canonicalToggleProp =
|
||||
hasCanonicalPair && canonicalMode && canonicalId
|
||||
? {
|
||||
mode: canonicalMode,
|
||||
onToggle: () => {
|
||||
const nextMode = canonicalMode === 'advanced' ? 'basic' : 'advanced'
|
||||
collaborativeSetBlockCanonicalMode(
|
||||
blockId,
|
||||
`${tool.type}:${canonicalId}`,
|
||||
nextMode
|
||||
)
|
||||
},
|
||||
}
|
||||
: undefined
|
||||
|
||||
const sbWithTitle = sb.title
|
||||
? sb
|
||||
: { ...sb, title: formatParameterLabel(effectiveParamId) }
|
||||
|
||||
return (
|
||||
<ToolSubBlockRenderer
|
||||
key={sb.id}
|
||||
blockId={blockId}
|
||||
subBlockId={subBlockId}
|
||||
toolIndex={toolIndex}
|
||||
subBlock={sbWithTitle}
|
||||
effectiveParamId={effectiveParamId}
|
||||
toolParams={tool.params}
|
||||
onParamChange={handleParamChange}
|
||||
disabled={disabled}
|
||||
canonicalToggle={canonicalToggleProp}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (useSubBlocks && displaySubBlocks.length > 0) {
|
||||
const allBlockSubBlocks = toolBlock?.subBlocks || []
|
||||
const coveredParamIds = new Set(
|
||||
allBlockSubBlocks.flatMap((sb) => {
|
||||
displaySubBlocks.flatMap((sb) => {
|
||||
const ids = [sb.id]
|
||||
if (sb.canonicalParamId) ids.push(sb.canonicalParamId)
|
||||
const cId = toolCanonicalIndex?.canonicalIdBySubBlockId[sb.id]
|
||||
@@ -1846,45 +1785,57 @@ export const ToolInput = memo(function ToolInput({
|
||||
})
|
||||
)
|
||||
|
||||
type RenderItem =
|
||||
| { kind: 'subblock'; sb: BlockSubBlockConfig }
|
||||
| { kind: 'oauth' }
|
||||
displaySubBlocks.forEach((sb) => {
|
||||
const effectiveParamId = sb.id
|
||||
const canonicalId = toolCanonicalIndex?.canonicalIdBySubBlockId[sb.id]
|
||||
const canonicalGroup = canonicalId
|
||||
? toolCanonicalIndex?.groupsById[canonicalId]
|
||||
: undefined
|
||||
const hasCanonicalPair = isCanonicalPair(canonicalGroup)
|
||||
const canonicalMode =
|
||||
canonicalGroup && hasCanonicalPair
|
||||
? resolveCanonicalMode(
|
||||
canonicalGroup,
|
||||
{ operation: tool.operation, ...tool.params },
|
||||
toolScopedOverrides
|
||||
)
|
||||
: undefined
|
||||
|
||||
const renderOrder: RenderItem[] = displaySubBlocks.map((sb) => ({
|
||||
kind: 'subblock' as const,
|
||||
sb,
|
||||
}))
|
||||
const canonicalToggleProp =
|
||||
hasCanonicalPair && canonicalMode && canonicalId
|
||||
? {
|
||||
mode: canonicalMode,
|
||||
onToggle: () => {
|
||||
const nextMode =
|
||||
canonicalMode === 'advanced' ? 'basic' : 'advanced'
|
||||
collaborativeSetBlockCanonicalMode(
|
||||
blockId,
|
||||
`${tool.type}:${canonicalId}`,
|
||||
nextMode
|
||||
)
|
||||
},
|
||||
}
|
||||
: undefined
|
||||
|
||||
if (showOAuth) {
|
||||
const credentialIdx = allBlockSubBlocks.findIndex(
|
||||
(sb) => sb.type === 'oauth-input'
|
||||
const sbWithTitle = sb.title
|
||||
? sb
|
||||
: { ...sb, title: formatParameterLabel(effectiveParamId) }
|
||||
|
||||
renderedElements.push(
|
||||
<ToolSubBlockRenderer
|
||||
key={sb.id}
|
||||
blockId={blockId}
|
||||
subBlockId={subBlockId}
|
||||
toolIndex={toolIndex}
|
||||
subBlock={sbWithTitle}
|
||||
effectiveParamId={effectiveParamId}
|
||||
toolParams={tool.params}
|
||||
onParamChange={handleParamChange}
|
||||
disabled={disabled}
|
||||
canonicalToggle={canonicalToggleProp}
|
||||
/>
|
||||
)
|
||||
if (credentialIdx >= 0) {
|
||||
const sbPositions = new Map(allBlockSubBlocks.map((sb, i) => [sb.id, i]))
|
||||
const insertAt = renderOrder.findIndex(
|
||||
(item) =>
|
||||
item.kind === 'subblock' &&
|
||||
(sbPositions.get(item.sb.id) ?? Number.POSITIVE_INFINITY) >
|
||||
credentialIdx
|
||||
)
|
||||
if (insertAt === -1) {
|
||||
renderOrder.push({ kind: 'oauth' })
|
||||
} else {
|
||||
renderOrder.splice(insertAt, 0, { kind: 'oauth' })
|
||||
}
|
||||
} else {
|
||||
renderOrder.unshift({ kind: 'oauth' })
|
||||
}
|
||||
}
|
||||
|
||||
for (const item of renderOrder) {
|
||||
if (item.kind === 'oauth') {
|
||||
const el = renderOAuthAccount()
|
||||
if (el) renderedElements.push(el)
|
||||
} else {
|
||||
renderedElements.push(renderSubBlock(item.sb))
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const uncoveredParams = displayParams.filter(
|
||||
(param) =>
|
||||
@@ -1922,11 +1873,6 @@ export const ToolInput = memo(function ToolInput({
|
||||
)
|
||||
}
|
||||
|
||||
{
|
||||
const el = renderOAuthAccount()
|
||||
if (el) renderedElements.push(el)
|
||||
}
|
||||
|
||||
const filteredParams = displayParams.filter((param) =>
|
||||
evaluateParameterCondition(param, tool)
|
||||
)
|
||||
|
||||
@@ -16,7 +16,7 @@ interface UseScrollManagementOptions {
|
||||
/**
|
||||
* Distance from bottom (in pixels) within which auto-scroll stays active
|
||||
* @remarks Lower values = less sticky (user can scroll away easier)
|
||||
* @defaultValue 100
|
||||
* @defaultValue 30
|
||||
*/
|
||||
stickinessThreshold?: number
|
||||
}
|
||||
@@ -41,7 +41,7 @@ export function useScrollManagement(
|
||||
const lastScrollTopRef = useRef(0)
|
||||
|
||||
const scrollBehavior = options?.behavior ?? 'smooth'
|
||||
const stickinessThreshold = options?.stickinessThreshold ?? 100
|
||||
const stickinessThreshold = options?.stickinessThreshold ?? 30
|
||||
|
||||
/** Scrolls the container to the bottom */
|
||||
const scrollToBottom = useCallback(() => {
|
||||
|
||||
@@ -223,12 +223,7 @@ function resolveToolsDisplay(
|
||||
* - Resolves tool names from block registry
|
||||
* - Shows '-' for other selector types that need hydration
|
||||
*/
|
||||
const SubBlockRow = memo(function SubBlockRow({
|
||||
title,
|
||||
value,
|
||||
subBlock,
|
||||
rawValue,
|
||||
}: SubBlockRowProps) {
|
||||
function SubBlockRow({ title, value, subBlock, rawValue }: SubBlockRowProps) {
|
||||
const isPasswordField = subBlock?.password === true
|
||||
const maskedValue = isPasswordField && value && value !== '-' ? '•••' : null
|
||||
|
||||
@@ -260,7 +255,7 @@ const SubBlockRow = memo(function SubBlockRow({
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Preview block component for workflow visualization.
|
||||
|
||||
@@ -122,25 +122,6 @@ export const ScheduleBlock: BlockConfig = {
|
||||
required: true,
|
||||
mode: 'trigger',
|
||||
condition: { field: 'scheduleType', value: 'custom' },
|
||||
wandConfig: {
|
||||
enabled: true,
|
||||
prompt: `You are an expert at writing cron expressions. Generate a valid cron expression based on the user's description.
|
||||
|
||||
Cron format: minute hour day-of-month month day-of-week
|
||||
- minute: 0-59
|
||||
- hour: 0-23
|
||||
- day-of-month: 1-31
|
||||
- month: 1-12
|
||||
- day-of-week: 0-7 (0 and 7 are Sunday)
|
||||
|
||||
Special characters: * (any), , (list), - (range), / (step)
|
||||
|
||||
{context}
|
||||
|
||||
Return ONLY the cron expression, nothing else. No explanation, no backticks, no quotes.`,
|
||||
placeholder: 'Describe your schedule (e.g., "every weekday at 9am")',
|
||||
generationType: 'cron-expression',
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
|
||||
@@ -604,7 +604,7 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
|
||||
case 'send': {
|
||||
baseParams.text = text
|
||||
if (threadTs) {
|
||||
baseParams.threadTs = threadTs
|
||||
baseParams.thread_ts = threadTs
|
||||
}
|
||||
// files is the canonical param from attachmentFiles (basic) or files (advanced)
|
||||
const normalizedFiles = normalizeFileInput(files)
|
||||
|
||||
@@ -40,7 +40,6 @@ export type GenerationType =
|
||||
| 'neo4j-parameters'
|
||||
| 'timestamp'
|
||||
| 'timezone'
|
||||
| 'cron-expression'
|
||||
|
||||
export type SubBlockType =
|
||||
| 'short-input' // Single line input
|
||||
|
||||
@@ -1008,7 +1008,7 @@ const VirtualizedViewerInner = memo(function VirtualizedViewerInner({
|
||||
* Non-virtualized code viewer implementation.
|
||||
* Renders all lines directly without windowing.
|
||||
*/
|
||||
const ViewerInner = memo(function ViewerInner({
|
||||
function ViewerInner({
|
||||
code,
|
||||
showGutter,
|
||||
language,
|
||||
@@ -1181,7 +1181,7 @@ const ViewerInner = memo(function ViewerInner({
|
||||
</Content>
|
||||
</Container>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Readonly code viewer with optional gutter and syntax highlighting.
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
import {
|
||||
keepPreviousData,
|
||||
type QueryClient,
|
||||
useInfiniteQuery,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
} from '@tanstack/react-query'
|
||||
import { keepPreviousData, useInfiniteQuery, useQuery } from '@tanstack/react-query'
|
||||
import { getEndDateFromTimeRange, getStartDateFromTimeRange } from '@/lib/logs/filters'
|
||||
import { parseQuery, queryToApiParams } from '@/lib/logs/query-parser'
|
||||
import type {
|
||||
@@ -152,45 +146,17 @@ export function useLogsList(
|
||||
|
||||
interface UseLogDetailOptions {
|
||||
enabled?: boolean
|
||||
refetchInterval?:
|
||||
| number
|
||||
| false
|
||||
| ((query: { state: { data?: WorkflowLog } }) => number | false | undefined)
|
||||
refetchInterval?: number | false
|
||||
}
|
||||
|
||||
export function useLogDetail(logId: string | undefined, options?: UseLogDetailOptions) {
|
||||
const queryClient = useQueryClient()
|
||||
return useQuery({
|
||||
queryKey: logKeys.detail(logId),
|
||||
queryFn: () => fetchLogDetail(logId as string),
|
||||
enabled: Boolean(logId) && (options?.enabled ?? true),
|
||||
refetchInterval: options?.refetchInterval ?? false,
|
||||
staleTime: 30 * 1000,
|
||||
initialData: () => {
|
||||
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,
|
||||
placeholderData: keepPreviousData,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -583,10 +583,7 @@ export function parseWorkflowJson(
|
||||
loops: workflowData.loops || {},
|
||||
parallels: workflowData.parallels || {},
|
||||
metadata: workflowData.metadata,
|
||||
variables:
|
||||
workflowData.variables && typeof workflowData.variables === 'object'
|
||||
? workflowData.variables
|
||||
: undefined,
|
||||
variables: Array.isArray(workflowData.variables) ? workflowData.variables : undefined,
|
||||
}
|
||||
|
||||
if (regenerateIdsFlag) {
|
||||
|
||||
@@ -33,44 +33,6 @@ interface DuplicateWorkflowResult {
|
||||
subflowsCount: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Remaps old variable IDs to new variable IDs inside block subBlocks.
|
||||
* Specifically targets `variables-input` subblocks whose value is an array
|
||||
* of variable assignments containing a `variableId` field.
|
||||
*/
|
||||
function remapVariableIdsInSubBlocks(
|
||||
subBlocks: Record<string, any>,
|
||||
varIdMap: Map<string, string>
|
||||
): Record<string, any> {
|
||||
const updated: Record<string, any> = {}
|
||||
|
||||
for (const [key, subBlock] of Object.entries(subBlocks)) {
|
||||
if (
|
||||
subBlock &&
|
||||
typeof subBlock === 'object' &&
|
||||
subBlock.type === 'variables-input' &&
|
||||
Array.isArray(subBlock.value)
|
||||
) {
|
||||
updated[key] = {
|
||||
...subBlock,
|
||||
value: subBlock.value.map((assignment: any) => {
|
||||
if (assignment && typeof assignment === 'object' && assignment.variableId) {
|
||||
const newVarId = varIdMap.get(assignment.variableId)
|
||||
if (newVarId) {
|
||||
return { ...assignment, variableId: newVarId }
|
||||
}
|
||||
}
|
||||
return assignment
|
||||
}),
|
||||
}
|
||||
} else {
|
||||
updated[key] = subBlock
|
||||
}
|
||||
}
|
||||
|
||||
return updated
|
||||
}
|
||||
|
||||
/**
|
||||
* Duplicate a workflow with all its blocks, edges, and subflows
|
||||
* This is a shared helper used by both the workflow duplicate API and folder duplicate API
|
||||
@@ -142,9 +104,6 @@ export async function duplicateWorkflow(
|
||||
.where(and(eq(workflow.workspaceId, targetWorkspaceId), folderCondition))
|
||||
const sortOrder = (minResult?.minOrder ?? 1) - 1
|
||||
|
||||
// Mapping from old variable IDs to new variable IDs (populated during variable duplication)
|
||||
const varIdMapping = new Map<string, string>()
|
||||
|
||||
// Create the new workflow first (required for foreign key constraints)
|
||||
await tx.insert(workflow).values({
|
||||
id: newWorkflowId,
|
||||
@@ -164,9 +123,8 @@ export async function duplicateWorkflow(
|
||||
variables: (() => {
|
||||
const sourceVars = (source.variables as Record<string, Variable>) || {}
|
||||
const remapped: Record<string, Variable> = {}
|
||||
for (const [oldVarId, variable] of Object.entries(sourceVars) as [string, Variable][]) {
|
||||
for (const [, variable] of Object.entries(sourceVars) as [string, Variable][]) {
|
||||
const newVarId = crypto.randomUUID()
|
||||
varIdMapping.set(oldVarId, newVarId)
|
||||
remapped[newVarId] = {
|
||||
...variable,
|
||||
id: newVarId,
|
||||
@@ -223,20 +181,6 @@ export async function duplicateWorkflow(
|
||||
}
|
||||
}
|
||||
|
||||
// Update variable references in subBlocks (e.g. variables-input assignments)
|
||||
let updatedSubBlocks = block.subBlocks
|
||||
if (
|
||||
varIdMapping.size > 0 &&
|
||||
block.subBlocks &&
|
||||
typeof block.subBlocks === 'object' &&
|
||||
!Array.isArray(block.subBlocks)
|
||||
) {
|
||||
updatedSubBlocks = remapVariableIdsInSubBlocks(
|
||||
block.subBlocks as Record<string, any>,
|
||||
varIdMapping
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
...block,
|
||||
id: newBlockId,
|
||||
@@ -244,7 +188,6 @@ export async function duplicateWorkflow(
|
||||
parentId: newParentId,
|
||||
extent: newExtent,
|
||||
data: updatedData,
|
||||
subBlocks: updatedSubBlocks,
|
||||
locked: false, // Duplicated blocks should always be unlocked
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
|
||||
@@ -57,15 +57,12 @@ export interface ExportWorkflowState {
|
||||
sortOrder?: number
|
||||
exportedAt?: string
|
||||
}
|
||||
variables?: Record<
|
||||
string,
|
||||
{
|
||||
id: string
|
||||
name: string
|
||||
type: 'string' | 'number' | 'boolean' | 'object' | 'array' | 'plain'
|
||||
value: unknown
|
||||
}
|
||||
>
|
||||
variables?: Array<{
|
||||
id: string
|
||||
name: string
|
||||
type: 'string' | 'number' | 'boolean' | 'object' | 'array' | 'plain'
|
||||
value: unknown
|
||||
}>
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -827,10 +827,11 @@ export function formatParameterLabel(paramId: string): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* SubBlock IDs that control tool routing, not user-facing parameters.
|
||||
* Excluded from tool-input rendering unless they have an explicit paramVisibility set.
|
||||
* SubBlock IDs that are "structural" — they control tool routing or auth,
|
||||
* not user-facing parameters. These are excluded from tool-input rendering
|
||||
* unless they have an explicit paramVisibility set.
|
||||
*/
|
||||
const STRUCTURAL_SUBBLOCK_IDS = new Set(['operation'])
|
||||
const STRUCTURAL_SUBBLOCK_IDS = new Set(['operation', 'authMethod', 'destinationType'])
|
||||
|
||||
/**
|
||||
* SubBlock types that represent auth/credential inputs handled separately
|
||||
@@ -954,8 +955,12 @@ export function getSubBlocksForToolInput(
|
||||
} else if (sb.id in toolParamVisibility) {
|
||||
visibility = toolParamVisibility[sb.id]
|
||||
} else if (sb.canonicalParamId) {
|
||||
// SubBlock has a canonicalParamId that doesn't directly match a tool param.
|
||||
// This means the block's params() function transforms it before sending to the tool
|
||||
// (e.g. listFolderId → folderId). These are user-facing inputs, default to user-or-llm.
|
||||
visibility = 'user-or-llm'
|
||||
} else {
|
||||
// SubBlock has no corresponding tool param — skip it
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,7 +57,7 @@ export const slackMessageTool: ToolConfig<SlackMessageParams, SlackMessageRespon
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Message text to send (supports Slack mrkdwn formatting)',
|
||||
},
|
||||
threadTs: {
|
||||
thread_ts: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
@@ -84,7 +84,7 @@ export const slackMessageTool: ToolConfig<SlackMessageParams, SlackMessageRespon
|
||||
channel: isDM ? undefined : params.channel,
|
||||
userId: isDM ? params.dmUserId : params.userId,
|
||||
text: params.text,
|
||||
thread_ts: params.threadTs || undefined,
|
||||
thread_ts: params.thread_ts || undefined,
|
||||
files: params.files || null,
|
||||
}
|
||||
},
|
||||
|
||||
@@ -516,7 +516,7 @@ export interface SlackMessageParams extends SlackBaseParams {
|
||||
dmUserId?: string
|
||||
userId?: string
|
||||
text: string
|
||||
threadTs?: string
|
||||
thread_ts?: string
|
||||
files?: UserFile[]
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user