Skip to content
7 changes: 5 additions & 2 deletions packages/server/src/adapters/web/workflow-bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ export function mapWorkflowEvent(event: WorkflowEmitterEvent): string | null {
return JSON.stringify({
type: 'workflow_step',
runId: event.runId,
nodeId: event.nodeId,
step: event.iteration - 1,
total: event.maxIterations,
name: `iteration-${String(event.iteration)}`,
Expand All @@ -47,9 +48,10 @@ export function mapWorkflowEvent(event: WorkflowEmitterEvent): string | null {
return JSON.stringify({
type: 'workflow_step',
runId: event.runId,
nodeId: event.nodeId,
step: event.iteration - 1,
// total: 0 intentionally — maxIterations is not carried by loop_iteration_completed/failed events.
// useWorkflowStatus.ts guards against 0 by preserving the prior wf.maxIterations value.
// workflow-store.ts handleLoopIteration guards against 0 by preserving the prior wf.maxIterations value.
total: 0,
name: `iteration-${String(event.iteration)}`,
status: 'completed',
Expand All @@ -62,9 +64,10 @@ export function mapWorkflowEvent(event: WorkflowEmitterEvent): string | null {
return JSON.stringify({
type: 'workflow_step',
runId: event.runId,
nodeId: event.nodeId,
step: event.iteration - 1,
// total: 0 intentionally — maxIterations is not carried by loop_iteration_completed/failed events.
// useWorkflowStatus.ts guards against 0 by preserving the prior wf.maxIterations value.
// workflow-store.ts handleLoopIteration guards against 0 by preserving the prior wf.maxIterations value.
total: 0,
name: `iteration-${String(event.iteration)}`,
status: 'failed',
Expand Down
114 changes: 84 additions & 30 deletions packages/web/src/components/workflows/DagNodeProgress.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { useState } from 'react';
import { StatusIcon } from './StatusIcon';
import { formatDurationMs } from '@/lib/format';
import type { DagNodeState } from '@/lib/types';
Expand All @@ -8,6 +9,84 @@ interface DagNodeProgressProps {
onNodeClick: (nodeId: string) => void;
}

function DagNodeItem({
node,
isActive,
onNodeClick,
}: {
node: DagNodeState;
isActive: boolean;
onNodeClick: (nodeId: string) => void;
}): React.ReactElement {
Comment on lines +12 to +20
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify whether DagNodeItem currently uses inline object typing for props.
# Expected: one match on the inline "} : {" props annotation before refactor.
rg -n -C2 --type=tsx 'function DagNodeItem|^\s*}:\s*\{' packages/web/src/components/workflows/DagNodeProgress.tsx

Repository: coleam00/Archon

Length of output: 86


🏁 Script executed:

# Corrected search - use -g glob pattern instead of --type=tsx
rg -n -C3 'function DagNodeItem' packages/web/src/components/workflows/DagNodeProgress.tsx

Repository: coleam00/Archon

Length of output: 180


🏁 Script executed:

# Also read the file to see current state
wc -l packages/web/src/components/workflows/DagNodeProgress.tsx

Repository: coleam00/Archon

Length of output: 120


🏁 Script executed:

# Check for any existing props interfaces in this file
rg -n 'interface.*Props|type.*Props' packages/web/src/components/workflows/DagNodeProgress.tsx

Repository: coleam00/Archon

Length of output: 93


🏁 Script executed:

# Read lines 6-40 to see DagNodeProgressProps interface and DagNodeItem function
sed -n '6,40p' packages/web/src/components/workflows/DagNodeProgress.tsx

Repository: coleam00/Archon

Length of output: 1016


Extract DagNodeItem props into a dedicated DagNodeItemProps interface.

Component props should use interfaces per the TypeScript guidelines for major abstractions. Currently, props are defined inline, which reduces reusability and inconsistency with the existing DagNodeProgressProps pattern used elsewhere in this file.

Suggested refactor
+interface DagNodeItemProps {
+  node: DagNodeState;
+  isActive: boolean;
+  onNodeClick: (nodeId: string) => void;
+}
+
 function DagNodeItem({
   node,
   isActive,
   onNodeClick,
-}: {
-  node: DagNodeState;
-  isActive: boolean;
-  onNodeClick: (nodeId: string) => void;
-}): React.ReactElement {
+}: DagNodeItemProps): React.ReactElement {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
function DagNodeItem({
node,
isActive,
onNodeClick,
}: {
node: DagNodeState;
isActive: boolean;
onNodeClick: (nodeId: string) => void;
}): React.ReactElement {
interface DagNodeItemProps {
node: DagNodeState;
isActive: boolean;
onNodeClick: (nodeId: string) => void;
}
function DagNodeItem({
node,
isActive,
onNodeClick,
}: DagNodeItemProps): React.ReactElement {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/web/src/components/workflows/DagNodeProgress.tsx` around lines 12 -
20, Create a new interface named DagNodeItemProps that describes the props for
DagNodeItem (including node: DagNodeState, isActive: boolean, onNodeClick:
(nodeId: string) => void), replace the inline prop type in the DagNodeItem
function signature with this DagNodeItemProps interface, and update any
references/usages to use the named interface to match the existing
DagNodeProgressProps pattern and improve reusability.

const [expanded, setExpanded] = useState(false);
const hasIterations = (node.iterations?.length ?? 0) > 0;

return (
<div>
<div
className={`w-full text-left px-2 py-1.5 rounded transition-colors cursor-pointer ${
isActive ? 'bg-accent/10 border-l-2 border-accent' : 'hover:bg-surface-hover'
}`}
onClick={(): void => {
onNodeClick(node.nodeId);
}}
role="row"
>
Comment on lines +26 to +34
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Row selection is not keyboard-accessible.

The clickable row is a non-focusable <div> with mouse-only activation. Keyboard users can’t select a node from this control.

♿ Minimal accessibility fix
       <div
         className={`w-full text-left px-2 py-1.5 rounded transition-colors cursor-pointer ${
           isActive ? 'bg-accent/10 border-l-2 border-accent' : 'hover:bg-surface-hover'
         }`}
         onClick={(): void => {
           onNodeClick(node.nodeId);
         }}
-        role="row"
+        onKeyDown={(e): void => {
+          if (e.key === 'Enter' || e.key === ' ') {
+            e.preventDefault();
+            onNodeClick(node.nodeId);
+          }
+        }}
+        role="button"
+        tabIndex={0}
       >
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/web/src/components/workflows/DagNodeProgress.tsx` around lines 26 -
34, The row div in DagNodeProgress.tsx is not keyboard-accessible; make it
focusable and operable via keyboard by either replacing the div with a semantic
button or adding tabIndex={0} and an onKeyDown handler that calls
onNodeClick(node.nodeId) for Enter and Space, keep role="row" or change to
role="button" and update aria-pressed/aria-current as appropriate when isActive;
ensure you reference the existing onNodeClick, node.nodeId and isActive symbols
when implementing the fix.

<div className="flex items-center gap-2 text-sm">
{hasIterations && (
<button
type="button"
onClick={(e): void => {
e.stopPropagation();
setExpanded(prev => !prev);
}}
className="text-text-tertiary hover:text-text-secondary shrink-0 text-xs cursor-pointer"
aria-label={expanded ? 'Collapse iterations' : 'Expand iterations'}
>
{expanded ? '\u25BC' : '\u25B6'}
</button>
)}
<StatusIcon status={node.status} />
<span className="truncate flex-1">{node.name}</span>
{node.currentIteration !== undefined && node.maxIterations !== undefined && (
<span className="text-xs text-text-secondary shrink-0">
{node.currentIteration}/{node.maxIterations}
</span>
)}
{node.duration !== undefined && (
<span className="text-xs text-text-secondary shrink-0">
{formatDurationMs(node.duration)}
</span>
)}
</div>
{node.error && (
<div className="text-xs text-red-400 mt-0.5 ml-6 truncate" title={node.error}>
{node.error.slice(0, 80)}
</div>
)}
{node.reason && (
<div className="text-xs text-text-tertiary mt-0.5 ml-6">
Skipped: {node.reason.replace(/_/g, ' ')}
</div>
)}
</div>
{expanded && hasIterations && (
<div className="ml-6 mt-0.5 space-y-0.5">
{(node.iterations ?? []).map(iter => (
<div key={iter.iteration} className="flex items-center gap-2 px-2 py-1 text-xs">
<StatusIcon status={iter.status} />
<span className="text-text-secondary flex-1">Iteration {iter.iteration}</span>
{iter.duration !== undefined && (
<span className="text-text-tertiary">{formatDurationMs(iter.duration)}</span>
)}
</div>
))}
</div>
)}
</div>
);
}

export function DagNodeProgress({
nodes,
activeNodeId,
Expand All @@ -22,37 +101,12 @@ export function DagNodeProgress({
return (
<div className="space-y-1 p-2">
{nodes.map(node => (
<button
<DagNodeItem
key={node.nodeId}
onClick={(): void => {
onNodeClick(node.nodeId);
}}
className={`w-full text-left px-2 py-1.5 rounded transition-colors ${
node.nodeId === activeNodeId
? 'bg-accent/10 border-l-2 border-accent'
: 'hover:bg-surface-hover'
}`}
>
<div className="flex items-center gap-2 text-sm">
<StatusIcon status={node.status} />
<span className="truncate flex-1">{node.name}</span>
{node.duration !== undefined && (
<span className="text-xs text-text-secondary shrink-0">
{formatDurationMs(node.duration)}
</span>
)}
</div>
{node.error && (
<div className="text-xs text-red-400 mt-0.5 ml-6 truncate" title={node.error}>
{node.error.slice(0, 80)}
</div>
)}
{node.reason && (
<div className="text-xs text-text-tertiary mt-0.5 ml-6">
Skipped: {node.reason.replace(/_/g, ' ')}
</div>
)}
</button>
node={node}
isActive={node.nodeId === activeNodeId}
onNodeClick={onNodeClick}
/>
))}
</div>
);
Expand Down
9 changes: 9 additions & 0 deletions packages/web/src/components/workflows/ExecutionDagNode.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ export interface ExecutionNodeData extends DagNodeData {
duration?: number;
error?: string;
selected?: boolean;
currentIteration?: number;
maxIterations?: number;
}

export type ExecutionFlowNode = Node<ExecutionNodeData>;
Expand All @@ -27,12 +29,14 @@ const TYPE_COLORS: Record<string, string> = {
command: 'text-purple-400',
prompt: 'text-accent-bright',
bash: 'text-amber-400',
loop: 'text-orange-400',
};

const TYPE_LABELS: Record<string, string> = {
command: 'CMD',
bash: 'BASH',
prompt: 'PROMPT',
loop: 'LOOP',
};

function ExecutionDagNodeRender({ data }: NodeProps<ExecutionFlowNode>): React.ReactElement {
Expand Down Expand Up @@ -60,6 +64,11 @@ function ExecutionDagNodeRender({ data }: NodeProps<ExecutionFlowNode>): React.R
</span>
)}
</div>
{data.currentIteration !== undefined && data.maxIterations !== undefined && (
<div className="text-[10px] text-text-tertiary mt-0.5">
{data.currentIteration}/{data.maxIterations} iterations
</div>
)}
{data.error && (
<div className="text-[10px] text-error mt-1 truncate" title={data.error}>
{data.error.slice(0, 60)}
Expand Down
2 changes: 2 additions & 0 deletions packages/web/src/components/workflows/WorkflowDagViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,8 @@ export function WorkflowDagViewer({
duration: live?.duration,
error: live?.error,
selected: node.id === selectedNodeId,
currentIteration: live?.currentIteration,
maxIterations: live?.maxIterations,
},
} as ExecutionFlowNode;
});
Expand Down
44 changes: 44 additions & 0 deletions packages/web/src/components/workflows/WorkflowExecution.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import type {
WorkflowRunStatus,
DagNodeState,
WorkflowStepStatus,
LoopIterationInfo,
} from '@/lib/types';

import type { WorkflowEventResponse } from '@/lib/api';
Expand Down Expand Up @@ -133,6 +134,49 @@ export function WorkflowExecution({ runId }: WorkflowExecutionProps): React.Reac
});
}
}

// Second pass: enrich loop nodes with iteration data
for (const e of data.events.filter(ev => ev.event_type.startsWith('loop_iteration_'))) {
const nodeId = e.step_name ?? '';
if (!nodeId) continue;
const existing = nodeMap.get(nodeId);
if (!existing) continue; // No node_started event yet — skip (events ordered in DB)

const iteration = e.data.iteration as number | undefined;
const maxIter = e.data.maxIterations as number | undefined;
if (iteration === undefined) continue;

let iterStatus: LoopIterationInfo['status'];
if (e.event_type === 'loop_iteration_started') {
iterStatus = 'running';
} else if (e.event_type === 'loop_iteration_completed') {
iterStatus = 'completed';
} else {
iterStatus = 'failed';
}

const existingIters: LoopIterationInfo[] = existing.iterations ?? [];
const iterIdx = existingIters.findIndex(it => it.iteration === iteration);
const iterState: LoopIterationInfo = {
iteration,
status: iterStatus,
duration: e.data.duration_ms as number | undefined,
Comment on lines +145 to +163
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Read the persisted loop duration field here.

The executor stores loop iteration elapsed time under data.duration, not data.duration_ms. With the current key, every historical iteration reloaded from REST gets duration: undefined, so iteration timing disappears after a refresh. The same field name should be aligned anywhere else this file reads loop iteration durations.

Suggested fix
               const iterState: LoopIterationInfo = {
                 iteration,
                 status: iterStatus,
-                duration: e.data.duration_ms as number | undefined,
+                duration: e.data.duration as number | undefined,
               };
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const iteration = e.data.iteration as number | undefined;
const maxIter = e.data.maxIterations as number | undefined;
if (iteration === undefined) continue;
let iterStatus: LoopIterationInfo['status'];
if (e.event_type === 'loop_iteration_started') {
iterStatus = 'running';
} else if (e.event_type === 'loop_iteration_completed') {
iterStatus = 'completed';
} else {
iterStatus = 'failed';
}
const existingIters: LoopIterationInfo[] = existing.iterations ?? [];
const iterIdx = existingIters.findIndex(it => it.iteration === iteration);
const iterState: LoopIterationInfo = {
iteration,
status: iterStatus,
duration: e.data.duration_ms as number | undefined,
const iteration = e.data.iteration as number | undefined;
const maxIter = e.data.maxIterations as number | undefined;
if (iteration === undefined) continue;
let iterStatus: LoopIterationInfo['status'];
if (e.event_type === 'loop_iteration_started') {
iterStatus = 'running';
} else if (e.event_type === 'loop_iteration_completed') {
iterStatus = 'completed';
} else {
iterStatus = 'failed';
}
const existingIters: LoopIterationInfo[] = existing.iterations ?? [];
const iterIdx = existingIters.findIndex(it => it.iteration === iteration);
const iterState: LoopIterationInfo = {
iteration,
status: iterStatus,
duration: e.data.duration as number | undefined,
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/web/src/components/workflows/WorkflowExecution.tsx` around lines 145
- 163, The code is reading loop iteration duration from the wrong field
(e.data.duration_ms) causing durations to be lost on reload; update the duration
read to use e.data.duration (and search for any other occurrences of duration_ms
in this file) so LoopIterationInfo.duration is populated from e.data.duration
wherever loop iteration events are parsed (e.g., the block building iterState
from e.data.duration_ms).

};
const newIters = [...existingIters];
if (iterIdx >= 0) {
newIters[iterIdx] = iterState;
} else {
newIters.push(iterState);
}

nodeMap.set(nodeId, {
...existing,
currentIteration: iteration,
maxIterations: maxIter ?? existing.maxIterations,
iterations: newIters,
});
}

return Array.from(nodeMap.values());
})(),
artifacts: data.events
Expand Down
46 changes: 46 additions & 0 deletions packages/web/src/components/workflows/WorkflowLogs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -388,10 +388,31 @@ export function WorkflowLogs({
filteredDbMessages = dbMessages;
}

// Collect DB text content for dedup against SSE text messages.
// During live execution, the same text (e.g., "🚀 Starting workflow...") can appear
// in both DB (from REST fetch on mount) and SSE (from event buffer replay).
// Without dedup, the text shows up twice in the message list.
const dbTextContents = new Set<string>();
for (const dm of filteredDbMessages) {
if (dm.role === 'assistant' && dm.content) {
dbTextContents.add(dm.content);
}
}

// Strip SSE tool calls that already appear in DB messages (completed).
// Also strip SSE text messages that are already in DB (prevents duplicate text).
const dedupedSse: ChatMessage[] = [];
for (const m of sseMessages) {
if (!m.toolCalls?.length) {
// Skip SSE text-only messages whose content already exists in DB.
if (m.content && dbTextContents.has(m.content)) {
continue;
}
// Also skip if DB has a message that starts with the SSE content
// (SSE text was flushed to DB before SSE finished accumulating).
if (m.content && [...dbTextContents].some(dc => dc.startsWith(m.content))) {
continue;
}
Comment on lines +391 to +415
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Prefix dedup is too broad and can hide valid live SSE text.

At Line 413, prefix matching against any DB assistant content can suppress unrelated SSE messages (especially short/common prefixes). Limit prefix-based suppression to actively streaming SSE text and preferably only against the latest DB assistant message.

Suggested adjustment
-    const dbTextContents = new Set<string>();
-    for (const dm of filteredDbMessages) {
-      if (dm.role === 'assistant' && dm.content) {
-        dbTextContents.add(dm.content);
-      }
-    }
+    const dbAssistantContents = filteredDbMessages
+      .filter((dm): dm is ChatMessage => dm.role === 'assistant' && !!dm.content)
+      .map(dm => dm.content);
+    const dbTextContents = new Set<string>(dbAssistantContents);
+    const latestDbAssistantContent =
+      dbAssistantContents.length > 0 ? dbAssistantContents[dbAssistantContents.length - 1] : '';

@@
-        if (m.content && [...dbTextContents].some(dc => dc.startsWith(m.content))) {
+        if (m.isStreaming && m.content && latestDbAssistantContent.startsWith(m.content)) {
           continue;
         }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/web/src/components/workflows/WorkflowLogs.tsx` around lines 391 -
415, The current prefix-based dedup (using [...dbTextContents].some(dc =>
dc.startsWith(m.content))) is too broad and can hide unrelated SSE text; change
it so prefix suppression only runs for actively streaming/partial SSE messages
(e.g., check a flag like m.isStreaming or m.isPartial) and compare the SSE
content only against the latest DB assistant message instead of every DB message
(derive the lastAssistantContent from filteredDbMessages). Update the condition
in the sseMessages loop (where dbTextContents and dedupedSse are used) to: skip
prefix-check unless m is streaming/partial, and when applied, only test
startsWith against lastAssistantContent.

if (m.isStreaming || m.content) dedupedSse.push(m);
continue;
}
Expand All @@ -415,7 +436,32 @@ export function WorkflowLogs({
const onText = useCallback((content: string): void => {
setSseMessages(prev => {
const last = prev[prev.length - 1];
// Workflow status messages (🚀 start, ✅ complete) should be their own message,
// matching ChatInterface's behavior and persistence segmentation. Without this,
// all text concatenates into one giant streaming message, breaking text dedup
// against DB messages (which are stored as separate segments).
const isWorkflowStatus = /^[\u{1F680}\u{2705}]/u.test(content);

if (last?.role === 'assistant' && last.isStreaming) {
const lastIsWorkflowStatus = /^[\u{1F680}\u{2705}]/u.test(last.content);

if ((isWorkflowStatus && last.content) || (lastIsWorkflowStatus && !isWorkflowStatus)) {
// Close the current streaming message and start a new one when:
// 1. Incoming is a workflow status and current has content
// 2. Current is a workflow status and incoming is regular text
return [
...prev.slice(0, -1),
{ ...last, isStreaming: false },
{
id: `msg-${String(Date.now())}`,
role: 'assistant' as const,
content,
timestamp: Date.now(),
isStreaming: true,
toolCalls: [],
},
];
}
return [...prev.slice(0, -1), { ...last, content: last.content + content }];
}
return [
Expand Down
10 changes: 9 additions & 1 deletion packages/web/src/hooks/useDashboardSSE.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import { useEffect } from 'react';
import { workflowSSEHandlers } from '@/stores/workflow-store';
import type { WorkflowStatusEvent, DagNodeEvent, WorkflowToolActivityEvent } from '@/lib/types';
import type {
WorkflowStatusEvent,
DagNodeEvent,
WorkflowToolActivityEvent,
LoopIterationEvent,
} from '@/lib/types';

/** Connects to the multiplexed dashboard SSE stream and routes events to the Zustand store. */
export function useDashboardSSE(): void {
Expand All @@ -25,6 +30,9 @@ export function useDashboardSSE(): void {
case 'workflow_tool_activity':
workflowSSEHandlers.onToolActivity(event as WorkflowToolActivityEvent);
break;
case 'workflow_step':
workflowSSEHandlers.onLoopIteration(event as LoopIterationEvent);
break;
// heartbeat — ignore
}
};
Expand Down
5 changes: 5 additions & 0 deletions packages/web/src/hooks/useSSE.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { useEffect, useRef, useState, useCallback } from 'react';
import type {
SSEEvent,
ErrorDisplay,
LoopIterationEvent,
WorkflowStatusEvent,
WorkflowArtifactEvent,
WorkflowDispatchEvent,
Expand Down Expand Up @@ -37,6 +38,7 @@ interface SSEHandlers {
onWorkflowStatus?: (event: WorkflowStatusEvent) => void;
onWorkflowArtifact?: (event: WorkflowArtifactEvent) => void;
onDagNode?: (event: DagNodeEvent) => void;
onLoopIteration?: (event: LoopIterationEvent) => void;
onWorkflowDispatch?: (event: WorkflowDispatchEvent) => void;
onWorkflowOutputPreview?: (event: WorkflowOutputPreviewEvent) => void;
onWarning?: (message: string) => void;
Expand Down Expand Up @@ -187,6 +189,9 @@ export function useSSE(
case 'dag_node':
h.onDagNode?.(data);
break;
case 'workflow_step':
h.onLoopIteration?.(data);
break;
case 'workflow_dispatch':
// Flush buffered text before dispatch events to ensure the dispatch
// message (🚀) is committed as an assistant message before
Expand Down
Loading
Loading