Skip to content

Commit bca7f2c

Browse files
authored
feat(logs): add Troubleshoot in Chat button for errored runs (#5341)
* feat(logs): add Troubleshoot in Chat button for errored runs Errored log runs now surface a "Troubleshoot in Chat" action in the log details panel. It tags the failed run as a logs context (executionId) and auto-sends a message to Chat asking Sim to investigate and fix the error, porting the old copilot "Fix in Chat" behavior to mothership and adding run-ID tagging. Cross-route handoff rides a one-shot MothershipHandoffStorage consumed once on the home surface mount, so the tagged run + prompt survive the navigation from Logs to Chat and the agent receives the full run error via the resolved logs context. * fix(logs): deliver troubleshoot to a mounted chat + harden handoff Review follow-ups: - Same-route case (Cursor): LogDetailsContent is also embedded in the Chat resource panel, where router.push('/home') doesn't remount Home, so the mount-only handoff consume never fired. Generalize the existing sendMothershipMessage event to carry contexts and be cancelable: deliver straight to a mounted chat when one claims it, and only persist + navigate when none is listening. - Corrupted-entry tombstone (Greptile): consume now clears whenever any entry exists, so a malformed/expired handoff can't linger across future mounts. - Silent store failure (Greptile): only navigate when the handoff actually stored, so a failed write never strands the user on an empty chat. * docs(logs): convert inline comments to TSDoc on declarations * style(logs): match Troubleshoot button icon gap to View Snapshot sibling * improvement(logs): use Chip for log-detail action buttons Aligns the log-details panel's labeled action buttons (View Snapshot, Troubleshoot in Chat) with the settings design language by swapping the emcn Button for the canonical Chip pill. variant='primary' preserves the prior filled emphasis; leftIcon keeps the icons canonical. * fix(chat): only consume troubleshoot handoff on the new-chat surface Gate the mount-time handoff consume on `!chatId` so an existing `/chat/[chatId]` mount can't claim a pending handoff if navigation races — a handoff always targets a fresh chat. * fix(logs): hover only the interactive Run ID row in log details The detail-card rows all hovered to --surface-2, but the card itself is --surface-2, so the hover was a no-op in light mode and only showed in dark. It also implied clickability on static readout rows. Now only the clickable Run ID row hovers, using the canonical --surface-active token; static rows carry no hover. * improvement(logs): use emcn Badge for the version pill Replaces the hand-rolled version span with the canonical Badge (variant='green' size='md', pixel-identical tokens), so all three detail badges (Level, Trigger, Version) render through the same component.
1 parent 507cee1 commit bca7f2c

5 files changed

Lines changed: 244 additions & 31 deletions

File tree

apps/sim/app/workspace/[workspaceId]/home/home.tsx

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import {
2929
LandingPromptStorage,
3030
type LandingWorkflowSeed,
3131
LandingWorkflowSeedStorage,
32+
MothershipHandoffStorage,
3233
} from '@/lib/core/utils/browser-storage'
3334
import {
3435
MOTHERSHIP_SEND_MESSAGE_EVENT,
@@ -313,15 +314,39 @@ export function Home({ chatId, userName, userId }: HomeProps) {
313314
[workspaceId, chatId, sendMessage]
314315
)
315316

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

334+
/**
335+
* Consumes a one-shot handoff left by another surface (e.g. "Troubleshoot in
336+
* Chat" on an errored log viewed from a different route) and auto-sends it
337+
* into this fresh chat, tagging the run so Sim can inspect the failure. Only
338+
* the cross-route path lands here — when a chat is already mounted the event
339+
* above delivers directly. Gated to the new-chat surface (`!chatId`): a
340+
* handoff always targets a fresh chat, so an existing `/chat/[chatId]` mount
341+
* must never claim it if navigation races. `consume` clears the entry
342+
* atomically, so it fires at most once even across a StrictMode remount.
343+
*/
344+
useEffect(() => {
345+
if (chatId) return
346+
const handoff = MothershipHandoffStorage.consume()
347+
if (handoff) sendMessage(handoff.message, undefined, handoff.contexts)
348+
}, [chatId, sendMessage])
349+
325350
function resolveResourceFromContext(
326351
context: ChatContext
327352
): { type: MothershipResourceType; id: string } | null {

apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx

Lines changed: 71 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@
22

33
import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
44
import {
5+
Badge,
56
Button,
7+
Chip,
68
ChipInput,
79
ChipModalTabs,
810
Code,
@@ -20,16 +22,19 @@ import {
2022
Tooltip,
2123
useCopyToClipboard,
2224
} from '@sim/emcn'
23-
import { Workflow } from '@sim/emcn/icons'
25+
import { Workflow, Wrench } from '@sim/emcn/icons'
2426
import { formatDuration } from '@sim/utils/formatting'
2527
import { ArrowDown, ArrowUp, Check, ChevronUp, Clipboard, Search, X } from 'lucide-react'
28+
import { useParams, useRouter } from 'next/navigation'
2629
import { useQueryState } from 'nuqs'
2730
import { createPortal } from 'react-dom'
2831
import type { WorkflowLogRow } from '@/lib/api/contracts/logs'
2932
import { BASE_EXECUTION_CHARGE } from '@/lib/billing/constants'
3033
import { apportionCredits, dollarsToCredits } from '@/lib/billing/credits/conversion'
34+
import { MothershipHandoffStorage } from '@/lib/core/utils/browser-storage'
3135
import { filterHiddenOutputKeys } from '@/lib/logs/execution/trace-spans/trace-spans'
3236
import type { TraceSpan } from '@/lib/logs/types'
37+
import { sendMothershipMessage } from '@/lib/mothership/events'
3338
import {
3439
ExecutionSnapshot,
3540
FileCards,
@@ -52,6 +57,7 @@ import { usePermissionConfig } from '@/hooks/use-permission-config'
5257
import { formatCost } from '@/providers/utils'
5358
import { useLogDetailsUIStore } from '@/stores/logs/store'
5459
import { MAX_LOG_DETAILS_WIDTH_RATIO, MIN_LOG_DETAILS_WIDTH } from '@/stores/logs/utils'
60+
import type { ChatContext } from '@/stores/panel'
5561

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

276282
const scrollAreaRef = useRef<HTMLDivElement>(null)
277283

284+
const router = useRouter()
285+
const { workspaceId } = useParams<{ workspaceId: string }>()
286+
278287
const { config: permissionConfig } = usePermissionConfig()
279288

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

394+
/**
395+
* Troubleshooting hands the failed run off to Chat, tagging it by
396+
* `executionId`. A real Chat run can't be debugged from inside itself, so
397+
* mothership-triggered logs are excluded — `isLikelyExecution` already encodes
398+
* "has an executionId and isn't a mothership run".
399+
*/
400+
const canTroubleshoot = log.status === 'failed' && isLikelyExecution
401+
402+
/**
403+
* Hands the failed run to Chat. When a chat is already mounted (e.g. the run
404+
* is being viewed inside Chat's resource panel) it consumes the tagged
405+
* message directly; otherwise a one-shot handoff is persisted and we navigate
406+
* to a fresh chat that picks it up on mount. Navigation is gated on a
407+
* successful store, so a failed write never strands the user on an empty chat.
408+
*/
409+
const handleTroubleshoot = useCallback(() => {
410+
if (!log.executionId) return
411+
const workflowName = log.workflow?.name?.trim() || null
412+
const context: ChatContext = {
413+
kind: 'logs',
414+
executionId: log.executionId,
415+
label: workflowName ?? 'this run',
416+
}
417+
const message = workflowName
418+
? `The "${workflowName}" workflow run failed. Investigate the error in this run and help me fix it.`
419+
: 'This workflow run failed. Investigate the error in this run and help me fix it.'
420+
if (sendMothershipMessage(message, [context])) return
421+
if (MothershipHandoffStorage.store({ message, contexts: [context] })) {
422+
router.push(`/workspace/${workspaceId}/home`)
423+
}
424+
}, [log.executionId, log.workflow?.name, workspaceId, router])
425+
385426
return (
386427
<>
387428
<div className='mt-4 flex min-h-0 flex-1 flex-col'>
@@ -434,7 +475,7 @@ export function LogDetailsContent({ log, onActiveTabChange }: LogDetailsContentP
434475
role='button'
435476
tabIndex={0}
436477
aria-label='Copy run ID'
437-
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)]'
478+
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)]'
438479
onClick={() => copyRunId(log.executionId!)}
439480
onKeyDown={(event) =>
440481
handleKeyboardActivation(event, () => copyRunId(log.executionId!))
@@ -450,15 +491,15 @@ export function LogDetailsContent({ log, onActiveTabChange }: LogDetailsContentP
450491
)}
451492

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

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

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

484525
{/* Version */}
485526
{log.deploymentVersion && (
486-
<div className='flex h-10 items-center gap-2 px-3 transition-colors hover-hover:bg-[var(--surface-2)]'>
527+
<div className='flex h-10 items-center gap-2 px-3'>
487528
<span className='flex-shrink-0 font-medium text-[var(--text-tertiary)] text-caption'>
488529
Version
489530
</span>
490531
<div className='flex w-0 flex-1 justify-end'>
491-
<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'>
532+
<Badge variant='green' size='md' className='max-w-full truncate'>
492533
{log.deploymentVersionName || `v${log.deploymentVersion}`}
493-
</span>
534+
</Badge>
494535
</div>
495536
</div>
496537
)}
497538

498539
{/* Snapshot */}
499540
{showWorkflowState && (
500-
<div className='flex h-10 items-center justify-between px-3 transition-colors hover-hover:bg-[var(--surface-2)]'>
541+
<div className='flex h-10 items-center justify-between px-3'>
501542
<span className='font-medium text-[var(--text-tertiary)] text-caption'>
502543
Snapshot
503544
</span>
504-
<Button
505-
variant='default'
506-
size='sm'
507-
className='gap-1'
545+
<Chip
546+
variant='primary'
547+
leftIcon={Eye}
548+
flush
508549
onClick={() => setIsExecutionSnapshotOpen(true)}
509550
>
510-
<Eye className='size-3' />
511551
View Snapshot
512-
</Button>
552+
</Chip>
513553
</div>
514554
)}
515555
</div>
@@ -541,17 +581,27 @@ export function LogDetailsContent({ log, onActiveTabChange }: LogDetailsContentP
541581
</div>
542582
)}
543583

584+
{/* Troubleshoot */}
585+
{canTroubleshoot && (
586+
<Chip
587+
variant='primary'
588+
leftIcon={Wrench}
589+
flush
590+
className='self-start'
591+
onClick={handleTroubleshoot}
592+
>
593+
Troubleshoot in Chat
594+
</Chip>
595+
)}
596+
544597
{/* Files */}
545598
{log.files && log.files.length > 0 && <FileCards files={log.files} isExecutionFile />}
546599

547600
{/* Cost Breakdown */}
548601
{hasCostInfo && costBreakdown && (
549602
<div className='divide-y divide-[var(--border)] overflow-hidden rounded-md border border-[var(--border)] bg-[var(--surface-2)] dark:bg-transparent'>
550603
{costBreakdown.rows.map((row) => (
551-
<div
552-
key={row.key}
553-
className='flex h-10 items-center justify-between px-3 transition-colors hover-hover:bg-[var(--surface-2)]'
554-
>
604+
<div key={row.key} className='flex h-10 items-center justify-between px-3'>
555605
<span className='min-w-0 truncate font-medium text-[var(--text-tertiary)] text-caption'>
556606
{row.label}
557607
</span>
@@ -560,7 +610,7 @@ export function LogDetailsContent({ log, onActiveTabChange }: LogDetailsContentP
560610
</span>
561611
</div>
562612
))}
563-
<div className='flex h-10 items-center justify-between px-3 transition-colors hover-hover:bg-[var(--surface-2)]'>
613+
<div className='flex h-10 items-center justify-between px-3'>
564614
<span className='font-medium text-[var(--text-secondary)] text-caption'>
565615
Total
566616
</span>
@@ -569,7 +619,7 @@ export function LogDetailsContent({ log, onActiveTabChange }: LogDetailsContentP
569619
</span>
570620
</div>
571621
{(costBreakdown.tokens.input > 0 || costBreakdown.tokens.output > 0) && (
572-
<div className='flex h-10 items-center justify-between px-3 transition-colors hover-hover:bg-[var(--surface-2)]'>
622+
<div className='flex h-10 items-center justify-between px-3'>
573623
<span className='font-medium text-[var(--text-tertiary)] text-caption'>
574624
Tokens
575625
</span>
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
/**
2+
* @vitest-environment jsdom
3+
*/
4+
import { beforeEach, describe, expect, it, vi } from 'vitest'
5+
import { MothershipHandoffStorage, STORAGE_KEYS } from '@/lib/core/utils/browser-storage'
6+
import type { ChatContext } from '@/stores/panel'
7+
8+
describe('MothershipHandoffStorage', () => {
9+
beforeEach(() => {
10+
localStorage.clear()
11+
})
12+
13+
it('round-trips a handoff and trims the message, preserving contexts', () => {
14+
const contexts: ChatContext[] = [{ kind: 'logs', executionId: 'run-1', label: 'My Flow' }]
15+
expect(MothershipHandoffStorage.store({ message: ' fix it ', contexts })).toBe(true)
16+
17+
expect(MothershipHandoffStorage.consume()).toEqual({ message: 'fix it', contexts })
18+
})
19+
20+
it('is one-shot — a second consume returns null', () => {
21+
MothershipHandoffStorage.store({ message: 'fix it' })
22+
23+
expect(MothershipHandoffStorage.consume()).not.toBeNull()
24+
expect(MothershipHandoffStorage.consume()).toBeNull()
25+
})
26+
27+
it('refuses to store an empty message', () => {
28+
expect(MothershipHandoffStorage.store({ message: ' ' })).toBe(false)
29+
expect(MothershipHandoffStorage.consume()).toBeNull()
30+
})
31+
32+
it('tombstones a corrupted entry (missing timestamp) instead of leaving it forever', () => {
33+
localStorage.setItem(STORAGE_KEYS.MOTHERSHIP_HANDOFF, JSON.stringify({ message: 'fix it' }))
34+
35+
expect(MothershipHandoffStorage.consume()).toBeNull()
36+
expect(localStorage.getItem(STORAGE_KEYS.MOTHERSHIP_HANDOFF)).toBeNull()
37+
})
38+
39+
it('drops and clears a handoff older than maxAge', () => {
40+
vi.useFakeTimers()
41+
try {
42+
vi.setSystemTime(new Date('2026-01-01T00:00:00Z'))
43+
MothershipHandoffStorage.store({ message: 'fix it' })
44+
45+
vi.advanceTimersByTime(61 * 1000)
46+
47+
expect(MothershipHandoffStorage.consume()).toBeNull()
48+
expect(localStorage.getItem(STORAGE_KEYS.MOTHERSHIP_HANDOFF)).toBeNull()
49+
} finally {
50+
vi.useRealTimers()
51+
}
52+
})
53+
})

0 commit comments

Comments
 (0)