diff --git a/apps/web/components/task/changes-panel-timeline.tsx b/apps/web/components/task/changes-panel-timeline.tsx
index b0f562d484..9b01c0cb65 100644
--- a/apps/web/components/task/changes-panel-timeline.tsx
+++ b/apps/web/components/task/changes-panel-timeline.tsx
@@ -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 (
{/* Vertical line + dot */}
@@ -77,18 +84,38 @@ function TimelineSection({
{/* Header */}
{label && (
-
- {label}
- {typeof count === "number" && (
- ({count})
- )}
-
+ {canCollapse ? (
+
+ ) : (
+
+ {label}
+ {typeof count === "number" && (
+ ({count})
+ )}
+
+ )}
{action}
)}
{/* Children (file list, buttons, etc.) */}
- {children}
+ {!collapsed && children}
);
@@ -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 && (
@@ -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"
>
{commits.map((commit, index) => (
diff --git a/apps/web/e2e/tests/git-changes-panel.spec.ts b/apps/web/e2e/tests/git-changes-panel.spec.ts
index 1de17952b0..7cd13e978f 100644
--- a/apps/web/e2e/tests/git-changes-panel.spec.ts
+++ b/apps/web/e2e/tests/git-changes-panel.spec.ts
@@ -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");
+ });
});
diff --git a/apps/web/e2e/tests/git-commit-pre-hooks.spec.ts b/apps/web/e2e/tests/git-commit-pre-hooks.spec.ts
index ec7ecd35a6..b44c8ec166 100644
--- a/apps/web/e2e/tests/git-commit-pre-hooks.spec.ts
+++ b/apps/web/e2e/tests/git-commit-pre-hooks.spec.ts
@@ -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();