diff --git a/apps/web/components/task/changes-panel-timeline-grouping.test.tsx b/apps/web/components/task/changes-panel-timeline-grouping.test.tsx new file mode 100644 index 0000000000..2345693499 --- /dev/null +++ b/apps/web/components/task/changes-panel-timeline-grouping.test.tsx @@ -0,0 +1,105 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { render, screen, cleanup } from "@testing-library/react"; +import type { ComponentProps } from "react"; + +vi.mock("./changes-panel-file-row", () => ({ + FileRow: ({ file }: { file: { path: string } }) => ( +
  • {file.path}
  • + ), + BulkActionBar: () => null, + DefaultActionButtons: () => null, +})); + +vi.mock("./commit-row", () => ({ + CommitRow: () => null, +})); + +vi.mock("@/hooks/use-multi-select", () => ({ + useMultiSelect: () => ({ + selectedPaths: new Set(), + isSelected: () => false, + handleClick: vi.fn(), + clearSelection: vi.fn(), + }), +})); + +import { FileListSection } from "./changes-panel-timeline"; + +afterEach(cleanup); + +type Props = ComponentProps; + +const baseProps: Omit = { + pendingStageFiles: new Set(), + onOpenDiff: vi.fn(), + onEditFile: vi.fn(), + onStage: vi.fn(), + onUnstage: vi.fn(), + onDiscard: vi.fn(), +}; + +function file(path: string, repo?: string): Props["files"][number] { + return { + path, + status: "modified", + staged: false, + plus: 1, + minus: 0, + oldPath: undefined, + repositoryName: repo, + }; +} + +describe("FileListSection — multi-repo grouping", () => { + it("renders no repo header when files lack a repository_name (single-repo)", () => { + render( + undefined} + files={[file("a.ts"), file("b.ts")]} + />, + ); + expect(screen.queryAllByTestId("changes-repo-header")).toHaveLength(0); + expect(screen.getAllByTestId("file-row")).toHaveLength(2); + }); + + it("renders one header per repo when 2+ repos are present", () => { + render( + undefined} + files={[ + file("src/app.tsx", "frontend"), + file("src/api.ts", "frontend"), + file("handlers/task.go", "backend"), + ]} + />, + ); + const headers = screen.getAllByTestId("changes-repo-header"); + expect(headers).toHaveLength(2); + expect(headers[0].textContent).toContain("frontend"); + expect(headers[0].textContent).toContain("2"); + expect(headers[1].textContent).toContain("backend"); + expect(headers[1].textContent).toContain("1"); + }); + + it("shows a header for a single named repo too", () => { + render( + undefined} + files={[file("a.ts", "only-repo")]} + />, + ); + expect(screen.getByTestId("changes-repo-header").textContent).toContain("only-repo"); + }); +}); diff --git a/apps/web/components/task/changes-panel-timeline.tsx b/apps/web/components/task/changes-panel-timeline.tsx index 1414ed280f..3e69d5ebae 100644 --- a/apps/web/components/task/changes-panel-timeline.tsx +++ b/apps/web/components/task/changes-panel-timeline.tsx @@ -34,8 +34,32 @@ type ChangedFile = { plus: number | undefined; minus: number | undefined; oldPath: string | undefined; + /** Repository this file belongs to in multi-repo workspaces; empty for single-repo. */ + repositoryName?: string; }; +/** + * Groups changed files by repository name, preserving original order within + * each group. Returns an empty-name leading group when files lack a + * repository name (single-repo workspaces) so the renderer can skip the + * per-repo header in that case. + */ +function groupChangedFilesByRepository( + files: ChangedFile[], +): Array<{ repositoryName: string; files: ChangedFile[] }> { + const order: string[] = []; + const buckets = new Map(); + for (const file of files) { + const name = file.repositoryName ?? ""; + if (!buckets.has(name)) { + buckets.set(name, []); + order.push(name); + } + buckets.get(name)!.push(file); + } + return order.map((name) => ({ repositoryName: name, files: buckets.get(name)! })); +} + // --- Timeline section dot colors --- const DOT_COLORS = { unstaged: "bg-yellow-500", @@ -297,6 +321,80 @@ type FileListSectionProps = { onBulkDiscard?: (paths: string[]) => void; }; +/** + * Renders the body of a file-list section. For multi-repo workspaces it + * groups files by repository under per-repo subheaders; single-repo + * workspaces fall back to a flat list (no header) so the existing UI is + * unchanged. + */ +function FileListBody(props: { + variant: "unstaged" | "staged"; + files: ChangedFile[]; + pendingStageFiles: Set; + multiSelect: ReturnType; + onKeyDown: (e: React.KeyboardEvent) => void; + onOpenDiff: (path: string) => void; + onEditFile: (path: string) => void; + onStage: (path: string) => void; + onUnstage: (path: string) => void; + onDiscard: (path: string) => void; +}) { + const { variant, files, pendingStageFiles, multiSelect } = props; + const groups = useMemo(() => groupChangedFilesByRepository(files), [files]); + const showRepoHeaders = groups.length > 1 || (groups[0]?.repositoryName ?? "") !== ""; + + const renderRow = (file: ChangedFile) => ( + + ); + + return ( +
    +
      + {groups.map((group) => + showRepoHeaders ? ( +
    • +
      + {group.repositoryName || "Other"} + + {group.files.length} + +
      +
        {group.files.map(renderRow)}
      +
    • + ) : ( + // Single-repo flat list: render rows inline so the DOM shape stays + // identical to the pre-multi-repo behavior. + group.files.map(renderRow) + ), + )} +
    +
    + ); +} + export function FileListSection(props: FileListSectionProps) { const { variant, @@ -332,29 +430,18 @@ export function FileListSection(props: FileListSectionProps) { data-testid={`${variant}-files-section`} > {files.length > 0 && ( -
    -
      - {files.map((file) => ( - - ))} -
    -
    + )} {files.length > 0 && (
    diff --git a/apps/web/components/task/changes-panel.tsx b/apps/web/components/task/changes-panel.tsx index c31344b422..23f0f11795 100644 --- a/apps/web/components/task/changes-panel.tsx +++ b/apps/web/components/task/changes-panel.tsx @@ -43,6 +43,8 @@ type ChangedFile = { plus: number | undefined; minus: number | undefined; oldPath: string | undefined; + /** Repository this file belongs to in multi-repo workspaces; empty for single-repo. */ + repositoryName?: string; }; function mapToChangedFiles(files: FileInfo[]): ChangedFile[] { @@ -53,6 +55,7 @@ function mapToChangedFiles(files: FileInfo[]): ChangedFile[] { plus: file.additions, minus: file.deletions, oldPath: file.old_path, + repositoryName: file.repository_name, })); } diff --git a/apps/web/hooks/domains/session/use-session-git.ts b/apps/web/hooks/domains/session/use-session-git.ts index ff65b2d747..688a8ea095 100644 --- a/apps/web/hooks/domains/session/use-session-git.ts +++ b/apps/web/hooks/domains/session/use-session-git.ts @@ -1,7 +1,7 @@ "use client"; import { useState, useCallback, useEffect, useMemo } from "react"; -import { useSessionGitStatus } from "./use-session-git-status"; +import { useSessionGitStatus, useSessionGitStatusByRepo } from "./use-session-git-status"; import { useSessionCommits } from "./use-session-commits"; import { useCumulativeDiff } from "./use-cumulative-diff"; import { useGitOperations } from "@/hooks/use-git-operations"; @@ -72,18 +72,44 @@ export type SessionGit = { ) => Promise; }; +/** + * Builds the SessionGit's flat file list. For multi-repo workspaces it + * stamps each FileInfo with its repository_name so consumers can group; + * for single-repo it returns the legacy single-status files unchanged. + */ +function aggregateFilesAcrossRepos( + statusByRepo: ReturnType, + gitStatus: ReturnType, +): FileInfo[] { + if (statusByRepo.length > 0) { + const out: FileInfo[] = []; + for (const { repository_name, status } of statusByRepo) { + if (!status?.files) continue; + for (const f of Object.values(status.files)) { + out.push(repository_name ? { ...f, repository_name } : f); + } + } + return out; + } + return gitStatus?.files ? Object.values(gitStatus.files) : []; +} + export function useSessionGit(sessionId: string | null | undefined): SessionGit { const sid = sessionId ?? null; const gitStatus = useSessionGitStatus(sid); + const statusByRepo = useSessionGitStatusByRepo(sid); const { commits, loading: commitsLoading } = useSessionCommits(sid); const { diff: cumulativeDiff } = useCumulativeDiff(sid); const gitOps = useGitOperations(sid); const [pendingStageFiles, setPendingStageFiles] = useState>(new Set()); + // Multi-repo: aggregate files from every repo's per-repo status, stamping + // each FileInfo with its repository_name so downstream UI can group them. + // Single-repo (or repo-less) sessions fall back to the legacy single status. const allFiles = useMemo( - () => (gitStatus?.files ? Object.values(gitStatus.files) : []), - [gitStatus], + () => aggregateFilesAcrossRepos(statusByRepo, gitStatus), + [statusByRepo, gitStatus], ); const unstagedFiles = useMemo(() => allFiles.filter((f) => !f.staged), [allFiles]); const stagedFiles = useMemo(() => allFiles.filter((f) => f.staged), [allFiles]); diff --git a/apps/web/lib/state/slices/session-runtime/types.ts b/apps/web/lib/state/slices/session-runtime/types.ts index 02da3f89e9..b24531aeed 100644 --- a/apps/web/lib/state/slices/session-runtime/types.ts +++ b/apps/web/lib/state/slices/session-runtime/types.ts @@ -49,6 +49,13 @@ export type FileInfo = { old_path?: string; diff?: string; diff_skip_reason?: "too_large" | "binary" | "truncated" | "budget_exceeded"; + /** + * Repository this file belongs to in multi-repo task workspaces. Stamped + * by useSessionGit when aggregating per-repo statuses; empty for single- + * repo workspaces. The Changes panel uses it to group files under + * per-repository headers. + */ + repository_name?: string; }; export type GitStatusEntry = {