Skip to content

Commit 4488390

Browse files
anderskevclaude
andauthored
fix(dashboard): show active state for workflow canvas nodes during execution (#225)
* docs(plans): add workflow canvas simplification design Addresses #218 - workflow canvas nodes not showing active state. Design decisions: - Replace custom components with ai-elements library - Event-driven pipeline (fixes real-time update bug) - Expandable nodes showing iteration history - Dynamic agent nodes instead of hardcoded 3-node pipeline 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * chore(dashboard): add ai-elements dependency * feat(dashboard): add AgentIteration and AgentNodeData types * feat(dashboard): add buildPipelineFromEvents for real-time pipeline updates * feat(dashboard): add AgentNode component with status-based styling Add a new React Flow node component for displaying agent stages in the workflow canvas. The component shows status-based styling (pending, active, completed, blocked), iteration badges for multiple runs, and expandable iteration history. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(dashboard): merge real-time events for instant pipeline updates - Add eventDrivenPipelineToCanvas converter to bridge event-driven pipeline builder with WorkflowCanvas component - WorkflowDetailPage now merges loader events with real-time WebSocket events using Map-based deduplication and sequence-based sorting - Pipeline visualization now updates in real-time as events arrive - ActivityLog receives merged events for consistent state - Add comprehensive tests for event merging, deduplication, and sorting Fixes #218 - nodes now show active state during execution 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * refactor(dashboard): simplify WorkflowCanvas with event-driven pipeline - Rewrite WorkflowCanvas to accept EventDrivenPipeline with Node<AgentNodeData> - Remove isLoading prop and loading/empty states (simplified API) - Update layout utility to use generic Node<T> type for flexibility - Update WorkflowsPage to use buildPipelineFromEvents for real-time updates - Update tests to match new event-driven pipeline format Part of #218 - workflow canvas active state fixes 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(dashboard): update tests for event-driven pipeline types - useWorkflows.test.tsx: Use system_info instead of stage_started for non-status event test, since stage_started is now a status event that triggers revalidation - layout.test.ts: Fix test to expect 'test' node type (matching the mock) instead of incorrectly expecting 'workflow' type 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(dashboard): resolve TypeScript strict type errors for XyFlow integration - Add index signature to AgentNodeData to satisfy XyFlow Node<T> constraint - Fix AgentNode component to use proper NodeProps<AgentNodeType> typing - Replace removed ./flow export with AgentNode export in components/index.ts - Add null checks for array access in pipeline.ts - Fix test files with proper non-null assertions and valid event types - Update WorkflowDetailPage to use EventDrivenPipeline directly 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat(orchestrator): emit STAGE_STARTED events from nodes on execution Nodes now emit STAGE_STARTED events when they actually begin execution, rather than relying on the streaming consumer to infer stage transitions. This provides accurate real-time pipeline visualization in the dashboard. Backend changes: - Add StageEventEmitter type for callback-based stage event emission - All nodes (architect, developer, reviewer, evaluator, plan_validator) emit STAGE_STARTED at the start of their execution - human_approval_node event emitted when interrupt is detected - Remove redundant STAGE_STARTED emission from stream chunk handler Frontend changes: - WorkflowCanvas: Use controlled state (props) instead of useNodesState/ useEdgesState since canvas is read-only - AgentNode: Wrap with memo() to prevent unnecessary re-renders - WorkflowDetailPage: Use targeted store selector to minimize re-renders Closes #218 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * refactor(dashboard): enhance workflow canvas with ai-elements integration Add new dependencies for improved UI components including ai-elements canvas, shiki for syntax highlighting, streamdown for markdown streaming, and various radix-ui primitives. Update AgentNode and WorkflowCanvas components with enhanced styling and type safety for XyFlow integration. Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix(dashboard): preserve completed workflow display and fix canvas timing - Add `agent="human_approval"` to approval-related events for consistent canvas node rendering and styling - Defer fitView to next animation frame to ensure React Flow has rendered nodes before calculating bounds - Include most recently completed workflow in active list so canvas doesn't immediately clear when workflow finishes - Add VALIDATOR and EVALUATOR agent styles, rename PLAN_VALIDATOR - Update mock data to use correct human_approval agent Fixes #218 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * docs: add active workflow screenshot to README * refactor(dashboard): polish activity log and workflow canvas UX - Simplify ActivityLog grid layout: reduce column widths and padding - Remove milliseconds from timestamp display (HH:MM:SS vs HH:MM:SS.mmm) - Remove brackets from agent name display (cleaner appearance) - Fix WorkflowCanvas fitView timing: use useNodesInitialized hook instead of requestAnimationFrame for more reliable behavior - Only call fitView when node count increases (not on initial render) - Show status text for all node states in AgentNode (pending/completed/blocked) - Add documentation comment to Canvas component - Delete completed plan file 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * test(orchestrator): add test for emitter extraction from config Adds coverage for the happy path where stream_emitter and stage_event_emitter are provided in the RunnableConfig. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent ee19d03 commit 4488390

43 files changed

Lines changed: 4413 additions & 1023 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
![Amelia Terminal](docs/design/terminal_screen.jpg)
44

5+
![Active Workflow](docs/design/amelia_active_workflow.png)
6+
57
[Amelia](https://en.wikipedia.org/wiki/Amelia_Earhart) is a local agentic coding system that orchestrates software development through Architect, Developer, and Reviewer agents. They argue about your code so you don't have to.
68

79
See the [**Roadmap**](https://existential-birds.github.io/amelia/reference/roadmap) for where we're headed.

amelia/core/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
AmeliaError: Base exception for all Amelia errors.
1010
ConfigurationError: Invalid or missing configuration.
1111
PathTraversalError: Attempted path traversal outside allowed directory.
12+
StageEventEmitter: Callback for emitting STAGE_STARTED events from nodes.
1213
StreamEmitter: Protocol for emitting stream events.
1314
StreamEvent: Event payload for streaming updates.
1415
StreamEventType: Enum of stream event categories.
@@ -22,6 +23,7 @@
2223
PathTraversalError as PathTraversalError,
2324
)
2425
from amelia.core.types import (
26+
StageEventEmitter as StageEventEmitter,
2527
StreamEmitter as StreamEmitter,
2628
StreamEvent as StreamEvent,
2729
StreamEventType as StreamEventType,

amelia/core/orchestrator.py

Lines changed: 42 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,13 @@
2222
from amelia.agents.reviewer import Reviewer
2323
from amelia.core.constants import resolve_plan_path
2424
from amelia.core.state import ExecutionState
25-
from amelia.core.types import Profile, StreamEmitter, StreamEvent, StreamEventType
25+
from amelia.core.types import (
26+
Profile,
27+
StageEventEmitter,
28+
StreamEmitter,
29+
StreamEvent,
30+
StreamEventType,
31+
)
2632
from amelia.drivers.factory import DriverFactory
2733
from amelia.server.models.tokens import TokenUsage
2834

@@ -33,23 +39,24 @@
3339

3440
def _extract_config_params(
3541
config: RunnableConfig | None,
36-
) -> tuple[StreamEmitter | None, str, Profile]:
37-
"""Extract stream_emitter, workflow_id, and profile from RunnableConfig.
42+
) -> tuple[StreamEmitter | None, StageEventEmitter | None, str, Profile]:
43+
"""Extract stream_emitter, stage_event_emitter, workflow_id, and profile from config.
3844
3945
Extracts values from config.configurable dictionary. workflow_id is required.
4046
4147
Args:
4248
config: Optional RunnableConfig with configurable parameters.
4349
4450
Returns:
45-
Tuple of (stream_emitter, workflow_id, profile).
51+
Tuple of (stream_emitter, stage_event_emitter, workflow_id, profile).
4652
4753
Raises:
4854
ValueError: If workflow_id (thread_id) or profile is not provided.
4955
"""
5056
config = config or {}
5157
configurable = config.get("configurable", {})
5258
stream_emitter = configurable.get("stream_emitter")
59+
stage_event_emitter = configurable.get("stage_event_emitter")
5360
workflow_id = configurable.get("thread_id")
5461
profile = configurable.get("profile")
5562

@@ -58,7 +65,7 @@ def _extract_config_params(
5865
if not profile:
5966
raise ValueError("profile is required in config.configurable")
6067

61-
return stream_emitter, workflow_id, profile
68+
return stream_emitter, stage_event_emitter, workflow_id, profile
6269

6370

6471
async def _save_token_usage(
@@ -143,7 +150,11 @@ async def plan_validator_node(
143150
Raises:
144151
ValueError: If plan file not found or empty.
145152
"""
146-
stream_emitter, workflow_id, profile = _extract_config_params(config)
153+
stream_emitter, stage_event_emitter, workflow_id, profile = _extract_config_params(config)
154+
155+
# Emit STAGE_STARTED event at the beginning of the node
156+
if stage_event_emitter:
157+
await stage_event_emitter("plan_validator_node")
147158

148159
if not state.issue:
149160
raise ValueError("Issue is required in state for plan validation")
@@ -241,8 +252,12 @@ async def call_architect_node(
241252
if state.issue is None:
242253
raise ValueError("Cannot call Architect: no issue provided in state.")
243254

244-
# Extract stream_emitter, workflow_id, and profile from config
245-
stream_emitter, workflow_id, profile = _extract_config_params(config)
255+
# Extract stream_emitter, stage_event_emitter, workflow_id, and profile from config
256+
stream_emitter, stage_event_emitter, workflow_id, profile = _extract_config_params(config)
257+
258+
# Emit STAGE_STARTED event at the beginning of the node
259+
if stage_event_emitter:
260+
await stage_event_emitter("architect_node")
246261

247262
# Get optional repository for token usage tracking
248263
config = config or {}
@@ -519,8 +534,12 @@ async def call_developer_node(
519534
if not state.goal:
520535
raise ValueError("Developer node has no goal. The architect should have generated a goal first.")
521536

522-
# Extract stream_emitter, workflow_id, and profile from config
523-
stream_emitter, workflow_id, profile = _extract_config_params(config)
537+
# Extract stream_emitter, stage_event_emitter, workflow_id, and profile from config
538+
stream_emitter, stage_event_emitter, workflow_id, profile = _extract_config_params(config)
539+
540+
# Emit STAGE_STARTED event at the beginning of the node
541+
if stage_event_emitter:
542+
await stage_event_emitter("developer_node")
524543

525544
# Get optional repository for token usage tracking
526545
config = config or {}
@@ -585,8 +604,12 @@ async def call_reviewer_node(
585604
has_code_changes_for_review=bool(state.code_changes_for_review),
586605
)
587606

588-
# Extract stream_emitter, workflow_id, and profile from config
589-
stream_emitter, workflow_id, profile = _extract_config_params(config)
607+
# Extract stream_emitter, stage_event_emitter, workflow_id, and profile from config
608+
stream_emitter, stage_event_emitter, workflow_id, profile = _extract_config_params(config)
609+
610+
# Emit STAGE_STARTED event at the beginning of the node
611+
if stage_event_emitter:
612+
await stage_event_emitter("reviewer_node")
590613

591614
# Get optional repository for token usage tracking
592615
config = config or {}
@@ -657,7 +680,11 @@ async def call_evaluation_node(
657680
Returns:
658681
Partial state dict with evaluation_result, approved_items, and driver_session_id.
659682
"""
660-
stream_emitter, workflow_id, profile = _extract_config_params(config)
683+
stream_emitter, stage_event_emitter, workflow_id, profile = _extract_config_params(config)
684+
685+
# Emit STAGE_STARTED event at the beginning of the node
686+
if stage_event_emitter:
687+
await stage_event_emitter("evaluation_node")
661688

662689
# Extract prompts from config for agent injection
663690
config = config or {}
@@ -753,8 +780,8 @@ def route_after_review(
753780
if state.last_review and state.last_review.approved:
754781
return "__end__"
755782

756-
# Extract profile from config
757-
_, _, profile = _extract_config_params(config)
783+
# Extract profile from config (only need profile, ignore stream/stage emitters)
784+
_, _, _, profile = _extract_config_params(config)
758785
max_iterations = profile.max_review_iterations
759786

760787
if state.review_iteration >= max_iterations:

amelia/core/types.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,3 +166,11 @@ class StreamEvent(BaseModel, frozen=True):
166166

167167
StreamEmitter = Callable[[StreamEvent], Awaitable[None]]
168168
"""Type alias for async streaming event emitter function."""
169+
170+
StageEventEmitter = Callable[[str], Awaitable[None]]
171+
"""Type alias for async stage event emitter function.
172+
173+
Takes the stage name (e.g., "architect_node") and emits a STAGE_STARTED event.
174+
This allows nodes to emit stage start events when they actually begin execution,
175+
rather than relying on the streaming consumer to predict the next stage.
176+
"""

amelia/server/orchestrator/service.py

Lines changed: 53 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,14 @@
1818

1919
from amelia.core.orchestrator import create_orchestrator_graph, create_review_graph
2020
from amelia.core.state import ExecutionState
21-
from amelia.core.types import Issue, Profile, Settings, StreamEmitter, StreamEvent
21+
from amelia.core.types import (
22+
Issue,
23+
Profile,
24+
Settings,
25+
StageEventEmitter,
26+
StreamEmitter,
27+
StreamEvent,
28+
)
2229
from amelia.ext import WorkflowEventType as ExtWorkflowEventType
2330
from amelia.ext.exceptions import PolicyDeniedError
2431
from amelia.ext.hooks import (
@@ -153,6 +160,30 @@ async def emit(event: StreamEvent) -> None:
153160

154161
return emit
155162

163+
def _create_stage_event_emitter(self, workflow_id: str) -> StageEventEmitter:
164+
"""Create a stage event emitter callback for nodes to signal stage start.
165+
166+
Nodes call this emitter when they begin execution, allowing real-time
167+
STAGE_STARTED events to be persisted and broadcast. This is the canonical
168+
way for nodes to emit stage start events, since stream_mode="updates"
169+
only provides chunks after nodes complete.
170+
171+
Args:
172+
workflow_id: The workflow ID to associate with emitted events.
173+
174+
Returns:
175+
Async callback that takes a stage name and emits STAGE_STARTED.
176+
"""
177+
async def emit_stage_started(stage_name: str) -> None:
178+
await self._emit(
179+
workflow_id,
180+
EventType.STAGE_STARTED,
181+
f"Starting {stage_name}",
182+
data={"stage": stage_name},
183+
)
184+
185+
return emit_stage_started
186+
156187
async def _resolve_prompts(self, workflow_id: str) -> dict[str, str]:
157188
"""Resolve all prompts for a workflow.
158189
@@ -753,11 +784,13 @@ async def _run_workflow(
753784

754785
# Create stream emitter and pass it via config
755786
stream_emitter = self._create_stream_emitter()
787+
stage_event_emitter = self._create_stage_event_emitter(workflow_id)
756788
config: RunnableConfig = {
757789
"configurable": {
758790
"thread_id": workflow_id,
759791
"execution_mode": "server",
760792
"stream_emitter": stream_emitter,
793+
"stage_event_emitter": stage_event_emitter,
761794
"profile": profile,
762795
"repository": self._repository,
763796
"prompts": prompts,
@@ -826,10 +859,19 @@ async def _run_workflow(
826859
# Sync plan from LangGraph checkpoint to ServerExecutionState
827860
# so it's available via REST API while blocked
828861
await self._sync_plan_from_checkpoint(workflow_id, graph, config)
862+
# Emit STAGE_STARTED for human_approval_node (interrupted before running)
863+
await self._emit(
864+
workflow_id,
865+
EventType.STAGE_STARTED,
866+
"Starting human_approval_node",
867+
agent="human_approval",
868+
data={"stage": "human_approval_node"},
869+
)
829870
await self._emit(
830871
workflow_id,
831872
EventType.APPROVAL_REQUIRED,
832873
"Plan ready for review - awaiting human approval",
874+
agent="human_approval",
833875
data={"paused_at": "human_approval_node"},
834876
)
835877
# Emit extension hook for approval gate
@@ -1030,11 +1072,13 @@ async def _run_review_workflow(
10301072

10311073
# Create stream emitter and pass it via config
10321074
stream_emitter = self._create_stream_emitter()
1075+
stage_event_emitter = self._create_stage_event_emitter(workflow_id)
10331076
config: RunnableConfig = {
10341077
"configurable": {
10351078
"thread_id": workflow_id,
10361079
"execution_mode": "server",
10371080
"stream_emitter": stream_emitter,
1081+
"stage_event_emitter": stage_event_emitter,
10381082
"profile": profile,
10391083
"repository": self._repository,
10401084
"prompts": prompts,
@@ -1191,6 +1235,7 @@ async def approve_workflow(self, workflow_id: str) -> None:
11911235
workflow_id,
11921236
EventType.APPROVAL_GRANTED,
11931237
"Plan approved",
1238+
agent="human_approval",
11941239
)
11951240
# Emit extension hook for approval
11961241
await emit_workflow_event(
@@ -1221,11 +1266,13 @@ async def approve_workflow(self, workflow_id: str) -> None:
12211266

12221267
# Create stream emitter and pass it via config
12231268
stream_emitter = self._create_stream_emitter()
1269+
stage_event_emitter = self._create_stage_event_emitter(workflow_id)
12241270
config: RunnableConfig = {
12251271
"configurable": {
12261272
"thread_id": workflow_id,
12271273
"execution_mode": "server",
12281274
"stream_emitter": stream_emitter,
1275+
"stage_event_emitter": stage_event_emitter,
12291276
"profile": profile,
12301277
"repository": self._repository,
12311278
"prompts": prompts,
@@ -1480,11 +1527,11 @@ async def _handle_stream_chunk(
14801527
"""Handle a chunk from astream(stream_mode='updates').
14811528
14821529
With stream_mode='updates', each chunk maps node names to their
1483-
state updates. We emit STAGE_STARTED before and STAGE_COMPLETED
1484-
after each node that's in STAGE_NODES.
1530+
state updates. We emit STAGE_COMPLETED after each node that's in
1531+
STAGE_NODES.
14851532
1486-
Additionally emits AGENT_MESSAGE and task lifecycle events (TASK_STARTED,
1487-
TASK_COMPLETED, TASK_FAILED) based on node output.
1533+
Note: STAGE_STARTED events are emitted by the nodes themselves when
1534+
they begin execution (via stage_event_emitter in config).
14881535
14891536
Args:
14901537
workflow_id: The workflow this chunk belongs to.
@@ -1498,18 +1545,10 @@ async def _handle_stream_chunk(
14981545
state.current_stage = node_name
14991546
await self._repository.update(state)
15001547

1501-
# Emit both started and completed for each node update
1502-
# (astream "updates" mode gives us the result after completion)
1503-
await self._emit(
1504-
workflow_id,
1505-
EventType.STAGE_STARTED,
1506-
f"Starting {node_name}",
1507-
data={"stage": node_name},
1508-
)
1509-
15101548
# Emit agent-specific messages based on node
15111549
await self._emit_agent_messages(workflow_id, node_name, output)
15121550

1551+
# Emit STAGE_COMPLETED for the current node
15131552
await self._emit(
15141553
workflow_id,
15151554
EventType.STAGE_COMPLETED,

dashboard/package.json

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,26 +21,37 @@
2121
"@radix-ui/react-collapsible": "^1.1.2",
2222
"@radix-ui/react-dialog": "^1.1.3",
2323
"@radix-ui/react-dropdown-menu": "^2.1.3",
24+
"@radix-ui/react-hover-card": "^1.1.15",
2425
"@radix-ui/react-navigation-menu": "^1.2.2",
2526
"@radix-ui/react-progress": "^1.1.1",
2627
"@radix-ui/react-scroll-area": "^1.2.2",
28+
"@radix-ui/react-select": "^2.2.6",
2729
"@radix-ui/react-separator": "^1.1.8",
2830
"@radix-ui/react-slot": "^1.1.1",
2931
"@radix-ui/react-tooltip": "^1.1.5",
32+
"@radix-ui/react-use-controllable-state": "^1.2.2",
3033
"@tailwindcss/typography": "^0.5.19",
3134
"@xyflow/react": "^12.10.0",
3235
"ai": "^5.0.108",
36+
"ai-elements": "^1.6.3",
3337
"class-variance-authority": "^0.7.1",
3438
"clsx": "^2.1.1",
39+
"cmdk": "^1.1.1",
40+
"embla-carousel-react": "^8.6.0",
3541
"lucide-react": "^0.460.0",
3642
"motion": "^12.23.25",
43+
"nanoid": "^5.1.6",
3744
"react": "^18.3.1",
3845
"react-dom": "^18.3.1",
3946
"react-markdown": "^10.1.0",
4047
"react-router-dom": "^7.0.2",
4148
"remark-breaks": "^4.0.0",
49+
"shiki": "^3.20.0",
4250
"sonner": "^1.7.1",
51+
"streamdown": "^1.6.11",
4352
"tailwind-merge": "^2.6.0",
53+
"tokenlens": "^1.3.1",
54+
"use-stick-to-bottom": "^1.1.1",
4455
"zustand": "^5.0.9"
4556
},
4657
"devDependencies": {

0 commit comments

Comments
 (0)