Files
kandev/apps/web/components/task-create-dialog-repo-chips.tsx
T
João Salavisa fd79d6170f feat(tasks): multi-branch tasks — N (repo, branch) pairs per task (#1226)
* feat(tasks): multi-branch tasks — N (repo, branch) pairs per task

Extends multi-repo into multi-branch so a single task can hold multiple
worktrees on the same repo (one per branch). Sibling worktrees layout at
<task>/<repo>/ + <task>/<repo>-<slug>/, dedicated PRWatch per branch,
live agentctl tracker rescan + frontend store updates so the UI splits
PRs / files / commits per branch without a session restart. MCP
add_branch_to_task_kandev tool is shielded to the worktree executor.

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

* fix(web): unify multi-branch repo label as <repo> · <branch> across panels

PR Changes and Commits panels rendered repo labels differently for multi-branch
tasks — Commits showed "kandev · feature/x" while PR Changes leaked the raw
"kandev-feature-x" agentctl tracker tag. Reformat tracker-tagged repo names via
formatRepoLabel using the longest known-repo prefix so kandev-cli-* doesn't
collapse to kandev · cli-*.

* fix(tasks): address multi-branch PR review — data races, slug collisions, MCP error codes

Backend
- agentctl process.Manager: add repoTrackersMu RWMutex guarding repoTrackers
  + workspaceTracker. Reads snapshot under RLock; RescanRepositories builds
  trackers first, takes Lock for swap/append. Validates newWorkDir via
  os.Stat before publishing cfg.WorkDir.
- worktree.Manager: reject invalid explicit BranchSlug up-front; cache key
  uses sanitized wt.BranchSlug so read/write paths are symmetric.
- worktree.store: narrow isDuplicateColumnError to "duplicate column name".
- task service AddBranchToTask: slug-collision guard rejects feat/a vs feat-a.
- orchestrator resolvePushRepo: two-pass exact-name match before subdir
  prefix fallback so "backend" doesn't swallow pushes for "backend-admin".
- executor buildRepoSpecs: duplicate repos always get a non-empty BranchSlug
  (falls back to branch-N when both branch fields sanitize to empty).
- MCP handler: classifyAddBranchError maps service errors to NOT_FOUND /
  CONFLICT / VALIDATION_ERROR; add ErrorCodeConflict constant.
- MCP server: extract registerAddBranchToTaskTool, register in task mode
  only (external mode contract: config tools + create_task_kandev).
- workspace_rescan handler: reject malformed JSON with 400 instead of
  silently rescanning the current root.
- branch_materializer: split MaterializeBranch into prepare + finalize
  helpers to satisfy the 80-line cap.

Frontend
- existingPrUrl returns undefined when a task has >1 PR so per-repo View PR
  buttons take over (no silent linking to a sibling).
- formatRepoLabel checks exact known-repo match before prefix splitting so
  "kandev-cli" isn't mislabeled as "kandev · cli".
- Repo chip key now includes base_branch so siblings differing only on
  base render as distinct chips.

Docs
- multi-branch spec uniqueness wording aligned with the canonical 4-column
  key (task_id, repository_id, base_branch, checkout_branch).
- worktree.CreateRequest.BranchSlug doc describes the sibling layout
  (<repo>-<slug>) instead of the obsolete nested form.

* fix(tasks): scope slug-collision check to repo only + use errors.Is for ErrTaskNotFound

- service_branches: drop base_branch from the slug-collision check. Worktree
  path is (repo, slug); two rows on the same repo cut from different bases
  still collide on disk.
- service_branches: prepareBranchAdd wraps taskrepo.ErrTaskNotFound via
  fmt.Errorf with %w so classifiers don't have to substring-match the
  formatted task UUID.
- mcp handlers: classifyAddBranchError uses errors.Is(err, ErrTaskNotFound).

* fix(agentctl): sanitize rescan work_dir before os.Stat to clear CodeQL path-injection

CodeQL flagged the rescan endpoint as a path-injection sink (the
HTTP-supplied work_dir flowed straight into os.Stat and downstream
scanRepositorySubdirs). Add validateRescanPath which:
- requires the input to be a clean absolute path with no ".." segments
- rejects anything that isn't equal to or an ancestor of the current
  cfg.WorkDir — the only legitimate use case is the branch materializer
  promoting <task>/<repo>/ to <task>/

Defense in depth: agentctl binds to localhost, but any colocated process
could otherwise direct it at /etc or the user's home by POSTing
/api/v1/workspace/rescan.

* fix(agentctl): tighten validateRescanPath, close TOCTOU on existingTrackers, add tests

- Drop the strings.Contains(clean, "..") guard. filepath.Clean already
  resolves traversal; the substring check rejects legitimate names like
  /home/u/..hidden without adding defense (the ancestor check via
  filepath.Rel is the real boundary).
- Drop the rel == "." branch in the ancestor check — early return for
  clean == currentClean already covers identity.
- Read existingTrackers inside the write-lock section (not under RLock
  then re-Lock) so two concurrent rescans can't both see 0 trackers and
  double-bootstrap the multi-repo set.
- Add table-driven TestValidateRescanPath covering rejection paths
  (relative, descendant, sibling, traversal-cleaned, empty input),
  legitimate ancestor promotion, exact match, no-anchor first-launch,
  and a /home/u/..hidden dotted-segment case.

* fix(agentctl): break taint flow into os.Stat — derive rescan path from cfg, not HTTP body

CodeQL's go/path-injection trace still flagged manager_rescan.go after the
prior sanitizer because the user-supplied newWorkDir flowed (after
validation) into os.Stat and then into cfg.WorkDir → tracker paths →
workspace_monitor os.Stat sinks.

Replace validateRescanPath with resolveRescanPath: it returns a path
DERIVED from currentWorkDir (no-op = currentClean, promotion =
filepath.Dir(currentClean)) and uses newPath only for comparison, never
as a source for the returned string. os.Stat now receives a path whose
provenance is trusted manager config.

Functionally the legitimate caller (branch materializer promoting
<task>/<repo>/ to <task>/) is unchanged.

* test(agentctl): make TestResolveRescanPath windows-portable

The Linux Backend Tests job passed but the Backend (windows) job rejected
every "absolute" case because filepath.IsAbs("/task/repo") is false on
Windows (no volume letter).

Build inputs via filepath.Join against absRoot() (filepath.Abs of "/" /
"\\") so the same table covers POSIX (`/...`) and Windows (`C:\...`)
without per-OS branches. Keep the traversal-after-Clean case POSIX-only
since filepath.Clean is the shared primitive and that input form needs a
volume to be considered absolute on Windows anyway.

* fix(agentctl): serialize RescanRepositories + re-check trackers under write lock

Two concurrent RescanRepositories calls could both observe an empty
tracker set (between the snapshot read and the bootstrap branch) and both
enter transitionToMultiRepoMode, leaking a duplicate bare-root tracker
and duplicate per-repo trackers. Same race in appendNewRepoTrackers would
double-append the same new child across both callers.

Fix:
- Add rescanMu sync.Mutex on Manager. Acquired for the full body of
  RescanRepositories so the snapshot → branch → mutate sequence is
  serialized. repoTrackersMu still guards individual field reads from
  unrelated goroutines (subscribe/unsubscribe/poll-mode).
- appendNewRepoTrackers re-checks membership inside the write-lock
  before appending and drops already-present trackers (Stop + detach
  outside the lock). Defense in depth.
- Test: add `dotted segment is legal as promotion` case so the
  ..hidden-segment promotion path stays exercised.

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 19:36:20 +01:00

718 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useEffect, useMemo } from "react";
import { IconPlus, IconX, IconCode, IconGitBranch, IconGitFork } from "@tabler/icons-react";
import { cn, formatUserHomePath } from "@/lib/utils";
import { Badge } from "@kandev/ui/badge";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import { useBranches, type BranchSource } from "@/hooks/domains/workspace/use-repository-branches";
import type { LocalRepository, Repository } from "@/lib/types/http";
import type { DialogFormState, TaskRepoRow } from "@/components/task-create-dialog-types";
import { autoSelectBranch } from "@/components/task-create-dialog-helpers";
import { scoreBranch } from "@/lib/utils/branch-filter";
import { scoreRepo } from "@/lib/utils/repo-filter";
import {
Pill,
sortBranches,
branchToOption,
computeBranchPlaceholder,
type PillOption,
} from "@/components/task-create-dialog-pill";
import { RemoteRepoChipsRow } from "@/components/task-create-dialog-remote-repo-chips";
import { FolderPicker } from "@/components/folder-picker";
import { SourceModeSwitch } from "@/components/task-create-dialog-source-mode";
import {
computeBranchPrefix,
computeBranchTooltip,
computeBranchDisabledReason,
} from "@/components/task-create-dialog-branch-utils";
function runAutoselect({
branches,
preferredDefaultBranch,
preferredDefaultBranchLoading,
onBranchChange,
}: {
branches: Array<{ name: string; type: "local" | "remote"; remote?: string }>;
preferredDefaultBranch: string | undefined;
preferredDefaultBranchLoading: boolean;
onBranchChange: (value: string) => void;
}) {
if (preferredDefaultBranchLoading) return;
// Local-executor flow: preferredDefaultBranch is the workspace's current
// ref (branch name OR detached-HEAD short SHA). Seed row.branch with it
// unconditionally so the chip displays "current: <ref>". When the ref is a
// SHA (detached HEAD) it won't appear in the branches list, but falling
// through to autoSelectBranch would pick main/master and surface "will
// switch to: master", which contradicts what the user actually has checked
// out. Sending the SHA back on submit is a no-op because the backend's
// skip-when-equal check compares against the same SHA.
if (preferredDefaultBranch) {
onBranchChange(preferredDefaultBranch);
return;
}
autoSelectBranch(branches, onBranchChange);
}
/**
* Chip row for the task-create dialog. Renders one chip per row in
* `fs.repositories`, plus a trailing "+" to add another. Each chip is two
* pills (repo, branch) and a remove (×). All chips are equivalent — there
* is no "primary" — and any row can hold either a workspace repo or a
* discovered on-machine path.
*
* In Remote mode the chips are replaced by `RemoteRepoChipsRow`, which
* renders its own chip row backed by `fs.remoteRepos`; the trailing toggle
* flips between the two modes.
*/
type RepoChipsRowProps = {
fs: DialogFormState;
repositories: Repository[];
isTaskStarted: boolean;
/** Required for loading branches on discovered (path-keyed) rows. */
workspaceId: string | null;
/**
* Per-row repo change handler. Resolves the picked value into either a
* workspace `repositoryId` or a discovered `localPath` and writes that
* into the row. Comes from useDialogHandlers so the resolution logic
* stays in one place.
*/
onRowRepositoryChange: (key: string, value: string) => void;
onRowBranchChange: (key: string, value: string) => void;
/** Toggles the Remote tab on/off. Remote-mode rows live in `fs.remoteRepos`. */
onToggleRemote?: () => void;
/**
* Fresh-branch toggle props. When `freshBranchAvailable` is true the toggle
* renders inline at the right edge of the chip row so it sits next to the
* branch pills it affects, instead of taking its own row under the
* agent/executor selectors.
*/
freshBranchAvailable?: boolean;
freshBranchEnabled?: boolean;
onToggleFreshBranch?: (enabled: boolean) => void;
/**
* When the task runs on the local executor, the chip seeds row.branch with
* the workspace's current branch (so the user sees what's on disk and the
* submit payload always carries an explicit value). The chip stays
* editable — picking a different existing branch triggers `git checkout`
* server-side; keeping the default skips git ops entirely. Fresh-branch
* mode is independent: it creates a new branch from a chosen base.
*/
isLocalExecutor?: boolean;
/** "No repository" mode: replace the chip row with a folder picker. */
onToggleNoRepository?: () => void;
onWorkspacePathChange?: (value: string) => void;
};
export function RepoChipsRow({
fs,
repositories,
isTaskStarted,
workspaceId,
onRowRepositoryChange,
onRowBranchChange,
onToggleRemote,
freshBranchAvailable,
freshBranchEnabled,
onToggleFreshBranch,
isLocalExecutor,
onToggleNoRepository,
onWorkspacePathChange,
}: RepoChipsRowProps) {
// Local executor branch behavior:
// - chip is clickable (user can switch to any existing branch on disk)
// - row.branch seeds from the workspace's current branch (currentLocalBranch)
// via the autoselect path, so the chip displays the current branch by
// default and the submit payload always carries an explicit value
// - if user keeps the default, backend's "branch == current → skip" logic
// runs (no git ops)
// - if user picks a different existing branch, backend runs `git checkout`
// - "Fork a new branch" toggle is a separate flow that creates a NEW branch
// from the selected base
// Other executors: branch is fully editable (no special pre-fill).
const branchLocked = false;
// No early returns above hooks. URL mode and started-state checks happen below.
if (isTaskStarted) return null;
// Multi-branch support: the same repo can appear multiple times on a task
// when each row picks a different branch. Uniqueness is enforced on the
// (repository_id, checkout_branch) pair at submit time by the backend, so
// the dropdown never filters repos out — picking "frontend" twice and
// assigning two different branches is a supported flow.
const hasDiscovered = fs.discoveredRepositories.length > 0;
const canAddMore = repositories.length > 0 || hasDiscovered;
const addHint = computeAddHint(canAddMore, repositories.length);
return (
// min-h-9 reserves enough vertical space for the tallest mode body so the
// modal doesn't jump when the user toggles between Repo / URL / None
// (None renders a single pill, Repo can render chips + branch + add and
// sometimes wraps when the segmented control crowds the row).
<div className="flex min-h-9 flex-wrap items-center gap-2" data-testid="repo-chips-row">
<ModeBody
fs={fs}
repositories={repositories}
workspaceId={workspaceId}
branchLocked={branchLocked}
isLocalExecutor={!!isLocalExecutor}
canAddMore={canAddMore}
addHint={addHint}
freshBranchAvailable={freshBranchAvailable}
freshBranchEnabled={freshBranchEnabled}
onRowRepositoryChange={onRowRepositoryChange}
onRowBranchChange={onRowBranchChange}
onToggleFreshBranch={onToggleFreshBranch}
onWorkspacePathChange={onWorkspacePathChange}
/>
<SourceModeSwitch
useRemote={fs.useRemote}
noRepository={fs.noRepository}
onToggleRemote={onToggleRemote}
onToggleNoRepository={onToggleNoRepository}
/>
</div>
);
}
function ModeBody({
fs,
repositories,
workspaceId,
branchLocked,
isLocalExecutor,
canAddMore,
addHint,
freshBranchAvailable,
freshBranchEnabled,
onRowRepositoryChange,
onRowBranchChange,
onToggleFreshBranch,
onWorkspacePathChange,
}: {
fs: DialogFormState;
repositories: Repository[];
workspaceId: string | null;
branchLocked: boolean;
isLocalExecutor: boolean;
canAddMore: boolean;
addHint: string | undefined;
freshBranchAvailable?: boolean;
freshBranchEnabled?: boolean;
onRowRepositoryChange: (key: string, value: string) => void;
onRowBranchChange: (key: string, value: string) => void;
onToggleFreshBranch?: (enabled: boolean) => void;
onWorkspacePathChange?: (value: string) => void;
}) {
if (fs.noRepository) {
return (
<FolderPicker
value={fs.workspacePath}
onChange={onWorkspacePathChange ?? (() => {})}
placeholder="pick a starting folder (optional)"
/>
);
}
if (fs.useRemote) {
return (
<RemoteRepoChipsRow
fs={fs}
onUpdateRow={fs.updateRemoteRepo}
onAddRow={fs.addRemoteRepo}
onRemoveRow={fs.removeRemoteRepo}
/>
);
}
return (
<ChipsList
fs={fs}
repositories={repositories}
workspaceId={workspaceId}
branchLocked={branchLocked}
isLocalExecutor={isLocalExecutor}
canAddMore={canAddMore}
addHint={addHint}
onRowRepositoryChange={onRowRepositoryChange}
onRowBranchChange={onRowBranchChange}
freshBranchToggle={
// Multi-repo runs use worktrees, so the existing-vs-fork choice
// is irrelevant — only surface the toggle for single-repo flows.
freshBranchAvailable && onToggleFreshBranch && fs.repositories.length === 1 ? (
<FreshBranchToggle enabled={!!freshBranchEnabled} onToggle={onToggleFreshBranch} />
) : null
}
/>
);
}
/**
* Renders the list of repo chips plus the trailing "+ add repository"
* button. Extracted from RepoChipsRow so the parent stays under the
* function-length cap; logic is unchanged.
*/
function ChipsList({
fs,
repositories,
workspaceId,
branchLocked,
isLocalExecutor,
canAddMore,
addHint,
freshBranchToggle,
onRowRepositoryChange,
onRowBranchChange,
}: {
fs: DialogFormState;
repositories: Repository[];
workspaceId: string | null;
branchLocked: boolean;
isLocalExecutor: boolean;
canAddMore: boolean;
addHint?: string;
freshBranchToggle?: React.ReactNode;
onRowRepositoryChange: (key: string, value: string) => void;
onRowBranchChange: (key: string, value: string) => void;
}) {
return (
<>
{fs.repositories.map((row) => (
<RepoChip
key={row.key}
row={row}
workspaceId={workspaceId}
repositories={repositories}
discoveredRepositories={fs.discoveredRepositories}
// Multi-branch: the same repository may be reused across rows when
// each row picks a different branch. Only exclude rows that hold
// the exact (repo, branch) pair this row would clash with on the
// backend — empty-branch rows can't collide yet, so they pass.
excludedRepoIds={collectExactDuplicateRepoIds(fs.repositories, row)}
branchLocked={branchLocked}
// For local-executor rows, seed row.branch with the workspace's
// current branch via this prop. Non-local rows leave it undefined
// and fall back to the existing last-used / preferred-default
// autoselect path.
preferredDefaultBranch={isLocalExecutor ? fs.currentLocalBranch : undefined}
preferredDefaultBranchLoading={isLocalExecutor ? fs.currentLocalBranchLoading : false}
branchPrefix={computeBranchPrefix({
isLocalExecutor,
rowBranch: row.branch,
currentLocalBranch: fs.currentLocalBranch,
freshBranchEnabled: !!fs.freshBranchEnabled,
})}
onRepositoryChange={(value) => onRowRepositoryChange(row.key, value)}
onBranchChange={(value) => onRowBranchChange(row.key, value)}
onRemove={() => fs.removeRepository(row.key)}
/>
))}
{freshBranchToggle}
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex">
<button
type="button"
onClick={fs.addRepository}
disabled={!canAddMore}
aria-label="Add repository"
data-testid="add-repository"
className={cn(
"h-7 w-7 inline-flex items-center justify-center rounded-md text-muted-foreground",
canAddMore
? "hover:bg-muted hover:text-foreground cursor-pointer"
: "opacity-40 cursor-not-allowed",
)}
>
<IconPlus className="h-3.5 w-3.5" />
</button>
</span>
</TooltipTrigger>
<TooltipContent>{addHint ?? "Add another repository"}</TooltipContent>
</Tooltip>
</>
);
}
function FreshBranchToggle({
enabled,
onToggle,
}: {
enabled: boolean;
onToggle: (enabled: boolean) => void;
}) {
return (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => onToggle(!enabled)}
data-testid="fresh-branch-toggle"
aria-pressed={enabled}
aria-label={
enabled
? "Fork a new branch from a base (turn off to use current checkout)"
: "Fork a new branch from a base instead of using current checkout"
}
className={cn(
"inline-flex h-7 w-7 items-center justify-center rounded-md border border-input cursor-pointer transition-colors",
enabled
? "bg-muted text-foreground"
: "bg-transparent text-muted-foreground hover:text-foreground hover:bg-muted/60",
)}
>
<IconGitFork className="h-3.5 w-3.5" />
</button>
</TooltipTrigger>
<TooltipContent className="max-w-xs">
{enabled
? "Fork mode: a new branch will be created from the selected base before the agent runs. Click to turn off and use your repository's current checkout instead."
: "By default the local executor uses your repository's current checkout. Click to fork a new branch from a base instead, leaving your working tree untouched."}
</TooltipContent>
</Tooltip>
);
}
function computeAddHint(canAddMore: boolean, workspaceRepoCount: number): string | undefined {
if (canAddMore) return undefined;
if (workspaceRepoCount === 0) return "No repositories available in this workspace";
return "All workspace repositories are already added";
}
/**
* Returns the set of repo ids/paths that would create a literal duplicate
* (same repo + same branch) of an *existing* row if `currentRow` adopted
* them — used to hide already-claimed pairings from the repo dropdown.
*
* Multi-branch tasks are supported: the same repo can appear across multiple
* rows as long as each row's branch differs. Rows with empty branches don't
* collide yet, so they don't contribute to the exclusion set.
*
* Same-row entries are skipped so the current row's own pick remains
* selectable; without that, after the user pairs (repo, branch) the chip
* would suddenly render its current repo as unavailable.
*/
function collectExactDuplicateRepoIds(rows: TaskRepoRow[], currentRow: TaskRepoRow): Set<string> {
const ids = new Set<string>();
for (const r of rows) {
if (r.key === currentRow.key) continue;
if (!r.branch || r.branch !== currentRow.branch) continue;
if (r.repositoryId) ids.add(r.repositoryId);
if (r.localPath) ids.add(r.localPath);
}
return ids;
}
type RepoChipProps = {
row: TaskRepoRow;
/** Required for path-based branch loading on discovered rows. */
workspaceId: string | null;
repositories: Repository[];
discoveredRepositories: LocalRepository[];
/** Repo IDs/paths to filter out of the dropdown (already in use elsewhere). */
excludedRepoIds: Set<string>;
/**
* Lock the branch pill regardless of branch availability. Used for the
* local executor where the user's actual checkout dictates the branch
* (and changing it would mutate their working tree). Fresh-branch mode
* unlocks it because we're explicitly creating a new branch from a base.
*/
branchLocked?: boolean;
/**
* When set, seed row.branch with this value (for an empty row). Used by
* the local-executor flow to surface the workspace's current ref — either
* a branch name like "main" or, on detached HEAD, the short commit SHA
* returned by the backend. The chip displays it verbatim ("current: main"
* or "current: 4fbc5d7"); on submit the backend's skip-when-equal check
* matches the same SHA so it's a no-op.
*
* When unset, the chip falls back to the existing last-used / preferred-
* default autoselect (main / master / develop, etc.).
*/
preferredDefaultBranch?: string;
/**
* True while preferredDefaultBranch is being resolved. Renders a
* "Loading branch…" placeholder so the chip doesn't briefly show an empty
* state in the window between dialog open and local-status resolving.
*/
preferredDefaultBranchLoading?: boolean;
/**
* Muted text shown before the branch value to qualify intent:
* - "current: " — local exec, picked branch == workspace current
* - "will switch to: " — local exec, picked branch != workspace current
* - "from: " — worktree / non-local exec (picked branch is the base)
* Empty when there's no branch value yet (chip shows the "branch"
* placeholder unprefixed).
*/
branchPrefix?: string;
onRepositoryChange: (value: string) => void;
onBranchChange: (value: string) => void;
onRemove: () => void;
};
function useRepoChipData({
row,
workspaceId,
repositories,
discoveredRepositories,
excludedRepoIds,
onBranchChange,
preferredDefaultBranch,
preferredDefaultBranchLoading,
}: Pick<
RepoChipProps,
| "row"
| "workspaceId"
| "repositories"
| "discoveredRepositories"
| "excludedRepoIds"
| "onBranchChange"
| "preferredDefaultBranch"
| "preferredDefaultBranchLoading"
>) {
const filteredRepos = useMemo(
() => repositories.filter((r) => !excludedRepoIds.has(r.id) || r.id === row.repositoryId),
[repositories, excludedRepoIds, row.repositoryId],
);
// Discovered (on-machine) repos not yet imported into the workspace. Drop
// ones whose path is already a workspace repo so the dropdown doesn't show
// the same folder twice.
const filteredDiscovered = useMemo(() => {
const workspaceRepoPaths = new Set(
filteredRepos
.map((r) => r.local_path)
.filter(Boolean)
.map((path: string) => normalizeRepoPath(path)),
);
return discoveredRepositories.filter(
(r) =>
!workspaceRepoPaths.has(normalizeRepoPath(r.path)) &&
(!excludedRepoIds.has(r.path) || r.path === row.localPath),
);
}, [filteredRepos, discoveredRepositories, excludedRepoIds, row.localPath]);
// One hook for both row sources (workspace repo by id OR on-machine path).
// Backend resolves either to an absolute path; the Zustand cache keys
// path-based entries with a synthetic key so workspace + discovered share
// one slice (and the same fetch dedupe).
const branchSource = useMemo<BranchSource | null>(() => {
if (!workspaceId) return null;
if (row.repositoryId) {
return { kind: "id", workspaceId, repositoryId: row.repositoryId };
}
if (row.localPath) {
return { kind: "path", workspaceId, path: row.localPath };
}
return null;
}, [workspaceId, row.repositoryId, row.localPath]);
const {
branches,
isLoading: branchesLoading,
refresh: refreshBranches,
} = useBranches(branchSource, !!branchSource);
// Once branches load for this row, pre-fill the branch pill. For
// local-executor rows we prefer the workspace's actual current branch
// (preferredDefaultBranch) so the chip shows what's on disk and the
// submit always carries an explicit branch. Otherwise fall back to the
// user's last-used branch / main / master / develop. Skipped if the user
// already picked a branch on this row.
//
// The `preferredDefaultBranchLoading` gate closes a race: without it,
// autoSelectBranch's last-used localStorage pick (e.g. "origin/master")
// would land in row.branch before currentLocalBranch resolved, then the
// row.branch guard above would short-circuit forever and the chip would
// render "will switch to: origin/master" even though the user just opened
// the dialog. With the gate, autoselect waits until local-status finishes
// so the current-branch preference gets first crack at row.branch.
useEffect(() => {
if (!branchSource || branchesLoading || branches.length === 0 || row.branch) return;
runAutoselect({
branches,
preferredDefaultBranch,
preferredDefaultBranchLoading: !!preferredDefaultBranchLoading,
onBranchChange,
});
}, [
branchSource,
branchesLoading,
branches,
row.branch,
onBranchChange,
preferredDefaultBranch,
preferredDefaultBranchLoading,
]);
const repoOptions: PillOption[] = useMemo(
() => [
...filteredRepos.map((r) => ({
value: r.id,
label: r.name,
keywords: [r.name, r.local_path, formatUserHomePath(r.local_path)].filter(
(s): s is string => !!s,
),
renderLabel: () => renderWorkspaceRepoOption(r),
})),
...filteredDiscovered.map((r) => ({
value: r.path,
label: leafSegment(r.path),
keywords: [r.path, formatUserHomePath(r.path)],
renderLabel: () => renderDiscoveredRepoOption(r.path),
})),
],
[filteredRepos, filteredDiscovered],
);
const branchOptions: PillOption[] = useMemo(
() => sortBranches(branches).map(branchToOption),
[branches],
);
return { repoOptions, branchOptions, branchesLoading, refreshBranches };
}
function computeRepoChipDisplay(
row: TaskRepoRow,
repositories: Repository[],
discoveredRepositories: LocalRepository[],
) {
const workspaceRepo = repositories.find((r) => r.id === row.repositoryId);
const discoveredRepo = discoveredRepositories.find((r) => r.path === row.localPath);
const repoLabel = workspaceRepo?.name ?? discoveredRepo?.path?.split("/").pop() ?? "";
const repoPath = workspaceRepo?.local_path || discoveredRepo?.path || "";
const repoTooltip = repoPath ? `Repository · ${formatUserHomePath(repoPath)}` : "Repository";
return { repoLabel, repoTooltip };
}
function RepoChip({
row,
workspaceId,
repositories,
discoveredRepositories,
excludedRepoIds,
branchLocked,
preferredDefaultBranch,
preferredDefaultBranchLoading,
branchPrefix,
onRepositoryChange,
onBranchChange,
onRemove,
}: RepoChipProps) {
const { repoOptions, branchOptions, branchesLoading, refreshBranches } = useRepoChipData({
row,
workspaceId,
repositories,
discoveredRepositories,
excludedRepoIds,
onBranchChange,
preferredDefaultBranch,
preferredDefaultBranchLoading,
});
const { repoLabel, repoTooltip } = computeRepoChipDisplay(
row,
repositories,
discoveredRepositories,
);
// Hide the value while the preferred-default-branch fetch is in flight to
// avoid the chip briefly rendering an empty placeholder before autoselect
// seeds row.branch with the workspace's current branch.
const branchValue = preferredDefaultBranchLoading ? "" : row.branch;
const hasRepo = !!(row.repositoryId || row.localPath);
const branchPlaceholder = computeBranchPlaceholder(
hasRepo,
branchesLoading || !!preferredDefaultBranchLoading,
branchOptions.length,
);
return (
<span
className="inline-flex items-center rounded-md border border-input bg-input/20 dark:bg-input/30 pr-0.5"
data-testid="repo-chip"
data-repository-id={row.repositoryId || row.localPath || ""}
>
<Pill
icon={<IconCode className="h-3 w-3 shrink-0 text-muted-foreground" />}
value={repoLabel}
placeholder="repository"
options={repoOptions}
onSelect={onRepositoryChange}
searchPlaceholder="Search repositories..."
emptyMessage="No repositories"
testId="repo-chip-trigger"
tooltip={repoTooltip}
filter={scoreRepo}
flat
/>
<Pill
icon={<IconGitBranch className="h-3 w-3 shrink-0 text-muted-foreground" />}
value={branchValue}
placeholder={branchPlaceholder}
prefix={branchPrefix}
options={branchOptions}
onSelect={onBranchChange}
disabled={branchLocked || !hasRepo || branchesLoading || branchOptions.length === 0}
disabledReason={computeBranchDisabledReason({
branchLocked: !!branchLocked,
hasRepo,
branchesLoading,
optionCount: branchOptions.length,
})}
searchPlaceholder="Search branches..."
emptyMessage="No branches"
testId="branch-chip-trigger"
tooltip={computeBranchTooltip(branchPrefix)}
onRefresh={refreshBranches}
refreshing={branchesLoading}
filter={scoreBranch}
flat
/>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={onRemove}
aria-label="Remove repository"
className="h-6 w-6 inline-flex items-center justify-center rounded text-muted-foreground hover:text-destructive hover:bg-muted/60 cursor-pointer"
data-testid="remove-repo-chip"
>
<IconX className="h-3 w-3" />
</button>
</TooltipTrigger>
<TooltipContent>Remove repository</TooltipContent>
</Tooltip>
</span>
);
}
function normalizeRepoPath(path: string): string {
return path.replace(/\\/g, "/").replace(/\/+$/g, "");
}
function renderWorkspaceRepoOption(repo: Repository) {
const display = repo.local_path ? formatUserHomePath(repo.local_path) : "";
return (
<span className="flex min-w-0 flex-1 flex-col overflow-hidden" title={display || repo.name}>
<span className="truncate">{repo.name}</span>
{display ? (
<span className="truncate text-[11px] text-muted-foreground">{display}</span>
) : null}
</span>
);
}
function renderDiscoveredRepoOption(path: string) {
const display = formatUserHomePath(path);
return (
<span className="flex min-w-0 flex-1 items-center gap-2 overflow-hidden" title={display}>
<span className="flex min-w-0 flex-1 flex-col overflow-hidden">
<span className="truncate">{leafSegment(path)}</span>
<span className="truncate text-[11px] text-muted-foreground">{display}</span>
</span>
<Badge variant="outline" className="text-[10px] text-muted-foreground shrink-0">
on disk
</Badge>
</span>
);
}
function leafSegment(path: string): string {
const cleaned = path.replace(/\\/g, "/").replace(/\/+$/g, "");
const idx = cleaned.lastIndexOf("/");
return idx >= 0 ? cleaned.slice(idx + 1) : cleaned;
}