diff --git a/.jules/bolt.md b/.jules/bolt.md new file mode 100644 index 00000000..3f759d74 --- /dev/null +++ b/.jules/bolt.md @@ -0,0 +1,3 @@ +## 2026-08-01 - ISO 문자열 파싱 최적화 +**Learning:** 반복문 내에서 `new Date().getTime()`을 호출하면 불필요한 Date 객체 할당이 발생하여 성능 저하 및 메인 스레드 블로킹을 유발함. 특히 가상화 리스트(`react-window` 등) 렌더링 루프에서는 이런 계산을 외부로 빼거나, V8 엔진에서 빠른 `Date.parse()`를 사용하는 것이 유리함. +**Action:** 앞으로는 성능이 중요한 렌더링 경로나 루프 내에서 날짜 파싱이 필요할 때 객체 할당을 피하기 위해 `Date.parse(string)`을 사용할 것. 또한 정적인 계산값(예: sessionStartedAt)은 상위 컴포넌트에서 `useMemo`로 처리하여 하위 컴포넌트에 원시 값으로 전달할 것. \ No newline at end of file diff --git a/packages/web/src/components/dashboard/event-list.tsx b/packages/web/src/components/dashboard/event-list.tsx index a3e4406c..5452c797 100644 --- a/packages/web/src/components/dashboard/event-list.tsx +++ b/packages/web/src/components/dashboard/event-list.tsx @@ -39,9 +39,9 @@ type FlatRow = const ROW_HEIGHT = 36; -function formatElapsed(timestamp: string, sessionStartedAt: string): string { - const t = new Date(timestamp).getTime(); - const start = new Date(sessionStartedAt).getTime(); +function formatElapsed(timestamp: string, sessionStartedAtMs: number): string { + const t = Date.parse(timestamp); + const start = sessionStartedAtMs; if (Number.isNaN(t) || Number.isNaN(start)) return ""; const diffSec = Math.max(0, Math.floor((t - start) / 1000)); const h = Math.floor(diffSec / 3600); @@ -123,7 +123,8 @@ function getSinglePreview(event: TimelineEvent): string { return normalized.slice(0, 80); } if (event.isSkillCall && event.skillName) return `Skill: ${event.skillName}`; - if (event.isAgentCall && event.agentType) return `Subagent: ${event.agentType}`; + if (event.isAgentCall && event.agentType) + return `Subagent: ${event.agentType}`; return event.toolName; } @@ -207,7 +208,7 @@ function RowView({ type RowProps = { rows: FlatRow[]; selectedIdx: number; - sessionStartedAt: string; + sessionStartedAtMs: number; onSelect: (idx: number) => void; onToggleGroup: (firstIdx: number) => void; }; @@ -217,7 +218,7 @@ function Row({ style, rows, selectedIdx, - sessionStartedAt, + sessionStartedAtMs, onSelect, onToggleGroup, }: RowComponentProps) { @@ -230,7 +231,7 @@ function Row({ onToggleGroup(row.groupFirstIdx)} @@ -241,18 +242,19 @@ function Row({ } const label = row.labelOverride ?? getSingleLabel(row.event); - const preview = row.labelOverride === "Tool" - ? row.event.kind === "tool" - ? row.event.toolName - : getSinglePreview(row.event) - : getSinglePreview(row.event); + const preview = + row.labelOverride === "Tool" + ? row.event.kind === "tool" + ? row.event.toolName + : getSinglePreview(row.event) + : getSinglePreview(row.event); return (
onSelect(row.idx)} @@ -271,6 +273,11 @@ export function EventList({ expandedGroups, onToggleGroup, }: EventListProps) { + const sessionStartedAtMs = useMemo( + () => Date.parse(sessionStartedAt), + [sessionStartedAt], + ); + const rows = useMemo( () => buildFlatRows(groups, expandedGroups, selectedIdx), [groups, expandedGroups, selectedIdx], @@ -292,7 +299,7 @@ export function EventList({ rowProps={{ rows, selectedIdx, - sessionStartedAt, + sessionStartedAtMs, onSelect, onToggleGroup, }} diff --git a/packages/web/src/components/dashboard/session-timeline-chart.tsx b/packages/web/src/components/dashboard/session-timeline-chart.tsx index f5a7a32c..4ed9f6e9 100644 --- a/packages/web/src/components/dashboard/session-timeline-chart.tsx +++ b/packages/web/src/components/dashboard/session-timeline-chart.tsx @@ -1,6 +1,6 @@ -'use client' +"use client"; -import React, { useMemo } from 'react' +import React, { useMemo } from "react"; import { ComposedChart, Bar, @@ -10,83 +10,83 @@ import { Tooltip, ResponsiveContainer, TooltipProps, -} from 'recharts' -import { formatTokens, formatCost, formatRelativeTime } from '@/lib/format' -import type { SessionTimelineUsage, SessionDetail } from '@argos/shared' +} from "recharts"; +import { formatTokens, formatCost, formatRelativeTime } from "@/lib/format"; +import type { SessionTimelineUsage, SessionDetail } from "@argos/shared"; interface SessionTimelineChartProps { - usageTimeline: SessionTimelineUsage[] - messages: SessionDetail['messages'] - sessionStartedAt: string + usageTimeline: SessionTimelineUsage[]; + messages: SessionDetail["messages"]; + sessionStartedAt: string; } interface ToolCallPoint { - timestamp: string - toolName: string - parsedTimestamp: number + timestamp: string; + toolName: string; + parsedTimestamp: number; } interface ChartDataItem { - relativeTime: string - input: number - output: number - cost: number - model?: string | null - toolSummary: string + relativeTime: string; + input: number; + output: number; + cost: number; + model?: string | null; + toolSummary: string; } function getToolSummaryForIndex( index: number, usageTimeline: SessionTimelineUsage[], - toolCalls: ToolCallPoint[] + toolCalls: ToolCallPoint[], ): string { - if (toolCalls.length === 0) return '' + if (toolCalls.length === 0) return ""; - const currentTimestamp = new Date(usageTimeline[index]!.timestamp).getTime() + const currentTimestamp = Date.parse(usageTimeline[index]!.timestamp); const prevTimestamp = - index > 0 ? new Date(usageTimeline[index - 1]!.timestamp).getTime() : 0 + index > 0 ? Date.parse(usageTimeline[index - 1]!.timestamp) : 0; // 현재 usageTimeline timestamp 이전이면서, 이전 usageTimeline timestamp 이후의 tool events 찾기 // 첫 번째 bar(index=0)는 prevTimestamp가 0이므로 해당 bar 이전의 모든 이벤트를 포함 const relevantTools = toolCalls.filter((e) => { - const toolTimestamp = e.parsedTimestamp - return toolTimestamp <= currentTimestamp && toolTimestamp > prevTimestamp - }) + const toolTimestamp = e.parsedTimestamp; + return toolTimestamp <= currentTimestamp && toolTimestamp > prevTimestamp; + }); - if (relevantTools.length === 0) return '' + if (relevantTools.length === 0) return ""; // 이름별로 카운트 - const counts = new Map() + const counts = new Map(); for (const tool of relevantTools) { - const name = tool.toolName || 'unknown' - counts.set(name, (counts.get(name) || 0) + 1) + const name = tool.toolName || "unknown"; + counts.set(name, (counts.get(name) || 0) + 1); } // 배열로 변환하여 카운트 내림차순 정렬 - const sorted = Array.from(counts.entries()).sort((a, b) => b[1] - a[1]) + const sorted = Array.from(counts.entries()).sort((a, b) => b[1] - a[1]); // 최대 3개까지만 표시 - const displayCount = Math.min(3, sorted.length) + const displayCount = Math.min(3, sorted.length); const displayItems = sorted.slice(0, displayCount).map(([name, count]) => { - return count > 1 ? `${name} x${count}` : name - }) + return count > 1 ? `${name} x${count}` : name; + }); - const remaining = sorted.length - displayCount + const remaining = sorted.length - displayCount; if (remaining > 0) { - return `${displayItems.join(', ')} +${remaining} more` + return `${displayItems.join(", ")} +${remaining} more`; } - return displayItems.join(', ') + return displayItems.join(", "); } function CustomTooltip({ active, payload, }: TooltipProps & { chartData?: ChartDataItem[] }) { - if (!active || !payload || payload.length === 0) return null + if (!active || !payload || payload.length === 0) return null; - const data = payload[0]?.payload as ChartDataItem | undefined - if (!data) return null + const data = payload[0]?.payload as ChartDataItem | undefined; + if (!data) return null; return (
@@ -95,16 +95,22 @@ function CustomTooltip({
Input Tokens: - {formatTokens(data.input)} + + {formatTokens(data.input)} +
Output Tokens: - {formatTokens(data.output)} + + {formatTokens(data.output)} +
Cost: - {formatCost(data.cost)} + + {formatCost(data.cost)} +
{data.model && (
@@ -120,7 +126,7 @@ function CustomTooltip({ )}
- ) + ); } export function SessionTimelineChart({ @@ -132,13 +138,13 @@ export function SessionTimelineChart({ // 리렌더링 시마다 발생하는 불필요한 연산을 방지함. (배열 생성 오버헤드 감소) const toolCalls: ToolCallPoint[] = useMemo(() => { return messages - .filter((m) => m.role === 'TOOL') + .filter((m) => m.role === "TOOL") .map((m) => ({ timestamp: m.timestamp, - toolName: m.toolName ?? 'unknown', - parsedTimestamp: new Date(m.timestamp).getTime(), - })) - }, [messages]) + toolName: m.toolName ?? "unknown", + parsedTimestamp: Date.parse(m.timestamp), + })); + }, [messages]); // ⚡ Bolt: usageTimeline 배열을 순회하며 차트 데이터를 생성하는 비용이 높은 작업을 // useMemo로 최적화하여 데이터 변경이 없을 때 캐시된 결과를 재사용함. @@ -151,13 +157,15 @@ export function SessionTimelineChart({ cost: u.estimatedCostUsd, model: u.model, toolSummary: getToolSummaryForIndex(idx, usageTimeline, toolCalls), - })) - }, [usageTimeline, sessionStartedAt, toolCalls]) + })); + }, [usageTimeline, sessionStartedAt, toolCalls]); if (usageTimeline.length === 0) { return ( -

No timeline data available

- ) +

+ No timeline data available +

+ ); } return ( @@ -169,16 +177,19 @@ export function SessionTimelineChart({ stroke="var(--color-muted-foreground)" tickLine={false} axisLine={false} - style={{ fontSize: '11px' }} + style={{ fontSize: "11px" }} /> + } + cursor={{ fill: "var(--color-muted)", opacity: 0.4 }} /> - } cursor={{ fill: 'var(--color-muted)', opacity: 0.4 }} /> - ) + ); }