-
Notifications
You must be signed in to change notification settings - Fork 3.2k
feat(web): loop node iteration visibility in workflow execution view #1026
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
8978ad6
c639063
a248438
6256668
968dfad
4e56c86
1eddf3e
60ddda3
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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'; | ||
|
|
@@ -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 { | ||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Row selection is not keyboard-accessible. The clickable row is a non-focusable ♿ 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 |
||
| <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, | ||
|
|
@@ -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> | ||
| ); | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -21,6 +21,7 @@ import type { | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| WorkflowRunStatus, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| DagNodeState, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| WorkflowStepStatus, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| LoopIterationInfo, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } from '@/lib/types'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import type { WorkflowEventResponse } from '@/lib/api'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Read the persisted loop duration field here. The executor stores loop iteration elapsed time under 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
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 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 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 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 |
||
| if (m.isStreaming || m.content) dedupedSse.push(m); | ||
| continue; | ||
| } | ||
|
|
@@ -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 [ | ||
|
|
||
There was a problem hiding this comment.
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:
Repository: coleam00/Archon
Length of output: 86
🏁 Script executed:
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.tsxRepository: coleam00/Archon
Length of output: 120
🏁 Script executed:
Repository: coleam00/Archon
Length of output: 93
🏁 Script executed:
Repository: coleam00/Archon
Length of output: 1016
Extract
DagNodeItemprops into a dedicatedDagNodeItemPropsinterface.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
DagNodeProgressPropspattern used elsewhere in this file.Suggested refactor
📝 Committable suggestion
🤖 Prompt for AI Agents