Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
feat(logs): add copy link and deep link support for log entries (#3855)
* feat(logs): add copy link and deep link support for log entries

* fix(logs): fetch next page when deep linked log is beyond initial page

* fix(logs): move Link icon to emcn and handle clipboard rejections

* fix(logs): track isFetching reactively and drop empty-list early-return

- Remove  guard that prevented clearing the
  pending ref when filters return no results
- Use  directly in the condition and add it to
  the effect deps so the effect re-triggers after a background refetch

* fix(logs): guard deep-link ref clear until query has succeeded

Only clear pendingExecutionIdRef when the query status is 'success',
preventing premature clearing before the initial fetch completes.
On mount, the query is disabled (isInitialized.current starts false),
so hasNextPage is false but no data has loaded yet — the ref was being
cleared in the same effect pass that set it.

* fix(logs): guard fetchNextPage call until query has succeeded

Add logsQuery.status === 'success' to the fetchNextPage branch so it
mirrors the clear branch. On mount the query is disabled (isFetching is
false, status is pending), causing the effect to call fetchNextPage()
before the query is initialized — now both branches require success.
  • Loading branch information
waleedlatif1 authored Mar 31, 2026
commit e9c94fa46275f7a328e792d933d2816c1ccafa9b
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/emcn'
import { Copy, Eye, ListFilter, SquareArrowUpRight, X } from '@/components/emcn/icons'
import { Copy, Eye, Link, ListFilter, SquareArrowUpRight, X } from '@/components/emcn/icons'
import type { WorkflowLog } from '@/stores/logs/filters/types'

interface LogRowContextMenuProps {
Expand All @@ -17,6 +17,7 @@ interface LogRowContextMenuProps {
onClose: () => void
log: WorkflowLog | null
onCopyExecutionId: () => void
onCopyLink: () => void
onOpenWorkflow: () => void
onOpenPreview: () => void
onToggleWorkflowFilter: () => void
Expand All @@ -35,6 +36,7 @@ export const LogRowContextMenu = memo(function LogRowContextMenu({
onClose,
log,
onCopyExecutionId,
onCopyLink,
onOpenWorkflow,
onOpenPreview,
onToggleWorkflowFilter,
Expand Down Expand Up @@ -71,6 +73,10 @@ export const LogRowContextMenu = memo(function LogRowContextMenu({
<Copy />
Copy Execution ID
</DropdownMenuItem>
<DropdownMenuItem disabled={!hasExecutionId} onSelect={onCopyLink}>
<Link />
Copy Link
</DropdownMenuItem>

<DropdownMenuSeparator />
<DropdownMenuItem disabled={!hasWorkflow} onSelect={onOpenWorkflow}>
Expand Down
64 changes: 37 additions & 27 deletions apps/sim/app/workspace/[workspaceId]/logs/logs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -266,16 +266,17 @@ export default function Logs() {
isSidebarOpen: false,
})
const isInitialized = useRef<boolean>(false)
const pendingExecutionIdRef = useRef<string | null>(null)

const [searchQuery, setSearchQuery] = useState('')
const debouncedSearchQuery = useDebounce(searchQuery, 300)

useEffect(() => {
const urlSearch = new URLSearchParams(window.location.search).get('search') || ''
if (urlSearch && urlSearch !== searchQuery) {
setSearchQuery(urlSearch)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
const params = new URLSearchParams(window.location.search)
const urlSearch = params.get('search')
if (urlSearch) setSearchQuery(urlSearch)
const urlExecutionId = params.get('executionId')
if (urlExecutionId) pendingExecutionIdRef.current = urlExecutionId
}, [])

const isLive = true
Expand All @@ -298,7 +299,6 @@ export default function Logs() {
const [contextMenuOpen, setContextMenuOpen] = useState(false)
const [contextMenuPosition, setContextMenuPosition] = useState({ x: 0, y: 0 })
const [contextMenuLog, setContextMenuLog] = useState<WorkflowLog | null>(null)
const contextMenuRef = useRef<HTMLDivElement>(null)

const [isPreviewOpen, setIsPreviewOpen] = useState(false)
const [previewLogId, setPreviewLogId] = useState<string | null>(null)
Expand Down Expand Up @@ -417,28 +417,30 @@ export default function Logs() {

useFolders(workspaceId)

logsRef.current = sortedLogs
selectedLogIndexRef.current = selectedLogIndex
selectedLogIdRef.current = selectedLogId
logsRefetchRef.current = logsQuery.refetch
activeLogRefetchRef.current = activeLogQuery.refetch
logsQueryRef.current = {
isFetching: logsQuery.isFetching,
hasNextPage: logsQuery.hasNextPage ?? false,
fetchNextPage: logsQuery.fetchNextPage,
}

useEffect(() => {
logsRef.current = sortedLogs
}, [sortedLogs])
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,
if (!pendingExecutionIdRef.current) return
const targetExecutionId = pendingExecutionIdRef.current
const found = sortedLogs.find((l) => l.executionId === targetExecutionId)
if (found) {
pendingExecutionIdRef.current = null
dispatch({ type: 'TOGGLE_LOG', logId: found.id })
} else if (!logsQuery.hasNextPage && logsQuery.status === 'success') {
pendingExecutionIdRef.current = null
} else if (!logsQuery.isFetching && logsQuery.status === 'success') {
logsQueryRef.current.fetchNextPage()
}
}, [logsQuery.isFetching, logsQuery.hasNextPage, logsQuery.fetchNextPage])
}, [sortedLogs, logsQuery.hasNextPage, logsQuery.isFetching, logsQuery.status])

useEffect(() => {
const timers = refreshTimersRef.current
Expand Down Expand Up @@ -490,10 +492,17 @@ export default function Logs() {

const handleCopyExecutionId = useCallback(() => {
if (contextMenuLog?.executionId) {
navigator.clipboard.writeText(contextMenuLog.executionId)
navigator.clipboard.writeText(contextMenuLog.executionId).catch(() => {})
}
}, [contextMenuLog])

const handleCopyLink = useCallback(() => {
if (contextMenuLog?.executionId) {
const url = `${window.location.origin}/workspace/${workspaceId}/logs?executionId=${contextMenuLog.executionId}`
navigator.clipboard.writeText(url).catch(() => {})
}
}, [contextMenuLog, workspaceId])

const handleOpenWorkflow = useCallback(() => {
const wfId = contextMenuLog?.workflow?.id || contextMenuLog?.workflowId
if (wfId) {
Expand Down Expand Up @@ -1165,6 +1174,7 @@ export default function Logs() {
onClose={handleCloseContextMenu}
log={contextMenuLog}
onCopyExecutionId={handleCopyExecutionId}
onCopyLink={handleCopyLink}
onOpenWorkflow={handleOpenWorkflow}
onOpenPreview={handleOpenPreview}
onToggleWorkflowFilter={handleToggleWorkflowFilter}
Expand Down
1 change: 1 addition & 0 deletions apps/sim/components/emcn/icons/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ export { Key } from './key'
export { KeySquare } from './key-square'
export { Layout } from './layout'
export { Library } from './library'
export { Link } from './link'
export { ListFilter } from './list-filter'
export { Loader } from './loader'
export { Lock } from './lock'
Expand Down
26 changes: 26 additions & 0 deletions apps/sim/components/emcn/icons/link.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import type { SVGProps } from 'react'

/**
* Link icon component
* @param props - SVG properties including className, size, etc.
*/
export function Link(props: SVGProps<SVGSVGElement>) {
return (
<svg
xmlns='http://www.w3.org/2000/svg'
width='24'
height='24'
viewBox='0 0 24 24'
fill='none'
stroke='currentColor'
strokeWidth='2'
strokeLinecap='round'
strokeLinejoin='round'
aria-hidden='true'
{...props}
>
<path d='M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71' />
<path d='M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71' />
</svg>
)
}
Loading