Skip to content

Commit 15d7cd0

Browse files
authored
Merge pull request #136 from microsoft/fix/progress-modal-phase-tracking
fix: progress modal phase tracking and UI improvements
2 parents 5e35c92 + 65210d4 commit 15d7cd0

File tree

13 files changed

+260
-53
lines changed

13 files changed

+260
-53
lines changed

.flake8

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@
22
max-line-length = 88
33
extend-ignore = E501
44
exclude = .venv, frontend
5-
ignore = E722,E203, W503, G004, G200, F,E711
5+
ignore = E722,E203, W503, G004, G200, F,E711,E704

src/frontend/src/commonComponents/ProgressModal/progressModal.tsx

Lines changed: 145 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -39,24 +39,26 @@ const ProgressModal: React.FC<ProgressModalProps> = ({
3939
migrationError = false,
4040
onNavigateHome
4141
}) => {
42-
// Calculate progress percentage based on phases
42+
// Calculate progress percentage based on step (stable step-level identifier)
4343
const getProgressPercentage = () => {
4444
if (migrationError) return 0; // Show 0% progress for errors
45-
if (!apiData || !apiData.phase) return 0;
46-
47-
const phases = ['Analysis', 'Design', 'YAML', 'Documentation'];
48-
const currentPhaseIndex = phases.indexOf(apiData.phase);
49-
50-
if (currentPhaseIndex === -1) return 0;
5145
if (processingCompleted && !migrationError) return 100;
52-
53-
// Each phase represents 25% of the progress
54-
const baseProgress = (currentPhaseIndex / phases.length) * 100;
55-
56-
// Add some progress within the current phase based on time elapsed
57-
const phaseProgress = Math.min(20, (currentPhaseIndex + 1) * 5);
58-
59-
return Math.min(95, baseProgress + phaseProgress);
46+
if (!apiData) return 0;
47+
48+
// Use apiData.step (stable: "analysis", "design", "yaml_conversion", "documentation")
49+
// rather than apiData.phase which changes to sub-phase names like "Platform Enhancement"
50+
const steps = ['analysis', 'design', 'yaml_conversion', 'documentation'];
51+
const currentStepIndex = steps.indexOf((apiData.step || '').toLowerCase());
52+
53+
if (currentStepIndex === -1) return 0;
54+
55+
// Each step represents 25% of the progress
56+
const baseProgress = (currentStepIndex / steps.length) * 100;
57+
58+
// Add some progress within the current step
59+
const stepProgress = Math.min(20, (currentStepIndex + 1) * 5);
60+
61+
return Math.min(95, baseProgress + stepProgress);
6062
};
6163

6264
const progressPercentage = getProgressPercentage();
@@ -180,12 +182,135 @@ const ProgressModal: React.FC<ProgressModalProps> = ({
180182
borderRadius: '6px',
181183
fontSize: '14px'
182184
}}>
183-
<div style={{ fontWeight: '500', marginBottom: '4px' }}>Current Activity:</div>
184-
<div style={{ color: '#666' }}>
185-
{apiData.agents?.find(agent => agent.includes('speaking') || agent.includes('thinking')) ||
186-
`Working on ${currentPhase?.toLowerCase()} phase...`}
185+
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '8px' }}>
186+
<span style={{ fontWeight: '500' }}>Current Activity:</span>
187+
{(() => {
188+
// Show step-level elapsed time from step_timings
189+
const stepTimings = apiData.step_timings || {};
190+
const currentStep = (apiData.step || '').toLowerCase();
191+
const timing = stepTimings[currentStep];
192+
if (timing?.started_at) {
193+
try {
194+
const started = new Date(timing.started_at.replace(' UTC', 'Z'));
195+
const diffSec = Math.max(0, Math.floor((Date.now() - started.getTime()) / 1000));
196+
let elapsed = '';
197+
if (diffSec < 60) elapsed = `${diffSec}s`;
198+
else if (diffSec < 3600) elapsed = `${Math.floor(diffSec / 60)}m ${diffSec % 60}s`;
199+
else elapsed = `${Math.floor(diffSec / 3600)}h ${Math.floor((diffSec % 3600) / 60)}m`;
200+
return (
201+
<span style={{ fontSize: '12px', color: '#888' }}>
202+
{elapsed}
203+
</span>
204+
);
205+
} catch { /* ignore */ }
206+
}
207+
return null;
208+
})()}
187209
</div>
188-
{apiData.active_agent_count && apiData.total_agents && (
210+
{(() => {
211+
// Parse all active agents from raw telemetry strings
212+
const activeAgents = (apiData.agents || []).filter((agent: string) =>
213+
agent.startsWith('✓')
214+
);
215+
216+
if (activeAgents.length === 0) {
217+
return (
218+
<div style={{ color: '#666' }}>
219+
Working on {currentPhase?.toLowerCase()} phase...
220+
</div>
221+
);
222+
}
223+
224+
return activeAgents.map((raw: string, idx: number) => {
225+
// Strip prefix: "✓[🤔🔥] " → ""
226+
const cleaned = raw.replace(/^[]\[.*?\]\s*/, '');
227+
// Agent name: everything before first ":"
228+
const colonIdx = cleaned.indexOf(':');
229+
const agentName = colonIdx > 0 ? cleaned.substring(0, colonIdx).trim() : 'Agent';
230+
231+
// Determine action from status keywords
232+
const actionIcons: Record<string, { icon: string; label: string }> = {
233+
'speaking': { icon: '🗣️', label: 'Speaking' },
234+
'thinking': { icon: '💭', label: 'Thinking' },
235+
'using_tool':{ icon: '🔧', label: 'Invoking Tool' },
236+
'analyzing': { icon: '🔍', label: 'Analyzing' },
237+
'responded': { icon: '✅', label: 'Responded' },
238+
'ready': { icon: '⏳', label: 'Ready' },
239+
};
240+
const statusPart = colonIdx > 0 ? cleaned.substring(colonIdx + 1) : cleaned;
241+
let actionInfo = { icon: '⚡', label: 'Working' };
242+
for (const [key, info] of Object.entries(actionIcons)) {
243+
if (statusPart.toLowerCase().includes(key)) {
244+
actionInfo = info;
245+
break;
246+
}
247+
}
248+
249+
// Extract tool name(s) from 🔧 segment
250+
const toolMatch = raw.match(/🔧\s*([^|]+)/);
251+
let toolName = '';
252+
if (toolMatch) {
253+
// Clean up: take tool names, strip long JSON args
254+
toolName = toolMatch[1]
255+
.trim()
256+
.replace(/\{[^}]*\}\.*/g, '') // remove JSON snippets
257+
.replace(/\(.*?\)/g, '') // remove parenthesized args
258+
.replace(/,\s*$/, '')
259+
.trim();
260+
if (toolName) {
261+
actionInfo = { icon: '🔧', label: 'Invoking Tool' };
262+
}
263+
}
264+
265+
// Extract action count from 📊 segment
266+
const actionsMatch = raw.match(/📊\s*(\d+)\s*actions?/);
267+
const actionCount = actionsMatch ? parseInt(actionsMatch[1]) : 0;
268+
269+
// Extract blocking info from 🚧 segment
270+
const blockingMatch = raw.match(/🚧\s*Blocking\s*(\d+)/);
271+
const blockingCount = blockingMatch ? parseInt(blockingMatch[1]) : 0;
272+
273+
return (
274+
<div key={idx} style={{
275+
marginBottom: idx < activeAgents.length - 1 ? '10px' : 0,
276+
padding: '8px 10px',
277+
backgroundColor: 'white',
278+
borderRadius: '6px',
279+
border: '1px solid #e8e8e8',
280+
}}>
281+
{/* Agent name + action */}
282+
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', marginBottom: '4px' }}>
283+
<span style={{ fontSize: '15px' }}>{actionInfo.icon}</span>
284+
<span style={{ fontWeight: '600', color: '#333' }}>{agentName}</span>
285+
<span style={{
286+
fontSize: '12px',
287+
color: '#0078d4',
288+
backgroundColor: '#e8f4fd',
289+
padding: '1px 8px',
290+
borderRadius: '10px',
291+
fontWeight: '500'
292+
}}>
293+
{actionInfo.label}
294+
</span>
295+
</div>
296+
{/* Tool + metrics row */}
297+
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', fontSize: '12px', color: '#666' }}>
298+
{toolName && (
299+
<span style={{ display: 'flex', alignItems: 'center', gap: '3px' }}>
300+
<span>🔧</span> {toolName}
301+
</span>
302+
)}
303+
{actionCount > 0 && (
304+
<span style={{ display: 'flex', alignItems: 'center', gap: '3px' }}>
305+
<span>📊</span> {actionCount} action{actionCount !== 1 ? 's' : ''}
306+
</span>
307+
)}
308+
</div>
309+
</div>
310+
);
311+
});
312+
})()}
313+
{apiData.active_agent_count != null && apiData.total_agents != null && (
189314
<div style={{ marginTop: '8px', fontSize: '12px', color: '#888' }}>
190315
{apiData.active_agent_count}/{apiData.total_agents} agents active
191316
{apiData.health_status?.includes('🟢') && ' 🟢'}

src/frontend/src/pages/processPage.tsx

Lines changed: 32 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -130,13 +130,21 @@ const ProcessPage: React.FC = () => {
130130
const getPhaseMessage = (apiResponse: any) => {
131131
if (!apiResponse) return "";
132132

133-
const { phase, active_agent_count, total_agents, health_status, agents } = apiResponse;
133+
const { step, phase, active_agent_count, total_agents, health_status, agents } = apiResponse;
134+
135+
// Map step identifiers to human-readable step names
136+
const stepDisplayNames: Record<string, string> = {
137+
'analysis': 'Analysis',
138+
'design': 'Design',
139+
'yaml_conversion': 'YAML',
140+
'documentation': 'Documentation'
141+
};
134142

135-
const phaseMessages = {
136-
'Analysis': 'Analyzing workloads and dependencies, existing container images and configurations',
137-
'Design': 'Designing target environment mappings to align with Azure AKS',
138-
'YAML': 'Converting container specifications and orchestration configs to Azure format',
139-
'Documentation': 'Generating migration report and deployment files'
143+
const stepMessages: Record<string, string> = {
144+
'analysis': 'Analyzing workloads and dependencies, existing container images and configurations',
145+
'design': 'Designing target environment mappings to align with Azure AKS',
146+
'yaml_conversion': 'Converting container specifications and orchestration configs to Azure format',
147+
'documentation': 'Generating migration report and deployment files'
140148
};
141149

142150
// Extract active agent information from agents array
@@ -156,11 +164,16 @@ const ProcessPage: React.FC = () => {
156164
agentActivity = ` - ${agentName} is thinking`;
157165
}
158166

159-
const baseMessage = phaseMessages[phase] || `${phase} phase in progress`;
167+
const stepKey = (step || '').toLowerCase();
168+
const stepName = stepDisplayNames[stepKey] || step || 'Processing';
169+
170+
// Use step-level description if phase matches the step, otherwise show sub-phase
171+
const baseMessage = stepMessages[stepKey] || `${phase} phase in progress`;
172+
// If phase differs from the step-level name, show the sub-phase as detail
173+
const phaseDetail = (phase && phase !== stepDisplayNames[stepKey]) ? `${phase} - ` : '';
160174
const agentInfo = active_agent_count && total_agents ? ` (${active_agent_count}/${total_agents} agents active)` : '';
161-
const healthIcon = health_status?.includes('🟢') ? ' 🟢' : '';
162175

163-
return `${phase} phase: ${baseMessage}${agentActivity}${agentInfo}`;
176+
return `${stepName}: ${phaseDetail}${baseMessage}${agentActivity}${agentInfo}`;
164177
};
165178

166179
// Polling function to check batch status
@@ -193,11 +206,19 @@ const ProcessPage: React.FC = () => {
193206
setLastUpdateTime(response.last_update_time);
194207

195208
// Update current phase and generate step message
196-
if (response.phase) {
209+
if (response.step || response.phase) {
197210
const newPhaseMessage = getPhaseMessage(response);
198211

199212
// Add the new message to steps ONLY if it's different from the last message
200-
setCurrentPhase(response.phase);
213+
const stepDisplayNames: Record<string, string> = {
214+
'analysis': 'Analysis',
215+
'design': 'Design',
216+
'yaml_conversion': 'YAML',
217+
'documentation': 'Documentation'
218+
};
219+
const stepKey = (response.step || '').toLowerCase();
220+
const stepLabel = stepDisplayNames[stepKey] || response.step || response.phase || 'Processing';
221+
setCurrentPhase(stepLabel);
201222
setPhaseSteps(prev => {
202223
// Check if the new message is different from the last message
203224
const lastMessage = prev[prev.length - 1];

src/processor/src/libs/base/orchestrator_base.py

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,11 @@
55

66
import json
77
import logging
8+
import re
89
from abc import abstractmethod
910
from typing import Any, Callable, Generic, MutableMapping, Sequence, TypeVar
1011

11-
from agent_framework import ChatAgent, ToolProtocol, ManagerSelectionResponse
12+
from agent_framework import ChatAgent, ManagerSelectionResponse, ToolProtocol
1213

1314
from libs.agent_framework.agent_builder import AgentBuilder
1415
from libs.agent_framework.agent_framework_helper import ClientType
@@ -110,23 +111,26 @@ async def create_agents(
110111
# Only attach tools when provided. (Coordinator should typically have none.)
111112
if agent_info.tools is not None:
112113
builder = (
113-
builder.with_tools(agent_info.tools)
114+
builder
115+
.with_tools(agent_info.tools)
114116
.with_temperature(0.0)
115117
.with_max_tokens(20_000)
116118
)
117119

118120
if agent_info.agent_name == "Coordinator":
119121
# Routing-only: keep deterministic and small.
120122
builder = (
121-
builder.with_temperature(0.0)
123+
builder
124+
.with_temperature(0.0)
122125
.with_response_format(ManagerSelectionResponse)
123126
.with_max_tokens(1_500)
124127
.with_tools(agent_info.tools) # for checking file existence
125128
)
126129
elif agent_info.agent_name == "ResultGenerator":
127130
# Structured JSON generation; deterministic and bounded.
128131
builder = (
129-
builder.with_temperature(0.0)
132+
builder
133+
.with_temperature(0.0)
130134
.with_max_tokens(12_000)
131135
.with_tool_choice("none")
132136
)
@@ -217,6 +221,19 @@ async def on_agent_response(self, response: AgentResponse):
217221
coordinator_response = ManagerSelectionResponse.model_validate(
218222
response_dict
219223
)
224+
225+
# Extract phase name from instruction (e.g., "Phase 2 : Platform Enhancement - ..." -> "Platform Enhancement")
226+
instruction = coordinator_response.instruction or ""
227+
phase_match = re.match(
228+
r"Phase\s+\d+\s*:\s*([^-]+)", instruction, re.IGNORECASE
229+
)
230+
if phase_match:
231+
phase_name = phase_match.group(1).strip().title()
232+
await telemetry.update_phase(
233+
process_id=self.task_param.process_id,
234+
phase=phase_name,
235+
)
236+
220237
if not coordinator_response.finish:
221238
if self.is_console_summarization_enabled():
222239
try:

src/processor/src/steps/analysis/orchestration/prompt_coordinator.txt

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -29,12 +29,12 @@ STEP OVERVIEW:
2929
SECTION 2: WORKFLOW EXECUTION
3030
═══════════════════════════════════════════════════════════════════
3131

32-
PHASE 0: HARD-TERMINATION TRIAGE + FOUNDATION (Chief Architect FIRST)
33-
- To reduce total runtime, the Chief Architect should do triage and foundation work in a single turn.
34-
- Before any platform enhancement, the Chief Architect MUST run a file-based triage for hard-termination conditions.
32+
PHASE 0: HARD-TERMINATION TRIAGE (Chief Architect FIRST)
33+
- Before any platform enhancement or report writing, the Chief Architect MUST run a file-based triage for hard-termination conditions.
3534
- Required output from Chief Architect:
3635
- Either the exact "IMMEDIATE HARD TERMINATION RECOMMENDATION" block with file-level evidence
3736
- Or `TRIAGE COMPLETE: NO HARD-TERMINATION CONDITIONS FOUND` with a short evidence summary (files listed + files read)
37+
- Do NOT proceed to PHASE 1/2/3 until triage is complete.
3838

3939
PHASE 1: FOUNDATION (Chief Architect)
4040
- Performs comprehensive source discovery and initial platform detection
@@ -266,15 +266,20 @@ ABSOLUTE RULE: Respond with single JSON object only.
266266
JSON SCHEMA:
267267
{
268268
"selected_participant": "<name from valid list>" | null,
269-
"instruction": "<what to do>" | "complete" | "hard_blocked",
269+
"instruction": "Phase X : Phase Title - <what to do>" | "complete" | "hard_blocked",
270270
"finish": true | false,
271271
"final_message": "<reason>" | null
272272
}
273273

274+
IMPORTANT: When continuing work (finish=false), instruction MUST start with
275+
"Phase X : Phase Title - " where X is the current phase number and Phase Title
276+
matches the PHASE heading from Section 2. This is required for the UI to display
277+
phase progress.
278+
274279
EXAMPLES:
275280

276281
Continue work (select next participant):
277-
{"selected_participant": "Chief Architect", "instruction": "Begin foundation analysis", "finish": false, "final_message": null}
282+
{"selected_participant": "Chief Architect", "instruction": "Phase 0 : Triage - Begin hard-termination triage before any analysis", "finish": false, "final_message": null}
278283

279284
Terminate with success:
280285
{"selected_participant": null, "instruction": "complete", "finish": true, "final_message": "All required sign-offs are PASS and analysis_result.md was verified."}

src/processor/src/steps/convert/orchestration/prompt_coordinator.txt

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -233,15 +233,20 @@ ABSOLUTE RULE: Respond with single JSON object only.
233233
JSON SCHEMA:
234234
{
235235
"selected_participant": "<name from valid list>" | null,
236-
"instruction": "<what to do>" | "complete" | "hard_blocked",
236+
"instruction": "Phase X : Phase Title - <what to do>" | "complete" | "hard_blocked",
237237
"finish": true | false,
238238
"final_message": "<reason>" | null
239239
}
240240

241+
IMPORTANT: When continuing work (finish=false), instruction MUST start with
242+
"Phase X : Phase Title - " where X is the current phase number and Phase Title
243+
matches the PHASE heading from Section 2. This is required for the UI to display
244+
phase progress.
245+
241246
EXAMPLES:
242247

243248
Continue work (select next participant):
244-
{"selected_participant": "YAML Expert", "instruction": "Discover source YAMLs and begin conversion", "finish": false, "final_message": null}
249+
{"selected_participant": "YAML Expert", "instruction": "Phase 1 : Conversion - Discover source YAMLs and begin conversion", "finish": false, "final_message": null}
245250

246251
Terminate with success:
247252
{"selected_participant": null, "instruction": "complete", "finish": true, "final_message": "Converted YAMLs and file_converting_result.md verified via blob tools and all required reviewers provided SIGN-OFF: PASS."}

0 commit comments

Comments
 (0)