Files
kandev/apps/web/components/task/changes-panel-repo-groups.tsx
T
João Salavisa 5c5e7bf6a7 fix(changes-panel): scope commits/diff to live branch divergence; flatten single-repo (#816)
* fix(changes-panel): scope commits/diff to live branch divergence; flatten single-repo

Two bugs in the COMMITS panel made it surface commits and file changes
that were already on main:

1. The commit list and the cumulative diff both anchored to a stale base.
   Commit list used `git merge-base HEAD <local-branch>` and the file diff
   used the session's stored base SHA captured once at session start. On
   long-lived worktrees the local main ref lags far behind origin/main, so
   the merge-base lands on an old SHA; once the agent merges main back in,
   the stored base is also stale. Both end up sweeping in dozens of main
   commits as if they were the branch's work.

2. `git log <base>..HEAD` follows both parents of a merge commit by
   default, surfacing main commits brought in via merges from main.

Fixes:
- agentctl computes merge-base against `origin/<target_branch>` (with a
  local-ref fallback for unfetched/local-only branches), and uses that
  for both the commit log and the cumulative diff. Mirrors GitHub's
  three-dot PR diff semantics — anchor to live divergence with the
  upstream, not a session-start snapshot.
- `git log` now passes `--first-parent` so only the branch's own line of
  work plus its merge commits show up; commits brought in via merges from
  main are excluded.

Frontend: drop the per-repo "REPOSITORY" sub-header in the COMMITS panel
for single-repo workspaces. CommitsRepoGroup gains a `showHeader` prop;
when the workspace has one untagged group the section renders flat with
the per-repo Push / PR (and Stage all / Commit / Unstage all) buttons
lifted into the section header. Multi-repo rendering unchanged.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix: address PR review feedback

- Scope --first-parent to the divergence-range path only (Greptile P1).
  The flag is wrong for open-ended history calls — future activity
  widgets etc. need the full graph.
- Log empty merge-base case in cumulative diff (Greptile P2). Silent
  fallback to stored base was invisible during diagnostics.
- Rename TestRunGitCumulativeDiffForRepo_PrefersOriginRef →
  TestComputeMergeBase_CorrectAnchorForCumulativeDiff (Claude). Test
  exercises computeMergeBase, not the runGitCumulativeDiffForRepo
  conditional; new name reflects what it actually proves.
- Add TestGetLog_NoBaseReturnsFullGraph to lock in the --first-parent
  scoping.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix: address PR review round 2

- Drop dead ActionButtonsSection from changes-panel-timeline.tsx (unused
  export, no consumers anywhere). Brings the file from 640 → 516 lines,
  comfortably under the 600-line TS limit Claude flagged.
- Extract isSingleRepoGroup helper into lib/group-by-repo.ts and use it
  in all three sites (CommitsSection, FileListBody, FileListSection) so
  single-repo detection has one source of truth.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-04 17:28:04 +01:00

313 lines
11 KiB
TypeScript

"use client";
import { useState } from "react";
import {
IconChevronDown,
IconChevronRight,
IconCloudUpload,
IconGitPullRequest,
} from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import { CommitRow, type CommitItem } from "./commit-row";
import { groupByRepositoryName } from "@/lib/group-by-repo";
type ChangedFile = {
path: string;
status: import("@/lib/state/store").FileInfo["status"];
staged: boolean;
plus: number | undefined;
minus: number | undefined;
oldPath: string | undefined;
repositoryName?: string;
};
export type RepoGroup = ReturnType<typeof groupByRepositoryName<ChangedFile>>[number];
/**
* Per-repo group inside a file-list section (Unstaged / Staged). Renders the
* collapsible repo header with optional inline action buttons (Stage all /
* Commit / Unstage all). Action labels and handlers are owned by the parent
* section so the same shape works for both variants.
*/
export function RepoGroupItem({
group,
collapsed,
onToggle,
renderRow,
primaryLabel,
secondaryLabel,
onRepoAction,
onRepoSecondaryAction,
displayName,
}: {
group: RepoGroup;
collapsed: boolean;
onToggle: () => void;
renderRow: (file: ChangedFile) => React.ReactNode;
primaryLabel: string;
secondaryLabel?: string;
onRepoAction?: (repo: string) => void;
onRepoSecondaryAction?: (repo: string) => void;
/** Optional display label override; defaults to group.repositoryName. */
displayName?: string;
}) {
const stop = (e: React.MouseEvent) => e.stopPropagation();
const label = displayName || group.repositoryName || "Repository";
return (
<li data-testid="changes-repo-group" data-repository-name={group.repositoryName || ""}>
<div className="flex items-center justify-between gap-2 px-1 py-0.5">
<button
type="button"
className="flex items-center gap-1.5 text-[11px] font-medium text-muted-foreground/80 uppercase tracking-wide cursor-pointer hover:text-foreground/80 min-w-0"
data-testid="changes-repo-header"
aria-expanded={!collapsed}
onClick={onToggle}
>
{collapsed ? (
<IconChevronRight className="h-3 w-3 text-muted-foreground/50 shrink-0" />
) : (
<IconChevronDown className="h-3 w-3 text-muted-foreground/50 shrink-0" />
)}
<span className="truncate">{label}</span>
<span className="text-muted-foreground/50 normal-case tracking-normal">
{group.items.length}
</span>
</button>
{(onRepoAction || onRepoSecondaryAction) && (
<div className="flex items-center gap-1" onClick={stop}>
{onRepoAction && (
<Button
size="sm"
variant="ghost"
className="h-5 text-[10px] px-1.5 cursor-pointer"
data-testid="repo-group-action"
onClick={() => onRepoAction(group.repositoryName)}
>
{primaryLabel}
</Button>
)}
{onRepoSecondaryAction && secondaryLabel && (
<Button
size="sm"
variant="ghost"
className="h-5 text-[10px] px-1.5 cursor-pointer text-muted-foreground"
data-testid="repo-group-secondary-action"
onClick={() => onRepoSecondaryAction(group.repositoryName)}
>
{secondaryLabel}
</Button>
)}
</div>
)}
</div>
{!collapsed && <ul className="space-y-0.5">{group.items.map(renderRow)}</ul>}
</li>
);
}
// Inline action buttons rendered in the file-list section header for single-repo
// workspaces. Mirrors the per-repo buttons that RepoGroupItem renders for
// multi-repo. Handlers receive an empty repo string which routes to the
// workspace root in single-repo mode.
export function FileSectionActions({
primaryLabel,
secondaryLabel,
onAction,
onSecondaryAction,
}: {
primaryLabel: string;
secondaryLabel?: string;
onAction?: (repo: string) => void;
onSecondaryAction?: (repo: string) => void;
}) {
if (!onAction && !onSecondaryAction) return null;
return (
<div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
{onAction && (
<Button
size="sm"
variant="ghost"
className="h-5 text-[10px] px-1.5 cursor-pointer"
data-testid="repo-group-action"
onClick={() => onAction("")}
>
{primaryLabel}
</Button>
)}
{onSecondaryAction && secondaryLabel && (
<Button
size="sm"
variant="ghost"
className="h-5 text-[10px] px-1.5 cursor-pointer text-muted-foreground"
data-testid="repo-group-secondary-action"
onClick={() => onSecondaryAction("")}
>
{secondaryLabel}
</Button>
)}
</div>
);
}
export function CommitsGroupActions({
repositoryName,
unpushedCount,
aheadCount,
prExists,
canCreatePR,
onRepoPush,
onRepoCreatePR,
stop,
}: {
repositoryName: string;
unpushedCount: number;
aheadCount: number;
prExists: boolean;
canCreatePR: boolean;
onRepoPush?: (repo: string) => void;
onRepoCreatePR?: (repo: string) => void;
stop: (e: React.MouseEvent) => void;
}) {
return (
<div className="flex items-center gap-1" onClick={stop}>
{onRepoPush && (unpushedCount > 0 || aheadCount > 0) && (
<Button
size="sm"
variant="ghost"
className="h-5 text-[10px] px-1.5 cursor-pointer gap-1"
data-testid="commits-repo-push"
onClick={() => onRepoPush(repositoryName)}
>
<IconCloudUpload className="h-3 w-3" />
Push
<span className="text-muted-foreground">{unpushedCount || aheadCount}</span>
</Button>
)}
{onRepoCreatePR && (
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="ghost"
className="h-5 text-[10px] px-1.5 cursor-pointer gap-1"
data-testid="commits-repo-create-pr"
onClick={() => onRepoCreatePR(repositoryName)}
disabled={!canCreatePR}
>
<IconGitPullRequest className="h-3 w-3" />
PR
</Button>
</TooltipTrigger>
{prExists && <TooltipContent>A pull request already exists for this task</TooltipContent>}
</Tooltip>
)}
</div>
);
}
/**
* Per-repo group inside the Commits section. Like {@link RepoGroupItem} but
* for commit rows; surfaces a Push button when the repo has unpushed commits.
*/
export function CommitsRepoGroup({
repositoryName,
displayName,
groupCommits,
aheadCount = 0,
existingPrUrl,
showHeader = true,
onOpenCommitDetail,
onAmendCommit,
onRevertCommit,
onResetToCommit,
onRepoPush,
onRepoCreatePR,
}: {
repositoryName: string;
displayName?: string;
groupCommits: CommitItem[];
aheadCount?: number;
existingPrUrl?: string;
/** When false, render commits flat without the per-repo header. Single-repo
* workspaces use this — the action buttons (Push / PR) move up to the
* section header so we don't render a redundant repo sub-header. */
showHeader?: boolean;
onOpenCommitDetail?: (sha: string, repo?: string) => void;
onAmendCommit?: (currentMessage: string, repo?: string) => void;
onRevertCommit?: (sha: string, repo?: string) => void;
onResetToCommit?: (sha: string, repo?: string) => void;
onRepoPush?: (repo: string) => void;
onRepoCreatePR?: (repo: string) => void;
/** Base branch passed through; reserved for richer per-repo PR UX. */
baseBranch?: string;
}) {
const [collapsed, setCollapsed] = useState(false);
// Each repo has its own "latest unpushed commit" — revert/amend in this
// group must target THIS repo's newest, not the merged-list newest.
const firstUnpushedInGroup = groupCommits.findIndex((c) => c.pushed !== true);
const unpushedCount = groupCommits.filter((c) => !c.pushed).length;
const stop = (e: React.MouseEvent) => e.stopPropagation();
const label = displayName || repositoryName || "Repository";
// Bug 10 acknowledged trade-off: `existingPrUrl` is sourced from a
// workspace-scoped `prByRepo` map keyed only by "" today — the kandev task
// model has one PR per task, not one PR per repo. As a result every per-
// repo group inherits the same PR URL. When the backend grows per-repo PR
// tracking, callers should key `prByRepo` by `repositoryName` and the
// `?? prByRepo[""]` fallback in CommitsSection should be removed so each
// group surfaces only its own PR. Until then, the visual is "PR exists for
// this task" rather than "PR exists for this repo" — accept the imprecision.
const prExists = !!existingPrUrl;
// The PR scope today is workspace-wide (one PR per task). Disable per-repo
// Create PR once a PR exists; the user can update it via push instead.
const canCreatePR = !!onRepoCreatePR && !prExists;
const rows = groupCommits.map((commit, index) => (
<CommitRow
key={commit.commit_sha}
commit={commit}
isLatest={index === firstUnpushedInGroup}
onOpenCommitDetail={onOpenCommitDetail}
onAmendCommit={commit.pushed ? undefined : onAmendCommit}
onRevertCommit={commit.pushed ? undefined : onRevertCommit}
onResetToCommit={commit.pushed ? undefined : onResetToCommit}
/>
));
if (!showHeader) {
return <>{rows}</>;
}
return (
<li data-testid="commits-repo-group" data-repository-name={repositoryName || ""}>
<div className="flex items-center justify-between gap-2 px-1 py-0.5">
<button
type="button"
className="flex items-center gap-1.5 text-[11px] font-medium text-muted-foreground/80 uppercase tracking-wide cursor-pointer hover:text-foreground/80 min-w-0"
data-testid="commits-repo-header"
aria-expanded={!collapsed}
onClick={() => setCollapsed((c) => !c)}
>
{collapsed ? (
<IconChevronRight className="h-3 w-3 text-muted-foreground/50 shrink-0" />
) : (
<IconChevronDown className="h-3 w-3 text-muted-foreground/50 shrink-0" />
)}
<span className="truncate">{label}</span>
<span className="text-muted-foreground/50 normal-case tracking-normal">
{groupCommits.length}
</span>
</button>
<CommitsGroupActions
repositoryName={repositoryName}
unpushedCount={unpushedCount}
aheadCount={aheadCount}
prExists={prExists}
canCreatePR={canCreatePR}
onRepoPush={onRepoPush}
onRepoCreatePR={onRepoCreatePR}
stop={stop}
/>
</div>
{!collapsed && <ul className="space-y-0.5">{rows}</ul>}
</li>
);
}