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
18 changes: 18 additions & 0 deletions docs/frontend-ui-audit-2026-08-11/ActivityGroups.md
Original file line number Diff line number Diff line change
@@ -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.
121 changes: 12 additions & 109 deletions src/engines/ChatPanel/ChatItems/EditActivityGroup/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 ||
Expand Down Expand Up @@ -88,99 +78,12 @@ export function sumEditDiffStats(events: readonly SessionEvent[]): {
);
}

function ActivityBlock({ event }: { event: SessionEvent }) {
const eventType = getRegistryEventType(
event as unknown as Record<string, unknown>
);
const EventComponent = getChatLazyComponent(eventType);
return (
<Suspense fallback={<ChatLoadingBlock />}>
{React.createElement(EventComponent, { event })}
</Suspense>
);
}

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<ToolUsageMetadata>(
(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 (
<ActivityBlock
event={suppressLoadingForNonLastRunningEvent(event, isLastItem)}
/>
);
}

const EditActivityGroup: React.FC<EditActivityGroupProps> = ({
events,
closedByBoundary = true,
}) => {
const { t } = useTranslation("sessions");
const items = useMemo<EditEventItem[]>(
() =>
events.map((event, index) => ({
event,
isLastItem: index === events.length - 1,
})),
[events]
);
const items = useMemo(() => buildActivityGroupItems(events), [events]);

if (items.length === 0) return null;

Expand All @@ -194,7 +97,7 @@ const EditActivityGroup: React.FC<EditActivityGroupProps> = ({
const hasDiffStats = diffStats.additions > 0 || diffStats.deletions > 0;

const firstEvent = items[0].event;
const groupToolUsage = aggregateToolUsage(items);
const groupToolUsage = aggregateActivityGroupToolUsage(events);

return (
<div
Expand Down Expand Up @@ -242,7 +145,7 @@ const EditActivityGroup: React.FC<EditActivityGroupProps> = ({
rightContent={
groupToolUsage ? <ToolUsageBadge usage={groupToolUsage} /> : undefined
}
renderItem={renderEditEvent}
renderItem={renderActivityGroupEvent}
/>
</div>
);
Expand Down
120 changes: 12 additions & 108 deletions src/engines/ChatPanel/ChatItems/TerminalActivityGroup/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,37 +6,29 @@
* 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";
import { isMcpToolEvent } from "@src/engines/ChatPanel/ChatHistory/chatItemPipeline/classifiers";
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: {
Expand Down Expand Up @@ -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<string, unknown>
);
const EventComponent = getChatLazyComponent(eventType);
const renderedEvent = React.createElement(EventComponent, { event });
return <Suspense fallback={<ChatLoadingBlock />}>{renderedEvent}</Suspense>;
}

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<ToolUsageMetadata>(
(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 (
<ActivityBlock
event={suppressLoadingForNonLastRunningEvent(event, isLastItem)}
/>
);
}

const TerminalActivityGroup: React.FC<TerminalActivityGroupProps> = ({
events,
closedByBoundary = true,
}) => {
const { t } = useTranslation("sessions");
const session = useAtomValue(sessionByIdAtom(events[0]?.sessionId ?? ""));
const items = useMemo<TerminalEventItem[]>(
() =>
events.map((event, index) => ({
event,
isLastItem: index === events.length - 1,
})),
[events]
);
const items = useMemo(() => buildActivityGroupItems(events), [events]);
const workItemResults = useMemo(
() =>
events.flatMap((event) => {
Expand Down Expand Up @@ -220,7 +124,7 @@ const TerminalActivityGroup: React.FC<TerminalActivityGroupProps> = ({
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 (
Expand Down Expand Up @@ -249,7 +153,7 @@ const TerminalActivityGroup: React.FC<TerminalActivityGroupProps> = ({
<ToolUsageBadge usage={groupToolUsage} />
) : undefined
}
renderItem={renderTerminalEvent}
renderItem={renderActivityGroupEvent}
/>
</div>
{workItemResults.map((card, index) => (
Expand Down
Loading
Loading