From 9813e4520216e6f4edc16e9bf67f9a3d100a6a43 Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Mon, 22 Jun 2026 10:26:54 +0000 Subject: [PATCH 1/6] =?UTF-8?q?=E2=9A=A1=20Bolt:=20hoist=20buildTimelineGr?= =?UTF-8?q?oups=20useMemo=20up=20to=20page.tsx=20to=20avoid=20duplicate=20?= =?UTF-8?q?calculation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `buildTimelineGroups` was calculated redundantly inside both `EventList` and `SessionActivityRibbon` inside `useMemo` hooks. This commit moves the `useMemo` computation into the parent `page.tsx` component and passes down `groups` as a prop to its children, preventing duplicate O(n) array iterations per `events` change. --- .../dashboard/[orgSlug]/sessions/[sessionId]/page.tsx | 5 ++++- packages/web/src/components/dashboard/event-list.tsx | 11 ++++++----- .../components/dashboard/session-activity-ribbon.tsx | 8 ++++---- 3 files changed, 14 insertions(+), 10 deletions(-) diff --git a/packages/web/src/app/dashboard/[orgSlug]/sessions/[sessionId]/page.tsx b/packages/web/src/app/dashboard/[orgSlug]/sessions/[sessionId]/page.tsx index 1bea6e3e..4477ad5b 100644 --- a/packages/web/src/app/dashboard/[orgSlug]/sessions/[sessionId]/page.tsx +++ b/packages/web/src/app/dashboard/[orgSlug]/sessions/[sessionId]/page.tsx @@ -13,7 +13,7 @@ import { SessionFilesTab, } from '@/components/dashboard/session-files' import { useSessionDetail } from '@/hooks/use-dashboard-sessions' -import { messagesToTimeline, formatSlashCommandText } from '@/lib/timeline-events' +import { messagesToTimeline, buildTimelineGroups, formatSlashCommandText } from '@/lib/timeline-events' import { extractSessionFiles } from '@/lib/session-files' import { formatTokens, @@ -46,6 +46,7 @@ export default function OrgSessionDetailPage({ () => (data ? messagesToTimeline(data.messages) : []), [data], ) + const groups = useMemo(() => buildTimelineGroups(events), [events]) const files = useMemo(() => extractSessionFiles(events), [events]) const [selectedIdx, setSelectedIdx] = useState(0) const safeIdx = @@ -221,6 +222,7 @@ export default function OrgSessionDetailPage({
void; sessionStartedAt: string; @@ -50,11 +51,10 @@ function formatElapsed(timestamp: string, sessionStartedAt: string): string { } function buildFlatRows( - events: TimelineEvent[], + groups: TimelineGroup[], expandedGroups: Set, selectedIdx: number, ): FlatRow[] { - const groups = buildTimelineGroups(events); const rows: FlatRow[] = []; for (const group of groups) { if (group.kind === "single") { @@ -261,6 +261,7 @@ function Row({ export function EventList({ events, + groups, selectedIdx, onSelect, sessionStartedAt, @@ -268,8 +269,8 @@ export function EventList({ onToggleGroup, }: EventListProps) { const rows = useMemo( - () => buildFlatRows(events, expandedGroups, selectedIdx), - [events, expandedGroups, selectedIdx], + () => buildFlatRows(groups, expandedGroups, selectedIdx), + [groups, expandedGroups, selectedIdx], ); if (events.length === 0) { diff --git a/packages/web/src/components/dashboard/session-activity-ribbon.tsx b/packages/web/src/components/dashboard/session-activity-ribbon.tsx index 87a305ea..694e3806 100644 --- a/packages/web/src/components/dashboard/session-activity-ribbon.tsx +++ b/packages/web/src/components/dashboard/session-activity-ribbon.tsx @@ -1,16 +1,17 @@ 'use client' -import { useMemo, useState, useRef } from 'react' +import { useState, useRef } from 'react' import { formatSlashCommandText, - buildTimelineGroups, type TimelineEvent, + type TimelineGroup, } from '@/lib/timeline-events' import { formatTokens, formatCost, formatRelativeTime } from '@/lib/format' import { segmentVisuals } from './session-ribbon-visuals' type Props = { events: TimelineEvent[] + groups: TimelineGroup[] selectedIdx: number | null onSelect: (idx: number) => void sessionStartedAt: string @@ -147,6 +148,7 @@ function MergedTooltipBody({ export function SessionActivityRibbon({ events, + groups, selectedIdx, onSelect, sessionStartedAt, @@ -156,8 +158,6 @@ export function SessionActivityRibbon({ const [hover, setHover] = useState(null) const containerRef = useRef(null) - const groups = useMemo(() => buildTimelineGroups(events), [events]) - if (events.length === 0) return null const trackMouse = (e: React.MouseEvent) => { From 35479064d72abf110351706016c7bc9c9533a940 Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Mon, 22 Jun 2026 20:11:44 +0000 Subject: [PATCH 2/6] refactor(web): optimize multiple reduce loops in weekly reports Replace chained `.reduce()` and `Object.values()` with a single `for...of` loop over `Object.keys()` to avoid unnecessary intermediate array allocations and improve iteration performance during report aggregation. --- packages/web/src/lib/server/weekly-report.ts | 23 ++++++++++++-------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/packages/web/src/lib/server/weekly-report.ts b/packages/web/src/lib/server/weekly-report.ts index 7eabddbd..eb95d36f 100644 --- a/packages/web/src/lib/server/weekly-report.ts +++ b/packages/web/src/lib/server/weekly-report.ts @@ -392,17 +392,22 @@ export async function getWeeklyReport( } // Insights — delegation - const totalAgentCalls = thisWeekRollups.reduce( - (sum, r) => sum + Object.values(r.agentCounts).reduce((a, b) => a + b, 0), - 0, - ) - const totalSkillCalls = thisWeekRollups.reduce( - (sum, r) => sum + Object.values(r.skillCounts).reduce((a, b) => a + b, 0), - 0, - ) + // ⚡ Bolt Optimization: + // 병목 지점: 기존 코드는 `thisWeekRollups`를 3번 순회하고, 매 순회마다 Object.values()로 중간 배열을 생성하여 메모리 할당 비용이 발생했습니다. + // 최적화 방법: 단일 for...of 루프와 Object.keys() 순회를 결합하여 N+1 순회를 1회 순회로 통합하고 중간 배열 할당을 제거했습니다. + // 기대 효과: `thisWeekRollups`의 크기가 클 경우, 불필요한 배열 생성 오버헤드와 O(N) 순회를 1/3로 줄여 리포트 생성 성능이 향상됩니다. + let totalAgentCalls = 0 + let totalSkillCalls = 0 const distinctSkillsThisWeek = new Set() + for (const r of thisWeekRollups) { - for (const k of Object.keys(r.skillCounts)) distinctSkillsThisWeek.add(k) + for (const k of Object.keys(r.agentCounts)) { + totalAgentCalls += r.agentCounts[k] + } + for (const k of Object.keys(r.skillCounts)) { + totalSkillCalls += r.skillCounts[k] + distinctSkillsThisWeek.add(k) + } } const insights: WeeklyInsights = { From 649df11c1c816fdb73fb61c8b201270dfbe86b82 Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Mon, 22 Jun 2026 21:49:09 +0000 Subject: [PATCH 3/6] feat(ux): add focus-visible styles to overview stats toggle button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit overview-stats 컴포넌트의 설명 텍스트를 펼치거나 접는 버튼에 키보드 포커스 스타일이 누락되어 있어, 키보드 내비게이션 사용자에게 현재 포커스 위치를 명확히 보여주지 못하는 문제를 수정했습니다. Tailwind CSS의 `focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded-sm` 클래스를 추가하여 탭(Tab) 키 이동 시 포커스 링이 보이도록 접근성을 개선했습니다. --- .Jules/palette.md | 3 +++ packages/web/src/components/dashboard/overview-stats.tsx | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) create mode 100644 .Jules/palette.md diff --git a/.Jules/palette.md b/.Jules/palette.md new file mode 100644 index 00000000..b5ac773f --- /dev/null +++ b/.Jules/palette.md @@ -0,0 +1,3 @@ +## 2024-06-22 - Overview Stats 토글 버튼 키보드 접근성 개선 +**Learning:** `overview-stats` 컴포넌트의 설명 텍스트를 펼치거나 접는 ` diff --git a/packages/web/src/components/layout/org-header.tsx b/packages/web/src/components/layout/org-header.tsx index c33de1fc..7f57f267 100644 --- a/packages/web/src/components/layout/org-header.tsx +++ b/packages/web/src/components/layout/org-header.tsx @@ -18,6 +18,7 @@ export function OrgHeader({ orgName }: { orgName?: string }) { diff --git a/packages/web/src/components/layout/org-sidebar.tsx b/packages/web/src/components/layout/org-sidebar.tsx index 7f28d8d0..6d37e315 100644 --- a/packages/web/src/components/layout/org-sidebar.tsx +++ b/packages/web/src/components/layout/org-sidebar.tsx @@ -124,6 +124,7 @@ export function OrgSidebar() { @@ -150,6 +151,7 @@ export function OrgSidebar() { From 60a3647d6ec5d24c1ebbb44aa95509f555f8ea96 Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Sun, 28 Jun 2026 20:21:22 +0000 Subject: [PATCH 6/6] =?UTF-8?q?perf(web):=20=EC=B5=9C=EC=A0=81=ED=99=94=20?= =?UTF-8?q?-=20EventList=EC=9D=98=20=EA=B0=80=EC=83=81=ED=99=94=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=20=EB=82=A0=EC=A7=9C=20=ED=8C=8C=EC=8B=B1=20?= =?UTF-8?q?=EC=98=A4=EB=B2=84=ED=97=A4=EB=93=9C=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 매 렌더링 시마다 `new Date(timestamp).getTime()`가 실행되는 것을 방지하기 위해, `buildFlatRows` 단계(메모이징됨)에서 한 번만 밀리초로 변환하도록 수정 - `FlatRow` 타입과 연관된 컴포넌트(`Row`, `RowView`)들이 변환된 `timestampMs`를 사용하도록 수정 - `sessionStartedAt` 또한 EventList 컴포넌트 레벨에서 한 번만 변환하여 하위 컴포넌트로 전달 --- .../src/components/dashboard/event-list.tsx | 49 +++++++++++++------ 1 file changed, 33 insertions(+), 16 deletions(-) diff --git a/packages/web/src/components/dashboard/event-list.tsx b/packages/web/src/components/dashboard/event-list.tsx index fe1c9bdd..11e98634 100644 --- a/packages/web/src/components/dashboard/event-list.tsx +++ b/packages/web/src/components/dashboard/event-list.tsx @@ -23,6 +23,7 @@ type FlatRow = kind: "event"; key: string; event: TimelineEvent; + timestampMs: number; idx: number; indented: boolean; labelOverride?: string; @@ -33,17 +34,22 @@ type FlatRow = toolName: string; count: number; firstEvent: TimelineEvent; + timestampMs: number; groupFirstIdx: number; isExpanded: boolean; }; const ROW_HEIGHT = 36; -function formatElapsed(timestamp: string, sessionStartedAt: string): string { - const t = new Date(timestamp).getTime(); - const start = new Date(sessionStartedAt).getTime(); - if (Number.isNaN(t) || Number.isNaN(start)) return ""; - const diffSec = Math.max(0, Math.floor((t - start) / 1000)); +function formatElapsedMs( + timestampMs: number, + sessionStartedAtMs: number, +): string { + if (Number.isNaN(timestampMs) || Number.isNaN(sessionStartedAtMs)) return ""; + const diffSec = Math.max( + 0, + Math.floor((timestampMs - sessionStartedAtMs) / 1000), + ); const h = Math.floor(diffSec / 3600); const m = Math.floor((diffSec % 3600) / 60); const s = diffSec % 60; @@ -62,6 +68,7 @@ function buildFlatRows( kind: "event", key: `s-${group.idx}`, event: group.event, + timestampMs: new Date(group.event.timestamp).getTime(), idx: group.idx, indented: false, }); @@ -73,6 +80,7 @@ function buildFlatRows( kind: "event", key: `gs-${idx}`, event, + timestampMs: new Date(event.timestamp).getTime(), idx, indented: false, labelOverride: "Tool", @@ -89,6 +97,7 @@ function buildFlatRows( toolName: group.toolName, count: group.items.length, firstEvent: group.items[0].event, + timestampMs: new Date(group.items[0].event.timestamp).getTime(), groupFirstIdx: firstIdx, isExpanded, }); @@ -98,6 +107,7 @@ function buildFlatRows( kind: "event", key: `gc-${idx}`, event, + timestampMs: new Date(event.timestamp).getTime(), idx, indented: true, labelOverride: "Tool", @@ -123,7 +133,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; } @@ -204,7 +215,7 @@ function RowView({ type RowProps = { rows: FlatRow[]; selectedIdx: number; - sessionStartedAt: string; + sessionStartedAtMs: number; onSelect: (idx: number) => void; onToggleGroup: (firstIdx: number) => void; }; @@ -214,7 +225,7 @@ function Row({ style, rows, selectedIdx, - sessionStartedAt, + sessionStartedAtMs, onSelect, onToggleGroup, }: RowComponentProps) { @@ -227,7 +238,7 @@ function Row({ onToggleGroup(row.groupFirstIdx)} @@ -238,18 +249,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)} @@ -268,6 +280,11 @@ export function EventList({ expandedGroups, onToggleGroup, }: EventListProps) { + const sessionStartedAtMs = useMemo( + () => new Date(sessionStartedAt).getTime(), + [sessionStartedAt], + ); + const rows = useMemo( () => buildFlatRows(groups, expandedGroups, selectedIdx), [groups, expandedGroups, selectedIdx], @@ -289,7 +306,7 @@ export function EventList({ rowProps={{ rows, selectedIdx, - sessionStartedAt, + sessionStartedAtMs, onSelect, onToggleGroup, }}