diff --git a/apps/sim/app/workspace/[workspaceId]/home/home.tsx b/apps/sim/app/workspace/[workspaceId]/home/home.tsx index c206ffd9bf0..d0f96f9c5f8 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/home.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/home.tsx @@ -29,6 +29,7 @@ import { LandingPromptStorage, type LandingWorkflowSeed, LandingWorkflowSeedStorage, + MothershipHandoffStorage, } from '@/lib/core/utils/browser-storage' import { MOTHERSHIP_SEND_MESSAGE_EVENT, @@ -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).detail?.message - if (message) sendMessage(message) + const detail = (e as CustomEvent).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 { diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx index e28a8036242..3c302c41dee 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx @@ -2,7 +2,9 @@ import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import { + Badge, Button, + Chip, ChipInput, ChipModalTabs, Code, @@ -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, @@ -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 @@ -275,6 +281,9 @@ export function LogDetailsContent({ log, onActiveTabChange }: LogDetailsContentP const scrollAreaRef = useRef(null) + const router = useRouter() + const { workspaceId } = useParams<{ workspaceId: string }>() + const { config: permissionConfig } = usePermissionConfig() const isInitialTabMountRef = useRef(true) @@ -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 ( <>
@@ -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!)) @@ -450,7 +491,7 @@ export function LogDetailsContent({ log, onActiveTabChange }: LogDetailsContentP )} {/* Level */} -
+
Level @@ -458,7 +499,7 @@ export function LogDetailsContent({ log, onActiveTabChange }: LogDetailsContentP
{/* Trigger */} -
+
Trigger @@ -472,7 +513,7 @@ export function LogDetailsContent({ log, onActiveTabChange }: LogDetailsContentP
{/* Duration */} -
+
Duration @@ -483,33 +524,32 @@ export function LogDetailsContent({ log, onActiveTabChange }: LogDetailsContentP {/* Version */} {log.deploymentVersion && ( -
+
Version
- + {log.deploymentVersionName || `v${log.deploymentVersion}`} - +
)} {/* Snapshot */} {showWorkflowState && ( -
+
Snapshot - +
)}
@@ -541,6 +581,19 @@ export function LogDetailsContent({ log, onActiveTabChange }: LogDetailsContentP
)} + {/* Troubleshoot */} + {canTroubleshoot && ( + + Troubleshoot in Chat + + )} + {/* Files */} {log.files && log.files.length > 0 && } @@ -548,10 +601,7 @@ export function LogDetailsContent({ log, onActiveTabChange }: LogDetailsContentP {hasCostInfo && costBreakdown && (
{costBreakdown.rows.map((row) => ( -
+
{row.label} @@ -560,7 +610,7 @@ export function LogDetailsContent({ log, onActiveTabChange }: LogDetailsContentP
))} -
+
Total @@ -569,7 +619,7 @@ export function LogDetailsContent({ log, onActiveTabChange }: LogDetailsContentP
{(costBreakdown.tokens.input > 0 || costBreakdown.tokens.output > 0) && ( -
+
Tokens diff --git a/apps/sim/lib/core/utils/browser-storage.test.ts b/apps/sim/lib/core/utils/browser-storage.test.ts new file mode 100644 index 00000000000..30b3fc1ffef --- /dev/null +++ b/apps/sim/lib/core/utils/browser-storage.test.ts @@ -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() + } + }) +}) diff --git a/apps/sim/lib/core/utils/browser-storage.ts b/apps/sim/lib/core/utils/browser-storage.ts index d22702f4c57..5f4840a878a 100644 --- a/apps/sim/lib/core/utils/browser-storage.ts +++ b/apps/sim/lib/core/utils/browser-storage.ts @@ -4,6 +4,7 @@ */ import { createLogger } from '@sim/logger' +import type { ChatContext } from '@/stores/panel' const logger = createLogger('BrowserStorage') @@ -103,6 +104,7 @@ export const STORAGE_KEYS = { LANDING_PAGE_PROMPT: 'sim_landing_page_prompt', LANDING_PAGE_WORKFLOW_SEED: 'sim_landing_page_workflow_seed', WORKSPACE_RECENCY: 'sim_workspace_recency', + MOTHERSHIP_HANDOFF: 'sim_mothership_handoff', } as const export class WorkspaceRecencyStorage { @@ -296,3 +298,72 @@ export class LandingWorkflowSeedStorage { return BrowserStorage.removeItem(LandingWorkflowSeedStorage.KEY) } } + +export interface MothershipHandoff { + /** The message to auto-send to Chat once the home surface mounts. */ + message: string + /** Structured contexts to attach — e.g. a `logs` mention tagging a run. */ + contexts?: ChatContext[] +} + +/** + * One-shot handoff that seeds an auto-sent Chat (mothership) message when the + * user is routed to the workspace home from elsewhere in the app — e.g. the + * "Troubleshoot in Chat" action on an errored log, which tags the failed run + * and asks Sim to fix it. + * + * The home surface consumes this exactly once on mount. A short max-age guards + * against a stale handoff firing on a later, unrelated visit, and `consume` + * always clears the entry so it can never be replayed. + */ +export class MothershipHandoffStorage { + private static readonly KEY = STORAGE_KEYS.MOTHERSHIP_HANDOFF + + /** + * Store a handoff to be auto-sent on the next home-surface mount. + * @returns True if stored, false when the message is empty. + */ + static store(handoff: MothershipHandoff): boolean { + const message = handoff.message.trim() + if (!message) { + return false + } + + return BrowserStorage.setItem(MothershipHandoffStorage.KEY, { + message, + contexts: handoff.contexts, + timestamp: Date.now(), + }) + } + + /** + * Retrieve and consume the stored handoff. Clears the entry as soon as one + * exists — before the validity and expiry checks — so a malformed or expired + * handoff is tombstoned rather than lingering across future mounts, and a + * valid handoff fires exactly once. + * @param maxAge - Maximum age in milliseconds (default: 60 seconds) + */ + static consume(maxAge: number = 60 * 1000): MothershipHandoff | null { + const data = BrowserStorage.getItem<{ + message?: string + contexts?: ChatContext[] + timestamp?: number + } | null>(MothershipHandoffStorage.KEY, null) + + if (!data) { + return null + } + + MothershipHandoffStorage.clear() + + if (!data.message || !data.timestamp || Date.now() - data.timestamp > maxAge) { + return null + } + + return { message: data.message, contexts: data.contexts } + } + + static clear(): boolean { + return BrowserStorage.removeItem(MothershipHandoffStorage.KEY) + } +} diff --git a/apps/sim/lib/mothership/events.ts b/apps/sim/lib/mothership/events.ts index c1c9e40ff59..9143a73c5b5 100644 --- a/apps/sim/lib/mothership/events.ts +++ b/apps/sim/lib/mothership/events.ts @@ -1,4 +1,5 @@ import { createLogger } from '@sim/logger' +import type { ChatContext } from '@/stores/panel' const logger = createLogger('MothershipEvents') @@ -11,23 +12,36 @@ export const MOTHERSHIP_SEND_MESSAGE_EVENT = 'mothership-send-message' export interface MothershipSendMessageDetail { message: string + /** Structured contexts to attach — e.g. a `logs` mention tagging a run. */ + contexts?: ChatContext[] } /** - * Dispatches a message to the Mothership chat. Producers (terminal block - * errors, console copilot actions, toast actions) call this; consumers - * listen for {@link MOTHERSHIP_SEND_MESSAGE_EVENT} on `window`. + * Dispatches a message to a mounted Mothership chat. Producers (terminal block + * errors, console copilot actions, toast actions, the log "Troubleshoot in + * Chat" action) call this; consumers listen for + * {@link MOTHERSHIP_SEND_MESSAGE_EVENT} on `window` and `preventDefault` to + * claim it. + * + * @returns `true` when a mounted host consumed the message, `false` when none + * was listening — callers that can fall back (e.g. cross-route navigation) use + * this to decide whether to persist a handoff instead. */ -export function sendMothershipMessage(message: string): void { +export function sendMothershipMessage(message: string, contexts?: ChatContext[]): boolean { const trimmed = message.trim() if (!trimmed) { logger.warn('sendMothershipMessage called with empty message') - return + return false } - window.dispatchEvent( + const consumed = !window.dispatchEvent( new CustomEvent(MOTHERSHIP_SEND_MESSAGE_EVENT, { - detail: { message: trimmed }, + detail: { message: trimmed, contexts }, + cancelable: true, }) ) - logger.info('Dispatched mothership message event', { messageLength: trimmed.length }) + logger.info('Dispatched mothership message event', { + messageLength: trimmed.length, + consumed, + }) + return consumed }