Skip to content

Commit 77900e7

Browse files
cursor[bot]cursoragentforestileao
authored
fix: Create a self-updating TimeAgo React component that avoids unnecessary re-renders (#3564)
<!-- CURSOR_AGENT_PR_BODY_BEGIN --> ## Summary Closes #3563 All `timeAgo` strings across the UI were static — they only refreshed when the user interacted with the UI. This PR creates a self-updating `TimeAgo` React component and replaces all static `formatTimeAgo()` usages with it, so timestamps stay accurate without user interaction. ## Changes ### New `TimeAgo` component (`web_src/src/components/TimeAgo/`) - Self-updating React component that renders a human-readable relative time string (e.g., `3s ago`, `2m ago`) - Uses a **single shared global timer** (`setInterval` at 1s) for all instances — efficient even with many events visible - Only re-renders when the displayed text actually changes (e.g., `"2m ago"` stays stable for a minute), minimizing render work for older timestamps - Wrapped in `React.memo` to prevent unnecessary parent re-renders - Properly cleans up the global timer when all instances unmount (no memory leaks) - Split into separate files for ESLint `react-refresh` compliance: - `TimeAgo.tsx` — the component itself - `helpers.ts` — `renderTimeAgo(date)` and `renderWithTimeAgo(prefix, date, separator?)` - `index.ts` — barrel re-exports ### Replaced all static `timeAgo` usages - **Direct UI components**: `componentBase`, `ChainItem`, `ExecutionChainPage` now use `<TimeAgo>` directly - **All mapper subtitle functions** (~160+ files): Replaced `formatTimeAgo()` with `renderTimeAgo()` for standalone usage or `renderWithTimeAgo()` for "prefix · timeAgo" patterns - **All trigger renderers** (~40+ files): Updated to use `renderTimeAgo()` / `renderWithTimeAgo()` for trigger event subtitles - **Utility functions** (`utils.ts`): Updated sidebar event and queue item subtitle generation - **Type definitions**: Updated `TriggerLastEventData.subtitle`, `TriggerRenderer.getTitleAndSubtitle`, and various mapper types to support `React.ReactNode` ### Bug fixes - Fixed tooltip showing `[object Object]` in `componentBase/index.tsx` by using a `typeof` check instead of `String()` coercion for ReactNode subtitles - Fixed template literal interpolation of `renderTimeAgo()` results (which return `React.ReactNode`) that would produce `[object Object]` — replaced with `renderWithTimeAgo()` helper ### What's preserved - `formatTimeAgo()` utility function is kept in `@/utils/date` for non-React contexts (e.g., `.replace(" ago", "")` duration text extraction in `utils.ts`) <!-- CURSOR_AGENT_PR_BODY_END --> <div><a href="https://cursor.com/agents/bc-b9343003-3036-498a-8fae-d541bf47c5a9"><picture><source media="(prefers-color-scheme: dark)" srcset="https://cursor.com/assets/images/open-in-web-dark.png"><source media="(prefers-color-scheme: light)" srcset="https://cursor.com/assets/images/open-in-web-light.png"><img alt="Open in Web" width="114" height="28" src="https://cursor.com/assets/images/open-in-web-dark.png"></picture></a>&nbsp;<a href="https://cursor.com/background-agent?bcId=bc-b9343003-3036-498a-8fae-d541bf47c5a9"><picture><source media="(prefers-color-scheme: dark)" srcset="https://cursor.com/assets/images/open-in-cursor-dark.png"><source media="(prefers-color-scheme: light)" srcset="https://cursor.com/assets/images/open-in-cursor-light.png"><img alt="Open in Cursor" width="131" height="28" src="https://cursor.com/assets/images/open-in-cursor-dark.png"></picture></a>&nbsp;</div> --------- Signed-off-by: Pedro F. Leao <pedroforestileao@gmail.com> Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Pedro F. Leao <pedroforestileao@gmail.com>
1 parent 6914062 commit 77900e7

272 files changed

Lines changed: 2459 additions & 2003 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import React, { useState, useEffect, useRef, useCallback } from "react";
2+
import { formatTimeAgo } from "@/utils/date";
3+
4+
const globalListeners = new Set<() => void>();
5+
let globalIntervalId: ReturnType<typeof setInterval> | null = null;
6+
7+
function startGlobalTimer() {
8+
if (globalIntervalId) return;
9+
globalIntervalId = setInterval(() => {
10+
globalListeners.forEach((cb) => cb());
11+
}, 1000);
12+
}
13+
14+
function stopGlobalTimer() {
15+
if (globalIntervalId && globalListeners.size === 0) {
16+
clearInterval(globalIntervalId);
17+
globalIntervalId = null;
18+
}
19+
}
20+
21+
interface TimeAgoProps {
22+
date: Date | string;
23+
className?: string;
24+
}
25+
26+
export const TimeAgo = React.memo(function TimeAgo({ date, className }: TimeAgoProps) {
27+
const d = typeof date === "string" ? new Date(date) : date;
28+
const dateMs = d.getTime();
29+
const [text, setText] = useState(() => formatTimeAgo(new Date(dateMs)));
30+
const lastTextRef = useRef(text);
31+
32+
const update = useCallback(() => {
33+
const newText = formatTimeAgo(new Date(dateMs));
34+
if (newText !== lastTextRef.current) {
35+
lastTextRef.current = newText;
36+
setText(newText);
37+
}
38+
}, [dateMs]);
39+
40+
useEffect(() => {
41+
update();
42+
globalListeners.add(update);
43+
startGlobalTimer();
44+
return () => {
45+
globalListeners.delete(update);
46+
stopGlobalTimer();
47+
};
48+
}, [update]);
49+
50+
return <span className={className}>{text}</span>;
51+
});
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import React from "react";
2+
import { TimeAgo } from "./TimeAgo";
3+
4+
/**
5+
* Creates a TimeAgo React element from a Date or string.
6+
* Use in .ts files where JSX is not available.
7+
*/
8+
export function renderTimeAgo(date: Date | string): React.ReactNode {
9+
return React.createElement(TimeAgo, { date });
10+
}
11+
12+
/**
13+
* Creates a React element with a text prefix followed by a separator and a self-updating TimeAgo.
14+
* Use in .ts files where JSX is not available.
15+
*/
16+
export function renderWithTimeAgo(prefix: string, date: Date | string, separator = " · "): React.ReactNode {
17+
return React.createElement(React.Fragment, null, prefix, separator, React.createElement(TimeAgo, { date }));
18+
}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export { TimeAgo } from "./TimeAgo";
2+
export { renderTimeAgo, renderWithTimeAgo } from "./helpers";

web_src/src/pages/workflowv2/mappers/addMemory.ts

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
1-
import {
1+
import type {
22
ComponentBaseContext,
33
ComponentBaseMapper,
44
ExecutionDetailsContext,
55
ExecutionInfo,
66
NodeInfo,
77
SubtitleContext,
88
} from "./types";
9-
import { ComponentBaseProps, EventSection } from "@/ui/componentBase";
9+
import type { ComponentBaseProps, EventSection } from "@/ui/componentBase";
10+
import type React from "react";
1011
import { getStateMap, getTriggerRenderer } from ".";
11-
import { formatTimeAgo } from "@/utils/date";
12+
import { renderTimeAgo } from "@/components/TimeAgo";
1213
import { defaultStateFunction } from "./stateRegistry";
1314

1415
type AddMemoryMetadata = {
@@ -41,9 +42,9 @@ export const addMemoryMapper: ComponentBaseMapper = {
4142
eventStateMap: getStateMap(componentName),
4243
};
4344
},
44-
subtitle(context: SubtitleContext): string {
45+
subtitle(context: SubtitleContext): string | React.ReactNode {
4546
const timestamp = context.execution.updatedAt || context.execution.createdAt;
46-
return timestamp ? formatTimeAgo(new Date(timestamp)) : "";
47+
return timestamp ? renderTimeAgo(new Date(timestamp)) : "";
4748
},
4849
getExecutionDetails(context: ExecutionDetailsContext): Record<string, string> {
4950
const details: Record<string, string> = {};
@@ -67,7 +68,7 @@ function getEventSections(nodes: NodeInfo[], execution: ExecutionInfo): EventSec
6768
const rootTriggerRenderer = getTriggerRenderer(rootTriggerNode?.componentName || "");
6869
const { title: fallbackTitle } = rootTriggerRenderer.getTitleAndSubtitle({ event: execution.rootEvent });
6970
const subtitleTimestamp = execution.updatedAt || execution.createdAt;
70-
const eventSubtitle = subtitleTimestamp ? formatTimeAgo(new Date(subtitleTimestamp)) : "";
71+
const eventSubtitle = subtitleTimestamp ? renderTimeAgo(new Date(subtitleTimestamp)) : "";
7172

7273
return [
7374
{

web_src/src/pages/workflowv2/mappers/approval.ts

Lines changed: 84 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,7 @@
11
/* eslint-disable @typescript-eslint/no-explicit-any */
2-
import {
3-
GroupsGroup,
4-
RolesRole,
5-
SuperplaneUsersUser,
6-
groupsListGroupUsers,
7-
canvasesInvokeNodeExecutionAction,
8-
CanvasesCanvasNodeExecution,
9-
} from "@/api-client";
10-
import {
2+
import type { GroupsGroup, RolesRole, SuperplaneUsersUser, CanvasesCanvasNodeExecution } from "@/api-client";
3+
import { groupsListGroupUsers, canvasesInvokeNodeExecutionAction } from "@/api-client";
4+
import type {
115
AdditionalDataBuilderContext,
126
ComponentAdditionalDataBuilder,
137
ComponentBaseContext,
@@ -19,24 +13,24 @@ import {
1913
StateFunction,
2014
SubtitleContext,
2115
} from "./types";
22-
import {
16+
import type {
2317
ComponentBaseProps,
2418
ComponentBaseSpec,
2519
EventSection,
2620
EventState,
2721
EventStateMap,
28-
DEFAULT_EVENT_STATE_MAP,
2922
} from "@/ui/componentBase";
23+
import { DEFAULT_EVENT_STATE_MAP } from "@/ui/componentBase";
3024
import { getTriggerRenderer } from ".";
3125
import { getBackgroundColorClass, getColorClass } from "@/utils/colors";
3226
import { ApprovalGroup } from "@/ui/approvalGroup";
3327
import React from "react";
34-
import { ApprovalItemProps } from "@/ui/approvalItem";
35-
import { QueryClient } from "@tanstack/react-query";
28+
import type { ApprovalItemProps } from "@/ui/approvalItem";
29+
import type { QueryClient } from "@tanstack/react-query";
3630
import { organizationKeys } from "@/hooks/useOrganizationData";
3731
import { withOrganizationHeader } from "@/utils/withOrganizationHeader";
3832
import { canvasKeys } from "@/hooks/useCanvasData";
39-
import { formatTimeAgo } from "@/utils/date";
33+
import { renderTimeAgo, renderWithTimeAgo } from "@/components/TimeAgo";
4034
import { showErrorToast } from "@/utils/toast";
4135

4236
type ApprovalConfiguration = {
@@ -92,6 +86,7 @@ export const APPROVAL_STATE_MAP: EventStateMap = {
9286
/**
9387
* Approval-specific state logic function
9488
*/
89+
// eslint-disable-next-line complexity
9590
export const approvalStateFunction: StateFunction = (execution: CanvasesCanvasNodeExecution): EventState => {
9691
if (
9792
execution.resultMessage &&
@@ -210,40 +205,43 @@ function getApprovalSpecs(items: ApprovalItem[], additionalData?: unknown): Comp
210205
{
211206
title: "approvals required",
212207
tooltipTitle: "approvals required",
213-
values: items.map((item) => {
214-
const type = (item.type || "").toString();
215-
let value =
216-
type === "anyone"
217-
? "Anyone"
218-
: type === "user"
219-
? item.user || ""
220-
: type === "role"
221-
? item.role || ""
222-
: type === "group"
223-
? item.group || ""
224-
: "";
225-
const label = type ? `${type[0].toUpperCase()}${type.slice(1)}` : "Item";
226-
227-
// Pretty-print values
228-
if (type === "user" && value && usersById[value]) {
229-
value = usersById[value].email || usersById[value].name || value;
230-
}
231-
if (type === "role" && value) {
232-
value = rolesByName[value] || value.replace(/^(org_|canvas_)/i, "");
233-
// Fallback to simple suffix mapping when not found
234-
const suffix = (item.role || "").split("_").pop();
235-
if (!rolesByName[item.role || ""] && suffix) {
236-
const map: any = { viewer: "Viewer", admin: "Admin", owner: "Owner" };
237-
value = map[suffix] || value;
208+
values: items.map(
209+
// eslint-disable-next-line complexity
210+
(item) => {
211+
const type = (item.type || "").toString();
212+
let value =
213+
type === "anyone"
214+
? "Anyone"
215+
: type === "user"
216+
? item.user || ""
217+
: type === "role"
218+
? item.role || ""
219+
: type === "group"
220+
? item.group || ""
221+
: "";
222+
const label = type ? `${type[0].toUpperCase()}${type.slice(1)}` : "Item";
223+
224+
// Pretty-print values
225+
if (type === "user" && value && usersById[value]) {
226+
value = usersById[value].email || usersById[value].name || value;
238227
}
239-
}
240-
return {
241-
badges: [
242-
{ label: `${label}:`, bgColor: "bg-gray-100", textColor: "text-gray-700" },
243-
{ label: value || "—", bgColor: "bg-emerald-100", textColor: "text-emerald-800" },
244-
],
245-
};
246-
}),
228+
if (type === "role" && value) {
229+
value = rolesByName[value] || value.replace(/^(org_|canvas_)/i, "");
230+
// Fallback to simple suffix mapping when not found
231+
const suffix = (item.role || "").split("_").pop();
232+
if (!rolesByName[item.role || ""] && suffix) {
233+
const map: any = { viewer: "Viewer", admin: "Admin", owner: "Owner" };
234+
value = map[suffix] || value;
235+
}
236+
}
237+
return {
238+
badges: [
239+
{ label: `${label}:`, bgColor: "bg-gray-100", textColor: "text-gray-700" },
240+
{ label: value || "—", bgColor: "bg-emerald-100", textColor: "text-emerald-800" },
241+
],
242+
};
243+
},
244+
),
247245
},
248246
];
249247
}
@@ -278,7 +276,7 @@ function getComponentSubtitle(execution: ExecutionInfo, additionalData?: unknown
278276
const approvalsApprovedCount = approvals?.filter((approval) => approval.approved).length || 0;
279277
const subtitle = `${approvalsApprovedCount}/${approvalsCount} approved`;
280278
if (execution.createdAt) {
281-
return `${subtitle} · ${formatTimeAgo(new Date(execution.createdAt))}`;
279+
return renderWithTimeAgo(subtitle, new Date(execution.createdAt));
282280
}
283281
return subtitle;
284282
}
@@ -291,22 +289,22 @@ function getComponentSubtitle(execution: ExecutionInfo, additionalData?: unknown
291289
const date = new Date(timestamp);
292290
const metadata = execution.metadata as Record<string, unknown> | undefined;
293291
const result = metadata?.result;
294-
const timeAgo = formatTimeAgo(date);
295292

296293
if (result === "approved") {
297-
return `Approved · ${timeAgo}`;
294+
return renderWithTimeAgo("Approved", date);
298295
}
299296

300297
if (result === "rejected") {
301-
return `Rejected · ${timeAgo}`;
298+
return renderWithTimeAgo("Rejected", date);
302299
}
303300

304-
return timeAgo;
301+
return renderTimeAgo(date);
305302
}
306303

307304
return "";
308305
}
309306

307+
// eslint-disable-next-line complexity
310308
function getApprovalDecisionLabel(record: ApprovalRecord, labelMaps?: ApprovalLabelMaps): string {
311309
const rolesByName = labelMaps?.rolesByName;
312310
const groupsByName = labelMaps?.groupsByName;
@@ -335,7 +333,17 @@ function getApprovalDecisionLabel(record: ApprovalRecord, labelMaps?: ApprovalLa
335333
}
336334

337335
function buildApprovalTimeline(records: ApprovalRecord[]) {
338-
return records
336+
return [...records]
337+
.sort((a, b) => {
338+
const aTimestamp = getApprovalDecisionTimestampValue(a);
339+
const bTimestamp = getApprovalDecisionTimestampValue(b);
340+
341+
if (aTimestamp === null && bTimestamp === null) return 0;
342+
if (aTimestamp === null) return 1;
343+
if (bTimestamp === null) return -1;
344+
345+
return aTimestamp - bTimestamp;
346+
})
339347
.map((record) => {
340348
const meta = getApprovalDecisionMeta(record);
341349
return {
@@ -344,18 +352,12 @@ function buildApprovalTimeline(records: ApprovalRecord[]) {
344352
timestamp: meta.timestamp,
345353
comment: meta.comment,
346354
};
347-
})
348-
.sort((a, b) => {
349-
if (!a.timestamp && !b.timestamp) return 0;
350-
if (!a.timestamp) return 1;
351-
if (!b.timestamp) return -1;
352-
return new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime();
353355
});
354356
}
355357

356358
function getApprovalDecisionMeta(record: ApprovalRecord): {
357359
status: string;
358-
timestamp?: string;
360+
timestamp?: string | React.ReactNode;
359361
comment?: string;
360362
} {
361363
const approvalComment = record.approval?.comment?.trim();
@@ -384,13 +386,33 @@ function getApprovalDecisionMeta(record: ApprovalRecord): {
384386
};
385387
}
386388

387-
function formatDecisionTimestamp(timestamp?: string): string | undefined {
389+
function formatDecisionTimestamp(timestamp?: string): string | React.ReactNode | undefined {
388390
if (!timestamp) return undefined;
389391

390392
const parsed = new Date(timestamp);
391393
if (Number.isNaN(parsed.getTime())) return undefined;
392394

393-
return formatTimeAgo(parsed);
395+
return renderTimeAgo(parsed);
396+
}
397+
398+
function getApprovalDecisionTimestampValue(record: ApprovalRecord): number | null {
399+
const timestamp =
400+
record.state === "approved"
401+
? record.approval?.approvedAt
402+
: record.state === "rejected"
403+
? record.rejection?.rejectedAt
404+
: undefined;
405+
406+
if (!timestamp) {
407+
return null;
408+
}
409+
410+
const parsed = new Date(timestamp).getTime();
411+
if (Number.isNaN(parsed)) {
412+
return null;
413+
}
414+
415+
return parsed;
394416
}
395417

396418
// ----------------------- Data Builder -----------------------

web_src/src/pages/workflowv2/mappers/aws/cloudwatch/on_alarm.ts

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
1-
import { TriggerProps } from "@/ui/trigger";
2-
import { MetadataItem } from "@/ui/metadataList";
1+
import type { TriggerProps } from "@/ui/trigger";
2+
import type React from "react";
3+
import type { MetadataItem } from "@/ui/metadataList";
34
import awsCloudwatchIcon from "@/assets/icons/integrations/aws.cloudwatch.svg";
4-
import { formatTimeAgo } from "@/utils/date";
5+
import { renderTimeAgo } from "@/components/TimeAgo";
56
import { getBackgroundColorClass } from "@/utils/colors";
6-
import { TriggerEventContext, TriggerRenderer, TriggerRendererContext } from "../../types";
7-
import { Predicate, formatPredicate, stringOrDash } from "../../utils";
8-
import { CloudWatchAlarmEvent } from "./types";
7+
import type { TriggerEventContext, TriggerRenderer, TriggerRendererContext } from "../../types";
8+
import type { Predicate } from "../../utils";
9+
import { formatPredicate, stringOrDash } from "../../utils";
10+
import type { CloudWatchAlarmEvent } from "./types";
911

1012
interface Configuration {
1113
region?: string;
@@ -44,7 +46,7 @@ function buildMetadataItems(configuration?: Configuration): MetadataItem[] {
4446
* Renderer for the "aws.cloudwatch.onAlarm" trigger
4547
*/
4648
export const onAlarmTriggerRenderer: TriggerRenderer = {
47-
getTitleAndSubtitle: (context: TriggerEventContext): { title: string; subtitle: string } => {
49+
getTitleAndSubtitle: (context: TriggerEventContext): { title: string; subtitle: string | React.ReactNode } => {
4850
const eventData = context.event?.data as CloudWatchAlarmEvent;
4951
const detail = eventData?.detail;
5052
const alarmName = detail?.alarmName;
@@ -58,7 +60,7 @@ export const onAlarmTriggerRenderer: TriggerRenderer = {
5860
title = alarmName;
5961
}
6062

61-
const subtitle = context.event?.createdAt ? formatTimeAgo(new Date(context.event?.createdAt || "")) : "";
63+
const subtitle = context.event?.createdAt ? renderTimeAgo(new Date(context.event?.createdAt || "")) : "";
6264
return { title, subtitle };
6365
},
6466

0 commit comments

Comments
 (0)