feat: mark selected task repositories (#1881)

This commit is contained in:
Carlos Florêncio
2026-07-23 16:32:25 +01:00
committed by GitHub
parent d475cdf597
commit 9cd0c27be0
24 changed files with 1323 additions and 217 deletions
@@ -4,8 +4,8 @@ import type { TaskRemoteRepoRow } from "@/components/task-create-dialog-types";
import type { PRInfo } from "@/hooks/domains/github/use-pr-info-by-url";
/** Minimal TaskRemoteRepoRow builder for the dedup tests. */
function remoteRow(key: string, url: string): TaskRemoteRepoRow {
return { key, url, branch: "", source: "paste" };
function remoteRow(key: string, url: string, branch = ""): TaskRemoteRepoRow {
return { key, url, branch, source: "paste" };
}
/** Builds a `prInfoByUrl` stub for `buildRepositoriesPayload`. The submit
@@ -573,6 +573,15 @@ describe("findDuplicateRemoteRepo", () => {
).toBe(REPO_LABEL);
});
it("allows the same repo on distinct selected branches", () => {
expect(
findDuplicateRemoteRepo([
remoteRow("r0", REPO_URL, "main"),
remoteRow("r1", REPO_URL, "feature/remote-duplicate"),
]),
).toBeNull();
});
it("flags a PR URL and a plain repo URL of the same repo", () => {
expect(findDuplicateRemoteRepo([remoteRow("r0", PR_1116), remoteRow("r1", REPO_URL)])).toBe(
REPO_LABEL,
@@ -217,16 +217,17 @@ export function validateCreateInputs(inputs: {
}
/**
* Detects two remote-repo rows that resolve to the same GitHub `owner/repo`.
* Detects two remote-repo rows that resolve to the same GitHub repository and
* selected branch.
*
* Both plain repo URLs and PR URLs are parsed via `parseGitHubAnyUrl`, so two
* different PRs of the same repo (`/pull/1116` and `/pull/1117`) or the same
* PR URL pasted twice are caught — they all collapse to the same backend
* repository, which would otherwise surface as an opaque UUID-laden error.
* identical branch are caught. Same-repository rows on distinct branches are
* valid multi-branch task inputs and must reach the backend.
*
* Rows with an empty URL, or a URL that can't be parsed to `owner/repo`
* (garbage), are skipped — only parseable rows participate in the comparison,
* which is case-insensitive on `owner/repo`.
* (garbage), are skipped — only parseable rows participate in the comparison.
* Repository identity is case-insensitive on `owner/repo`; branch identity is
* case-sensitive because Git branch names are case-sensitive.
*
* Returns the human-readable label (`owner/repo`, preserving the first row's
* casing) of the first duplicate found, or `null` when every parseable row is
@@ -238,7 +239,7 @@ export function findDuplicateRemoteRepo(remoteRepos: TaskRemoteRepoRow[]): strin
const parsed = parseGitHubAnyUrl(row.url ?? "");
if (!parsed) continue;
const label = `${parsed.owner}/${parsed.repo}`;
const key = label.toLowerCase();
const key = `${label.toLowerCase()}\u0000${row.branch}`;
const existing = seen.get(key);
if (existing) return existing;
seen.set(key, label);
@@ -5,10 +5,6 @@ import type { TaskRemoteRepoRow } from "./task-create-dialog-types";
import { TooltipProvider } from "@kandev/ui/tooltip";
import type { UseRemoteRepositoriesResult } from "@/hooks/domains/integrations/use-remote-repositories";
// Each test passes a stubbed `accessibleRepos` prop to the chip. The hook
// itself now lives at the chips-row level (see chips-row test); the chip is
// pure presentational glue over the result. Defaults to an empty/idle state
// and individual tests override the slice they care about.
type AccessibleRepo = {
provider: "github" | "gitlab" | "azure_devops";
owner: string;
@@ -52,42 +48,39 @@ function remoteTestURL(repo: AccessibleRepo): string {
}
return `https://${repo.provider}.com/${repo.owner}/${repo.name}`;
}
import { RemoteRepoChip } from "./task-create-dialog-remote-repo-chip";
const TRIGGER_TID = "remote-repo-chip-trigger";
const INPUT_TID = "remote-repo-input";
const ALREADY_ADDED_MARKER = "already-added-repository-marker";
const FULL_NAME = "acme/site";
const URL_ACME_SITE = "https://github.com/acme/site";
function githubSite(overrides: Partial<AccessibleRepo> = {}): AccessibleRepo {
return {
provider: "github",
owner: "acme",
name: "site",
full_name: FULL_NAME,
default_branch: "main",
private: false,
...overrides,
};
}
afterEach(() => {
cleanup();
});
function row(overrides: Partial<TaskRemoteRepoRow> = {}): TaskRemoteRepoRow {
return { key: "remote-0", url: "", branch: "", source: "paste", ...overrides };
}
function renderInProvider(ui: Parameters<typeof render>[0]) {
return render(<TooltipProvider>{ui}</TooltipProvider>);
}
const noopBranch = () => undefined;
const noopRemove = () => undefined;
describe("RemoteRepoChip — write paths", () => {
it("picker selection writes URL + picker metadata (incl. default_branch) via onURLChange", () => {
const accessibleRepos = makeAccessible({
repos: [
{
provider: "github",
owner: "acme",
name: "site",
full_name: FULL_NAME,
default_branch: "trunk",
private: false,
},
],
repos: [githubSite({ default_branch: "trunk" })],
});
const onURLChange = vi.fn();
renderInProvider(
@@ -113,7 +106,6 @@ describe("RemoteRepoChip — write paths", () => {
}),
);
});
it("selects an Azure DevOps repository with provider metadata", () => {
const accessibleRepos = makeAccessible({
repos: [
@@ -147,7 +139,6 @@ describe("RemoteRepoChip — write paths", () => {
expect.objectContaining({ provider: "azure_devops", fullName: "Platform/api" }),
);
});
it("calls onRemove when the X button is clicked", () => {
const onRemove = vi.fn();
renderInProvider(
@@ -165,7 +156,77 @@ describe("RemoteRepoChip — write paths", () => {
expect(onRemove).toHaveBeenCalledOnce();
});
});
describe("RemoteRepoChip — already added marker", () => {
it("marks a provider repository selected in another row and still selects it", () => {
const onURLChange = vi.fn();
renderInProvider(
<RemoteRepoChip
row={row()}
branches={[]}
branchesLoading={false}
accessibleRepos={makeAccessible({
repos: [githubSite()],
})}
selectedRepositoryIdentities={["github:id:acme/site"]}
onURLChange={onURLChange}
onBranchChange={noopBranch}
onRemove={noopRemove}
/>,
);
fireEvent.click(screen.getByTestId(TRIGGER_TID));
const marker = screen.getByTestId(ALREADY_ADDED_MARKER);
expect(marker.getAttribute("aria-label")).toBe("Already added");
expect(marker.classList).toContain("text-primary");
expect(screen.queryByText("Already added")).toBeNull();
fireEvent.click(screen.getByText(FULL_NAME).closest("button") as HTMLButtonElement);
expect(onURLChange).toHaveBeenCalledOnce();
});
it("does not mark an identical provider id from a different provider", () => {
renderInProvider(
<RemoteRepoChip
row={row()}
branches={[]}
branchesLoading={false}
accessibleRepos={makeAccessible({
repos: [
{
provider: "gitlab",
owner: "acme",
name: "site",
full_name: FULL_NAME,
default_branch: "main",
private: false,
},
],
})}
selectedRepositoryIdentities={["github:id:acme/site"]}
onURLChange={vi.fn()}
onBranchChange={noopBranch}
onRemove={noopRemove}
/>,
);
fireEvent.click(screen.getByTestId(TRIGGER_TID));
expect(screen.queryByTestId(ALREADY_ADDED_MARKER)).toBeNull();
});
it("marks an option from a normalized pasted URL identity", () => {
renderInProvider(
<RemoteRepoChip
row={row()}
branches={[]}
branchesLoading={false}
accessibleRepos={makeAccessible({
repos: [githubSite()],
})}
selectedRepositoryIdentities={["url:github:acme/site"]}
onURLChange={vi.fn()}
onBranchChange={noopBranch}
onRemove={noopRemove}
/>,
);
fireEvent.click(screen.getByTestId(TRIGGER_TID));
expect(screen.getByTestId(ALREADY_ADDED_MARKER)).toBeTruthy();
});
});
describe("RemoteRepoChip — unified search", () => {
it("uses ordinary text as repository search without committing it on blur", () => {
const onURLChange = vi.fn();
@@ -190,19 +251,9 @@ describe("RemoteRepoChip — unified search", () => {
expect(screen.queryByRole("alert")).toBeNull();
expect(onURLChange).not.toHaveBeenCalled();
});
it("picker click after searching commits only the selected repository", () => {
const accessibleRepos = makeAccessible({
repos: [
{
provider: "github",
owner: "acme",
name: "site",
full_name: FULL_NAME,
default_branch: "main",
private: false,
},
],
repos: [githubSite()],
});
const onURLChange = vi.fn();
renderInProvider(
@@ -234,20 +285,10 @@ describe("RemoteRepoChip — unified search", () => {
);
});
});
describe("RemoteRepoChip — picker focus", () => {
it("does not commit a typed URL on blur when focus moves to a repository option", () => {
const accessibleRepos = makeAccessible({
repos: [
{
provider: "github",
owner: "acme",
name: "site",
full_name: FULL_NAME,
default_branch: "main",
private: false,
},
],
repos: [githubSite()],
});
const onURLChange = vi.fn();
renderInProvider(
@@ -267,7 +308,6 @@ describe("RemoteRepoChip — picker focus", () => {
const option = screen.getByText(FULL_NAME).closest("button") as HTMLButtonElement;
fireEvent.blur(input, { relatedTarget: option });
fireEvent.click(option);
expect(onURLChange).toHaveBeenCalledTimes(1);
expect(onURLChange).toHaveBeenCalledWith(
URL_ACME_SITE,
@@ -280,7 +320,6 @@ describe("RemoteRepoChip — picker focus", () => {
);
});
});
describe("RemoteRepoChip — branch pill", () => {
it("is disabled when the URL is empty", () => {
renderInProvider(
@@ -297,7 +336,6 @@ describe("RemoteRepoChip — branch pill", () => {
const branchTrigger = screen.getByTestId("remote-branch-chip-trigger") as HTMLButtonElement;
expect(branchTrigger.disabled).toBe(true);
});
it("enables once URL is present and branches load", () => {
const branches: Branch[] = [
{ name: "main", type: "remote", remote: "origin" },
@@ -319,9 +357,6 @@ describe("RemoteRepoChip — branch pill", () => {
});
it("is enabled when the row already has a branch even if branch options haven't loaded yet", () => {
// Picker pre-fill sets `row.branch` before the branch list fetch finishes.
// The pill must show the value as the active selection rather than
// greying out and confusing the user into thinking pre-fill failed.
renderInProvider(
<RemoteRepoChip
row={row({ url: URL_ACME_SITE, branch: "trunk" })}
@@ -341,23 +376,8 @@ describe("RemoteRepoChip — branch pill", () => {
describe("RemoteRepoChip — option layout", () => {
it("never renders an option description line, even when the repo has a description", () => {
// Inverted from the original "renders description as a second line" test —
// the description was dropped from the picker to keep each row compact and
// one-line, after the picker switched to client-side filtering over a
// single fetch (descriptions added visual noise without aiding the
// case-insensitive full_name substring match).
const accessibleRepos = makeAccessible({
repos: [
{
provider: "github",
owner: "acme",
name: "site",
full_name: FULL_NAME,
default_branch: "main",
description: "The acme corporate website",
private: false,
},
],
repos: [githubSite({ description: "The acme corporate website" })],
});
renderInProvider(
<RemoteRepoChip
@@ -373,7 +393,6 @@ describe("RemoteRepoChip — option layout", () => {
fireEvent.click(screen.getByTestId(TRIGGER_TID));
expect(screen.queryByTestId("remote-repo-option-description")).toBeNull();
expect(screen.queryByText("The acme corporate website")).toBeNull();
// The owner/name line is still rendered.
expect(screen.getByText(FULL_NAME)).toBeTruthy();
});
});
@@ -405,16 +424,7 @@ describe("RemoteRepoChip — picker loading state", () => {
branchesLoading={false}
accessibleRepos={makeAccessible({
loading: true,
repos: [
{
provider: "github",
owner: "acme",
name: "site",
full_name: FULL_NAME,
default_branch: "main",
private: false,
},
],
repos: [githubSite()],
})}
onURLChange={vi.fn()}
onBranchChange={noopBranch}
@@ -588,7 +598,6 @@ describe("RemoteRepoChip — trigger label", () => {
onRemove={noopRemove}
/>,
);
// Short URLs render verbatim; the middle-ellipsis only fires past ~30 chars.
expect(screen.getByTestId(TRIGGER_TID).textContent).toContain("github.com/foo/bar");
});
});
@@ -5,6 +5,7 @@ import Link from "@/components/routing/app-link";
import {
IconBrandGithub,
IconBrandGitlab,
IconCheck,
IconGitBranch,
IconLink,
IconX,
@@ -35,46 +36,20 @@ import {
RemoteRepositoryProviderIcon,
RemoteRepoProviderTabs,
} from "@/components/task-create-dialog-remote-repo-provider-tabs";
import { remoteRepositoryMatchesSelection } from "./task-create-dialog-remote-repo-identity";
export { selectedRemoteRepositoryIdentity } from "./task-create-dialog-remote-repo-identity";
const TRUNCATE_THRESHOLD = 30;
/**
* Props for the per-row remote-repo chip used in the Remote tab of the
* task-create dialog. The chip itself is presentational — branches and the
* loading flag are passed in by the parent row (which keys them off the
* row's URL via `branchesByUrl`), and writes happen through the supplied
* callbacks.
*
* `onURLChange` receives the new URL plus how it was produced. The "picker"
* arm also carries the canonical `owner/name`, provider, and the repo's
* `default_branch` so the parent can pre-fill the row's branch without
* waiting for the branch list to load; the "paste" arm leaves metadata
* undefined so the row drops any stale picker data and the user picks
* their own branch.
*/
export type RemoteRepoChipProps = {
row: TaskRemoteRepoRow;
branches: Branch[];
branchesLoading: boolean;
/**
* PR info for the row's URL (when the URL is a PR URL and the info has
* loaded). Drives the per-row PR-head auto-select effect: if the row's
* branch is empty, the chip writes the PR head branch into it. The
* dialog separately reads the first row's `suggestedTitle` to autofill
* the task title.
*/
prInfo?: PRInfo;
/**
* Shared `useAccessibleRepos` result hoisted up to the chips-row level so
* one backend request serves every chip in the row (previously each open
* popover fired its own request). Each chip still keeps its own local
* search-text state — the hoisted hook only owns the in-flight fetch and
* the cache. When two popovers happen to be open at once with different
* search texts, both see the same `repos` (the last `search(q)` wins);
* that's acceptable because in practice only one popover is open at a
* time.
*/
accessibleRepos: UseRemoteRepositoriesResult;
/** Identities selected by other rows. Matching entries remain selectable. */
selectedRepositoryIdentities?: string[];
onURLChange: (
url: string,
source: "picker" | "paste",
@@ -107,6 +82,7 @@ export function RemoteRepoChip({
branchesLoading,
prInfo,
accessibleRepos,
selectedRepositoryIdentities = [],
onURLChange,
onBranchChange,
onRemove,
@@ -118,7 +94,12 @@ export function RemoteRepoChip({
data-testid="remote-repo-chip"
data-remote-url={row.url}
>
<RemoteRepoPill row={row} accessibleRepos={accessibleRepos} onURLChange={onURLChange} />
<RemoteRepoPill
row={row}
accessibleRepos={accessibleRepos}
selectedRepositoryIdentities={selectedRepositoryIdentities}
onURLChange={onURLChange}
/>
<RemoteBranchPill
url={row.url}
branch={row.branch}
@@ -209,10 +190,12 @@ function computeAutoSelectedBranch(prInfo: PRInfo | undefined, branches: Branch[
function RemoteRepoPill({
row,
accessibleRepos,
selectedRepositoryIdentities,
onURLChange,
}: {
row: TaskRemoteRepoRow;
accessibleRepos: UseRemoteRepositoriesResult;
selectedRepositoryIdentities: string[];
onURLChange: RemoteRepoChipProps["onURLChange"];
}) {
const [open, setOpen] = useState(false);
@@ -243,6 +226,7 @@ function RemoteRepoPill({
>
<RemoteRepoPopoverContent
accessible={accessibleRepos}
selectedRepositoryIdentities={selectedRepositoryIdentities}
onPick={(repo) => {
onURLChange(repo.url, "picker", {
provider: repo.provider,
@@ -297,10 +281,12 @@ function truncateMiddle(value: string, max: number): string {
function RemoteRepoPopoverContent({
accessible,
selectedRepositoryIdentities,
onPick,
onPaste,
}: {
accessible: UseRemoteRepositoriesResult;
selectedRepositoryIdentities: string[];
onPick: (repo: RemoteRepository) => void;
onPaste: (value: string) => void;
}) {
@@ -311,7 +297,6 @@ function RemoteRepoPopoverContent({
useEffect(() => {
triggerSearch(value);
}, [value, triggerSearch]);
const commitURL = (candidate: string) => {
const trimmed = candidate.trim();
if (!parseGitHubAnyUrl(trimmed) && !looksLikeSupportedRemoteURL(trimmed)) {
@@ -325,20 +310,14 @@ function RemoteRepoPopoverContent({
return true;
};
const visibleUrlError = accessible.unavailable ? null : urlError;
const showProviderTabs = accessible.availableProviders.length > 1;
const selectedProvider =
activeProvider && accessible.availableProviders.includes(activeProvider)
? activeProvider
: accessible.availableProviders[0];
const visibleRepos = showProviderTabs
? accessible.repos.filter((repo) => repo.provider === selectedProvider)
: accessible.repos;
const { showProviderTabs, selectedProvider, visibleRepos } = visibleProviderRepositories(
accessible,
activeProvider,
);
return (
<div className="flex flex-col">
<input
autoFocus
type="text"
value={value}
onChange={(event) => {
setValue(event.target.value);
@@ -384,6 +363,7 @@ function RemoteRepoPopoverContent({
/>
<PickerList
accessible={{ ...accessible, repos: visibleRepos }}
selectedRepositoryIdentities={selectedRepositoryIdentities}
onPick={onPick}
urlError={visibleUrlError}
/>
@@ -398,6 +378,21 @@ function RemoteRepoPopoverContent({
);
}
function visibleProviderRepositories(
accessible: UseRemoteRepositoriesResult,
activeProvider: RemoteRepositoryProvider | null,
) {
const showProviderTabs = accessible.availableProviders.length > 1;
const selectedProvider =
activeProvider && accessible.availableProviders.includes(activeProvider)
? activeProvider
: accessible.availableProviders[0];
const visibleRepos = showProviderTabs
? accessible.repos.filter((repo) => repo.provider === selectedProvider)
: accessible.repos;
return { showProviderTabs, selectedProvider, visibleRepos };
}
function looksLikeURL(value: string): boolean {
if (!value) return false;
const withScheme = /^[a-z][a-z\d+.-]*:\/\//i.test(value) ? value : `https://${value}`;
@@ -423,10 +418,12 @@ function looksLikeSupportedRemoteURL(value: string): boolean {
function PickerList({
accessible,
selectedRepositoryIdentities,
onPick,
urlError,
}: {
accessible: UseRemoteRepositoriesResult;
selectedRepositoryIdentities: string[];
onPick: (repo: RemoteRepository) => void;
urlError: string | null;
}) {
@@ -457,7 +454,14 @@ function PickerList({
</div>
) : null}
{repos.map((repo) => (
<RepoOption key={`${repo.provider}:${repo.id}`} repo={repo} onPick={onPick} />
<RepoOption
key={`${repo.provider}:${repo.id}`}
repo={repo}
alreadyAdded={selectedRepositoryIdentities.some((identity) =>
remoteRepositoryMatchesSelection(repo, identity),
)}
onPick={onPick}
/>
))}
</div>
);
@@ -465,9 +469,11 @@ function PickerList({
function RepoOption({
repo,
alreadyAdded,
onPick,
}: {
repo: RemoteRepository;
alreadyAdded: boolean;
onPick: (repo: RemoteRepository) => void;
}) {
return (
@@ -484,15 +490,31 @@ function RepoOption({
<RemoteRepositoryProviderIcon provider={repo.provider} />
<span className="truncate">{repo.fullName}</span>
</span>
{repo.private ? (
<Badge variant="outline" className="text-[10px] text-muted-foreground shrink-0">
private
</Badge>
) : null}
<span className="flex shrink-0 items-center gap-1">
{alreadyAdded ? <AlreadyAddedMarker /> : null}
{repo.private ? (
<Badge variant="outline" className="text-[10px] text-muted-foreground">
private
</Badge>
) : null}
</span>
</button>
);
}
function AlreadyAddedMarker() {
return (
<span
role="img"
aria-label="Already added"
data-testid="already-added-repository-marker"
className="text-primary"
>
<IconCheck aria-hidden="true" className="h-4 w-4" />
</span>
);
}
function ConnectProvidersBanner() {
return (
<div className="px-3 py-3 text-xs text-muted-foreground">
@@ -29,17 +29,28 @@ type ChipURLChange = (
source: "picker" | "paste",
metadata?: { provider: "github" | "gitlab"; fullName: string; defaultBranch: string },
) => void;
const PICKER_URL = vi.hoisted(() => "https://github.com/acme/site");
const REMOTE_REPO_CHIP_TEST_ID = "remote-repo-chip";
const SELECTED_IDENTITIES_ATTRIBUTE = "data-selected-repository-identities";
vi.mock("./task-create-dialog-remote-repo-chip", () => ({
selectedRemoteRepositoryIdentity: (row: TaskRemoteRepoRow) =>
row.provider && row.providerRepoId ? `${row.provider}:id:${row.providerRepoId}` : undefined,
RemoteRepoChip: ({
row,
onRemove,
onURLChange,
selectedRepositoryIdentities = [],
}: {
row: TaskRemoteRepoRow;
onRemove: () => void;
onURLChange: ChipURLChange;
selectedRepositoryIdentities?: string[];
}) => (
<div data-testid="remote-repo-chip" data-url={row.url}>
<div
data-testid={REMOTE_REPO_CHIP_TEST_ID}
data-url={row.url}
{...{ [SELECTED_IDENTITIES_ATTRIBUTE]: selectedRepositoryIdentities.join(",") }}
>
<span data-testid="remote-repo-chip-url">{row.url}</span>
<button type="button" data-testid="remote-chip-remove" onClick={onRemove}>
x
@@ -48,7 +59,7 @@ vi.mock("./task-create-dialog-remote-repo-chip", () => ({
type="button"
data-testid="remote-chip-fire-picker"
onClick={() =>
onURLChange("https://github.com/acme/site", "picker", {
onURLChange(PICKER_URL, "picker", {
provider: "github",
fullName: "acme/site",
defaultBranch: "trunk",
@@ -106,7 +117,111 @@ function renderInProvider(ui: Parameters<typeof render>[0]) {
return render(<TooltipProvider>{ui}</TooltipProvider>);
}
describe("RemoteRepoChipsRow", () => {
describe("RemoteRepoChipsRow identity tracking", () => {
it("passes another row's provider/id identity to the current chip", () => {
const fs = makeFs({
remoteRepos: [
{
key: "remote-0",
url: PICKER_URL,
branch: "main",
source: "picker",
provider: "github",
providerRepoId: "site-id",
},
{ key: "remote-1", url: "", branch: "", source: "paste" },
],
});
renderInProvider(
<RemoteRepoChipsRow fs={fs} onUpdateRow={vi.fn()} onAddRow={vi.fn()} onRemoveRow={vi.fn()} />,
);
expect(
screen
.getAllByTestId(REMOTE_REPO_CHIP_TEST_ID)[1]
?.getAttribute(SELECTED_IDENTITIES_ATTRIBUTE),
).toBe("github:id:site-id");
});
it("excludes the current row and clears another row's identity after it changes or is removed", () => {
const initial = makeFs({
remoteRepos: [
{
key: "remote-0",
url: PICKER_URL,
branch: "main",
source: "picker",
provider: "github",
providerRepoId: "site-id",
},
{
key: "remote-1",
url: "https://github.com/acme/docs",
branch: "main",
source: "picker",
provider: "github",
providerRepoId: "docs-id",
},
],
});
const { rerender } = renderInProvider(
<RemoteRepoChipsRow
fs={initial}
onUpdateRow={vi.fn()}
onAddRow={vi.fn()}
onRemoveRow={vi.fn()}
/>,
);
expect(
screen
.getAllByTestId(REMOTE_REPO_CHIP_TEST_ID)[0]
?.getAttribute(SELECTED_IDENTITIES_ATTRIBUTE),
).toBe("github:id:docs-id");
expect(
screen
.getAllByTestId(REMOTE_REPO_CHIP_TEST_ID)[1]
?.getAttribute(SELECTED_IDENTITIES_ATTRIBUTE),
).toBe("github:id:site-id");
rerender(
<TooltipProvider>
<RemoteRepoChipsRow
fs={makeFs({
remoteRepos: [
initial.remoteRepos[0]!,
{ ...initial.remoteRepos[1]!, providerRepoId: "other-id" },
],
})}
onUpdateRow={vi.fn()}
onAddRow={vi.fn()}
onRemoveRow={vi.fn()}
/>
</TooltipProvider>,
);
expect(
screen
.getAllByTestId(REMOTE_REPO_CHIP_TEST_ID)[0]
?.getAttribute(SELECTED_IDENTITIES_ATTRIBUTE),
).toBe("github:id:other-id");
rerender(
<TooltipProvider>
<RemoteRepoChipsRow
fs={makeFs({ remoteRepos: [initial.remoteRepos[0]!] })}
onUpdateRow={vi.fn()}
onAddRow={vi.fn()}
onRemoveRow={vi.fn()}
/>
</TooltipProvider>,
);
expect(
screen.getByTestId(REMOTE_REPO_CHIP_TEST_ID).getAttribute(SELECTED_IDENTITIES_ATTRIBUTE),
).toBe("");
});
});
describe("RemoteRepoChipsRow controls", () => {
it("renders one chip per row in fs.remoteRepos", () => {
const fs = makeFs({
remoteRepos: [
@@ -117,7 +232,7 @@ describe("RemoteRepoChipsRow", () => {
renderInProvider(
<RemoteRepoChipsRow fs={fs} onUpdateRow={vi.fn()} onAddRow={vi.fn()} onRemoveRow={vi.fn()} />,
);
expect(screen.getAllByTestId("remote-repo-chip")).toHaveLength(2);
expect(screen.getAllByTestId(REMOTE_REPO_CHIP_TEST_ID)).toHaveLength(2);
});
it("renders a placeholder chip when remoteRepos is empty", () => {
@@ -199,7 +314,7 @@ describe("RemoteRepoChipsRow — onURLChange wiring", () => {
);
fireEvent.click(screen.getByTestId("remote-chip-fire-picker"));
expect(onUpdateRow).toHaveBeenCalledWith("remote-0", {
url: "https://github.com/acme/site",
url: PICKER_URL,
source: "picker",
provider: "github",
fullName: "acme/site",
@@ -7,6 +7,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import type { DialogFormState, TaskRemoteRepoRow } from "@/components/task-create-dialog-types";
import {
RemoteRepoChip,
selectedRemoteRepositoryIdentity,
type RemoteRepoChipProps,
} from "@/components/task-create-dialog-remote-repo-chip";
import { useRemoteRepositories } from "@/hooks/domains/integrations/use-remote-repositories";
@@ -66,19 +67,26 @@ export function RemoteRepoChipsRow({
const rows = fs.remoteRepos;
return (
<div className="flex min-h-9 flex-wrap items-center gap-2" data-testid="remote-repo-chips-row">
{rows.map((row) => (
<RemoteRepoChip
key={row.key}
row={row}
branches={fs.branchesByUrl.branches(row.url)}
branchesLoading={fs.branchesByUrl.loading(row.url)}
prInfo={fs.prInfoByUrl.info(row.url)}
accessibleRepos={accessibleRepos}
onURLChange={makeURLChange(onUpdateRow, row.key)}
onBranchChange={(branch) => onUpdateRow(row.key, { branch })}
onRemove={() => onRemoveRow(row.key)}
/>
))}
{rows.map((row) => {
const selectedRepositoryIdentities = rows
.filter((otherRow) => otherRow.key !== row.key)
.map(selectedRemoteRepositoryIdentity)
.filter((identity): identity is string => Boolean(identity));
return (
<RemoteRepoChip
key={row.key}
row={row}
branches={fs.branchesByUrl.branches(row.url)}
branchesLoading={fs.branchesByUrl.loading(row.url)}
prInfo={fs.prInfoByUrl.info(row.url)}
accessibleRepos={accessibleRepos}
selectedRepositoryIdentities={selectedRepositoryIdentities}
onURLChange={makeURLChange(onUpdateRow, row.key)}
onBranchChange={(branch) => onUpdateRow(row.key, { branch })}
onRemove={() => onRemoveRow(row.key)}
/>
);
})}
<AddRowButton onAddRow={onAddRow} />
</div>
);
@@ -0,0 +1,81 @@
import { describe, expect, it } from "vitest";
import type { TaskRemoteRepoRow } from "./task-create-dialog-types";
import {
remoteRepositoryMatchesSelection,
selectedRemoteRepositoryIdentity,
} from "./task-create-dialog-remote-repo-identity";
const GITHUB_REPOSITORY_URL = "https://github.com/acme/site";
function row(overrides: Partial<TaskRemoteRepoRow> = {}): TaskRemoteRepoRow {
return { key: "remote-0", url: "", branch: "", source: "paste", ...overrides };
}
describe("Remote repository identities", () => {
it("prefers provider-qualified IDs when metadata is complete", () => {
expect(
selectedRemoteRepositoryIdentity(
row({
url: "https://github.com/another/repository",
source: "picker",
provider: "github",
providerRepoId: "acme/site",
}),
),
).toBe("github:id:acme/site");
});
it.each([
["GitHub SSH", "git@github.com:acme/site.git", GITHUB_REPOSITORY_URL],
[
"www GitHub tree",
"https://www.github.com/acme/site/tree/feature/docs",
GITHUB_REPOSITORY_URL,
],
["GitHub pull request", "https://github.com/acme/site/pull/42/files", GITHUB_REPOSITORY_URL],
["GitHub blob", "https://github.com/acme/site/blob/main/src/index.ts", GITHUB_REPOSITORY_URL],
["GitLab SSH", "git@gitlab.com:acme/site.git", "https://gitlab.com/acme/site"],
[
"Azure DevOps SSH",
"git@ssh.dev.azure.com:v3/acme/Platform/api",
"https://dev.azure.com/acme/Platform/_git/api",
],
])("equates %s fallback URLs with their picker repository", (_label, fallback, picker) => {
expect(selectedRemoteRepositoryIdentity(row({ url: fallback }))).toBe(
selectedRemoteRepositoryIdentity(row({ url: picker })),
);
});
it.each([
["HTTPS", "https://github.com/AcMe/SiTe"],
["SSH", "git@github.com:AcMe/SiTe.git"],
])("normalizes GitHub %s owner and repository case", (_transport, url) => {
expect(selectedRemoteRepositoryIdentity(row({ url }))).toBe(
selectedRemoteRepositoryIdentity(row({ url: GITHUB_REPOSITORY_URL })),
);
});
it("keeps distinct fallback repositories separate", () => {
expect(selectedRemoteRepositoryIdentity(row({ url: GITHUB_REPOSITORY_URL }))).not.toBe(
selectedRemoteRepositoryIdentity(row({ url: "https://github.com/acme/docs" })),
);
});
it("does not match an identical raw provider ID from a different provider", () => {
expect(
remoteRepositoryMatchesSelection(
{
provider: "gitlab",
id: "acme/site",
owner: "acme",
name: "site",
fullName: "acme/site",
url: "https://gitlab.com/acme/site",
defaultBranch: "main",
private: false,
},
"github:id:acme/site",
),
).toBe(false);
});
});
@@ -0,0 +1,82 @@
import type { RemoteRepository } from "@/hooks/domains/integrations/use-remote-repositories";
import type { TaskRemoteRepoRow } from "@/components/task-create-dialog-types";
export function selectedRemoteRepositoryIdentity(row: TaskRemoteRepoRow): string | undefined {
if (row.provider && row.providerRepoId) return `${row.provider}:id:${row.providerRepoId}`;
const url = normalizeRemoteRepositoryURL(row.url);
return url ? `url:${url}` : undefined;
}
export function remoteRepositoryMatchesSelection(
repo: RemoteRepository,
selectedIdentity: string,
): boolean {
if (selectedIdentity === `${repo.provider}:id:${repo.id}`) return true;
const normalizedURL = normalizeRemoteRepositoryURL(repo.url);
return Boolean(normalizedURL && selectedIdentity === `url:${normalizedURL}`);
}
export function normalizeRemoteRepositoryURL(value: string): string | undefined {
const trimmed = value.trim();
if (!trimmed) return undefined;
const sshIdentity = remoteSSHRepositoryIdentity(trimmed);
if (sshIdentity) return sshIdentity;
const candidate = /^[a-z][a-z\d+.-]*:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`;
try {
const url = new URL(candidate);
const providerIdentity = remoteHTTPSRepositoryIdentity(url);
if (providerIdentity) return providerIdentity;
const path = url.pathname.replace(/\/+$/, "").replace(/\.git$/i, "");
return `${url.protocol.toLowerCase()}//${url.hostname.toLowerCase()}${path}`;
} catch {
return trimmed
.replace(/\/+$/, "")
.replace(/\.git$/i, "")
.toLowerCase();
}
}
function remoteSSHRepositoryIdentity(value: string): string | undefined {
const github = value.match(/^git@github\.com:([^/\s]+)\/([^/\s]+?)(?:\.git)?\/?$/i);
if (github) return githubRepositoryIdentity(github[1], github[2]);
const gitlab = value.match(/^git@gitlab\.com:([^\s]+?)(?:\.git)?\/?$/i);
if (gitlab) return `gitlab:${gitlab[1]}`;
const azure = value.match(
/^git@ssh\.dev\.azure\.com:v3\/([^/\s]+)\/([^/\s]+)\/([^/\s]+?)(?:\.git)?\/?$/i,
);
return azure ? `azure_devops:${azure[1]}/${azure[2]}/${azure[3]}` : undefined;
}
function remoteHTTPSRepositoryIdentity(url: URL): string | undefined {
const segments = url.pathname.split("/").filter(Boolean);
switch (url.hostname.toLowerCase()) {
case "github.com":
case "www.github.com":
return segments.length >= 2
? githubRepositoryIdentity(segments[0], stripGitSuffix(segments[1]))
: undefined;
case "gitlab.com": {
const separatorIndex = segments.indexOf("-");
const repositorySegments = separatorIndex >= 0 ? segments.slice(0, separatorIndex) : segments;
return repositorySegments.length >= 2
? `gitlab:${repositorySegments.map(stripGitSuffix).join("/")}`
: undefined;
}
case "dev.azure.com":
return segments.length >= 4 && segments[2]?.toLowerCase() === "_git"
? `azure_devops:${segments[0]}/${segments[1]}/${stripGitSuffix(segments[3])}`
: undefined;
default:
return undefined;
}
}
function githubRepositoryIdentity(owner: string, repository: string): string {
return `github:${owner.toLowerCase()}/${repository.toLowerCase()}`;
}
function stripGitSuffix(value: string): string {
return value.replace(/\.git$/i, "");
}
@@ -1,5 +1,5 @@
import { describe, it, expect, vi, afterEach } from "vitest";
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
import { render, screen, fireEvent, cleanup, within } from "@testing-library/react";
import type { Branch, Repository } from "@/lib/types/http";
import type { DialogFormState, TaskRepoRow } from "./task-create-dialog-types";
import { TooltipProvider } from "@kandev/ui/tooltip";
@@ -26,6 +26,7 @@ vi.mock("@/hooks/domains/workspace/use-repository-branches", () => ({
// branching logic (workspace chips vs. remote chips vs. folder picker).
vi.mock("./task-create-dialog-remote-repo-chip", () => ({
RemoteRepoChip: () => <div data-testid="remote-repo-chip" />,
selectedRemoteRepositoryIdentity: () => null,
}));
import { RepoChipsRow } from "./task-create-dialog-repo-chips";
@@ -36,6 +37,8 @@ afterEach(cleanup);
const REPO_FRONT_ID = "repo-front";
const REPO_BACK_ID = "repo-back";
const REPO_CHIP_TRIGGER = "repo-chip-trigger";
const ALREADY_ADDED_MARKER = "already-added-repository-marker";
const DISCOVERED_REPO_PATH = "/home/me/projects/local-project";
function makeRepo(id: string, name: string): Repository {
return {
@@ -290,7 +293,7 @@ describe("RepoChipsRow", () => {
fs={makeFs({
repositories: [row({ key: "r0" })],
discoveredRepositories: [
{ path: "/home/me/projects/local-project", name: "local-project" },
{ path: DISCOVERED_REPO_PATH, name: "local-project" },
// Same path as a workspace repo — should NOT appear (dedup by path).
{ path: "/repos/frontend", name: "frontend-dup" },
] as unknown as DialogFormState["discoveredRepositories"],
@@ -319,7 +322,7 @@ describe("RepoChipsRow", () => {
fs={makeFs({
repositories: [row({ key: "r0" })],
discoveredRepositories: [
{ path: "/home/me/projects/local-project", name: "local-project" },
{ path: DISCOVERED_REPO_PATH, name: "local-project" },
] as unknown as DialogFormState["discoveredRepositories"],
})}
repositories={[]}
@@ -331,7 +334,7 @@ describe("RepoChipsRow", () => {
);
fireEvent.click(screen.getByTestId(REPO_CHIP_TRIGGER));
fireEvent.click(screen.getByText("~/projects/local-project"));
expect(onRowRepositoryChange).toHaveBeenCalledWith("r0", "/home/me/projects/local-project");
expect(onRowRepositoryChange).toHaveBeenCalledWith("r0", DISCOVERED_REPO_PATH);
});
// Regression: discovered (path-keyed) rows used to call the branch loader
@@ -405,29 +408,38 @@ describe("RepoChipsRow", () => {
});
});
describe("WorkspaceRepoChips", () => {
const repositories = [makeRepo(REPO_FRONT_ID, "frontend"), makeRepo(REPO_BACK_ID, "backend")];
const rows = [
row({ key: "r0", repositoryId: REPO_FRONT_ID, branch: "main" }),
row({ key: "r1", branch: "develop" }),
];
const workspaceRepositories = [
makeRepo(REPO_FRONT_ID, "frontend"),
makeRepo(REPO_BACK_ID, "backend"),
];
const workspaceRows = [
row({ key: "r0", repositoryId: REPO_FRONT_ID, branch: "main" }),
row({ key: "r1", branch: "develop" }),
];
function renderWorkspaceChips(allowDuplicateRepositories: boolean) {
return renderInProvider(
<WorkspaceRepoChips
rows={rows}
repositories={repositories}
workspaceId="ws-1"
canAddMore
allowDuplicateRepositories={allowDuplicateRepositories}
onAdd={vi.fn()}
onRemove={vi.fn()}
onRowRepositoryChange={NOOP}
onRowBranchChange={NOOP}
/>,
);
}
function renderWorkspaceChips(
allowDuplicateRepositories: boolean,
chipRows: TaskRepoRow[] = workspaceRows,
discoveredRepositories: DialogFormState["discoveredRepositories"] = [],
onRowRepositoryChange = NOOP,
) {
return renderInProvider(
<WorkspaceRepoChips
rows={chipRows}
repositories={workspaceRepositories}
discoveredRepositories={discoveredRepositories}
workspaceId="ws-1"
canAddMore
allowDuplicateRepositories={allowDuplicateRepositories}
onAdd={vi.fn()}
onRemove={vi.fn()}
onRowRepositoryChange={onRowRepositoryChange}
onRowBranchChange={NOOP}
/>,
);
}
describe("WorkspaceRepoChips duplicate policy", () => {
it("excludes repositories already selected by another quick-chat row", () => {
renderWorkspaceChips(false);
@@ -446,3 +458,149 @@ describe("WorkspaceRepoChips", () => {
expect(screen.getByRole("option", { name: /^backend/ })).toBeTruthy();
});
});
describe("WorkspaceRepoChips workspace markers", () => {
it("marks another task row's workspace repository while keeping it selectable", () => {
const onRowRepositoryChange = vi.fn();
renderWorkspaceChips(true, workspaceRows, [], onRowRepositoryChange);
fireEvent.click(screen.getAllByTestId(REPO_CHIP_TRIGGER)[1]);
const selectedElsewhere = screen.getByRole("option", { name: /^frontend/ });
expect(selectedElsewhere).toBeTruthy();
const marker = within(selectedElsewhere).getByTestId(ALREADY_ADDED_MARKER);
expect(marker.getAttribute("aria-label")).toBe("Already added");
expect(marker.classList).toContain("text-primary");
fireEvent.click(selectedElsewhere);
expect(onRowRepositoryChange).toHaveBeenCalledWith("r1", REPO_FRONT_ID);
});
it("does not mark the only selected workspace repository when its row is reopened", () => {
renderWorkspaceChips(true, [row({ key: "r0", repositoryId: REPO_FRONT_ID })]);
fireEvent.click(screen.getByTestId(REPO_CHIP_TRIGGER));
fireEvent.keyDown(document, { key: "Escape" });
expect(screen.queryByRole("option", { name: /^frontend/ })).toBeNull();
fireEvent.click(screen.getByTestId(REPO_CHIP_TRIGGER));
expect(
within(screen.getByRole("option", { name: /^frontend/ })).queryByTestId(ALREADY_ADDED_MARKER),
).toBeNull();
});
it("clears a workspace marker when the selecting sibling changes or is removed", () => {
const { rerender } = renderWorkspaceChips(true, [
row({ key: "r0", repositoryId: REPO_FRONT_ID }),
row({ key: "r1" }),
]);
fireEvent.click(screen.getAllByTestId(REPO_CHIP_TRIGGER)[1]);
expect(
within(screen.getByRole("option", { name: /^frontend/ })).getByTestId(ALREADY_ADDED_MARKER),
).toBeTruthy();
rerender(
<TooltipProvider>
<WorkspaceRepoChips
rows={[row({ key: "r0", repositoryId: REPO_BACK_ID }), row({ key: "r1" })]}
repositories={workspaceRepositories}
workspaceId="ws-1"
canAddMore
onAdd={vi.fn()}
onRemove={vi.fn()}
onRowRepositoryChange={NOOP}
onRowBranchChange={NOOP}
/>
</TooltipProvider>,
);
expect(
within(screen.getByRole("option", { name: /^frontend/ })).queryByTestId(ALREADY_ADDED_MARKER),
).toBeNull();
expect(
within(screen.getByRole("option", { name: /^backend/ })).getByTestId(ALREADY_ADDED_MARKER),
).toBeTruthy();
rerender(
<TooltipProvider>
<WorkspaceRepoChips
rows={[row({ key: "r1" })]}
repositories={workspaceRepositories}
workspaceId="ws-1"
canAddMore
onAdd={vi.fn()}
onRemove={vi.fn()}
onRowRepositoryChange={NOOP}
onRowBranchChange={NOOP}
/>
</TooltipProvider>,
);
expect(
within(screen.getByRole("option", { name: /^backend/ })).queryByTestId(ALREADY_ADDED_MARKER),
).toBeNull();
});
});
describe("WorkspaceRepoChips discovered markers", () => {
it("marks normalized discovered paths selected by another task row and clears on rerender", () => {
const discoveredRepositories = [
{ path: DISCOVERED_REPO_PATH, name: "local-project" },
] as unknown as DialogFormState["discoveredRepositories"];
const { rerender } = renderWorkspaceChips(
true,
[row({ key: "r0", localPath: `${DISCOVERED_REPO_PATH}/` }), row({ key: "r1" })],
discoveredRepositories,
);
fireEvent.click(screen.getAllByTestId(REPO_CHIP_TRIGGER)[1]);
expect(
within(screen.getByRole("option", { name: /^local-project/ })).getByTestId(
ALREADY_ADDED_MARKER,
),
).toBeTruthy();
rerender(
<TooltipProvider>
<WorkspaceRepoChips
rows={[
row({ key: "r0", localPath: "/home/me/projects/another-project" }),
row({ key: "r1" }),
]}
repositories={workspaceRepositories}
discoveredRepositories={discoveredRepositories}
workspaceId="ws-1"
canAddMore
onAdd={vi.fn()}
onRemove={vi.fn()}
onRowRepositoryChange={NOOP}
onRowBranchChange={NOOP}
/>
</TooltipProvider>,
);
expect(
within(screen.getByRole("option", { name: /^local-project/ })).queryByTestId(
ALREADY_ADDED_MARKER,
),
).toBeNull();
rerender(
<TooltipProvider>
<WorkspaceRepoChips
rows={[row({ key: "r1" })]}
repositories={workspaceRepositories}
discoveredRepositories={discoveredRepositories}
workspaceId="ws-1"
canAddMore
onAdd={vi.fn()}
onRemove={vi.fn()}
onRowRepositoryChange={NOOP}
onRowBranchChange={NOOP}
/>
</TooltipProvider>,
);
expect(
within(screen.getByRole("option", { name: /^local-project/ })).queryByTestId(
ALREADY_ADDED_MARKER,
),
).toBeNull();
});
});
@@ -83,11 +83,9 @@ export function RepoChipsRow({
// 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.
// Multi-branch support keeps repository options selectable across rows. The
// picker marks selections already made elsewhere so users can intentionally
// choose the same repository for another branch without doing so by mistake.
const hasDiscovered = fs.discoveredRepositories.length > 0;
const canAddMore = repositories.length > 0 || hasDiscovered;
const addHint = computeAddHint(canAddMore, repositories.length);
@@ -1,7 +1,7 @@
"use client";
import { useMemo } from "react";
import { IconPlus, IconX, IconCode, IconGitBranch } from "@tabler/icons-react";
import { IconPlus, IconX, IconCode, IconGitBranch, IconCheck } from "@tabler/icons-react";
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";
@@ -81,9 +81,10 @@ export function WorkspaceRepoChips({
workspaceId={workspaceId}
repositories={repositories}
discoveredRepositories={discoveredRepositories ?? []}
// Task creation allows the same repository on different branches;
// quick chat excludes a repository as soon as another row uses it.
// Task creation marks other rows' picks but keeps every option
// selectable; quick chat excludes a repository once another row uses it.
excludedRepoIds={collectExcludedRepoIds(rows, row, allowDuplicateRepositories)}
selectedElsewhere={collectSelectedRepoIdentities(rows, 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
@@ -137,8 +138,8 @@ export function WorkspaceRepoChips({
* Returns the repo ids/paths that should be hidden from `currentRow` based on
* the caller's repository-duplication mode.
*
* When duplicates are allowed, only an exact (repo, branch) pairing is
* excluded, so task creation can target multiple branches from one repo.
* When duplicates are allowed, task creation keeps every repository available
* so a user can intentionally pick the same repository for another branch.
* When duplicates are disabled, quick chat excludes the entire repository
* after another row selects it, regardless of branch.
*
@@ -151,16 +152,35 @@ function collectExcludedRepoIds(
currentRow: TaskRepoRow,
allowDuplicateRepositories: boolean,
): Set<string> {
if (allowDuplicateRepositories) return new Set();
const ids = new Set<string>();
for (const r of rows) {
if (r.key === currentRow.key) continue;
if (allowDuplicateRepositories && (!r.branch || r.branch !== currentRow.branch)) continue;
if (r.repositoryId) ids.add(r.repositoryId);
if (r.localPath) ids.add(r.localPath);
}
return ids;
}
function collectSelectedRepoIdentities(rows: TaskRepoRow[], currentRow: TaskRepoRow): Set<string> {
const identities = new Set<string>();
for (const row of rows) {
if (row.key === currentRow.key) continue;
if (row.repositoryId) identities.add(repoIdIdentity(row.repositoryId));
if (row.localPath) identities.add(repoPathIdentity(row.localPath));
}
return identities;
}
function repoIdIdentity(id: string): string {
return `id:${id}`;
}
function repoPathIdentity(path: string): string {
return `path:${normalizeRepoPath(path)}`;
}
type RepoChipProps = {
row: TaskRepoRow;
/** Required for path-based branch loading on discovered rows. */
@@ -169,6 +189,8 @@ type RepoChipProps = {
discoveredRepositories: LocalRepository[];
/** Repo IDs/paths to filter out of the dropdown (already in use elsewhere). */
excludedRepoIds: Set<string>;
/** Repository identities selected in another row, rendered as a marker. */
selectedElsewhere: Set<string>;
/**
* Lock the branch pill regardless of branch availability. Used for the
* local executor where the user's actual checkout dictates the branch
@@ -216,6 +238,7 @@ function useRepoChipData({
repositories,
discoveredRepositories,
excludedRepoIds,
selectedElsewhere,
onBranchChange,
preferredDefaultBranch,
preferredDefaultBranchLoading,
@@ -228,6 +251,7 @@ function useRepoChipData({
| "repositories"
| "discoveredRepositories"
| "excludedRepoIds"
| "selectedElsewhere"
| "onBranchChange"
| "preferredDefaultBranch"
| "preferredDefaultBranchLoading"
@@ -287,16 +311,22 @@ function useRepoChipData({
keywords: [r.name, r.local_path, formatUserHomePath(r.local_path)].filter(
(s): s is string => !!s,
),
renderLabel: () => renderWorkspaceRepoOption(r),
renderLabel: () =>
renderWorkspaceRepoOption(
r,
selectedElsewhere.has(repoIdIdentity(r.id)) ||
(!!r.local_path && selectedElsewhere.has(repoPathIdentity(r.local_path))),
),
})),
...filteredDiscovered.map((r) => ({
value: r.path,
label: leafSegment(r.path),
keywords: [r.path, formatUserHomePath(r.path)],
renderLabel: () => renderDiscoveredRepoOption(r.path),
renderLabel: () =>
renderDiscoveredRepoOption(r.path, selectedElsewhere.has(repoPathIdentity(r.path))),
})),
],
[filteredRepos, filteredDiscovered],
[filteredRepos, filteredDiscovered, selectedElsewhere],
);
const branchOptions: PillOption[] = useMemo(
() => sortBranches(branches).map(branchToOption),
@@ -324,6 +354,7 @@ function RepoChip({
repositories,
discoveredRepositories,
excludedRepoIds,
selectedElsewhere,
branchLocked,
preferredDefaultBranch,
preferredDefaultBranchLoading,
@@ -340,6 +371,7 @@ function RepoChip({
repositories,
discoveredRepositories,
excludedRepoIds,
selectedElsewhere,
onBranchChange,
preferredDefaultBranch,
preferredDefaultBranchLoading,
@@ -351,14 +383,13 @@ function RepoChip({
repositories,
discoveredRepositories,
);
const branchValue = preferredDefaultBranchLoading ? "" : row.branch;
const hasRepo = !!(row.repositoryId || row.localPath);
const branchValue = preferredDefaultBranchLoading ? "" : row.branch;
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"
@@ -401,41 +432,53 @@ function RepoChip({
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>
<RepoChipRemoveButton onRemove={onRemove} />
</span>
);
}
function RepoChipRemoveButton({ onRemove }: { onRemove: () => void }) {
return (
<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>
);
}
function normalizeRepoPath(path: string): string {
return path.replace(/\\/g, "/").replace(/\/+$/g, "");
}
function renderWorkspaceRepoOption(repo: Repository) {
function renderWorkspaceRepoOption(repo: Repository, alreadyAdded: boolean) {
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
className="flex min-w-0 flex-1 items-center gap-2 overflow-hidden"
title={display || repo.name}
>
<span className="flex min-w-0 flex-1 flex-col overflow-hidden">
<span className="truncate">{repo.name}</span>
{display ? (
<span className="truncate text-[11px] text-muted-foreground">{display}</span>
) : null}
</span>
{alreadyAdded ? <AlreadyAddedMarker /> : null}
</span>
);
}
function renderDiscoveredRepoOption(path: string) {
function renderDiscoveredRepoOption(path: string, alreadyAdded: boolean) {
const display = formatUserHomePath(path);
return (
<span className="flex min-w-0 flex-1 items-center gap-2 overflow-hidden" title={display}>
@@ -446,6 +489,20 @@ function renderDiscoveredRepoOption(path: string) {
<Badge variant="outline" className="text-[10px] text-muted-foreground shrink-0">
on disk
</Badge>
{alreadyAdded ? <AlreadyAddedMarker /> : null}
</span>
);
}
function AlreadyAddedMarker() {
return (
<span
role="img"
aria-label="Already added"
data-testid="already-added-repository-marker"
className="shrink-0 text-primary"
>
<IconCheck aria-hidden="true" className="h-4 w-4" />
</span>
);
}
@@ -122,4 +122,42 @@ test.describe("Create task Remote repo picker on mobile", () => {
);
expect(hasHorizontalOverflow).toBe(false);
});
test("marks an already selected provider repository without disabling its touch selection", async ({
apiClient,
testPage,
}) => {
await apiClient.mockGitHubAddRepos("mock-user", [
{ full_name: "mock-user/duplicate", owner: "mock-user", name: "duplicate", private: false },
]);
await openRemotePicker(testPage);
const firstOption = testPage
.getByTestId("remote-repo-option")
.filter({ hasText: "mock-user/duplicate" });
await expect(firstOption).toBeVisible({ timeout: 10_000 });
await firstOption.tap();
await testPage.getByTestId("remote-add-row").tap();
await testPage.getByTestId("remote-repo-chip-trigger").nth(1).tap();
const duplicateOption = testPage
.getByTestId("remote-repo-option")
.filter({ hasText: "mock-user/duplicate" });
await expect(duplicateOption.getByTestId("already-added-repository-marker")).toBeVisible();
const [optionBox, viewport] = await Promise.all([
duplicateOption.boundingBox(),
testPage.viewportSize(),
]);
expect(optionBox).not.toBeNull();
expect(viewport).not.toBeNull();
expect(optionBox!.x).toBeGreaterThanOrEqual(0);
expect(optionBox!.x + optionBox!.width).toBeLessThanOrEqual(viewport!.width);
expect(optionBox!.y + optionBox!.height).toBeLessThanOrEqual(viewport!.height);
await duplicateOption.tap();
await expect(testPage.getByTestId("remote-repo-chip-trigger").nth(1)).toContainText(
"mock-user/duplicate",
);
});
});
@@ -0,0 +1,28 @@
import { test, expect } from "../../fixtures/test-base";
import { useRegularMode } from "../../helpers/regular-mode";
import { MobileKanbanPage } from "../../pages/mobile-kanban-page";
useRegularMode();
test.describe("Create task workspace repository picker on mobile", () => {
test("marks another row's repository while keeping it selectable", async ({ testPage }) => {
const mobile = new MobileKanbanPage(testPage);
await mobile.goto();
await mobile.mobileFab.click();
const dialog = testPage.getByTestId("create-task-dialog");
await expect(dialog).toBeVisible();
const repositoryChips = dialog.getByTestId("repo-chip-trigger");
await expect(repositoryChips.first()).toContainText("E2E Repo");
await dialog.getByTestId("add-repository").click();
await expect(repositoryChips).toHaveCount(2);
await repositoryChips.nth(1).click();
const selectedElsewhere = testPage.getByRole("option", { name: /^E2E Repo/ });
await expect(selectedElsewhere).toBeVisible();
await expect(selectedElsewhere.getByTestId("already-added-repository-marker")).toBeVisible();
await selectedElsewhere.click();
await expect(repositoryChips.nth(1)).toContainText("E2E Repo");
});
});
@@ -0,0 +1,100 @@
---
spec: docs/specs/tasks/multi-branch/spec.md
created: 2026-07-22
status: completed
---
# Implementation Plan: Mark Selected Task Repositories
## Overview
Teach both task-creation repository pickers to distinguish repositories already chosen in another row without removing those options. This preserves intentional same-repository, different-branch tasks while making accidental duplicate selection obvious. Workspace/on-disk and Remote provider pickers can be implemented in parallel because they own separate components and tests.
## Backend
No backend, API, persistence, or validation changes. Existing multi-branch task contracts remain unchanged.
## Frontend
### Workspace and discovered repositories
- `apps/web/components/task-create-dialog-workspace-repo-chips.tsx`: derive the repository IDs and normalized local paths selected by rows other than the current row; keep those options available in task creation and render a compact accent-colored check with an accessible `Already added` label beside each matching option. Preserve quick chat's current hide-selected behavior through its explicit duplicate policy.
- `apps/web/components/task-create-dialog-repo-chips.tsx`: remove the stale comment that describes task creation as never filtering only because branch pairs differ, and document the marked-but-selectable behavior.
- Reuse the existing `Pill` option rendering. This changes option content only; the existing popover, responsive behavior, scroll owner, and touch targets remain intact.
### Remote provider repositories
- `apps/web/components/task-create-dialog-remote-repo-chips.tsx`: compute per-row repository identities selected by other Remote rows using provider/id metadata with normalized URL fallback, and pass them to each chip.
- `apps/web/components/task-create-dialog-remote-repo-chip.tsx`: mark matching provider options with the same compact accent-colored check while leaving them selectable. The current row's own repository is not marked unless another row also selects it.
### Mobile design contract
- Desktop outcome and mobile entry point: the existing task-creation dialog and its repository pill triggers continue to expose the same choices; the marker appears inside the same option row at both viewport sizes.
- Nearest shipped exemplar: the existing workspace `Pill` and Remote repository popover option rows remain the interaction and geometry baseline.
- Presentation and hierarchy: no new surface or navigation is introduced. The selector popover remains the single scroll owner, with the repository name primary and a compact check as the secondary duplicate signal.
- Shared behavior: identity derivation and marker state are viewport-independent. Existing mobile option rows retain their 44px touch target and viewport containment.
## Tests
- **What:** another workspace row's repository ID is marked, remains selectable, and the current row does not mark itself. **File:** `apps/web/components/task-create-dialog-repo-chips.test.tsx`. **How:** component tests opening each row's `Pill` options.
- **What:** discovered local paths use normalized identity and update when the other row changes or disappears. **File:** `apps/web/components/task-create-dialog-repo-chips.test.tsx`. **How:** component rerender tests with path-keyed rows.
- **What:** another Remote row's provider repository is marked by provider/id or normalized URL while remaining selectable, and the current row is excluded. **Files:** `apps/web/components/task-create-dialog-remote-repo-chip.test.tsx` and a focused row-level test beside `task-create-dialog-remote-repo-chips.tsx`. **How:** component tests with provider-backed repository fixtures.
## E2E Tests
- **Scenario:** a selected workspace repository is marked in the next selector on a phone viewport and remains selectable. **File:** `apps/web/e2e/tests/task/mobile-create-task-repository-selection.spec.ts`. **What to verify:** open the mobile create-task dialog, select a seeded repository, add a row, open the next picker, and assert the matching option exposes the accessible `Already added` check and can still be chosen.
- **Scenario:** a selected provider-backed Remote repository is marked in the next selector on a phone viewport. **File:** `apps/web/e2e/tests/task/mobile-create-task-remote-repo.spec.ts`. **What to verify:** select a mocked provider repository, add a Remote row, reopen the picker, assert the matching option exposes the accessible check, remains inside the viewport, and remains selectable.
## Implementation Waves
Wave 1 (parallel):
- [x] [task-01-workspace-repository-marker](task-01-workspace-repository-marker.md) — done
- [x] [task-02-remote-repository-marker](task-02-remote-repository-marker.md) — done
Wave 2:
- [x] [task-03-remote-identity-remediation](task-03-remote-identity-remediation.md) — done
- [x] [task-04-workspace-coverage-remediation](task-04-workspace-coverage-remediation.md) — done
Wave 3:
- [x] [task-05-remote-identity-review-fix](task-05-remote-identity-review-fix.md) — done
Wave 4:
- [x] [task-06-github-case-normalization](task-06-github-case-normalization.md) — done
Wave 5:
- [x] [task-07-lint-cleanup](task-07-lint-cleanup.md) — done
Wave 6:
- [x] [task-08-remote-popover-lint](task-08-remote-popover-lint.md) — done
Wave 7:
- QA integration and mobile behavior — QA worker, balanced model
- Code review — code-review worker, frontier model
- Full format/typecheck/test/lint verification — verify worker, cheap model
Wave 8:
- [x] [task-09-compact-selected-marker](task-09-compact-selected-marker.md) — done
## Verification
- `cd apps && pnpm --filter @kandev/web test -- components/task-create-dialog-repo-chips.test.tsx`
- `cd apps && pnpm --filter @kandev/web test -- components/task-create-dialog-remote-repo-chip.test.tsx components/task-create-dialog-remote-repo-chips.test.tsx`
- `cd apps && pnpm --filter @kandev/web e2e -- e2e/tests/task/mobile-create-task-repository-selection.spec.ts --project=mobile-chrome`
- `cd apps && pnpm --filter @kandev/web e2e -- e2e/tests/task/mobile-create-task-remote-repo.spec.ts --project=mobile-chrome`
- `make fmt`
- `make typecheck test lint`
## Risks
- Remote rows can originate from provider picks or pasted URLs; identity comparison must normalize URLs without conflating different providers or repositories.
- Supported HTTPS, SSH, and provider subresource URLs must canonicalize to the same repository identity as provider picker options.
- Marker state must exclude only the current row, or reopening a single selected row will incorrectly label its own value as a duplicate.
- Quick chat deliberately hides repositories already selected in another context row; the task-dialog marker must not regress that separate behavior.
@@ -0,0 +1,44 @@
---
id: "01-workspace-repository-marker"
title: "Mark selected workspace repositories"
status: done
wave: 1
depends_on: []
plan: "plan.md"
spec: "../../specs/tasks/multi-branch/spec.md"
---
# Task 01: Mark selected workspace repositories
## Acceptance
- A workspace or discovered-on-disk repository selected in another task row remains present and displays `Already added` in the current row's picker.
- A row's own selection is not marked unless another row also selects the same normalized repository identity; changing or removing the other row clears the marker on rerender.
- Quick chat continues to hide repositories selected by another context row, and marked task-dialog options remain selectable for intentional multi-branch use.
## Verification
- `cd apps && pnpm --filter @kandev/web test -- components/task-create-dialog-repo-chips.test.tsx`
- `cd apps && pnpm --filter @kandev/web e2e -- e2e/tests/task/mobile-create-task-repository-selection.spec.ts --project=mobile-chrome`
## Files likely touched
- `apps/web/components/task-create-dialog-workspace-repo-chips.tsx`
- `apps/web/components/task-create-dialog-repo-chips.tsx`
- `apps/web/components/task-create-dialog-repo-chips.test.tsx`
- `apps/web/e2e/tests/task/mobile-create-task-repository-selection.spec.ts`
- `docs/plans/mark-selected-task-repositories/task-01-workspace-repository-marker.md`
## Inputs
- `docs/specs/tasks/multi-branch/spec.md`, Frontend and Task-creation scenarios.
- `docs/plans/mark-selected-task-repositories/plan.md`, Workspace and discovered repositories plus Mobile design contract.
- Existing `WorkspaceRepoChips` duplicate policy used by quick chat.
## Dependencies
None.
## Output contract
Update only this task file's status, not `plan.md`. Report the behavior implemented, files changed, tests and E2E commands run, blockers, and remaining risks. Follow `apps/web/AGENTS.md`, `/tdd`, `/e2e`, and `/mobile-parity`; do not spawn subagents or broaden scope.
@@ -0,0 +1,45 @@
---
id: "02-remote-repository-marker"
title: "Mark selected Remote repositories"
status: done
wave: 1
depends_on: []
plan: "plan.md"
spec: "../../specs/tasks/multi-branch/spec.md"
---
# Task 02: Mark selected Remote repositories
## Acceptance
- A provider-backed repository selected in another Remote row remains present and displays `Already added` in the current row's provider picker.
- Matching prefers provider/id metadata and uses normalized repository URL fallback for pasted or incomplete row metadata without marking unrelated repositories.
- A row's own selection is not marked unless another row selects the same repository; marked options remain touch-accessible and selectable.
## Verification
- `cd apps && pnpm --filter @kandev/web test -- components/task-create-dialog-remote-repo-chip.test.tsx components/task-create-dialog-remote-repo-chips.test.tsx`
- `cd apps && pnpm --filter @kandev/web e2e -- e2e/tests/task/mobile-create-task-remote-repo.spec.ts --project=mobile-chrome`
## Files likely touched
- `apps/web/components/task-create-dialog-remote-repo-chips.tsx`
- `apps/web/components/task-create-dialog-remote-repo-chip.tsx`
- `apps/web/components/task-create-dialog-remote-repo-chip.test.tsx`
- `apps/web/components/task-create-dialog-remote-repo-chips.test.tsx`
- `apps/web/e2e/tests/task/mobile-create-task-remote-repo.spec.ts`
- `docs/plans/mark-selected-task-repositories/task-02-remote-repository-marker.md`
## Inputs
- `docs/specs/tasks/multi-branch/spec.md`, Frontend and Task-creation scenarios.
- `docs/plans/mark-selected-task-repositories/plan.md`, Remote provider repositories plus Mobile design contract.
- Existing `TaskRemoteRepoRow` picker metadata and `RemoteRepository` identity fields.
## Dependencies
None.
## Output contract
Update only this task file's status, not `plan.md`. Report the behavior implemented, files changed, tests and E2E commands run, blockers, and remaining risks. Follow `apps/web/AGENTS.md`, `/tdd`, `/e2e`, and `/mobile-parity`; do not spawn subagents or broaden scope.
@@ -0,0 +1,49 @@
---
id: "03-remote-identity-remediation"
title: "Canonicalize Remote repository identity"
status: done
wave: 2
depends_on: ["02-remote-repository-marker"]
plan: "plan.md"
spec: "../../specs/tasks/multi-branch/spec.md"
---
# Task 03: Canonicalize Remote repository identity
## Acceptance
- Supported HTTPS and SSH forms of the same GitHub, GitLab, or Azure DevOps repository derive the same fallback identity as the provider picker option, including GitHub issue/PR/tree/blob URLs collapsing to the repository root.
- Provider-qualified IDs do not collide across providers; current-row exclusion and live clearing after another row changes or is removed are covered by row-level regression tests.
- Marked Remote options remain selectable and all existing Remote marker/mobile tests stay green.
## Verification
- `cd apps && pnpm --filter @kandev/web test -- components/task-create-dialog-remote-repo-chip.test.tsx components/task-create-dialog-remote-repo-chips.test.tsx`
- `cd apps/web && pnpm e2e:run --project mobile-chrome tests/task/mobile-create-task-remote-repo.spec.ts`
## Files likely touched
- `apps/web/components/task-create-dialog-remote-repo-chip.tsx`
- `apps/web/components/task-create-dialog-remote-repo-chip.test.tsx`
- `apps/web/components/task-create-dialog-remote-repo-chips.test.tsx`
- `docs/plans/mark-selected-task-repositories/task-03-remote-identity-remediation.md`
## Inputs
- QA report: SSH and subresource URL fallback mismatch at `normalizeRemoteRepositoryURL`.
- Code-review blockers 1 and 2.
- Existing URL parsing patterns in `apps/web/lib/utils/github-repo-url.ts` and `apps/web/hooks/domains/github/use-branches-by-url.ts`.
## Dependencies
Task 02.
## Completion notes
- Canonical fallback identities now normalize GitHub, GitLab, and Azure DevOps HTTPS/SSH repository forms. GitHub issue, pull-request, tree, and blob links resolve to their repository root.
- Provider-qualified picker IDs remain the primary identity and cannot collide across providers. Row-level coverage proves current-row exclusion plus immediate identity clearing when a sibling changes or is removed.
- RED: the focused Remote unit command failed on six new SSH/subresource equivalence cases before the normalization change. GREEN: the same suite passes 40 tests; the assigned `mobile-chrome` Remote E2E passes all 3 scenarios.
## Output contract
Update only this task file's status, not `plan.md`. Report RED/GREEN evidence, files changed, exact tests and E2E results, blockers, and risks. Follow `apps/web/AGENTS.md`, `/tdd`, `/e2e`, and `/mobile-parity`; do not spawn subagents or broaden scope.
@@ -0,0 +1,48 @@
---
id: "04-workspace-coverage-remediation"
title: "Prove workspace marker exclusion"
status: done
wave: 2
depends_on: ["01-workspace-repository-marker"]
plan: "plan.md"
spec: "../../specs/tasks/multi-branch/spec.md"
---
# Task 04: Prove workspace marker exclusion
## Acceptance
- A component regression test reopens the only selected workspace row and proves its own option is not marked `Already added`.
- Rerender coverage proves a marker disappears when the selecting sibling changes or is removed, including normalized discovered-path identity where practical.
- Existing quick-chat hide and marked-but-selectable task-dialog tests remain green.
## Verification
- `cd apps && pnpm --filter @kandev/web test -- components/task-create-dialog-repo-chips.test.tsx`
## Files likely touched
- `apps/web/components/task-create-dialog-repo-chips.test.tsx`
- `docs/plans/mark-selected-task-repositories/task-04-workspace-coverage-remediation.md`
## Inputs
- Code-review blocker 3 and QA coverage report.
- Existing `WorkspaceRepoChips` component tests.
## Dependencies
Task 01.
## Output contract
Update only this task file's status, not `plan.md`. Report the scenarios covered, files changed, commands/results, blockers, and remaining gaps. Follow `apps/web/AGENTS.md` and `/tdd`; do not spawn subagents or broaden scope.
## Completion notes
- Added workspace-selector regressions for reopening the only selected row without a self-marker, and for clearing a workspace marker when the selecting sibling changes and when that sibling is removed.
- Extended the existing discovered-repository regression to preserve normalized trailing-slash identity, then clear the marker after a sibling changes paths and again after that sibling is removed.
- Preserved the existing quick-chat hide-selected and task-dialog marked-but-selectable coverage.
- Prove-It note: the prerequisite implementation was already integrated when this test packet began, so the behavioral regressions were green on their first runnable assertion. The initial test invocation did fail only because this suite lacks the Jest-DOM `toHaveAccessibleName` matcher; it was immediately replaced with a plain Vitest `textContent` assertion. No production code was changed.
- Verification: `cd apps && pnpm --filter @kandev/web test -- components/task-create-dialog-repo-chips.test.tsx` — passed (1 file, 20 tests).
- Blockers: none. Remaining gap: browser/mobile rendering remains owned by the separate E2E task.
@@ -0,0 +1,49 @@
---
id: "05-remote-identity-review-fix"
title: "Close Remote identity review gaps"
status: done
wave: 3
depends_on: ["03-remote-identity-remediation"]
plan: "plan.md"
spec: "../../specs/tasks/multi-branch/spec.md"
---
# Task 05: Close Remote identity review gaps
## Acceptance
- Accepted `www.github.com` repository and subresource URLs canonicalize to the same GitHub repository identity as picker options.
- The provider-collision regression uses an identical raw provider ID for two different providers and proves the option is not marked across providers.
- Pure Remote repository identity logic and its equivalence matrix live in a focused utility/test module, keeping `task-create-dialog-remote-repo-chip.test.tsx` within the configured 600-line limit while preserving rendered marker coverage.
## Verification
- `cd apps && pnpm --filter @kandev/web test -- components/task-create-dialog-remote-repo-identity.test.ts components/task-create-dialog-remote-repo-chip.test.tsx components/task-create-dialog-remote-repo-chips.test.tsx`
- `cd apps && pnpm --filter @kandev/web lint`
## Files likely touched
- `apps/web/components/task-create-dialog-remote-repo-identity.ts`
- `apps/web/components/task-create-dialog-remote-repo-identity.test.ts`
- `apps/web/components/task-create-dialog-remote-repo-chip.tsx`
- `apps/web/components/task-create-dialog-remote-repo-chip.test.tsx`
- `docs/plans/mark-selected-task-repositories/task-05-remote-identity-review-fix.md`
## Inputs
- Second-round code-review blockers for `www.github.com` canonicalization and identical-ID provider collision.
- Code-review max-lines suggestion for `task-create-dialog-remote-repo-chip.test.tsx`.
## Dependencies
Task 03.
## Completion notes
- Added `www.github.com` handling to the pure Remote repository identity helper and moved the HTTPS/SSH/subresource equivalence matrix out of the component test.
- The provider-collision regression now uses the same raw ID (`acme/site`) for GitHub and GitLab and proves the GitLab option remains unmarked.
- RED: `www.github.com` tree identity did not equal the GitHub picker identity. GREEN: focused unit coverage passes 41 tests and web lint passes with zero warnings.
## Output contract
Update only this task file's status, not `plan.md`. Report RED/GREEN evidence, files changed, exact tests/lint results, blockers, and risks. Follow `apps/web/AGENTS.md` and `/tdd`; do not spawn subagents or broaden scope.
@@ -0,0 +1,46 @@
---
id: "06-github-case-normalization"
title: "Normalize GitHub repository case"
status: done
wave: 4
depends_on: ["05-remote-identity-review-fix"]
plan: "plan.md"
spec: "../../specs/tasks/multi-branch/spec.md"
---
# Task 06: Normalize GitHub repository case
## Acceptance
- GitHub HTTPS and SSH identities normalize owner and repository segments case-insensitively, so case-variant URLs match the same provider picker repository.
- GitLab and Azure DevOps identity semantics remain unchanged.
- A focused case-variant regression fails before the production change and all Remote identity tests pass afterward.
## Verification
- `cd apps && pnpm --filter @kandev/web test -- components/task-create-dialog-remote-repo-identity.test.ts components/task-create-dialog-remote-repo-chip.test.tsx components/task-create-dialog-remote-repo-chips.test.tsx`
- `cd apps && pnpm --filter @kandev/web lint`
## Files likely touched
- `apps/web/components/task-create-dialog-remote-repo-identity.ts`
- `apps/web/components/task-create-dialog-remote-repo-identity.test.ts`
- `docs/plans/mark-selected-task-repositories/task-06-github-case-normalization.md`
## Inputs
- Final code-review finding at `task-create-dialog-remote-repo-identity.ts` GitHub SSH/HTTPS identity construction.
## Dependencies
Task 05.
## Completion notes
- GitHub HTTPS and SSH identities now both use the same lowercasing helper for owner and repository segments; GitLab and Azure DevOps parsing is unchanged.
- RED: mixed-case GitHub HTTPS and SSH URLs produced `url:github:AcMe/SiTe` rather than the picker identity. GREEN: focused Remote tests pass 43 tests.
- The required lint command was run but exits on seven existing/concurrent warnings in forbidden row-level and workspace files; Task 06's owned identity module and test produced no lint warning.
## Output contract
Update only this task file's status, not `plan.md`. Report RED/GREEN evidence, files changed, exact tests/lint results, blockers, and risks. Follow `apps/web/AGENTS.md` and `/tdd`; do not spawn subagents or broaden scope.
@@ -0,0 +1,41 @@
---
id: "07-lint-cleanup"
title: "Clear repository marker lint warnings"
status: done
wave: 5
depends_on: ["04-workspace-coverage-remediation", "06-github-case-normalization"]
plan: "plan.md"
spec: "../../specs/tasks/multi-branch/spec.md"
---
# Task 07: Clear repository marker lint warnings
## Acceptance
- `RepoChip` and the changed repository-marker test callbacks comply with the configured 100-line function limit through cohesive extraction/splitting without changing behavior or assertions.
- All task-owned duplicate-literal warnings are removed with local constants or clearer test helpers, without speculative abstraction.
- Focused repository-marker tests and the complete web lint command pass with zero warnings/errors.
## Verification
- `cd apps && pnpm --filter @kandev/web test -- components/task-create-dialog-remote-repo-identity.test.ts components/task-create-dialog-repo-chips.test.tsx components/task-create-dialog-remote-repo-chip.test.tsx components/task-create-dialog-remote-repo-chips.test.tsx`
- `cd apps && pnpm --filter @kandev/web lint`
## Files likely touched
- `apps/web/components/task-create-dialog-workspace-repo-chips.tsx`
- `apps/web/components/task-create-dialog-repo-chips.test.tsx`
- `apps/web/components/task-create-dialog-remote-repo-chips.test.tsx`
- `docs/plans/mark-selected-task-repositories/task-07-lint-cleanup.md`
## Inputs
- Final code-review lint finding: `RepoChip` at 101 lines and changed test callbacks at 169 and 144 lines, plus four duplicate-literal warnings in task-owned files.
## Dependencies
Tasks 04 and 06.
## Output contract
Update only this task file's status, not `plan.md`. Report files changed, exact lint warnings resolved, focused test/lint results, blockers, and risks. Follow `apps/web/AGENTS.md`; do not spawn subagents or broaden scope.
@@ -0,0 +1,45 @@
---
id: "08-remote-popover-lint"
title: "Reduce Remote popover function size"
status: done
wave: 6
depends_on: ["07-lint-cleanup"]
plan: "plan.md"
spec: "../../specs/tasks/multi-branch/spec.md"
---
# Task 08: Reduce Remote popover function size
## Acceptance
- `RemoteRepoPopoverContent` complies with the configured 100-line function limit after normal formatting through the smallest cohesive extraction.
- Repository search, URL validation/paste/blur/Enter behavior, provider tabs, option markers, and mobile rendering remain unchanged.
- Focused Remote tests and full web lint pass with zero warnings/errors.
## Verification
- `cd apps && pnpm --filter @kandev/web test -- components/task-create-dialog-remote-repo-identity.test.ts components/task-create-dialog-remote-repo-chip.test.tsx components/task-create-dialog-remote-repo-chips.test.tsx`
- `make fmt`
- `cd apps && pnpm --filter @kandev/web lint`
## Files likely touched
- `apps/web/components/task-create-dialog-remote-repo-chip.tsx`
- `docs/plans/mark-selected-task-repositories/task-08-remote-popover-lint.md`
## Inputs
- Final verifier lint failure: `RemoteRepoPopoverContent` is 102 lines after formatting, with a 100-line maximum.
## Dependencies
Task 07.
## Completion notes
- Extracted provider-tab selection and visible-repository derivation into `visibleProviderRepositories`, leaving search, validation, URL commit event handling, markers, and rendering in the popover component.
- Focused Remote tests pass 43 tests. `make fmt` and full web lint both complete successfully with zero warnings.
## Output contract
Update only this task file's status, not `plan.md`. Report the extraction, files changed, exact format/test/lint results, blockers, and risks. Follow `apps/web/AGENTS.md`; do not spawn subagents or broaden scope.
@@ -0,0 +1,23 @@
---
spec: docs/specs/tasks/multi-branch/spec.md
created: 2026-07-23
status: completed
---
# Task 09: Compact selected-repository marker
## Objective
Replace the space-consuming visible `Already added` badge with one compact accent-colored check in workspace/on-disk and Remote repository picker options, retaining an accessible `Already added` label.
## Acceptance
- A duplicate workspace, on-disk, or Remote option renders one accent-colored check rather than visible `Already added` text.
- The check is exposed to assistive technology as `Already added`; the option remains selectable for same-repository, different-branch tasks.
- The existing picker composition, scroll owner, and 44px mobile option-row target remain unchanged.
## Verification
- Focused web component tests for workspace and Remote picker options.
- Mobile task-creation E2E coverage for both picker variants.
- Fresh desktop and mobile screenshots of the marker state for PR #1881.
+10
View File
@@ -49,11 +49,20 @@ Workarounds (sibling tasks, manually managing two worktrees) lost shared context
- `TaskRepository.checkout_branch` was already on the http type.
- Worktrees are keyed by `worktree.id` in the Zustand store, so two worktrees with the same `repository_id` already coexist.
- Repo chips in chat-message renderers now key on `(repository_id, checkout_branch)` so multi-branch tasks render distinct chips instead of collapsing.
- In the task-creation dialog, every workspace, discovered-on-disk, and remote provider repository picker shows an accent-colored check (with an accessible `Already added` label) when another repository row already selects that repository. The current row does not mark its own selection.
- Marked options remain selectable so users can intentionally create a multi-branch task from one repository. Removing or changing the other row removes the marker immediately.
- Review surfaces expose one linked pull request at a time when a task has multiple PRs. A task-scoped selector defaults to the primary (oldest) PR, remembers an in-session override, and falls back to the primary PR when that override disappears.
- Selecting a PR changes the remote PR diff contribution while preserving the existing source precedence: uncommitted worktree changes, then cumulative committed changes, then the selected PR. PR-only views and PR timeline rows resolve the exact PR rather than the task primary.
- The selector is available on desktop, phone, and coarse-pointer tablet. Phone uses a touch-sized bottom-menu treatment inside the existing Review surface; switching keeps Review open and exposes selected-PR loading, empty, and retry states.
- Full "+ Branch" UI affordance and grouped repo > branch tabs are deferred — agents drive multi-branch via the MCP tool today.
### Task-creation scenarios
- **GIVEN** a task-creation repository row selects a workspace or discovered-on-disk repository, **WHEN** the user opens another repository selector, **THEN** that repository remains selectable and is visibly marked by a compact accent-colored check whose accessible label is `Already added`.
- **GIVEN** a task-creation Remote row selects a provider-backed repository, **WHEN** the user opens another Remote repository selector, **THEN** the same provider repository remains selectable and is visibly marked by the same compact accent-colored check.
- **GIVEN** a repository is marked because another row selects it, **WHEN** the user changes or removes that other row, **THEN** the marker disappears from the open or next-opened selector.
- **GIVEN** a row already selects a repository and no other row selects it, **WHEN** the user reopens that row's selector, **THEN** its current repository is not marked as a duplicate.
## Non-goals
- **Auto-stack PRs.** Multi-branch lets you open N PRs; it does not detect base/branch relationships and stack them. Users do that themselves.
@@ -77,6 +86,7 @@ Workarounds (sibling tasks, manually managing two worktrees) lost shared context
- `TestAddBranchToTask_HappyPath` — second branch appended after the fact lands as a new row.
- `TestAddBranchToTask_RejectsDuplicate` — re-adding the same `(repo, branch)` errors.
- `TestLaunchPreparedSession_MultiBranch_ReusesWorktreeIDsByBranchSlug` — a follow-on session for the same task reuses each existing branch worktree instead of preparing a new task directory.
- Task-creation component and mobile E2E coverage prove the accessible, compact selected-repository marker for workspace/on-disk and Remote provider selectors while preserving option selection.
- Web unit tests prove selected-PR default, override, task isolation, and removed-PR fallback behavior.
- Desktop and mobile Playwright tests prove a two-PR task can switch Review from the primary PR to a sibling PR without stale files, overflow, or closing the surface.