feat(web): add collapsible sections to changes panel (#457)

* feat(web): add collapsible sections to changes panel

Sections (Unstaged, Staged, Commits, PR Changes, PR Commits) can now
be collapsed/expanded by clicking the section header or chevron arrow.
Includes Playwright E2E test validating collapse and expand behavior.

* fix(web): add aria-expanded to collapsible section toggles

Addresses CodeRabbit review: adds aria-expanded attribute to section
toggle buttons for screen reader support, and asserts the attribute
in the E2E test.

* fix(web): render section toggle as button only when collapsible

Non-collapsible sections keep a plain <span> label to avoid
ambiguous getByRole("button") matches in E2E tests (e.g. "Commit"
action button vs "Commits (0)" section header).

* fix(web): use exact match for Commit button in pre-hooks E2E test

The collapsible "Commits" section toggle button now matches
getByRole("button", { name: "Commit" }) via substring. Use exact
matching to only target the action button.
This commit is contained in:
José Almeida
2026-03-25 08:19:53 +00:00
committed by GitHub
parent 758c9591eb
commit fde262a481
3 changed files with 142 additions and 8 deletions
@@ -1,10 +1,12 @@
"use client";
import { useState } from "react";
import {
IconGitCommit,
IconGitPullRequest,
IconCloudUpload,
IconChevronDown,
IconChevronRight,
IconLoader2,
} from "@tabler/icons-react";
@@ -54,6 +56,7 @@ function TimelineSection({
action,
isLast,
children,
collapsible = true,
"data-testid": testId,
}: {
dotColor: string;
@@ -62,8 +65,12 @@ function TimelineSection({
action?: React.ReactNode;
isLast?: boolean;
children?: React.ReactNode;
collapsible?: boolean;
"data-testid"?: string;
}) {
const [collapsed, setCollapsed] = useState(false);
const canCollapse = collapsible && !!label;
return (
<div className="relative flex gap-2.5" data-testid={testId}>
{/* Vertical line + dot */}
@@ -77,18 +84,38 @@ function TimelineSection({
{/* Header */}
{label && (
<div className="flex items-center justify-between gap-2 -mt-0.5 mb-1">
<span className="text-[11px] font-medium uppercase tracking-wider text-foreground/70">
{label}
{typeof count === "number" && (
<span className="ml-1 text-muted-foreground/50 font-normal">({count})</span>
)}
</span>
{canCollapse ? (
<button
type="button"
className="flex items-center gap-1 text-[11px] font-medium uppercase tracking-wider text-foreground/70 cursor-pointer hover:text-foreground/90"
onClick={() => setCollapsed((c) => !c)}
aria-expanded={!collapsed}
data-testid={`${testId ?? label.toLowerCase()}-collapse-toggle`}
>
{label}
{typeof count === "number" && (
<span className="text-muted-foreground/50 font-normal">({count})</span>
)}
{collapsed ? (
<IconChevronRight className="h-3 w-3 text-muted-foreground/50" />
) : (
<IconChevronDown className="h-3 w-3 text-muted-foreground/50" />
)}
</button>
) : (
<span className="text-[11px] font-medium uppercase tracking-wider text-foreground/70">
{label}
{typeof count === "number" && (
<span className="ml-1 text-muted-foreground/50 font-normal">({count})</span>
)}
</span>
)}
{action}
</div>
)}
{/* Children (file list, buttons, etc.) */}
{children}
{!collapsed && children}
</div>
</div>
);
@@ -359,6 +386,7 @@ export function PRFilesSection({ files, isLast, onOpenDiff }: PRFilesSectionProp
label="PR Changes"
count={files.length}
isLast={isLast}
data-testid="pr-changes-section"
>
{files.length > 0 && (
<ul className="space-y-0.5">
@@ -428,6 +456,7 @@ export function PRCommitsSection({ commits, isLast, onOpenCommitDetail }: PRComm
label="PR Commits"
count={commits.length}
isLast={isLast}
data-testid="pr-commits-timeline-section"
>
<ul className="space-y-0.5">
{commits.map((commit, index) => (
@@ -1001,4 +1001,108 @@ test.describe("Git Changes Panel", () => {
// The cumulative diff should NOT show "No changes"
await expect(testPage.locator("text=No changes")).not.toBeVisible({ timeout: 5_000 });
});
/**
* Verifies that sections in the changes panel can be collapsed and expanded.
* Clicking the section header (label + count + chevron) toggles visibility of the section content.
*/
test("sections can be collapsed and expanded", async ({
testPage,
apiClient,
seedData,
backend,
}) => {
const profile = await createStandardProfile(apiClient, "Git Collapse Profile");
await apiClient.createTaskWithAgent(seedData.workspaceId, "Git Collapse Test", profile.id, {
description: "Testing section collapse",
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
});
const session = await openTaskSession(testPage, "Git Collapse Test");
// Set up git helper
const repoDir = path.join(backend.tmpDir, "repos", "e2e-repo");
const gitEnv = {
...process.env,
HOME: backend.tmpDir,
GIT_AUTHOR_NAME: "E2E Test",
GIT_AUTHOR_EMAIL: "e2e@test.local",
GIT_COMMITTER_NAME: "E2E Test",
GIT_COMMITTER_EMAIL: "e2e@test.local",
};
const git = new GitHelper(repoDir, gitEnv);
// Create an unstaged file and a committed file so both sections appear
git.createFile("collapse-committed.txt", "committed content");
git.stageFile("collapse-committed.txt");
git.commit("Collapse test commit");
git.createFile("collapse-unstaged.txt", "unstaged content");
// Click the Changes tab
await session.clickTab("Changes");
await expect(session.changes).toBeVisible({ timeout: 10_000 });
// Wait for both sections to appear
await expect(testPage.getByTestId("unstaged-files-section")).toBeVisible({ timeout: 15_000 });
await expect(testPage.getByTestId("commits-section")).toBeVisible({ timeout: 15_000 });
// Verify the unstaged file is visible
await expect(session.changes.getByText("collapse-unstaged.txt")).toBeVisible({
timeout: 5_000,
});
// Verify the commit is visible
await expect(session.changes.getByText("Collapse test commit")).toBeVisible({ timeout: 5_000 });
// --- Collapse the unstaged section ---
const unstagedToggle = testPage.getByTestId("unstaged-files-section-collapse-toggle");
await expect(unstagedToggle).toBeVisible({ timeout: 5_000 });
await expect(unstagedToggle).toHaveAttribute("aria-expanded", "true");
await unstagedToggle.click();
// The unstaged file should now be hidden and toggle reflects collapsed state
await expect(session.changes.getByText("collapse-unstaged.txt")).not.toBeVisible({
timeout: 5_000,
});
await expect(unstagedToggle).toHaveAttribute("aria-expanded", "false");
// The section header should still be visible (with count)
await expect(unstagedToggle).toBeVisible();
await expect(unstagedToggle).toContainText("Unstaged");
// --- Collapse the commits section ---
const commitsToggle = testPage.getByTestId("commits-section-collapse-toggle");
await expect(commitsToggle).toBeVisible({ timeout: 5_000 });
await expect(commitsToggle).toHaveAttribute("aria-expanded", "true");
await commitsToggle.click();
// The commit should now be hidden and toggle reflects collapsed state
await expect(session.changes.getByText("Collapse test commit")).not.toBeVisible({
timeout: 5_000,
});
await expect(commitsToggle).toHaveAttribute("aria-expanded", "false");
// --- Expand the unstaged section back ---
await unstagedToggle.click();
// The unstaged file should be visible again
await expect(session.changes.getByText("collapse-unstaged.txt")).toBeVisible({
timeout: 5_000,
});
await expect(unstagedToggle).toHaveAttribute("aria-expanded", "true");
// --- Expand the commits section back ---
await commitsToggle.click();
// The commit should be visible again
await expect(session.changes.getByText("Collapse test commit")).toBeVisible({ timeout: 5_000 });
await expect(commitsToggle).toHaveAttribute("aria-expanded", "true");
// Clean up
git.deleteFile("collapse-unstaged.txt");
});
});
@@ -125,7 +125,8 @@ test.describe("Git commit pre-hooks", () => {
await session.changes.getByRole("button", { name: "Stage all" }).click();
// Wait for the "Commit" button to appear (indicates staged section is ready)
const commitButton = session.changes.getByRole("button", { name: "Commit" });
// Use exact match to avoid matching the "Commits" section toggle
const commitButton = session.changes.getByRole("button", { name: "Commit", exact: true });
await expect(commitButton).toBeVisible({ timeout: 15_000 });
await commitButton.click();