diff --git a/docs/frontend-ui-audit-2026-08-11/ActivityGroups.md b/docs/frontend-ui-audit-2026-08-11/ActivityGroups.md new file mode 100644 index 0000000000..ffd061e0e7 --- /dev/null +++ b/docs/frontend-ui-audit-2026-08-11/ActivityGroups.md @@ -0,0 +1,18 @@ +# Frontend UI Audit — Activity Groups + +Scope: `EditActivityGroup`, `TerminalActivityGroup`, and their shared event projection. This is a behavior-preserving component refactor; no rendered styles, copy, layout, focus behavior, or interaction contract changed. + +| Line | Element | Verdict | Reason | Suggested change | +| --------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- | ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------- | +| `src/engines/ChatPanel/ChatItems/activityGroupProjection.tsx:17` | Event-item projection, intermediate running-state normalization, lazy registry rendering, and tool-usage aggregation | abstract | Edit and terminal groups previously duplicated the same presentation pipeline. One shared owner prevents loading-state and usage-badge behavior from drifting while leaving domain summaries separate. | Reuse the shared projection from both activity-group components. | +| `src/engines/ChatPanel/ChatItems/EditActivityGroup/index.tsx:111` | Edit activity stack | keep with reason | `StackedBlock`, tool icons, workstation diff tokens, and the shared usage badge already implement the design-system contracts. The edit/read and diff-stat summary is specific to edit activity. | Keep the edit summary local and continue using shared primitives. | +| `src/engines/ChatPanel/ChatItems/TerminalActivityGroup/index.tsx:140` | Terminal activity stack | keep with reason | The stack uses the same shared primitives, while terminal/MCP/wait counts and durable Work Item result cards are terminal-domain behavior. Moving them into the generic projection would leak domain rules. | Keep terminal summary and Work Item projection local. | +| `src/engines/ChatPanel/ChatItems/EditActivityGroup/index.tsx:119` | Existing summary typography and spacing | keep with reason | The existing classes compose established text and diff-stat tokens; this refactor introduces no arbitrary visual value or parallel component style. | No visual change. | + +## Summary + +- Fix: 0 +- Keep with reason: 3 +- Abstract: 1 +- Sweep candidates: 0 +- Accessibility: no semantic or interactive changes; `StackedBlock` retains the existing keyboard/collapse contract. diff --git a/src/engines/ChatPanel/ChatItems/EditActivityGroup/index.tsx b/src/engines/ChatPanel/ChatItems/EditActivityGroup/index.tsx index 806b166b53..163a8ad7b5 100644 --- a/src/engines/ChatPanel/ChatItems/EditActivityGroup/index.tsx +++ b/src/engines/ChatPanel/ChatItems/EditActivityGroup/index.tsx @@ -4,38 +4,28 @@ * Groups file edits and the reads performed after them into one collapsible * stack. Each event still renders through the event registry. */ -import React, { Suspense, useMemo } from "react"; +import React, { useMemo } from "react"; import { useTranslation } from "react-i18next"; import { getToolIcon } from "@src/config/toolIcons"; import { DIFF_STATS } from "@src/config/workstation/tokens"; import ToolUsageBadge from "@src/engines/ChatPanel/blocks/ToolCallBlock/ToolUsageBadge"; -import { - ChatLoadingBlock, - StackedBlock, -} from "@src/engines/ChatPanel/blocks/primitives"; -import { - type SessionEvent, - TOOL_USAGE_ARGS_KEY, - type ToolUsageMetadata, -} from "@src/engines/SessionCore/core/types"; +import { StackedBlock } from "@src/engines/ChatPanel/blocks/primitives"; +import { type SessionEvent } from "@src/engines/SessionCore/core/types"; import { extractEditData } from "@src/engines/SessionCore/rendering/props/propsDataExtractors"; -import { getChatLazyComponent } from "@src/engines/SessionCore/rendering/registry/events"; +import { normalizeFunctionName } from "@src/lib/activityData/activityNormalizers"; + import { - getRegistryEventType, - normalizeFunctionName, -} from "@src/lib/activityData/activityNormalizers"; + aggregateActivityGroupToolUsage, + buildActivityGroupItems, + renderActivityGroupEvent, +} from "../activityGroupProjection"; interface EditActivityGroupProps { events: SessionEvent[]; closedByBoundary?: boolean; } -interface EditEventItem { - event: SessionEvent; - isLastItem: boolean; -} - function getCanonicalName(event: SessionEvent): string { return ( event.uiCanonical || @@ -88,99 +78,12 @@ export function sumEditDiffStats(events: readonly SessionEvent[]): { ); } -function ActivityBlock({ event }: { event: SessionEvent }) { - const eventType = getRegistryEventType( - event as unknown as Record - ); - const EventComponent = getChatLazyComponent(eventType); - return ( - }> - {React.createElement(EventComponent, { event })} - - ); -} - -function suppressLoadingForNonLastRunningEvent( - event: SessionEvent, - isLastItem: boolean -): SessionEvent { - if (isLastItem || event.displayStatus !== "running") return event; - return { - ...event, - displayStatus: "completed", - activityStatus: "processed", - isDelta: false, - }; -} - -function readToolUsage(event: SessionEvent): ToolUsageMetadata | undefined { - if (event.toolUsage) return event.toolUsage; - const raw = event.args?.[TOOL_USAGE_ARGS_KEY]; - if (!raw || typeof raw !== "object") return undefined; - return raw as ToolUsageMetadata; -} - -function aggregateToolUsage( - items: readonly EditEventItem[] -): ToolUsageMetadata | undefined { - const usages = items - .map((item) => readToolUsage(item.event)) - .filter((usage): usage is ToolUsageMetadata => Boolean(usage)); - if (usages.length === 0) return undefined; - - return usages.reduce( - (total, usage) => ({ - decisionCompletionTokens: - total.decisionCompletionTokens + usage.decisionCompletionTokens, - resultContextTokens: - total.resultContextTokens + usage.resultContextTokens, - followupCompletionTokens: - total.followupCompletionTokens + usage.followupCompletionTokens, - inputBytes: total.inputBytes + usage.inputBytes, - outputBytes: total.outputBytes + usage.outputBytes, - relatedCacheReadTokens: - total.relatedCacheReadTokens + usage.relatedCacheReadTokens, - relatedCacheWriteTokens: - total.relatedCacheWriteTokens + usage.relatedCacheWriteTokens, - attributionMethod: - total.attributionMethod === usage.attributionMethod - ? total.attributionMethod - : usage.attributionMethod, - }), - { - decisionCompletionTokens: 0, - resultContextTokens: 0, - followupCompletionTokens: 0, - inputBytes: 0, - outputBytes: 0, - relatedCacheReadTokens: 0, - relatedCacheWriteTokens: 0, - attributionMethod: usages[0].attributionMethod, - } - ); -} - -function renderEditEvent({ event, isLastItem }: EditEventItem) { - return ( - - ); -} - const EditActivityGroup: React.FC = ({ events, closedByBoundary = true, }) => { const { t } = useTranslation("sessions"); - const items = useMemo( - () => - events.map((event, index) => ({ - event, - isLastItem: index === events.length - 1, - })), - [events] - ); + const items = useMemo(() => buildActivityGroupItems(events), [events]); if (items.length === 0) return null; @@ -194,7 +97,7 @@ const EditActivityGroup: React.FC = ({ const hasDiffStats = diffStats.additions > 0 || diffStats.deletions > 0; const firstEvent = items[0].event; - const groupToolUsage = aggregateToolUsage(items); + const groupToolUsage = aggregateActivityGroupToolUsage(events); return (
= ({ rightContent={ groupToolUsage ? : undefined } - renderItem={renderEditEvent} + renderItem={renderActivityGroupEvent} />
); diff --git a/src/engines/ChatPanel/ChatItems/TerminalActivityGroup/index.tsx b/src/engines/ChatPanel/ChatItems/TerminalActivityGroup/index.tsx index ec1784908a..0fa60fd560 100644 --- a/src/engines/ChatPanel/ChatItems/TerminalActivityGroup/index.tsx +++ b/src/engines/ChatPanel/ChatItems/TerminalActivityGroup/index.tsx @@ -6,7 +6,7 @@ * renders through the registry, preserving its specialized behavior. */ import { useAtomValue } from "jotai"; -import React, { Suspense, useMemo } from "react"; +import React, { useMemo } from "react"; import { useTranslation } from "react-i18next"; import { getToolIcon } from "@src/config/toolIcons"; @@ -14,29 +14,21 @@ import { isMcpToolEvent } from "@src/engines/ChatPanel/ChatHistory/chatItemPipel import ToolUsageBadge from "@src/engines/ChatPanel/blocks/ToolCallBlock/ToolUsageBadge"; import OrgtrackEnvelopeCard from "@src/engines/ChatPanel/blocks/ToolCallBlock/cards/OrgtrackEnvelopeCard"; import { parseOrgtrackEnvelope } from "@src/engines/ChatPanel/blocks/ToolCallBlock/helpers"; -import { - ChatLoadingBlock, - StackedBlock, -} from "@src/engines/ChatPanel/blocks/primitives"; -import { - type SessionEvent, - TOOL_USAGE_ARGS_KEY, - type ToolUsageMetadata, -} from "@src/engines/SessionCore/core/types"; -import { getChatLazyComponent } from "@src/engines/SessionCore/rendering/registry/events"; -import { getRegistryEventType } from "@src/lib/activityData/activityNormalizers"; +import { StackedBlock } from "@src/engines/ChatPanel/blocks/primitives"; +import { type SessionEvent } from "@src/engines/SessionCore/core/types"; import { sessionByIdAtom } from "@src/store/session/sessionAtom"; +import { + aggregateActivityGroupToolUsage, + buildActivityGroupItems, + renderActivityGroupEvent, +} from "../activityGroupProjection"; + interface TerminalActivityGroupProps { events: SessionEvent[]; closedByBoundary?: boolean; } -interface TerminalEventItem { - event: SessionEvent; - isLastItem: boolean; -} - function parseTerminalOrgtrackEnvelope( event: SessionEvent, context: { @@ -98,101 +90,13 @@ export function buildGroupSummary( return parts.join(t("tools.terminalSummary.separator")); } -function ActivityBlock({ event }: { event: SessionEvent }) { - const eventType = getRegistryEventType( - event as unknown as Record - ); - const EventComponent = getChatLazyComponent(eventType); - const renderedEvent = React.createElement(EventComponent, { event }); - return }>{renderedEvent}; -} - -function suppressLoadingForNonLastRunningEvent( - event: SessionEvent, - isLastItem: boolean -): SessionEvent { - if (isLastItem || event.displayStatus !== "running") return event; - - return { - ...event, - displayStatus: "completed", - activityStatus: "processed", - isDelta: false, - }; -} - -function readToolUsage(event: SessionEvent): ToolUsageMetadata | undefined { - if (event.toolUsage) return event.toolUsage; - const raw = event.args?.[TOOL_USAGE_ARGS_KEY]; - if (!raw || typeof raw !== "object") return undefined; - return raw as ToolUsageMetadata; -} - -function aggregateToolUsage( - items: readonly TerminalEventItem[] -): ToolUsageMetadata | undefined { - const usages = items - .map((item) => readToolUsage(item.event)) - .filter((usage): usage is ToolUsageMetadata => Boolean(usage)); - if (usages.length === 0) return undefined; - - return usages.reduce( - (total, usage) => ({ - decisionCompletionTokens: - total.decisionCompletionTokens + usage.decisionCompletionTokens, - resultContextTokens: - total.resultContextTokens + usage.resultContextTokens, - followupCompletionTokens: - total.followupCompletionTokens + usage.followupCompletionTokens, - inputBytes: total.inputBytes + usage.inputBytes, - outputBytes: total.outputBytes + usage.outputBytes, - relatedCacheReadTokens: - total.relatedCacheReadTokens + usage.relatedCacheReadTokens, - relatedCacheWriteTokens: - total.relatedCacheWriteTokens + usage.relatedCacheWriteTokens, - attributionMethod: - total.attributionMethod === usage.attributionMethod - ? total.attributionMethod - : usage.attributionMethod, - }), - { - decisionCompletionTokens: 0, - resultContextTokens: 0, - followupCompletionTokens: 0, - inputBytes: 0, - outputBytes: 0, - relatedCacheReadTokens: 0, - relatedCacheWriteTokens: 0, - attributionMethod: usages[0].attributionMethod, - } - ); -} - -function renderTerminalEvent( - { event, isLastItem }: TerminalEventItem, - _index: number -): React.ReactNode { - return ( - - ); -} - const TerminalActivityGroup: React.FC = ({ events, closedByBoundary = true, }) => { const { t } = useTranslation("sessions"); const session = useAtomValue(sessionByIdAtom(events[0]?.sessionId ?? "")); - const items = useMemo( - () => - events.map((event, index) => ({ - event, - isLastItem: index === events.length - 1, - })), - [events] - ); + const items = useMemo(() => buildActivityGroupItems(events), [events]); const workItemResults = useMemo( () => events.flatMap((event) => { @@ -220,7 +124,7 @@ const TerminalActivityGroup: React.FC = ({ if (items.length === 0) return null; const firstEvent = items[0].event; - const groupToolUsage = aggregateToolUsage(items); + const groupToolUsage = aggregateActivityGroupToolUsage(events); const groupSummary = buildGroupSummary(events, t); return ( @@ -249,7 +153,7 @@ const TerminalActivityGroup: React.FC = ({ ) : undefined } - renderItem={renderTerminalEvent} + renderItem={renderActivityGroupEvent} /> {workItemResults.map((card, index) => ( diff --git a/src/engines/ChatPanel/ChatItems/activityGroupProjection.test.ts b/src/engines/ChatPanel/ChatItems/activityGroupProjection.test.ts new file mode 100644 index 0000000000..b03c872c46 --- /dev/null +++ b/src/engines/ChatPanel/ChatItems/activityGroupProjection.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from "vitest"; + +import { + TOOL_USAGE_ARGS_KEY, + type ToolUsageMetadata, +} from "@src/engines/SessionCore/core/types"; +import { makeSessionEvent } from "@src/engines/SessionCore/rendering/props/__tests__/fixtures"; + +import { + aggregateActivityGroupToolUsage, + buildActivityGroupItems, + suppressIntermediateRunningState, +} from "./activityGroupProjection"; + +function usage(value: number, attributionMethod: string): ToolUsageMetadata { + return { + decisionCompletionTokens: value, + resultContextTokens: value, + followupCompletionTokens: value, + inputBytes: value, + outputBytes: value, + relatedCacheReadTokens: value, + relatedCacheWriteTokens: value, + attributionMethod, + }; +} + +describe("activity group projection", () => { + it("marks only the final event as the live group tail", () => { + const first = makeSessionEvent(); + const second = makeSessionEvent(); + + expect(buildActivityGroupItems([first, second])).toEqual([ + { event: first, isLastItem: false }, + { event: second, isLastItem: true }, + ]); + }); + + it("suppresses a stale running state only before the live tail", () => { + const running = makeSessionEvent({ + displayStatus: "running", + activityStatus: "agent", + isDelta: true, + }); + + expect(suppressIntermediateRunningState(running, true)).toBe(running); + expect(suppressIntermediateRunningState(running, false)).toMatchObject({ + displayStatus: "completed", + activityStatus: "processed", + isDelta: false, + }); + }); + + it("aggregates direct and serialized tool usage metadata", () => { + const first = makeSessionEvent(); + first.toolUsage = usage(2, "direct"); + const second = makeSessionEvent({ + args: { [TOOL_USAGE_ARGS_KEY]: usage(3, "fallback") }, + }); + + expect(aggregateActivityGroupToolUsage([first, second])).toEqual( + usage(5, "fallback") + ); + }); + + it("returns no badge data when the group has no usage metadata", () => { + expect( + aggregateActivityGroupToolUsage([makeSessionEvent()]) + ).toBeUndefined(); + }); +}); diff --git a/src/engines/ChatPanel/ChatItems/activityGroupProjection.tsx b/src/engines/ChatPanel/ChatItems/activityGroupProjection.tsx new file mode 100644 index 0000000000..b6fde5b3a1 --- /dev/null +++ b/src/engines/ChatPanel/ChatItems/activityGroupProjection.tsx @@ -0,0 +1,107 @@ +import React, { Suspense } from "react"; + +import { ChatLoadingBlock } from "@src/engines/ChatPanel/blocks/primitives"; +import { + type SessionEvent, + TOOL_USAGE_ARGS_KEY, + type ToolUsageMetadata, +} from "@src/engines/SessionCore/core/types"; +import { getChatLazyComponent } from "@src/engines/SessionCore/rendering/registry/events"; +import { getRegistryEventType } from "@src/lib/activityData/activityNormalizers"; + +export interface ActivityGroupEventItem { + event: SessionEvent; + isLastItem: boolean; +} + +export function buildActivityGroupItems( + events: readonly SessionEvent[] +): ActivityGroupEventItem[] { + return events.map((event, index) => ({ + event, + isLastItem: index === events.length - 1, + })); +} + +export function suppressIntermediateRunningState( + event: SessionEvent, + isLastItem: boolean +): SessionEvent { + if (isLastItem || event.displayStatus !== "running") return event; + return { + ...event, + displayStatus: "completed", + activityStatus: "processed", + isDelta: false, + }; +} + +function readToolUsage(event: SessionEvent): ToolUsageMetadata | undefined { + if (event.toolUsage) return event.toolUsage; + const raw = event.args?.[TOOL_USAGE_ARGS_KEY]; + if (!raw || typeof raw !== "object") return undefined; + return raw as ToolUsageMetadata; +} + +export function aggregateActivityGroupToolUsage( + events: readonly SessionEvent[] +): ToolUsageMetadata | undefined { + const usages = events + .map(readToolUsage) + .filter((usage): usage is ToolUsageMetadata => Boolean(usage)); + if (usages.length === 0) return undefined; + + return usages.reduce( + (total, usage) => ({ + decisionCompletionTokens: + total.decisionCompletionTokens + usage.decisionCompletionTokens, + resultContextTokens: + total.resultContextTokens + usage.resultContextTokens, + followupCompletionTokens: + total.followupCompletionTokens + usage.followupCompletionTokens, + inputBytes: total.inputBytes + usage.inputBytes, + outputBytes: total.outputBytes + usage.outputBytes, + relatedCacheReadTokens: + total.relatedCacheReadTokens + usage.relatedCacheReadTokens, + relatedCacheWriteTokens: + total.relatedCacheWriteTokens + usage.relatedCacheWriteTokens, + attributionMethod: + total.attributionMethod === usage.attributionMethod + ? total.attributionMethod + : usage.attributionMethod, + }), + { + decisionCompletionTokens: 0, + resultContextTokens: 0, + followupCompletionTokens: 0, + inputBytes: 0, + outputBytes: 0, + relatedCacheReadTokens: 0, + relatedCacheWriteTokens: 0, + attributionMethod: usages[0].attributionMethod, + } + ); +} + +function ActivityGroupEventBlock({ event }: { event: SessionEvent }) { + const eventType = getRegistryEventType( + event as unknown as Record + ); + const EventComponent = getChatLazyComponent(eventType); + return ( + }> + {React.createElement(EventComponent, { event })} + + ); +} + +export function renderActivityGroupEvent({ + event, + isLastItem, +}: ActivityGroupEventItem): React.ReactNode { + return ( + + ); +}