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
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ const EVENT_COLUMN_WIDTH_CLASS = {

type EventColumnWidth = keyof typeof EVENT_COLUMN_WIDTH_CLASS

const ROW_CLASS = 'flex w-full items-center gap-3 px-3 py-2 text-left'

function ActivityLogRow({
entry,
eventColumn,
Expand All @@ -42,6 +44,39 @@ function ActivityLogRow({
const [expanded, setExpanded] = useState(false)
const expandable = entry.details != null

const cells = (
<>
<span className='w-[160px] flex-shrink-0 text-[var(--text-secondary)] text-small'>
{entry.timestamp}
</span>
<span className={cn(EVENT_COLUMN_WIDTH_CLASS[eventColumn], 'flex-shrink-0')}>
{entry.event}
</span>
<span className='min-w-0 flex-1 text-[var(--text-primary)] text-small'>
{typeof entry.description === 'string' ? (
<FloatingOverflowText label={entry.description} className='block truncate' />
) : (
entry.description
)}
</span>
<span className='flex w-[160px] flex-shrink-0 items-center justify-end gap-1.5 text-[var(--text-secondary)] text-small'>
{typeof entry.actor === 'string' ? (
<FloatingOverflowText label={entry.actor} className='block min-w-0 truncate' />
) : (
<span className='min-w-0 truncate'>{entry.actor}</span>
)}
{expandable && (
<ChevronDown
className={cn(
'size-[14px] flex-shrink-0 text-[var(--text-muted)] transition-transform duration-200',
expanded && 'rotate-180'
)}
/>
)}
</span>
</>
)

return (
<div
className={cn(
Expand All @@ -50,42 +85,21 @@ function ActivityLogRow({
expanded && 'bg-[var(--surface-2)]'
)}
>
<button
type='button'
aria-expanded={expandable ? expanded : undefined}
className='flex w-full items-center gap-3 px-3 py-2 text-left'
onClick={() => expandable && setExpanded(!expanded)}
disabled={!expandable}
>
<span className='w-[160px] flex-shrink-0 text-[var(--text-secondary)] text-small'>
{entry.timestamp}
</span>
<span className={cn(EVENT_COLUMN_WIDTH_CLASS[eventColumn], 'flex-shrink-0')}>
{entry.event}
</span>
<span className='min-w-0 flex-1 text-[var(--text-primary)] text-small'>
{typeof entry.description === 'string' ? (
<FloatingOverflowText label={entry.description} className='block truncate' />
) : (
entry.description
)}
</span>
<span className='flex w-[160px] flex-shrink-0 items-center justify-end gap-1.5 text-[var(--text-secondary)] text-small'>
{typeof entry.actor === 'string' ? (
<FloatingOverflowText label={entry.actor} className='block min-w-0 truncate' />
) : (
<span className='min-w-0 truncate'>{entry.actor}</span>
)}
{expandable && (
<ChevronDown
className={cn(
'size-[14px] flex-shrink-0 text-[var(--text-muted)] transition-transform duration-200',
expanded && 'rotate-180'
)}
/>
)}
</span>
</button>
{expandable ? (
<button
type='button'
aria-expanded={expanded}
className={ROW_CLASS}
onClick={() => setExpanded(!expanded)}
>
{cells}
</button>
) : (
// A row with nothing to expand is inert content, not a disabled control:
// browsers suppress pointer events over a disabled button AND its
// descendants, which would swallow the hover tooltips inside the cells.
<div className={ROW_CLASS}>{cells}</div>
)}
{expandable && expanded && (
<div className='px-3 pb-2'>
<div className='flex flex-col gap-1.5 rounded-lg border border-[var(--border-1)] bg-[var(--surface-3)] p-3 text-small'>
Expand Down
6 changes: 6 additions & 0 deletions apps/sim/background/fork-content-copy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,15 @@ import {
* non-transactional best-effort (per-row inserts with fresh ids), so a blind
* re-run would duplicate rows; a partial failure simply leaves the fork's content
* incomplete (the workflows themselves committed synchronously).
*
* Runs on `large-2x` (8 vCPU / 16 GB), matching `knowledge-connector-sync`: the
* copy materializes table rows, KB chunks and their embedding vectors, and file
* blobs in memory, and `maxAttempts: 1` means an OOM is unrecoverable — a
* half-copied fork with no retry.
*/
export const forkContentCopyTask = task({
id: 'fork-content-copy',
machine: 'large-2x',
retry: { maxAttempts: 1 },
queue: {
name: 'fork-content-copy',
Expand Down
2 changes: 1 addition & 1 deletion apps/sim/background/knowledge-connector-sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ export async function executeConnectorSyncJob(payload: unknown) {
export const knowledgeConnectorSync = task({
id: 'knowledge-connector-sync',
maxDuration: 1800,
machine: 'large-1x',
machine: 'large-2x',
retry: {
maxAttempts: 3,
factor: 2,
Expand Down
2 changes: 1 addition & 1 deletion apps/sim/background/schedule-execution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1457,7 +1457,7 @@ export async function executeJobInline(payload: JobExecutionPayload) {

export const scheduleExecutionTaskOptions = {
id: 'schedule-execution',
machine: 'medium-1x' as const,
machine: 'medium-2x' as const,
retry: {
maxAttempts: 1,
},
Expand Down
2 changes: 1 addition & 1 deletion apps/sim/background/workflow-execution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -230,7 +230,7 @@ export async function executeWorkflowJob(payload: WorkflowExecutionPayload) {

export const workflowExecutionTask = task({
id: 'workflow-execution',
machine: 'medium-1x',
machine: 'medium-2x',
queue: {
concurrencyLimit: WORKFLOW_EXECUTION_CONCURRENCY_LIMIT,
},
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
/**
* @vitest-environment jsdom
*/
import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { BackgroundWorkItem } from '@/lib/api/contracts/workspace-fork'

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

vi.mock('@/ee/workspace-forking/hooks/background-work', () => ({
useWorkspaceBackgroundWork: mockUseWorkspaceBackgroundWork,
}))

vi.mock('@/app/workspace/[workspaceId]/settings/components/settings-empty-state', () => ({
SettingsEmptyState: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}))

vi.mock('@/app/workspace/[workspaceId]/components', () => ({
FloatingOverflowText: ({ label, className }: { label: string; className?: string }) => (
<span className={className}>{label}</span>
),
}))

import { ForkActivityPanel } from '@/ee/workspace-forking/components/fork-activity-panel/fork-activity-panel'

const WORKSPACE_ID = 'ws-1'
const PARTNER_ID = 'ws-parent'
const WORKSPACE_NAMES = new Map([[PARTNER_ID, 'another workspace']])

function makeJob(overrides: Partial<BackgroundWorkItem> = {}): BackgroundWorkItem {
return {
id: 'job-1',
workspaceId: PARTNER_ID,
workflowId: null,
kind: 'fork_content_copy',
status: 'completed',
message: null,
error: null,
metadata: { childWorkspaceId: WORKSPACE_ID, actorName: 'Brandon Tarr' },
startedAt: '2026-07-28T15:58:00.000Z',
completedAt: null,
...overrides,
} as BackgroundWorkItem
}

let container: HTMLDivElement
let root: Root

function renderJobs(jobs: BackgroundWorkItem[]) {
mockUseWorkspaceBackgroundWork.mockReturnValue({
data: { pages: [{ items: jobs, nextCursor: null }] },
isPending: false,
isError: false,
hasNextPage: false,
fetchNextPage: vi.fn(),
isFetchingNextPage: false,
})
act(() => {
root.render(<ForkActivityPanel workspaceId={WORKSPACE_ID} workspaceNames={WORKSPACE_NAMES} />)
})
}

/** The Event-column badge for the single rendered row (`Badge`'s base class). */
function badgeElement(): HTMLElement {
const badge = container.querySelector<HTMLElement>('.inline-flex')
if (!badge) throw new Error('badge not found')
return badge
}

/** Hover the badge the way React sees it — `onPointerEnter` is delegated from `pointerover`. */
function hover(element: HTMLElement) {
act(() => {
element.dispatchEvent(
new MouseEvent('pointerover', { bubbles: true, clientX: 100, clientY: 100 })
)
})
}

function tooltipText(): string | null {
return document.body.querySelector('[role="tooltip"]')?.textContent ?? null
}

describe('ForkActivityPanel event badge tooltip', () => {
beforeEach(() => {
vi.clearAllMocks()
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
})

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

it('shows the failure reason when hovering a failed (red) badge', () => {
renderJobs([makeJob({ status: 'failed', error: 'Storage quota exceeded while copying files' })])

expect(tooltipText()).toBeNull()
hover(badgeElement())
expect(tooltipText()).toBe('Storage quota exceeded while copying files')
})

it('falls back to a generic label when a failed row carries no error text', () => {
renderJobs([makeJob({ status: 'failed', error: null })])

hover(badgeElement())
expect(tooltipText()).toBe('Failed')
})

it('truncates a very long failure reason', () => {
renderJobs([makeJob({ status: 'failed', error: 'x'.repeat(500) })])

hover(badgeElement())
const text = tooltipText() ?? ''
expect(text.endsWith('...')).toBe(true)
expect(text.length).toBeLessThan(260)
})

it('says "In progress" when hovering a processing (grey) badge', () => {
renderJobs([makeJob({ status: 'processing' })])

hover(badgeElement())
expect(tooltipText()).toBe('In progress')
})

it('says "Queued" when hovering a pending (grey) badge', () => {
renderJobs([makeJob({ status: 'pending' })])

hover(badgeElement())
expect(tooltipText()).toBe('Queued')
})

it('shows nothing when hovering a completed (blue) badge', () => {
renderJobs([makeJob({ status: 'completed' })])

hover(badgeElement())
expect(tooltipText()).toBeNull()
})

it('surfaces the partial-copy summary on a completed_with_warnings (amber) badge', () => {
renderJobs([
makeJob({
status: 'completed_with_warnings',
message: 'Copied 12 items; 3 could not be copied',
}),
])

hover(badgeElement())
expect(tooltipText()).toBe('Copied 12 items; 3 could not be copied')
})

it('keeps a still-running row hoverable by not rendering it as a disabled button', () => {
renderJobs([makeJob({ status: 'processing' })])

// A processing row has no report yet, so it is not expandable. It must still
// be a plain row — a disabled button would swallow the badge's hover events.
expect(container.querySelector('button[disabled]')).toBeNull()
})

it('still renders an expandable row as a button', () => {
renderJobs([makeJob({ status: 'failed', error: 'boom' })])

const row = container.querySelector('button')
expect(row).not.toBeNull()
expect(row?.getAttribute('aria-expanded')).toBe('false')
})
})
Loading
Loading