Skip to content

Commit 1ef598e

Browse files
Merge pull request #1061 from microsoft/psl-ts-macetimerfinal
feat(ui): dynamic processing timer + total completion time on plan finalize
2 parents 2fee0fc + 7480f95 commit 1ef598e

7 files changed

Lines changed: 158 additions & 33 deletions

File tree

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { Spinner } from '@fluentui/react-components';
2+
import { formatElapsedTime } from '@/utils';
3+
4+
interface ProcessingStatusIndicatorProps {
5+
message: string;
6+
elapsedSeconds?: number;
7+
}
8+
9+
const ProcessingStatusIndicator = ({
10+
message,
11+
elapsedSeconds,
12+
}: ProcessingStatusIndicatorProps) => {
13+
const showElapsedSuffix = typeof elapsedSeconds === 'number' && elapsedSeconds > 0;
14+
const elapsedSuffix = showElapsedSuffix ? ` (${formatElapsedTime(elapsedSeconds)})` : '';
15+
16+
return (
17+
<div
18+
style={{
19+
maxWidth: '800px',
20+
margin: '0 auto 32px auto',
21+
padding: '0 24px',
22+
}}
23+
>
24+
<div
25+
style={{
26+
display: 'flex',
27+
alignItems: 'center',
28+
gap: '16px',
29+
backgroundColor: 'var(--colorNeutralBackground2)',
30+
borderRadius: '8px',
31+
border: '1px solid var(--colorNeutralStroke1)',
32+
padding: '16px',
33+
}}
34+
>
35+
<Spinner size="small" />
36+
<span
37+
style={{
38+
fontSize: '14px',
39+
color: 'var(--colorNeutralForeground1)',
40+
fontWeight: '500',
41+
}}
42+
>
43+
{message}
44+
{elapsedSuffix}
45+
</span>
46+
</div>
47+
</div>
48+
);
49+
};
50+
51+
export default ProcessingStatusIndicator;

