Skip to content
29 changes: 27 additions & 2 deletions apps/sim/app/workspace/[workspaceId]/home/home.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
LandingPromptStorage,
type LandingWorkflowSeed,
LandingWorkflowSeedStorage,
MothershipHandoffStorage,
} from '@/lib/core/utils/browser-storage'
import {
MOTHERSHIP_SEND_MESSAGE_EVENT,
Expand Down Expand Up @@ -313,15 +314,39 @@ export function Home({ chatId, userName, userId }: HomeProps) {
[workspaceId, chatId, sendMessage]
)

/**
* Handles cross-surface send requests (terminal/console "Fix in Chat", the
* log "Troubleshoot in Chat" action). `preventDefault` claims the event so a
* producer that dispatched it while this chat is mounted knows a live chat
* consumed the message and skips its navigate-and-persist fallback.
*/
useEffect(() => {
const handler = (e: Event) => {
const message = (e as CustomEvent<MothershipSendMessageDetail>).detail?.message
if (message) sendMessage(message)
const detail = (e as CustomEvent<MothershipSendMessageDetail>).detail
if (!detail?.message) return
e.preventDefault()
sendMessage(detail.message, undefined, detail.contexts)
}
window.addEventListener(MOTHERSHIP_SEND_MESSAGE_EVENT, handler)
return () => window.removeEventListener(MOTHERSHIP_SEND_MESSAGE_EVENT, handler)
}, [sendMessage])

/**
* Consumes a one-shot handoff left by another surface (e.g. "Troubleshoot in
* Chat" on an errored log viewed from a different route) and auto-sends it
* into this fresh chat, tagging the run so Sim can inspect the failure. Only
* the cross-route path lands here — when a chat is already mounted the event
* above delivers directly. Gated to the new-chat surface (`!chatId`): a
* handoff always targets a fresh chat, so an existing `/chat/[chatId]` mount
* must never claim it if navigation races. `consume` clears the entry
* atomically, so it fires at most once even across a StrictMode remount.
*/
useEffect(() => {
if (chatId) return
const handoff = MothershipHandoffStorage.consume()
if (handoff) sendMessage(handoff.message, undefined, handoff.contexts)
}, [chatId, sendMessage])

function resolveResourceFromContext(
context: ChatContext
): { type: MothershipResourceType; id: string } | null {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@

import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import {
Badge,
Button,
Chip,
ChipInput,
ChipModalTabs,
Code,
Expand All @@ -20,16 +22,19 @@ import {
Tooltip,
useCopyToClipboard,
} from '@sim/emcn'
import { Workflow } from '@sim/emcn/icons'
import { Workflow, Wrench } from '@sim/emcn/icons'
import { formatDuration } from '@sim/utils/formatting'
import { ArrowDown, ArrowUp, Check, ChevronUp, Clipboard, Search, X } from 'lucide-react'
import { useParams, useRouter } from 'next/navigation'
import { useQueryState } from 'nuqs'
import { createPortal } from 'react-dom'
import type { WorkflowLogRow } from '@/lib/api/contracts/logs'
import { BASE_EXECUTION_CHARGE } from '@/lib/billing/constants'
import { apportionCredits, dollarsToCredits } from '@/lib/billing/credits/conversion'
import { MothershipHandoffStorage } from '@/lib/core/utils/browser-storage'
import { filterHiddenOutputKeys } from '@/lib/logs/execution/trace-spans/trace-spans'
import type { TraceSpan } from '@/lib/logs/types'
import { sendMothershipMessage } from '@/lib/mothership/events'
import {
ExecutionSnapshot,
FileCards,
Expand All @@ -52,6 +57,7 @@ import { usePermissionConfig } from '@/hooks/use-permission-config'
import { formatCost } from '@/providers/utils'
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'

/**
* Renders an already-apportioned integer credit value. `dollars` is only used
Expand Down Expand Up @@ -275,6 +281,9 @@ export function LogDetailsContent({ log, onActiveTabChange }: LogDetailsContentP

const scrollAreaRef = useRef<HTMLDivElement>(null)

const router = useRouter()
const { workspaceId } = useParams<{ workspaceId: string }>()

const { config: permissionConfig } = usePermissionConfig()

const isInitialTabMountRef = useRef(true)
Expand Down Expand Up @@ -382,6 +391,38 @@ export function LogDetailsContent({ log, onActiveTabChange }: LogDetailsContentP
const formattedTimestamp = formatDate(log.createdAt)
const logStatus = getDisplayStatus(log.status)

/**
* Troubleshooting hands the failed run off to Chat, tagging it by
* `executionId`. A real Chat run can't be debugged from inside itself, so
* mothership-triggered logs are excluded — `isLikelyExecution` already encodes
* "has an executionId and isn't a mothership run".
*/
const canTroubleshoot = log.status === 'failed' && isLikelyExecution

/**
* Hands the failed run to Chat. When a chat is already mounted (e.g. the run
* is being viewed inside Chat's resource panel) it consumes the tagged
* message directly; otherwise a one-shot handoff is persisted and we navigate
* to a fresh chat that picks it up on mount. Navigation is gated on a
* successful store, so a failed write never strands the user on an empty chat.
*/
const handleTroubleshoot = useCallback(() => {
if (!log.executionId) return
const workflowName = log.workflow?.name?.trim() || null
const context: ChatContext = {
kind: 'logs',
executionId: log.executionId,
label: workflowName ?? 'this run',
}
const message = workflowName
? `The "${workflowName}" workflow run failed. Investigate the error in this run and help me fix it.`
: 'This workflow run failed. Investigate the error in this run and help me fix it.'
if (sendMothershipMessage(message, [context])) return
if (MothershipHandoffStorage.store({ message, contexts: [context] })) {
router.push(`/workspace/${workspaceId}/home`)
}
}, [log.executionId, log.workflow?.name, workspaceId, router])

return (
<>
<div className='mt-4 flex min-h-0 flex-1 flex-col'>
Expand Down Expand Up @@ -434,7 +475,7 @@ export function LogDetailsContent({ log, onActiveTabChange }: LogDetailsContentP
role='button'
tabIndex={0}
aria-label='Copy run ID'
className='flex h-10 min-w-0 cursor-pointer items-center justify-between gap-4 px-3 transition-colors hover-hover:bg-[var(--surface-2)]'
className='flex h-10 min-w-0 cursor-pointer items-center justify-between gap-4 px-3 transition-colors hover-hover:bg-[var(--surface-active)]'
onClick={() => copyRunId(log.executionId!)}
onKeyDown={(event) =>
handleKeyboardActivation(event, () => copyRunId(log.executionId!))
Expand All @@ -450,15 +491,15 @@ export function LogDetailsContent({ log, onActiveTabChange }: LogDetailsContentP
)}

{/* Level */}
<div className='flex h-10 items-center justify-between px-3 transition-colors hover-hover:bg-[var(--surface-2)]'>
<div className='flex h-10 items-center justify-between px-3'>
<span className='font-medium text-[var(--text-tertiary)] text-caption'>
Level
</span>
<StatusBadge status={logStatus} />
</div>

{/* Trigger */}
<div className='flex h-10 items-center justify-between px-3 transition-colors hover-hover:bg-[var(--surface-2)]'>
<div className='flex h-10 items-center justify-between px-3'>
<span className='font-medium text-[var(--text-tertiary)] text-caption'>
Trigger
</span>
Expand All @@ -472,7 +513,7 @@ export function LogDetailsContent({ log, onActiveTabChange }: LogDetailsContentP
</div>

{/* Duration */}
<div className='flex h-10 items-center justify-between px-3 transition-colors hover-hover:bg-[var(--surface-2)]'>
<div className='flex h-10 items-center justify-between px-3'>
<span className='font-medium text-[var(--text-tertiary)] text-caption'>
Duration
</span>
Expand All @@ -483,33 +524,32 @@ export function LogDetailsContent({ log, onActiveTabChange }: LogDetailsContentP

{/* Version */}
{log.deploymentVersion && (
<div className='flex h-10 items-center gap-2 px-3 transition-colors hover-hover:bg-[var(--surface-2)]'>
<div className='flex h-10 items-center gap-2 px-3'>
<span className='flex-shrink-0 font-medium text-[var(--text-tertiary)] text-caption'>
Version
</span>
<div className='flex w-0 flex-1 justify-end'>
<span className='max-w-full truncate rounded-md bg-[var(--badge-success-bg)] px-[9px] py-0.5 font-medium text-[var(--badge-success-text)] text-caption'>
<Badge variant='green' size='md' className='max-w-full truncate'>
{log.deploymentVersionName || `v${log.deploymentVersion}`}
</span>
</Badge>
</div>
</div>
)}

{/* Snapshot */}
{showWorkflowState && (
<div className='flex h-10 items-center justify-between px-3 transition-colors hover-hover:bg-[var(--surface-2)]'>
<div className='flex h-10 items-center justify-between px-3'>
<span className='font-medium text-[var(--text-tertiary)] text-caption'>
Snapshot
</span>
<Button
variant='default'
size='sm'
className='gap-1'
<Chip
variant='primary'
leftIcon={Eye}
flush
onClick={() => setIsExecutionSnapshotOpen(true)}
>
<Eye className='size-3' />
View Snapshot
</Button>
</Chip>
</div>
)}
</div>
Expand Down Expand Up @@ -541,17 +581,27 @@ export function LogDetailsContent({ log, onActiveTabChange }: LogDetailsContentP
</div>
)}

{/* Troubleshoot */}
{canTroubleshoot && (
<Chip
variant='primary'
leftIcon={Wrench}
flush
className='self-start'
onClick={handleTroubleshoot}
>
Troubleshoot in Chat
</Chip>
)}

{/* Files */}
{log.files && log.files.length > 0 && <FileCards files={log.files} isExecutionFile />}

{/* Cost Breakdown */}
{hasCostInfo && costBreakdown && (
<div className='divide-y divide-[var(--border)] overflow-hidden rounded-md border border-[var(--border)] bg-[var(--surface-2)] dark:bg-transparent'>
{costBreakdown.rows.map((row) => (
<div
key={row.key}
className='flex h-10 items-center justify-between px-3 transition-colors hover-hover:bg-[var(--surface-2)]'
>
<div key={row.key} className='flex h-10 items-center justify-between px-3'>
<span className='min-w-0 truncate font-medium text-[var(--text-tertiary)] text-caption'>
{row.label}
</span>
Expand All @@ -560,7 +610,7 @@ export function LogDetailsContent({ log, onActiveTabChange }: LogDetailsContentP
</span>
</div>
))}
<div className='flex h-10 items-center justify-between px-3 transition-colors hover-hover:bg-[var(--surface-2)]'>
<div className='flex h-10 items-center justify-between px-3'>
<span className='font-medium text-[var(--text-secondary)] text-caption'>
Total
</span>
Expand All @@ -569,7 +619,7 @@ export function LogDetailsContent({ log, onActiveTabChange }: LogDetailsContentP
</span>
</div>
{(costBreakdown.tokens.input > 0 || costBreakdown.tokens.output > 0) && (
<div className='flex h-10 items-center justify-between px-3 transition-colors hover-hover:bg-[var(--surface-2)]'>
<div className='flex h-10 items-center justify-between px-3'>
<span className='font-medium text-[var(--text-tertiary)] text-caption'>
Tokens
</span>
Expand Down
53 changes: 53 additions & 0 deletions apps/sim/lib/core/utils/browser-storage.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
/**
* @vitest-environment jsdom
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { MothershipHandoffStorage, STORAGE_KEYS } from '@/lib/core/utils/browser-storage'
import type { ChatContext } from '@/stores/panel'

describe('MothershipHandoffStorage', () => {
beforeEach(() => {
localStorage.clear()
})

it('round-trips a handoff and trims the message, preserving contexts', () => {
const contexts: ChatContext[] = [{ kind: 'logs', executionId: 'run-1', label: 'My Flow' }]
expect(MothershipHandoffStorage.store({ message: ' fix it ', contexts })).toBe(true)

expect(MothershipHandoffStorage.consume()).toEqual({ message: 'fix it', contexts })
})

it('is one-shot — a second consume returns null', () => {
MothershipHandoffStorage.store({ message: 'fix it' })

expect(MothershipHandoffStorage.consume()).not.toBeNull()
expect(MothershipHandoffStorage.consume()).toBeNull()
})

it('refuses to store an empty message', () => {
expect(MothershipHandoffStorage.store({ message: ' ' })).toBe(false)
expect(MothershipHandoffStorage.consume()).toBeNull()
})

it('tombstones a corrupted entry (missing timestamp) instead of leaving it forever', () => {
localStorage.setItem(STORAGE_KEYS.MOTHERSHIP_HANDOFF, JSON.stringify({ message: 'fix it' }))

expect(MothershipHandoffStorage.consume()).toBeNull()
expect(localStorage.getItem(STORAGE_KEYS.MOTHERSHIP_HANDOFF)).toBeNull()
})

it('drops and clears a handoff older than maxAge', () => {
vi.useFakeTimers()
try {
vi.setSystemTime(new Date('2026-01-01T00:00:00Z'))
MothershipHandoffStorage.store({ message: 'fix it' })

vi.advanceTimersByTime(61 * 1000)

expect(MothershipHandoffStorage.consume()).toBeNull()
expect(localStorage.getItem(STORAGE_KEYS.MOTHERSHIP_HANDOFF)).toBeNull()
} finally {
vi.useRealTimers()
}
})
})
Loading
Loading