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
42 changes: 39 additions & 3 deletions packages/core/src/orchestrator/orchestrator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -377,7 +377,9 @@ export async function dispatchBackgroundWorkflow(
preCreatedRun
);
// Surface workflow output to parent conversation as a result card
if (result.success && !('paused' in result) && result.summary) {
if ('paused' in result) {
// Paused workflows (approval gates) — no result card yet
} else if (result.success && result.summary) {
try {
await ctx.platform.sendMessage(ctx.conversationId, result.summary, {
category: 'workflow_result',
Expand All @@ -393,6 +395,27 @@ export async function dispatchBackgroundWorkflow(
'workflow_output_surface_failed'
);
}
} else if (!result.success && result.workflowRunId) {
// Surface failure as a result card so the chat shows status + "View full logs"
try {
await ctx.platform.sendMessage(
ctx.conversationId,
`Workflow **${workflow.name}** failed: ${result.error}`,
{
category: 'workflow_result',
segment: 'new',
workflowResult: {
workflowName: workflow.name,
runId: result.workflowRunId,
},
}
);
} catch (surfaceError) {
getLog().warn(
{ err: toError(surfaceError), conversationId: ctx.conversationId },
'workflow_output_surface_failed'
);
}
}
} catch (error) {
const err = toError(error);
Expand All @@ -404,9 +427,22 @@ export async function dispatchBackgroundWorkflow(
},
'background_workflow_failed'
);
// Surface error to parent conversation so the user knows
// Surface error to parent conversation — include workflowResult metadata when
// we have a pre-created run ID so the chat renders a result card with "View full logs"
const failureRunId = preCreatedRun?.id;
const failureMessage = `Workflow **${workflow.name}** failed: ${err.message}`;
await ctx.platform
.sendMessage(ctx.conversationId, `Workflow **${workflow.name}** failed: ${err.message}`)
.sendMessage(
ctx.conversationId,
failureMessage,
failureRunId
? {
category: 'workflow_result',
segment: 'new',
workflowResult: { workflowName: workflow.name, runId: failureRunId },
}
: undefined
)
.catch((sendErr: unknown) => {
getLog().error({ err: toError(sendErr) }, 'background_workflow_notify_failed');
});
Expand Down
12 changes: 12 additions & 0 deletions packages/docs-web/src/content/docs/adapters/web.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,18 @@ While a workflow runs, a progress card appears in the conversation showing:

For paused workflows (approval gates), the progress card shows **Approve** and **Reject** buttons so you can control the workflow directly from the chat.

### Workflow Result Card

When a workflow reaches a terminal state (completed, failed, or cancelled), the progress card is replaced by a result card in the conversation. The result card shows:

- **Status icon** -- Visual indicator for completed, failed, or cancelled
- **Header** -- "Workflow complete", "Workflow failed", or "Workflow cancelled" depending on outcome
- **Node count** -- How many nodes completed out of the total nodes that reached a terminal state (e.g., `3/4 nodes`)
- **Duration** -- Total elapsed time for the run
- **Artifacts** -- Any files or outputs produced by the workflow, with direct links

Click the arrow button in the result card header to open the full execution detail page.

### Execution Detail Page

Click on a workflow run (from the dashboard or progress card) to open the execution detail page at `/workflows/runs/:runId`. This shows:
Expand Down
107 changes: 100 additions & 7 deletions packages/web/src/components/chat/MessageList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,20 @@ import { memo, useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { useQuery } from '@tanstack/react-query';
import { ArrowDown, Sparkles, ArrowRight, MessageSquare } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { MessageBubble } from './MessageBubble';
import { ToolCallCard } from './ToolCallCard';
import { ErrorCard } from './ErrorCard';
import { WorkflowProgressCard } from './WorkflowProgressCard';
import { useAutoScroll } from '@/hooks/useAutoScroll';
import type { ChatMessage } from '@/lib/types';
import { useWorkflowStore } from '@/stores/workflow-store';
import { getWorkflowRun } from '@/lib/api';
import { StatusIcon } from '@/components/workflows/StatusIcon';
import { ArtifactSummary } from '@/components/workflows/ArtifactSummary';
import { formatDurationMs, ensureUtc } from '@/lib/format';
import type { ChatMessage, WorkflowArtifact, ArtifactType } from '@/lib/types';

// Hoisted to module scope to prevent new references on every render
const WORKFLOW_RESULT_MARKDOWN_COMPONENTS = {
Expand Down Expand Up @@ -37,32 +43,119 @@ function WorkflowResultCard({
const navigate = useNavigate();
const [expanded, setExpanded] = useState(false);

// Zustand live state (populated if user had the page open during execution)
const liveState = useWorkflowStore(state => state.workflows.get(runId));

// One-time API fetch: staleTime: Infinity because a terminal run record is immutable —
// status, timestamps, and events do not change once completed/failed/cancelled.
const { data: runData, isError } = useQuery({
queryKey: ['workflowRun', runId],
queryFn: () => getWorkflowRun(runId),
staleTime: Infinity,
});

// Merge: prefer live state when available
const status = liveState?.status ?? runData?.run.status ?? 'completed';
const dagNodes = liveState?.dagNodes ?? [];
const storeArtifacts = liveState?.artifacts ?? [];
const startedAt =
liveState?.startedAt ??
(runData?.run.started_at ? new Date(ensureUtc(runData.run.started_at)).getTime() : null);
const completedAt =
liveState?.completedAt ??
(runData?.run.completed_at ? new Date(ensureUtc(runData.run.completed_at)).getTime() : null);
const duration = startedAt != null && completedAt != null ? completedAt - startedAt : null;

// Node counts: prefer live dagNodes (exact), fall back to events (approximation —
// totalCount is nodes that reached a terminal state, not the workflow's full node count).
let completedCount: number;
let totalCount: number;
if (dagNodes.length > 0) {
completedCount = dagNodes.filter(n => n.status === 'completed').length;
// Only count terminal nodes (same semantics as events fallback path)
totalCount = dagNodes.filter(
n => n.status === 'completed' || n.status === 'failed' || n.status === 'skipped'
).length;
} else {
const events = runData?.events ?? [];
const terminalEvents = events.filter(
e =>
e.event_type === 'node_completed' ||
e.event_type === 'node_failed' ||
e.event_type === 'node_skipped'
);
completedCount = events.filter(e => e.event_type === 'node_completed').length;
totalCount = terminalEvents.length;
}

// Artifacts: prefer live store, fall back to events
const eventArtifacts: WorkflowArtifact[] = (runData?.events ?? [])
.filter(e => e.event_type === 'workflow_artifact')
.map(e => {
const d = e.data;
return {
type: (typeof d.artifactType === 'string'
? d.artifactType
: 'file_created') as ArtifactType,
label: typeof d.label === 'string' ? d.label : '',
url: typeof d.url === 'string' ? d.url : undefined,
path: typeof d.path === 'string' ? d.path : undefined,
};
});
const artifacts = storeArtifacts.length > 0 ? storeArtifacts : eventArtifacts;

// If API fetch failed and no live state, show degraded card with just content + link
const fetchFailed = isError && !liveState;

// Status-aware header title
const headerTitle =
status === 'failed'
? 'Workflow failed'
: status === 'cancelled'
? 'Workflow cancelled'
: 'Workflow complete';

// Expand/collapse for text content
const lines = content.split('\n');
const isTruncatable = content.length > 500 || lines.length > 8;
const previewText = lines.slice(0, 8).join('\n').slice(0, 500);
const preview = isTruncatable
? previewText + (previewText.length < content.length ? '...' : '')
: content;

const displayContent = expanded || !isTruncatable ? content : preview;

return (
<div className="rounded-lg border border-border bg-surface overflow-hidden max-w-3xl">
<div className="flex items-center gap-2 px-3 py-2 border-b border-border bg-surface-elevated">
<span className="text-success text-xs shrink-0">&#x2713;</span>
<span className="shrink-0">
<StatusIcon status={fetchFailed ? 'completed' : status} />
</span>
<span className="text-xs font-medium text-text-primary truncate flex-1">
Workflow complete: {workflowName}
{headerTitle}: {workflowName}
</span>
{!fetchFailed && totalCount > 0 && (
<span className="shrink-0 text-[10px] text-text-secondary">
{completedCount}/{totalCount} nodes
</span>
)}
{!fetchFailed && duration != null && (
<span className="rounded-full bg-surface px-2 py-0.5 text-[10px] text-text-secondary shrink-0">
{formatDurationMs(duration)}
</span>
)}
<button
onClick={(): void => {
navigate(`/workflows/runs/${runId}`);
}}
onClick={() => navigate(`/workflows/runs/${runId}`)}
className="text-[10px] text-primary hover:text-accent-bright transition-colors shrink-0"
>
View full logs &rarr;
</button>
</div>
<div className="px-3 py-2">
{!fetchFailed && artifacts.length > 0 && (
<div className="mb-2">
<ArtifactSummary artifacts={artifacts} runId={runId} />
</div>
)}
<div className="chat-markdown text-xs text-text-secondary">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
Expand Down
1 change: 1 addition & 0 deletions packages/web/src/stores/workflow-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ function invalidateWorkflowQueries(): void {
const keys = [
'workflow-runs',
'workflowRuns',
'workflowRun',
'workflow-runs-status',
'conversations',
'workflowMessages',
Expand Down