src/App/src/components/content/PlanChat.tsx

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ interface SimplifiedPlanChatProps extends PlanChatProps {
2121
showBufferingText: boolean;
2222
agentMessages: AgentMessageData[];
2323
showProcessingPlanSpinner: boolean;
24+
processingElapsedSeconds: number;
25+
processingStatusMessage: string;
2426
showApprovalButtons: boolean;
2527
handleApprovePlan: () => Promise<void>;
2628
handleRejectPlan: () => Promise<void>;
@@ -45,13 +47,13 @@ const PlanChat: React.FC<SimplifiedPlanChatProps> = ({
4547
showBufferingText,
4648
agentMessages,
4749
showProcessingPlanSpinner,
50+
processingElapsedSeconds,
51+
processingStatusMessage,
4852
showApprovalButtons,
4953
handleApprovePlan,
5054
handleRejectPlan,
5155
processingApproval
5256
}) => {
53-
// States
54-
5557
if (!planData)
5658
return (
5759
<ContentNotFound subtitle="The requested page could not be found." />
@@ -86,7 +88,7 @@ const PlanChat: React.FC<SimplifiedPlanChatProps> = ({
8688
{renderPlanResponse(planApprovalRequest, handleApprovePlan, handleRejectPlan, processingApproval, showApprovalButtons)}
8789
{renderAgentMessages(agentMessages, undefined, undefined, finalResultRef)}
8890

89-
{showProcessingPlanSpinner && renderPlanExecutionMessage()}
91+
{showProcessingPlanSpinner && renderPlanExecutionMessage(processingElapsedSeconds, processingStatusMessage)}
9092
{/* Streaming plan updates — hidden while an approval prompt is pending so
9193
the approval action is presented at the appropriate step instead of
9294
after the thinking process visibly completes. */}

src/App/src/components/content/streaming/StreamingPlanState.tsx

Lines changed: 9 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { Spinner } from "@fluentui/react-components";
2+
import ProcessingStatusIndicator from "../../common/ProcessingStatusIndicator.tsx";
23

34
// Simple thinking message to show while creating plan
45
const renderThinkingState = (waitingForPlan: boolean) => {
@@ -54,32 +55,15 @@ const renderThinkingState = (waitingForPlan: boolean) => {
5455
};
5556

5657
// Simple message to show while executing the plan
57-
const renderPlanExecutionMessage = () => {
58+
const renderPlanExecutionMessage = (
59+
processingElapsedSeconds?: number,
60+
processingStatusMessage = 'Processing your plan and coordinating with AI agents...',
61+
) => {
5862
return (
59-
<div style={{
60-
maxWidth: '800px',
61-
margin: '0 auto 32px auto',
62-
padding: '0 24px'
63-
}}>
64-
<div style={{
65-
display: 'flex',
66-
alignItems: 'center',
67-
gap: '16px',
68-
backgroundColor: 'var(--colorNeutralBackground2)',
69-
borderRadius: '8px',
70-
border: '1px solid var(--colorNeutralStroke1)',
71-
padding: '16px'
72-
}}>
73-
<Spinner size="small" />
74-
<span style={{
75-
fontSize: '14px',
76-
color: 'var(--colorNeutralForeground1)',
77-
fontWeight: '500'
78-
}}>
79-
Processing your plan and coordinating with AI agents...
80-
</span>
81-
</div>
82-
</div>
63+
<ProcessingStatusIndicator
64+
message={processingStatusMessage}
65+
elapsedSeconds={processingElapsedSeconds}
66+
/>
8367
);
8468
};
8569

src/App/src/hooks/usePlanWebSocket.tsx

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
selectPlanData,
1616
selectContinueWithWebsocketFlow,
1717
selectPlanApproved,
18+
selectShowProcessingPlanSpinner,
1819
approvalRequestReceived,
1920
planCompletedFinal,
2021
planFailedFinal,
@@ -44,11 +45,11 @@ import {
4445
ProcessedPlanData,
4546
} from '@/models';
4647
import { APIService } from '@/api/apiService';
48+
import { ToastIntent } from '@/components/toast/InlineToaster';
49+
import { formatElapsedTime } from '@/utils';
4750

4851
const apiService = new APIService();
4952

50-
import { ToastIntent } from '@/components/toast/InlineToaster';
51-
5253
interface UsePlanWebSocketProps {
5354
planId: string | undefined;
5455
scrollToBottom: () => void;
@@ -99,8 +100,20 @@ export function usePlanWebSocket({
99100
const dispatch = useAppDispatch();
100101
const planData = useAppSelector(selectPlanData);
101102
const planApproved = useAppSelector(selectPlanApproved);
103+
const showProcessingPlanSpinner = useAppSelector(selectShowProcessingPlanSpinner);
102104
const continueWithWebsocketFlow = useAppSelector(selectContinueWithWebsocketFlow);
103105
const streamingMessageBuffer = useAppSelector(selectStreamingMessageBuffer);
106+
const processingStartedAtRef = React.useRef<number | null>(null);
107+
108+
useEffect(() => {
109+
if (showProcessingPlanSpinner) {
110+
if (processingStartedAtRef.current === null) {
111+
processingStartedAtRef.current = Date.now();
112+
}
113+
} else {
114+
processingStartedAtRef.current = null;
115+
}
116+
}, [showProcessingPlanSpinner]);
104117

105118
// ── PLAN_APPROVAL_REQUEST ─────────────────────────────────────
106119
useEffect(() => {
@@ -161,6 +174,7 @@ export function usePlanWebSocket({
161174
dispatch(addAgentMessage(agentMessageData));
162175
dispatch(setShowBufferingText(false));
163176
dispatch(setShowProcessingPlanSpinner(false));
177+
processingStartedAtRef.current = null;
164178
dispatch(setSubmittingChatDisableInput(false));
165179
scrollToBottom();
166180
persistAgentMessage(agentMessageData, planData, dispatch);
@@ -181,6 +195,12 @@ export function usePlanWebSocket({
181195
WebsocketMessageType.FINAL_RESULT_MESSAGE,
182196
(finalMessage: any) => {
183197
if (!finalMessage) return;
198+
const completionElapsedSeconds = processingStartedAtRef.current
199+
? Math.max(Math.round((Date.now() - processingStartedAtRef.current) / 1000), 0)
200+
: null;
201+
const completionTimeLine = completionElapsedSeconds !== null
202+
? `\n\n**Total completion time: ${formatElapsedTime(completionElapsedSeconds)}**`
203+
: '';
184204
const messageStatus = finalMessage?.data?.status;
185205

186206
if (messageStatus === PlanStatus.COMPLETED) {
@@ -190,14 +210,15 @@ export function usePlanWebSocket({
190210
timestamp: Date.now(),
191211
steps: [],
192212
next_steps: [],
193-
content: finalMessage.data?.content || '',
213+
content: (finalMessage.data?.content || '') + completionTimeLine,
194214
raw_data: finalMessage,
195215
};
196216
dispatch(setShowBufferingText(true));
197217
dispatch(addAgentMessage(agentMessageData));
198218
dispatch(setSelectedTeam(planData?.team || null));
199219
/* P0: single compound action replaces setShowProcessingPlanSpinner(false) + markPlanCompleted() */
200220
dispatch(planCompletedFinal());
221+
processingStartedAtRef.current = null;
201222
scrollToFinalResult();
202223
webSocketService.disconnect();
203224
persistAgentMessage(agentMessageData, planData, dispatch, true, streamingMessageBuffer);
@@ -256,6 +277,7 @@ export function usePlanWebSocket({
256277
};
257278
dispatch(addAgentMessage(errorAgent));
258279
dispatch(planFailedFinal());
280+
processingStartedAtRef.current = null;
259281
dispatch(setShowBufferingText(false));
260282
dispatch(setSubmittingChatDisableInput(true));
261283
scrollToBottom();

src/App/src/pages/PlanPage.tsx

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,30 @@ import '../styles/PlanPage.css';
7878
// Singleton API service
7979
const apiService = new APIService();
8080

81+
const getPlanProcessingStatusMessage = (elapsedSeconds: number): string => {
82+
if (elapsedSeconds < 8) {
83+
return 'Processing your plan and coordinating with AI agents...';
84+
}
85+
86+
if (elapsedSeconds < 20) {
87+
return 'Assigning tasks to specialized agents...';
88+
}
89+
90+
if (elapsedSeconds < 35) {
91+
return 'Agents are analyzing and researching...';
92+
}
93+
94+
if (elapsedSeconds < 50) {
95+
return 'Compiling results from agents...';
96+
}
97+
98+
if (elapsedSeconds < 90) {
99+
return 'Finalizing responses...';
100+
}
101+
102+
return 'Still processing, please wait...';
103+
};
104+
81105
/* ================================================================
82106
* PlanPage — refactored to use Redux + extracted hooks
83107
* ================================================================ */
@@ -114,6 +138,8 @@ const PlanPage: React.FC = () => {
114138

115139
/* ── Cancellation alert hook ────────────────────────────── */
116140
const [pendingNavigation, setPendingNavigation] = React.useState<(() => void) | null>(null);
141+
const [processingElapsedSeconds, setProcessingElapsedSeconds] = React.useState<number>(0);
142+
const processingStatusMessage = getPlanProcessingStatusMessage(processingElapsedSeconds);
117143

118144
const { isPlanActive } = usePlanCancellationAlert({
119145
planData,
@@ -288,6 +314,21 @@ const PlanPage: React.FC = () => {
288314
return () => clearInterval(interval);
289315
}, [loading, dispatch]);
290316

317+
/* ── Plan execution elapsed timer ───────────────────────── */
318+
useEffect(() => {
319+
if (!showProcessingPlanSpinner) {
320+
setProcessingElapsedSeconds(0);
321+
return;
322+
}
323+
324+
setProcessingElapsedSeconds(0);
325+
const interval = setInterval(() => {
326+
setProcessingElapsedSeconds((currentSeconds: number) => currentSeconds + 1);
327+
}, 1000);
328+
329+
return () => clearInterval(interval);
330+
}, [showProcessingPlanSpinner]);
331+
291332
/* ── Initial plan load ──────────────────────────────────── */
292333
useEffect(() => {
293334
if (!planId) {
@@ -367,6 +408,8 @@ const PlanPage: React.FC = () => {
367408
showBufferingText={showBufferingText}
368409
agentMessages={agentMessages}
369410
showProcessingPlanSpinner={showProcessingPlanSpinner}
411+
processingElapsedSeconds={processingElapsedSeconds}
412+
processingStatusMessage={processingStatusMessage}
370413
showApprovalButtons={showApprovalButtons}
371414
processingApproval={processingApproval}
372415
handleApprovePlan={handleApprovePlan}

src/App/src/utils/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
* - agentIconUtils → agent-to-icon mapping
99
*/
1010

11-
export { formatDate } from './utils';
11+
export { formatDate, formatElapsedTime } from './utils';
1212
export { getErrorMessage, getErrorStyle } from './errorUtils';
1313
export { formatErrorMessage, extractPlainAnswer, truncate } from './messageUtils';
1414
export {

src/App/src/utils/utils.tsx

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,4 +67,27 @@ export const formatDate = (
6767
});
6868

6969
return formatted;
70-
}
70+
}
71+
72+
/**
73+
* Formats an elapsed-time duration in seconds for display in processing
74+
* indicators and completion messages.
75+
*
76+
* Examples:
77+
* - 5 → "5s"
78+
* - 59 → "59s"
79+
* - 60 → "1min 0sec"
80+
* - 75 → "1min 15sec"
81+
*
82+
* @param elapsedSeconds Non-negative integer seconds elapsed.
83+
* @returns Human-readable elapsed-time string.
84+
*/
85+
export const formatElapsedTime = (elapsedSeconds: number): string => {
86+
if (elapsedSeconds < 60) {
87+
return `${elapsedSeconds}s`;
88+
}
89+
90+
const minutes = Math.floor(elapsedSeconds / 60);
91+
const seconds = elapsedSeconds % 60;
92+
return `${minutes}min ${seconds}sec`;
93+
};

0 commit comments

Comments
 (0)