diff --git a/apps/web/components/task-create-dialog-helpers.multi-repo.test.ts b/apps/web/components/task-create-dialog-helpers.multi-repo.test.ts index ab599efba5..3d69fa3499 100644 --- a/apps/web/components/task-create-dialog-helpers.multi-repo.test.ts +++ b/apps/web/components/task-create-dialog-helpers.multi-repo.test.ts @@ -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, diff --git a/apps/web/components/task-create-dialog-helpers.ts b/apps/web/components/task-create-dialog-helpers.ts index da051d5806..aa5b89bf3c 100644 --- a/apps/web/components/task-create-dialog-helpers.ts +++ b/apps/web/components/task-create-dialog-helpers.ts @@ -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); diff --git a/apps/web/components/task-create-dialog-remote-repo-chip.test.tsx b/apps/web/components/task-create-dialog-remote-repo-chip.test.tsx index 4b80a921f8..e3aa0725b0 100644 --- a/apps/web/components/task-create-dialog-remote-repo-chip.test.tsx +++ b/apps/web/components/task-create-dialog-remote-repo-chip.test.tsx @@ -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 { + return { + provider: "github", + owner: "acme", + name: "site", + full_name: FULL_NAME, + default_branch: "main", + private: false, + ...overrides, + }; +} afterEach(() => { cleanup(); }); - function row(overrides: Partial = {}): TaskRemoteRepoRow { return { key: "remote-0", url: "", branch: "", source: "paste", ...overrides }; } - function renderInProvider(ui: Parameters[0]) { return render({ui}); } - 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( + , + ); + 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( + , + ); + fireEvent.click(screen.getByTestId(TRIGGER_TID)); + expect(screen.queryByTestId(ALREADY_ADDED_MARKER)).toBeNull(); + }); + it("marks an option from a normalized pasted URL identity", () => { + renderInProvider( + , + ); + 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( { 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( { 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"); }); }); diff --git a/apps/web/components/task-create-dialog-remote-repo-chip.tsx b/apps/web/components/task-create-dialog-remote-repo-chip.tsx index b9cc05f2ce..b1509f00f9 100644 --- a/apps/web/components/task-create-dialog-remote-repo-chip.tsx +++ b/apps/web/components/task-create-dialog-remote-repo-chip.tsx @@ -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} > - + { 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 (
{ setValue(event.target.value); @@ -384,6 +363,7 @@ function RemoteRepoPopoverContent({ /> @@ -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({
) : null} {repos.map((repo) => ( - + + remoteRepositoryMatchesSelection(repo, identity), + )} + onPick={onPick} + /> ))} ); @@ -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({ {repo.fullName} - {repo.private ? ( - - private - - ) : null} + + {alreadyAdded ? : null} + {repo.private ? ( + + private + + ) : null} + ); } +function AlreadyAddedMarker() { + return ( + + + ); +} + function ConnectProvidersBanner() { return (
diff --git a/apps/web/components/task-create-dialog-remote-repo-chips.test.tsx b/apps/web/components/task-create-dialog-remote-repo-chips.test.tsx index 6a36857452..d7efabb857 100644 --- a/apps/web/components/task-create-dialog-remote-repo-chips.test.tsx +++ b/apps/web/components/task-create-dialog-remote-repo-chips.test.tsx @@ -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[]; }) => ( -
+
{row.url} - - Remove repository - + ); } +function RepoChipRemoveButton({ onRemove }: { onRemove: () => void }) { + return ( + + + + + Remove repository + + ); +} + 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 ( - - {repo.name} - {display ? ( - {display} - ) : null} + + + {repo.name} + {display ? ( + {display} + ) : null} + + {alreadyAdded ? : null} ); } -function renderDiscoveredRepoOption(path: string) { +function renderDiscoveredRepoOption(path: string, alreadyAdded: boolean) { const display = formatUserHomePath(path); return ( @@ -446,6 +489,20 @@ function renderDiscoveredRepoOption(path: string) { on disk + {alreadyAdded ? : null} + + ); +} + +function AlreadyAddedMarker() { + return ( + + ); } diff --git a/apps/web/e2e/tests/task/mobile-create-task-remote-repo.spec.ts b/apps/web/e2e/tests/task/mobile-create-task-remote-repo.spec.ts index b22dd9ce2e..af9a59ae89 100644 --- a/apps/web/e2e/tests/task/mobile-create-task-remote-repo.spec.ts +++ b/apps/web/e2e/tests/task/mobile-create-task-remote-repo.spec.ts @@ -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", + ); + }); }); diff --git a/apps/web/e2e/tests/task/mobile-create-task-repository-selection.spec.ts b/apps/web/e2e/tests/task/mobile-create-task-repository-selection.spec.ts new file mode 100644 index 0000000000..ca4abb8238 --- /dev/null +++ b/apps/web/e2e/tests/task/mobile-create-task-repository-selection.spec.ts @@ -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"); + }); +}); diff --git a/docs/plans/mark-selected-task-repositories/plan.md b/docs/plans/mark-selected-task-repositories/plan.md new file mode 100644 index 0000000000..fa351816c2 --- /dev/null +++ b/docs/plans/mark-selected-task-repositories/plan.md @@ -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. diff --git a/docs/plans/mark-selected-task-repositories/task-01-workspace-repository-marker.md b/docs/plans/mark-selected-task-repositories/task-01-workspace-repository-marker.md new file mode 100644 index 0000000000..709ba2b969 --- /dev/null +++ b/docs/plans/mark-selected-task-repositories/task-01-workspace-repository-marker.md @@ -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. diff --git a/docs/plans/mark-selected-task-repositories/task-02-remote-repository-marker.md b/docs/plans/mark-selected-task-repositories/task-02-remote-repository-marker.md new file mode 100644 index 0000000000..ad13d39024 --- /dev/null +++ b/docs/plans/mark-selected-task-repositories/task-02-remote-repository-marker.md @@ -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. diff --git a/docs/plans/mark-selected-task-repositories/task-03-remote-identity-remediation.md b/docs/plans/mark-selected-task-repositories/task-03-remote-identity-remediation.md new file mode 100644 index 0000000000..9b96b3346a --- /dev/null +++ b/docs/plans/mark-selected-task-repositories/task-03-remote-identity-remediation.md @@ -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. diff --git a/docs/plans/mark-selected-task-repositories/task-04-workspace-coverage-remediation.md b/docs/plans/mark-selected-task-repositories/task-04-workspace-coverage-remediation.md new file mode 100644 index 0000000000..d45cb82779 --- /dev/null +++ b/docs/plans/mark-selected-task-repositories/task-04-workspace-coverage-remediation.md @@ -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. diff --git a/docs/plans/mark-selected-task-repositories/task-05-remote-identity-review-fix.md b/docs/plans/mark-selected-task-repositories/task-05-remote-identity-review-fix.md new file mode 100644 index 0000000000..a5899626d0 --- /dev/null +++ b/docs/plans/mark-selected-task-repositories/task-05-remote-identity-review-fix.md @@ -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. diff --git a/docs/plans/mark-selected-task-repositories/task-06-github-case-normalization.md b/docs/plans/mark-selected-task-repositories/task-06-github-case-normalization.md new file mode 100644 index 0000000000..db3758448a --- /dev/null +++ b/docs/plans/mark-selected-task-repositories/task-06-github-case-normalization.md @@ -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. diff --git a/docs/plans/mark-selected-task-repositories/task-07-lint-cleanup.md b/docs/plans/mark-selected-task-repositories/task-07-lint-cleanup.md new file mode 100644 index 0000000000..8fa560b7b9 --- /dev/null +++ b/docs/plans/mark-selected-task-repositories/task-07-lint-cleanup.md @@ -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. diff --git a/docs/plans/mark-selected-task-repositories/task-08-remote-popover-lint.md b/docs/plans/mark-selected-task-repositories/task-08-remote-popover-lint.md new file mode 100644 index 0000000000..61a7c5d6e5 --- /dev/null +++ b/docs/plans/mark-selected-task-repositories/task-08-remote-popover-lint.md @@ -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. diff --git a/docs/plans/mark-selected-task-repositories/task-09-compact-selected-marker.md b/docs/plans/mark-selected-task-repositories/task-09-compact-selected-marker.md new file mode 100644 index 0000000000..0c548af6b9 --- /dev/null +++ b/docs/plans/mark-selected-task-repositories/task-09-compact-selected-marker.md @@ -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. diff --git a/docs/specs/tasks/multi-branch/spec.md b/docs/specs/tasks/multi-branch/spec.md index 3dcbd1a38b..f2579a18e9 100644 --- a/docs/specs/tasks/multi-branch/spec.md +++ b/docs/specs/tasks/multi-branch/spec.md @@ -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.