feat(web/changes-panel): per-repo group headers in the dockview Changes tab

The Changes tab in the dockview panel (the Files / Changes (N) tab the
user actually interacts with — not the standalone Review dialog) was
still rendering a flat file list, so multi-repo task changes either
collided onto the legacy single-status (showing only one repo's files)
or rendered without grouping.

- FileInfo gains repository_name (optional, single-repo unchanged).
- useSessionGit aggregates files from useSessionGitStatusByRepo when
  multi-repo data is present, stamping each FileInfo with its
  repository_name. Falls back to the legacy single status for
  single-repo workspaces. Aggregation extracted into
  aggregateFilesAcrossRepos to keep useSessionGit under the 100-line
  function lint cap.
- ChangedFile (changes-panel.tsx + changes-panel-timeline.tsx) carries
  repositoryName through to the renderer.
- FileListSection delegates to a new FileListBody that groups rows by
  repositoryName; renders a small per-repo subheader when 2+ repos OR
  any single named repo is present, otherwise falls back to the
  pre-multi-repo flat list (same DOM shape).
- Tests cover: no header for plain (no repo_name) files, headers
  per repo for 2+ groups, single named repo still gets a label.

Restart the backend so the per-repo trackers spawn for in-flight
multi-repo tasks; new launches are unaffected.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Kandev Agent
2026-04-26 10:47:14 +01:00
parent efd8a07c01
commit 7c00767b6e
5 changed files with 254 additions and 26 deletions
@@ -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 } }) => (
<li data-testid="file-row">{file.path}</li>
),
BulkActionBar: () => null,
DefaultActionButtons: () => null,
}));
vi.mock("./commit-row", () => ({
CommitRow: () => null,
}));
vi.mock("@/hooks/use-multi-select", () => ({
useMultiSelect: () => ({
selectedPaths: new Set<string>(),
isSelected: () => false,
handleClick: vi.fn(),
clearSelection: vi.fn(),
}),
}));
import { FileListSection } from "./changes-panel-timeline";
afterEach(cleanup);
type Props = ComponentProps<typeof FileListSection>;
const baseProps: Omit<Props, "files" | "variant" | "isLast" | "actionLabel" | "onAction"> = {
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(
<FileListSection
{...baseProps}
variant="unstaged"
isLast={false}
actionLabel="Stage all"
onAction={() => 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(
<FileListSection
{...baseProps}
variant="unstaged"
isLast={false}
actionLabel="Stage all"
onAction={() => 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(
<FileListSection
{...baseProps}
variant="unstaged"
isLast={false}
actionLabel="Stage all"
onAction={() => undefined}
files={[file("a.ts", "only-repo")]}
/>,
);
expect(screen.getByTestId("changes-repo-header").textContent).toContain("only-repo");
});
});
@@ -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<string, ChangedFile[]>();
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<string>;
multiSelect: ReturnType<typeof useMultiSelect>;
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) => (
<FileRow
key={`${file.repositoryName ?? ""}:${file.path}`}
file={file}
isPending={pendingStageFiles.has(file.path)}
isSelected={multiSelect.isSelected(file.path)}
onSelect={multiSelect.handleClick}
onOpenDiff={props.onOpenDiff}
onStage={props.onStage}
onUnstage={props.onUnstage}
onDiscard={props.onDiscard}
onEditFile={props.onEditFile}
/>
);
return (
<div>
<ul
data-testid={`${variant}-file-list`}
className="space-y-0.5"
tabIndex={-1}
onKeyDown={props.onKeyDown}
>
{groups.map((group) =>
showRepoHeaders ? (
<li
key={group.repositoryName || "__no_repo__"}
data-testid="changes-repo-group"
data-repository-name={group.repositoryName || ""}
>
<div
className="flex items-center gap-1.5 px-1 py-0.5 text-[11px] font-medium text-muted-foreground/80 uppercase tracking-wide"
data-testid="changes-repo-header"
>
<span className="truncate">{group.repositoryName || "Other"}</span>
<span className="text-muted-foreground/50 normal-case tracking-normal">
{group.files.length}
</span>
</div>
<ul className="space-y-0.5">{group.files.map(renderRow)}</ul>
</li>
) : (
// Single-repo flat list: render rows inline so the DOM shape stays
// identical to the pre-multi-repo behavior.
group.files.map(renderRow)
),
)}
</ul>
</div>
);
}
export function FileListSection(props: FileListSectionProps) {
const {
variant,
@@ -332,29 +430,18 @@ export function FileListSection(props: FileListSectionProps) {
data-testid={`${variant}-files-section`}
>
{files.length > 0 && (
<div>
<ul
data-testid={`${variant}-file-list`}
className="space-y-0.5"
tabIndex={-1}
onKeyDown={handleKeyDown}
>
{files.map((file) => (
<FileRow
key={file.path}
file={file}
isPending={pendingStageFiles.has(file.path)}
isSelected={multiSelect.isSelected(file.path)}
onSelect={multiSelect.handleClick}
onOpenDiff={onOpenDiff}
onStage={onStage}
onUnstage={onUnstage}
onDiscard={onDiscard}
onEditFile={onEditFile}
/>
))}
</ul>
</div>
<FileListBody
variant={variant}
files={files}
pendingStageFiles={pendingStageFiles}
multiSelect={multiSelect}
onKeyDown={handleKeyDown}
onOpenDiff={onOpenDiff}
onStage={onStage}
onUnstage={onUnstage}
onDiscard={onDiscard}
onEditFile={onEditFile}
/>
)}
{files.length > 0 && (
<div className="mt-1.5 flex items-center gap-1.5">
@@ -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,
}));
}
@@ -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<PRCreateResult>;
};
/**
* 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<typeof useSessionGitStatusByRepo>,
gitStatus: ReturnType<typeof useSessionGitStatus>,
): 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<Set<string>>(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<FileInfo[]>(
() => (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]);
@@ -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 = {