Skip to content
Open
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
103 changes: 84 additions & 19 deletions src/browser/components/ChatPane/ChatPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,10 @@ import {
shouldBypassDeferredMessages,
} from "@/browser/utils/messages/messageUtils";
import { computeTaskReportLinking } from "@/browser/utils/messages/taskReportLinking";
import { computeToolCoalesceInfos } from "@/browser/utils/messages/toolCoalescing";
import { BashCollapsedSummaryModeProvider } from "@/browser/features/Tools/BashCollapsedSummaryModeContext";
import { BashOutputCollapsedIndicator } from "@/browser/features/Tools/BashOutputCollapsedIndicator";
import { CoalescedToolCall } from "@/browser/features/Tools/CoalescedToolCall";
import {
getInterruptionContext,
getLastMainRetryCandidateMessage,
Expand Down Expand Up @@ -338,6 +340,13 @@ const ChatPaneContent: React.FC<ChatPaneContentProps> = (props) => {
// Track which bash_output groups are expanded (keyed by first message ID)
const [expandedBashGroups, setExpandedBashGroups] = useState<Set<string>>(new Set());

// Track which tool-coalesce groups (file_read / file_edit bursts) the user
// has expanded. Keyed by the head message ID so the entry survives later
// additions to the same group without changing identity.
const [expandedToolCoalesceGroups, setExpandedToolCoalesceGroups] = useState<Set<string>>(
new Set()
);

// Extract state from workspace state

// Keep a ref to the latest workspace state so event handlers (passed to memoized children)
Expand Down Expand Up @@ -429,6 +438,12 @@ const ChatPaneContent: React.FC<ChatPaneContentProps> = (props) => {
[deferredMessages]
);

// Precompute tool-coalesce metadata (file_read / file_edit bursts) once per snapshot.
const toolCoalesceInfos = useMemo(
() => computeToolCoalesceInfos(deferredMessages),
[deferredMessages]
);

const autoCompactionResult = useMemo(
() =>
checkAutoCompaction(
Expand Down Expand Up @@ -681,6 +696,7 @@ const ChatPaneContent: React.FC<ChatPaneContentProps> = (props) => {
useEffect(() => {
setEditingState({ workspaceId, message: undefined });
setExpandedBashGroups(new Set());
setExpandedToolCoalesceGroups(new Set());
}, [workspaceId]);

const handleChatInputReady = useCallback((api: ChatInputAPI) => {
Expand Down Expand Up @@ -1132,6 +1148,21 @@ const ChatPaneContent: React.FC<ChatPaneContentProps> = (props) => {
return null;
}

// Tool-coalesce groups (file_read / file_edit bursts). The head
// call is replaced by a summary row when collapsed; members are
// hidden entirely until the user expands the group.
const toolCoalesceGroup = toolCoalesceInfos[index];
const coalesceHeadId = toolCoalesceGroup
? deferredMessages[toolCoalesceGroup.headIndex]?.id
: undefined;
const isToolCoalesceExpanded = coalesceHeadId
? expandedToolCoalesceGroups.has(coalesceHeadId)
: false;

if (toolCoalesceGroup?.position === "member" && !isToolCoalesceExpanded) {
return null;
Comment thread
ammar-agent marked this conversation as resolved.
}

const isAtCutoff =
editCutoffHistoryId !== undefined &&
msg.type !== "history-hidden" &&
Expand All @@ -1145,27 +1176,61 @@ const ChatPaneContent: React.FC<ChatPaneContentProps> = (props) => {
? taskReportLinking
: undefined;

// Render order at the head of a coalesced group:
// - collapsed: summary row replaces the head's MessageRenderer.
// - expanded: summary row sits at the top (acts as the
// collapse toggle) and the head's normal
// MessageRenderer renders directly below, with
// the rest of the group's members following on
// subsequent iterations.
const renderCoalesceSummary =
toolCoalesceGroup?.position === "head" && coalesceHeadId;
const renderNormalMessage = !renderCoalesceSummary || isToolCoalesceExpanded;
const toggleCoalesceGroup =
coalesceHeadId !== undefined
? () =>
setExpandedToolCoalesceGroups((prev) => {
const next = new Set(prev);
if (next.has(coalesceHeadId)) {
next.delete(coalesceHeadId);
} else {
next.add(coalesceHeadId);
}
return next;
})
: undefined;

return (
<React.Fragment key={`${workspaceId}:${msg.id}`}>
<MessageRenderer
message={msg}
onEditUserMessage={transcriptOnly ? undefined : handleEditUserMessage}
workspaceId={workspaceId}
isCompacting={isCompacting}
onReviewNote={handleReviewNote}
isLatestProposePlan={
msg.type === "tool" &&
msg.toolName === "propose_plan" &&
msg.id === latestProposePlanId
}
bashOutputGroup={bashOutputGroup}
taskReportLinking={taskReportLinkingForMessage}
userMessageNavigation={
msg.type === "user"
? userMessageNavigationByHistoryId?.get(msg.historyId)
: undefined
}
/>
{renderCoalesceSummary && toolCoalesceGroup && toggleCoalesceGroup && (
<CoalescedToolCall
kind={toolCoalesceGroup.kind}
filePaths={toolCoalesceGroup.filePaths}
expanded={isToolCoalesceExpanded}
onToggle={toggleCoalesceGroup}
/>
)}
{renderNormalMessage && (
<MessageRenderer
message={msg}
onEditUserMessage={transcriptOnly ? undefined : handleEditUserMessage}
workspaceId={workspaceId}
isCompacting={isCompacting}
onReviewNote={handleReviewNote}
isLatestProposePlan={
msg.type === "tool" &&
msg.toolName === "propose_plan" &&
msg.id === latestProposePlanId
}
bashOutputGroup={bashOutputGroup}
taskReportLinking={taskReportLinkingForMessage}
userMessageNavigation={
msg.type === "user"
? userMessageNavigationByHistoryId?.get(msg.historyId)
: undefined
}
/>
)}
{/* Show collapsed indicator after the first item in a bash_output group */}
{bashOutputGroup?.position === "first" && groupKey && (
<BashOutputCollapsedIndicator
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,21 @@ import { userEvent, waitFor, within } from "@storybook/test";
import { ProvidersSection } from "./ProvidersSection.js";
import { SettingsSectionStory, setupSettingsStory } from "./settingsStoryUtils.js";

// Chromatic snapshots are disabled here: every story below already drives a
// `play` function that asserts the relevant behavior (empty state, configured
// providers, env-sourced indicators, expanded provider config). The visual
// layout of the providers settings panel has no scroll-fade/animation/state
// nuances worth a pixel baseline, so we free this file's snapshots for use
// elsewhere in the global Chromatic budget (see
// `tests/ui/storybook/budget.test.ts`).
const meta: Meta = {
...lightweightMeta,
title: "Settings/Sections/ProvidersSection",
component: ProvidersSection,
parameters: {
...lightweightMeta.parameters,
chromatic: CHROMATIC_DISABLED,
},
};

export default meta;
Expand Down Expand Up @@ -82,9 +93,7 @@ export const ProvidersEnvSourced: Story = {
<ProvidersSection />
</SettingsSectionStory>
),
parameters: {
chromatic: CHROMATIC_DISABLED,
},
// (meta-level `chromatic: CHROMATIC_DISABLED` already covers this story.)
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);

Expand Down
195 changes: 195 additions & 0 deletions src/browser/features/Tools/CoalescedToolCall.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import type { ReactNode } from "react";
import { useState } from "react";
import { expect, userEvent, waitFor, within } from "@storybook/test";

import { CoalescedToolCall } from "@/browser/features/Tools/CoalescedToolCall";
import { CHROMATIC_DISABLED, lightweightMeta } from "@/browser/stories/meta.js";

/**
* Layout shell rendered inside each story so the meta-level decorator
* stack (which provides `TooltipProvider` via `StoryUiShell`) is not
* shadowed by a story-local `decorators` override.
*/
function StoryLayout(props: { children: ReactNode }) {
return (
<div className="bg-background p-6">
<div className="w-full max-w-3xl">{props.children}</div>
</div>
);
}

/**
* Interactive wrapper that owns the expansion state so play functions can
* exercise the real click-to-expand / click-to-collapse UX. The static
* `expanded` prop on stories is treated as the initial state.
*/
function InteractiveCoalesced(
args: React.ComponentProps<typeof CoalescedToolCall>
): React.ReactElement {
const [expanded, setExpanded] = useState(args.expanded);
return (
<StoryLayout>
<CoalescedToolCall
kind={args.kind}
filePaths={args.filePaths}
expanded={expanded}
onToggle={() => setExpanded((prev) => !prev)}
/>
</StoryLayout>
);
}

// Chromatic visual coverage is intentionally disabled β€” the global snapshot
// budget is tight, and the meaningful surface (header copy, click toggle,
// dedupe, kind verb) is exercised here via `play` functions that run under
// `test-storybook` in CI.
const meta = {
...lightweightMeta,
title: "App/Chat/Tools/CoalescedToolCall",
component: CoalescedToolCall,
parameters: {
...lightweightMeta.parameters,
chromatic: CHROMATIC_DISABLED,
},
render: (args) => <InteractiveCoalesced {...args} />,
} satisfies Meta<typeof CoalescedToolCall>;

export default meta;

type Story = StoryObj<typeof meta>;

const NOOP = () => undefined;

// Storybook's test runner uses the document body as the canvas root in some
// configurations. Querying the body ensures we still find rendered content
// when the decorator stack inserts wrappers above the story root.
function getCanvas(canvasElement: HTMLElement) {
return within(canvasElement.ownerDocument.body);
}

/**
* Two consecutive file_read calls coalesce into a "Read files …" row. The
* play function clicks it and verifies it toggles to expanded.
*/
export const TwoReadsClickToExpand: Story = {
args: {
kind: "file_read",
filePaths: ["src/App.tsx", "src/main.ts"],
expanded: false,
onToggle: NOOP,
},
play: async ({ canvasElement }) => {
const canvas = getCanvas(canvasElement);

// Header copy: past-tense verb + plural noun + joined paths.
const summary = await canvas.findByText(/Read files/);
await expect(canvas.findByText("src/App.tsx, src/main.ts")).resolves.toBeTruthy();

// Toggle starts collapsed, then expanded after a click.
const header = summary.closest("[aria-expanded]");
if (!(header instanceof HTMLElement)) {
throw new Error("Coalesce header missing aria-expanded element");
}
await expect(header).toHaveAttribute("aria-expanded", "false");
await userEvent.click(header);
await waitFor(() => expect(header).toHaveAttribute("aria-expanded", "true"));

// Clicking again collapses; covers both branches of the toggle.
await userEvent.click(header);
await waitFor(() => expect(header).toHaveAttribute("aria-expanded", "false"));
},
};

/**
* A burst of five reads β€” exercises the joined-paths layout and the icon
* column hugging the leading path.
*/
export const ManyReads: Story = {
args: {
kind: "file_read",
filePaths: [
"src/App.tsx",
"src/main.ts",
"src/preload.ts",
"src/config.ts",
"src/browser/features/Tools/CoalescedToolCall.tsx",
],
expanded: false,
onToggle: NOOP,
},
play: async ({ canvasElement }) => {
const canvas = getCanvas(canvasElement);
await canvas.findByText(/Read files/);
// All five paths render in chronological order, joined by ", ".
await expect(
canvas.findByText(
"src/App.tsx, src/main.ts, src/preload.ts, src/config.ts, src/browser/features/Tools/CoalescedToolCall.tsx"
)
).resolves.toBeTruthy();
},
};

/**
* file_edit groups use the past-tense "Wrote" verb. Mirrors the canonical
* file-edit icon variant.
*/
export const TwoEdits: Story = {
args: {
kind: "file_edit",
filePaths: ["src/App.tsx", "src/main.ts"],
expanded: false,
onToggle: NOOP,
},
play: async ({ canvasElement }) => {
const canvas = getCanvas(canvasElement);
await canvas.findByText(/Wrote files/);
await expect(canvas.findByText("src/App.tsx, src/main.ts")).resolves.toBeTruthy();
},
};

/**
* Display-only dedupe: a burst that touches the same file repeatedly should
* still render it once. Verifies the React.useMemo dedupe path in
* `CoalescedToolCall`.
*/
export const DeduplicatedPaths: Story = {
args: {
kind: "file_edit",
// 5 raw calls; 3 unique files. First-occurrence order: a, b, c.
filePaths: ["src/a.ts", "src/b.ts", "src/a.ts", "src/c.ts", "src/b.ts"],
expanded: false,
onToggle: NOOP,
},
play: async ({ canvasElement }) => {
const canvas = getCanvas(canvasElement);
await canvas.findByText(/Wrote files/);

// Each unique path appears exactly once in the joined list, in
// first-occurrence order. Asserting on the exact string is the simplest
// way to express both the order and the dedupe simultaneously.
await expect(canvas.findByText("src/a.ts, src/b.ts, src/c.ts")).resolves.toBeTruthy();
},
};

/**
* Expanded state β€” verifies aria-expanded reflects the initial prop and the
* chevron indicator rotates.
*/
export const InitiallyExpanded: Story = {
args: {
kind: "file_read",
filePaths: ["src/App.tsx", "src/main.ts", "src/preload.ts"],
expanded: true,
onToggle: NOOP,
},
play: async ({ canvasElement }) => {
const canvas = getCanvas(canvasElement);
const summary = await canvas.findByText(/Read files/);
const header = summary.closest("[aria-expanded]");
if (!(header instanceof HTMLElement)) {
throw new Error("Coalesce header missing aria-expanded element");
}
await expect(header).toHaveAttribute("aria-expanded", "true");
},
};
Loading
Loading