Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .jules/bolt.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
## 2026-08-01 - ISO 문자열 파싱 최적화
**Learning:** 반복문 내에서 `new Date().getTime()`을 호출하면 불필요한 Date 객체 할당이 발생하여 성능 저하 및 메인 스레드 블로킹을 유발함. 특히 가상화 리스트(`react-window` 등) 렌더링 루프에서는 이런 계산을 외부로 빼거나, V8 엔진에서 빠른 `Date.parse()`를 사용하는 것이 유리함.
**Action:** 앞으로는 성능이 중요한 렌더링 경로나 루프 내에서 날짜 파싱이 필요할 때 객체 할당을 피하기 위해 `Date.parse(string)`을 사용할 것. 또한 정적인 계산값(예: sessionStartedAt)은 상위 컴포넌트에서 `useMemo`로 처리하여 하위 컴포넌트에 원시 값으로 전달할 것.
35 changes: 21 additions & 14 deletions packages/web/src/components/dashboard/event-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -207,7 +208,7 @@ function RowView({
type RowProps = {
rows: FlatRow[];
selectedIdx: number;
sessionStartedAt: string;
sessionStartedAtMs: number;
onSelect: (idx: number) => void;
onToggleGroup: (firstIdx: number) => void;
};
Expand All @@ -217,7 +218,7 @@ function Row({
style,
rows,
selectedIdx,
sessionStartedAt,
sessionStartedAtMs,
onSelect,
onToggleGroup,
}: RowComponentProps<RowProps>) {
Expand All @@ -230,7 +231,7 @@ function Row({
<RowView
label="Tool"
preview={`${row.toolName} x${row.count}`}
time={formatElapsed(row.firstEvent.timestamp, sessionStartedAt)}
time={formatElapsed(row.firstEvent.timestamp, sessionStartedAtMs)}
icon={getIcon(row.firstEvent)}
isSelected={false}
onClick={() => onToggleGroup(row.groupFirstIdx)}
Expand All @@ -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 (
<div style={style} role="listitem">
<RowView
label={label}
preview={preview}
time={formatElapsed(row.event.timestamp, sessionStartedAt)}
time={formatElapsed(row.event.timestamp, sessionStartedAtMs)}
icon={getIcon(row.event)}
isSelected={row.idx === selectedIdx}
onClick={() => onSelect(row.idx)}
Expand All @@ -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],
Expand All @@ -292,7 +299,7 @@ export function EventList({
rowProps={{
rows,
selectedIdx,
sessionStartedAt,
sessionStartedAtMs,
onSelect,
onToggleGroup,
}}
Expand Down
121 changes: 66 additions & 55 deletions packages/web/src/components/dashboard/session-timeline-chart.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use client'
"use client";

import React, { useMemo } from 'react'
import React, { useMemo } from "react";
import {
ComposedChart,
Bar,
Expand All @@ -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<string, number>()
const counts = new Map<string, number>();
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<number, string> & { 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 (
<div className="rounded-lg border border-border bg-popover text-popover-foreground shadow-lg p-3">
Expand All @@ -95,16 +95,22 @@ function CustomTooltip({
<div className="flex items-center gap-2">
<span className="h-2.5 w-2.5 rounded-full bg-chart-1" />
<span className="text-muted-foreground">Input Tokens:</span>
<span className="font-medium tabular-nums">{formatTokens(data.input)}</span>
<span className="font-medium tabular-nums">
{formatTokens(data.input)}
</span>
</div>
<div className="flex items-center gap-2">
<span className="h-2.5 w-2.5 rounded-full bg-chart-2" />
<span className="text-muted-foreground">Output Tokens:</span>
<span className="font-medium tabular-nums">{formatTokens(data.output)}</span>
<span className="font-medium tabular-nums">
{formatTokens(data.output)}
</span>
</div>
<div className="pt-1 mt-1 border-t border-border">
<span className="text-muted-foreground">Cost:</span>
<span className="font-medium ml-2 tabular-nums">{formatCost(data.cost)}</span>
<span className="font-medium ml-2 tabular-nums">
{formatCost(data.cost)}
</span>
</div>
{data.model && (
<div>
Expand All @@ -120,7 +126,7 @@ function CustomTooltip({
)}
</div>
</div>
)
);
}

export function SessionTimelineChart({
Expand All @@ -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로 최적화하여 데이터 변경이 없을 때 캐시된 결과를 재사용함.
Expand All @@ -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 (
<p className="text-center text-muted-foreground py-8">No timeline data available</p>
)
<p className="text-center text-muted-foreground py-8">
No timeline data available
</p>
);
}

return (
Expand All @@ -169,16 +177,19 @@ export function SessionTimelineChart({
stroke="var(--color-muted-foreground)"
tickLine={false}
axisLine={false}
style={{ fontSize: '11px' }}
style={{ fontSize: "11px" }}
/>
<YAxis
tickFormatter={formatTokens}
stroke="var(--color-muted-foreground)"
tickLine={false}
axisLine={false}
style={{ fontSize: '11px' }}
style={{ fontSize: "11px" }}
/>
<Tooltip
content={<CustomTooltip />}
cursor={{ fill: "var(--color-muted)", opacity: 0.4 }}
/>
<Tooltip content={<CustomTooltip />} cursor={{ fill: 'var(--color-muted)', opacity: 0.4 }} />
<Bar
dataKey="input"
stackId="tokens"
Expand All @@ -193,5 +204,5 @@ export function SessionTimelineChart({
/>
</ComposedChart>
</ResponsiveContainer>
)
);
}
Loading