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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 40 additions & 3 deletions apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,6 @@ import {
TERMINAL_SESSION_RESOURCE_ID,
} from '@/lib/copilot/resources/types'
import { executeBrowserToolOnClient } from '@/lib/copilot/tools/client/browser-tool-execution'
import { executeLocalFilesystemTool } from '@/lib/copilot/tools/client/local-filesystem'
import {
bindRunToolToExecution,
cancelRunToolExecution,
Expand Down Expand Up @@ -2009,11 +2008,49 @@ export function useChat(
return
}
handledClientLocalFilesystemToolIdsRef.current.add(toolCallId)
executeLocalFilesystemTool(toolCallId, toolName, toolArgs, {
const options = {
workspaceId,
chatId: chatIdRef.current ?? selectedChatIdRef.current,
signal: abortControllerRef.current?.signal,
})
}
/**
* Dynamic on purpose: the local-filesystem executor only runs for desktop-local
* VFS tool calls, and a static import kept it in the shared chat chunk on every
* surface that mounts the composer. The guard, the dedupe add, and the option
* capture above stay synchronous, so re-entrancy behaviour is unchanged. If the
* chunk fails to load (deploy skew), the server-side tool call must still settle:
* report an error completion rather than leaving it hanging with the dedupe ref
* already marked handled.
*/
import('@/lib/copilot/tools/client/local-filesystem').then(
(m) => m.executeLocalFilesystemTool(toolCallId, toolName, toolArgs, options),
async (error) => {
logger.error('Failed to load local filesystem tool executor', { error })
/**
* The recovery itself can reject (the helper chunks or the completion POST can
* fail for the same reason the executor chunk did). Contain it: an unhandled
* rejection here would settle nothing and surface as a console error, exactly
* like the executor's own report-failure path, which also degrades to a log.
*/
try {
const [{ reportClientToolCompletion }, { ASYNC_TOOL_CONFIRMATION_STATUS }] =
await Promise.all([
import('@/lib/copilot/tools/client/completion'),
import('@/lib/copilot/async-runs/lifecycle'),
])
await reportClientToolCompletion(
toolCallId,
ASYNC_TOOL_CONFIRMATION_STATUS.error,
'Local filesystem tool failed to load'
)
} catch (reportError) {
logger.error('Failed to report local filesystem tool load failure', {
toolCallId,
error: reportError,
})
}
}
)
Comment thread
waleedlatif1 marked this conversation as resolved.
},
[workspaceId]
)
Expand Down
8 changes: 7 additions & 1 deletion apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,13 @@ import {
useFolderAncestors,
} from '@/app/workspace/[workspaceId]/components/folders'
import { DocumentsEmptyState } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state'
import { DocumentTagsModal } from '@/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components'
/**
* Deep import on purpose: the `[documentId]/components` barrel also exports `ChunkEditor`,
* which needs exact token counts and therefore `js-tiktoken` (~2.5 MB gzip of BPE rank
* tables). Importing the modal through the barrel shipped the tokenizer to the document
* LIST route, which never edits chunks.
*/
import { DocumentTagsModal } from '@/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components/document-tags-modal'
import {
ActionBar,
AddConnectorModal,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
export { Dashboard } from './dashboard'
export { LogDetails, LogDetailsContent } from './log-details'
export { ExecutionSnapshot } from './log-details/components/execution-snapshot'
export { FileCards } from './log-details/components/file-download'
export { TraceView } from './log-details/components/trace-view'
export { LogRowContextMenu } from './log-row-context-menu'

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
/**
* @vitest-environment jsdom
*/
import { act, type ReactNode } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'

const { mockToastError } = vi.hoisted(() => ({
mockToastError: vi.fn(),
}))

vi.mock('@sim/emcn', () => ({
Loader: () => <span aria-hidden='true' />,
Modal: ({
children,
open,
onOpenChange,
}: {
children: ReactNode
open: boolean
onOpenChange: (open: boolean) => void
}) =>
open ? (
<div>
{children}
<button type='button' onClick={() => onOpenChange(false)}>
Close
</button>
</div>
) : null,
ModalBody: ({ children }: { children: ReactNode }) => <div>{children}</div>,
ModalContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
ModalDescription: ({ children }: { children: ReactNode }) => <p>{children}</p>,
ModalHeader: ({ children }: { children: ReactNode }) => <h2>{children}</h2>,
toast: { error: mockToastError },
}))

import {
SnapshotBoundary,
SnapshotModalFallback,
} from '@/app/workspace/[workspaceId]/logs/components/log-details/components/execution-snapshot/snapshot-boundary'

const LOAD_ERROR = new Error('snapshot chunk failed')

function ThrowingSnapshot() {
throw LOAD_ERROR
}

describe('SnapshotBoundary', () => {
let container: HTMLDivElement
let root: Root

beforeEach(() => {
vi.clearAllMocks()
container = document.createElement('div')
document.body.appendChild(container)
act(() => {
root = createRoot(container)
})
})

afterEach(() => {
act(() => root.unmount())
container.remove()
})

it('contains a background pre-warm failure without notifying or closing', () => {
const onLoadError = vi.fn()

act(() => {
root.render(
<SnapshotBoundary isOpen={false} onLoadError={onLoadError}>
<ThrowingSnapshot />
</SnapshotBoundary>
)
})

expect(container.childNodes).toHaveLength(0)
expect(mockToastError).not.toHaveBeenCalled()
expect(onLoadError).not.toHaveBeenCalled()
})

it('notifies and closes an explicitly opened snapshot after a load failure', () => {
const onLoadError = vi.fn()

act(() => {
root.render(
<SnapshotBoundary isOpen onLoadError={onLoadError}>
<ThrowingSnapshot />
</SnapshotBoundary>
)
})

expect(container.childNodes).toHaveLength(0)
expect(mockToastError).toHaveBeenCalledWith(
'Could not load the workflow snapshot. Refresh and try again.'
)
expect(onLoadError).toHaveBeenCalledOnce()
})

it('keeps the modal shell visible while the snapshot bundle loads', () => {
const onClose = vi.fn()

act(() => {
root.render(<SnapshotModalFallback isOpen onClose={onClose} />)
})

expect(container.textContent).toContain('Workflow State')
expect(container.textContent).toContain('Loading run snapshot…')

const closeButton = container.querySelector('button')
expect(closeButton).not.toBeNull()
act(() => closeButton?.click())
expect(onClose).toHaveBeenCalledOnce()
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
'use client'

import { Component, type ErrorInfo, type ReactNode } from 'react'
import {
Loader,
Modal,
ModalBody,
ModalContent,
ModalDescription,
ModalHeader,
toast,
} from '@sim/emcn'
import { createLogger } from '@sim/logger'

const logger = createLogger('ExecutionSnapshotBoundary')

interface SnapshotBoundaryProps {
children: ReactNode
isOpen: boolean
onLoadError: () => void
}

interface SnapshotBoundaryState {
hasError: boolean
}

const reportedErrors = new WeakSet<Error>()

interface SnapshotModalFallbackProps {
isOpen: boolean
onClose: () => void
}

export function SnapshotModalFallback({ isOpen, onClose }: SnapshotModalFallbackProps) {
return (
<Modal
open={isOpen}
onOpenChange={(open) => {
if (!open) onClose()
}}
>
<ModalContent size='full' className='flex h-[90vh] flex-col'>
<ModalHeader>Workflow State</ModalHeader>
<ModalBody className='!p-0 flex min-h-0 flex-1 items-center justify-center overflow-hidden'>
<ModalDescription className='sr-only'>
Loading the workflow state snapshot for this execution
</ModalDescription>
<div className='flex items-center gap-2 text-[var(--text-secondary)]'>
<Loader className='size-[16px]' animate />
<span className='text-small'>Loading run snapshot…</span>
</div>
</ModalBody>
</ModalContent>
</Modal>
)
}

/**
* Error boundary for the lazily loaded execution snapshot.
*
* `Suspense` handles the pending state of the lazy import but not its
* rejection — a failed chunk load (deploy skew, offline) would otherwise
* unwind to the route-level boundary and replace the whole logs page with an
* error view over an optional modal. Mirrors `PreviewErrorBoundary` in the
* file viewer: contain, log, degrade. The snapshot is an overlay, so the
* degraded state renders nothing. Closed snapshots are mounted to pre-warm
* their chunk and data, so a background failure is logged without interrupting
* the user. If the user actually opens a failed snapshot, the caller closes
* the modal state and a toast explains why it did not open.
*
* Callers must remount this boundary when the snapshot identity changes and
* when a pre-warmed snapshot is explicitly opened. Error boundaries reset only
* via remount; without both transitions, a failed pre-warm would leave the
* later open action stuck in the already-tripped state.
*/
export class SnapshotBoundary extends Component<SnapshotBoundaryProps, SnapshotBoundaryState> {
public state: SnapshotBoundaryState = { hasError: false }

public static getDerivedStateFromError(): SnapshotBoundaryState {
return { hasError: true }
}

public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
if (!reportedErrors.has(error)) {
reportedErrors.add(error)
logger.error('Execution snapshot failed to load', {
error: error.message,
componentStack: errorInfo.componentStack,
})
}

if (this.props.isOpen) {
toast.error('Could not load the workflow snapshot. Refresh and try again.')
this.props.onLoadError()
}
}
Comment thread
waleedlatif1 marked this conversation as resolved.

public render() {
return this.state.hasError ? null : this.props.children
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,16 @@
'use client'

import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import {
lazy,
memo,
Suspense,
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from 'react'
import {
Badge,
Button,
Expand Down Expand Up @@ -48,11 +58,17 @@ import { filterHiddenOutputKeys } from '@/lib/logs/execution/trace-spans/trace-s
import type { TraceSpan } from '@/lib/logs/types'
import { sendMothershipMessage } from '@/lib/mothership/events'
import { DELETED_WORKFLOW_LABEL } from '@/lib/workflows/workflow-labels'
/**
* Deep imports on purpose: importing these back through the parent `logs/components`
* barrel forms a parent->child cycle that would keep the barrel edge to the snapshot
* alive and silently defeat the ExecutionSnapshot lazy split below.
*/
import {
ExecutionSnapshot,
FileCards,
TraceView,
} from '@/app/workspace/[workspaceId]/logs/components'
SnapshotBoundary,
SnapshotModalFallback,
} from '@/app/workspace/[workspaceId]/logs/components/log-details/components/execution-snapshot/snapshot-boundary'
import { FileCards } from '@/app/workspace/[workspaceId]/logs/components/log-details/components/file-download'
import { TraceView } from '@/app/workspace/[workspaceId]/logs/components/log-details/components/trace-view'
import { useLogDetailsResize } from '@/app/workspace/[workspaceId]/logs/hooks'
import {
logDetailsTabParam,
Expand All @@ -73,6 +89,17 @@ import { useLogDetailsUIStore } from '@/stores/logs/store'
import { MAX_LOG_DETAILS_WIDTH_RATIO, MIN_LOG_DETAILS_WIDTH } from '@/stores/logs/utils'
import type { ChatContext } from '@/stores/panel'

/**
* Lazy per the code-splitting rule in `sim-imports.md`: the snapshot renders the workflow
* preview canvas, whose graph is ~7.6 MB of source. Rendering is gated on the detail's
* open state, so the chunk is fetched on first use, never during SSR or hydration.
*/
const ExecutionSnapshot = lazy(() =>
import(
'@/app/workspace/[workspaceId]/logs/components/log-details/components/execution-snapshot/execution-snapshot'
).then((m) => ({ default: m.ExecutionSnapshot }))
)

/**
* Renders an already-apportioned integer credit value. `dollars` is only used
* to distinguish a genuine zero ("0 credits") from a sub-credit charge that
Expand Down Expand Up @@ -679,13 +706,28 @@ export function LogDetailsContent({ log, onActiveTabChange }: LogDetailsContentP

{/* Frozen Canvas Modal */}
{log.executionId && (
<ExecutionSnapshot
executionId={log.executionId}
traceSpans={traceSpans}
isModal
<SnapshotBoundary
key={`${log.executionId}:${isExecutionSnapshotOpen ? 'open' : 'closed'}`}
isOpen={isExecutionSnapshotOpen}
onClose={() => setIsExecutionSnapshotOpen(false)}
/>
onLoadError={() => setIsExecutionSnapshotOpen(false)}
>
<Suspense
fallback={
<SnapshotModalFallback
isOpen={isExecutionSnapshotOpen}
onClose={() => setIsExecutionSnapshotOpen(false)}
/>
}
>
<ExecutionSnapshot
executionId={log.executionId}
traceSpans={traceSpans}
isModal
isOpen={isExecutionSnapshotOpen}
onClose={() => setIsExecutionSnapshotOpen(false)}
/>
</Suspense>
</SnapshotBoundary>
Comment thread
waleedlatif1 marked this conversation as resolved.
)}
Comment thread
waleedlatif1 marked this conversation as resolved.
Comment thread
waleedlatif1 marked this conversation as resolved.
</>
)
Expand Down
Loading
Loading