fix(web): stabilize remote repository picker (#1747)

This commit is contained in:
Carlos Florêncio
2026-07-17 16:52:37 +01:00
committed by GitHub
parent 4757fffdea
commit 851cd94b15
9 changed files with 448 additions and 208 deletions
@@ -0,0 +1,47 @@
import { describe, expect, it } from "vitest";
import type { TaskRemoteRepoRow } from "./task-create-dialog-types";
import { computeTriggerLabel } from "./task-create-dialog-remote-repo-chip";
function row(overrides: Partial<TaskRemoteRepoRow> = {}): TaskRemoteRepoRow {
return { key: "remote-0", url: "", branch: "", source: "paste", ...overrides };
}
describe("computeTriggerLabel", () => {
it("returns the empty-state placeholder when url is empty", () => {
expect(computeTriggerLabel(row())).toBe("Pick or paste a repo");
});
it("returns picker fullName when source is 'picker' and metadata is present", () => {
expect(
computeTriggerLabel(
row({
url: "https://github.com/octocat/hello-world",
source: "picker",
provider: "github",
fullName: "octocat/hello-world",
}),
),
).toBe("octocat/hello-world");
});
it("returns short paste URLs verbatim", () => {
const label = computeTriggerLabel(row({ url: "github.com/x/y", source: "paste" }));
expect(label).toBe("github.com/x/y");
expect(label).not.toContain("\u2026");
});
it("middle-truncates long paste URLs while preserving first and last chars", () => {
const url = "github.com/some-very-long-org/some-very-long-repo-name";
const label = computeTriggerLabel(row({ url, source: "paste" }));
expect(label).toContain("\u2026");
expect(label.length).toBeLessThan(url.length);
expect(label.startsWith(url[0]!)).toBe(true);
expect(label.endsWith(url[url.length - 1]!)).toBe(true);
});
it("strips https:// and www. prefixes from paste labels", () => {
expect(
computeTriggerLabel(row({ url: "https://www.github.com/foo/bar", source: "paste" })),
).toBe("github.com/foo/bar");
});
});
@@ -31,11 +31,13 @@ function makeAccessible(
};
}
import { RemoteRepoChip, computeTriggerLabel } from "./task-create-dialog-remote-repo-chip";
import { RemoteRepoChip } from "./task-create-dialog-remote-repo-chip";
const TRIGGER_TID = "remote-repo-chip-trigger";
const INPUT_TID = "remote-repo-input";
const FULL_NAME = "acme/site";
const URL_ACME_SITE = "https://github.com/acme/site";
const URL_FOO_BAR_PR = "https://github.com/foo/bar/pull/42";
afterEach(() => {
cleanup();
@@ -87,46 +89,6 @@ describe("RemoteRepoChip — write paths", () => {
});
});
it("paste input writes URL with source=paste (no metadata) on Enter", () => {
const onURLChange = vi.fn();
renderInProvider(
<RemoteRepoChip
row={row()}
branches={[]}
branchesLoading={false}
accessibleRepos={makeAccessible()}
onURLChange={onURLChange}
onBranchChange={noopBranch}
onRemove={noopRemove}
/>,
);
fireEvent.click(screen.getByTestId(TRIGGER_TID));
const input = screen.getByTestId("remote-paste-url-input") as HTMLInputElement;
fireEvent.change(input, { target: { value: "https://github.com/acme/api" } });
fireEvent.keyDown(input, { key: "Enter" });
expect(onURLChange).toHaveBeenCalledWith("https://github.com/acme/api", "paste");
});
it("paste input also commits on blur", () => {
const onURLChange = vi.fn();
renderInProvider(
<RemoteRepoChip
row={row()}
branches={[]}
branchesLoading={false}
accessibleRepos={makeAccessible()}
onURLChange={onURLChange}
onBranchChange={noopBranch}
onRemove={noopRemove}
/>,
);
fireEvent.click(screen.getByTestId(TRIGGER_TID));
const input = screen.getByTestId("remote-paste-url-input") as HTMLInputElement;
fireEvent.change(input, { target: { value: "https://github.com/foo/bar" } });
fireEvent.blur(input);
expect(onURLChange).toHaveBeenCalledWith("https://github.com/foo/bar", "paste");
});
it("calls onRemove when the X button is clicked", () => {
const onRemove = vi.fn();
renderInProvider(
@@ -145,12 +107,138 @@ describe("RemoteRepoChip — write paths", () => {
});
});
describe("RemoteRepoChip — paste/picker race", () => {
it("picker click after typing in paste input does not trigger paste commit", () => {
// Race: user types into paste input, then clicks a picker option. The
// input's onBlur fires first (focus moves to the option button). Without
// the guard, blur would commit the typed value AND close the popover,
// and the subsequent picker click would be dropped.
describe("RemoteRepoChip — URL entry", () => {
it("writes a GitHub URL with source=paste on Enter", () => {
const onURLChange = vi.fn();
renderInProvider(
<RemoteRepoChip
row={row()}
branches={[]}
branchesLoading={false}
accessibleRepos={makeAccessible()}
onURLChange={onURLChange}
onBranchChange={noopBranch}
onRemove={noopRemove}
/>,
);
fireEvent.click(screen.getByTestId(TRIGGER_TID));
const input = screen.getByTestId(INPUT_TID) as HTMLInputElement;
fireEvent.change(input, { target: { value: "https://github.com/acme/api" } });
fireEvent.keyDown(input, { key: "Enter" });
expect(onURLChange).toHaveBeenCalledWith("https://github.com/acme/api", "paste");
});
it("commits a pasted GitHub URL immediately", () => {
const onURLChange = vi.fn();
renderInProvider(
<RemoteRepoChip
row={row()}
branches={[]}
branchesLoading={false}
accessibleRepos={makeAccessible()}
onURLChange={onURLChange}
onBranchChange={noopBranch}
onRemove={noopRemove}
/>,
);
fireEvent.click(screen.getByTestId(TRIGGER_TID));
const input = screen.getByTestId(INPUT_TID) as HTMLInputElement;
fireEvent.paste(input, {
clipboardData: { getData: () => URL_FOO_BAR_PR },
});
expect(onURLChange).toHaveBeenCalledWith(URL_FOO_BAR_PR, "paste");
});
it("commits a typed GitHub URL on blur", () => {
const onURLChange = vi.fn();
renderInProvider(
<RemoteRepoChip
row={row()}
branches={[]}
branchesLoading={false}
accessibleRepos={makeAccessible()}
onURLChange={onURLChange}
onBranchChange={noopBranch}
onRemove={noopRemove}
/>,
);
fireEvent.click(screen.getByTestId(TRIGGER_TID));
const input = screen.getByTestId(INPUT_TID) as HTMLInputElement;
fireEvent.change(input, { target: { value: "https://github.com/foo/bar/issues/42" } });
fireEvent.blur(input);
expect(onURLChange).toHaveBeenCalledWith("https://github.com/foo/bar/issues/42", "paste");
});
it("commits a typed GitHub PR URL when Tab cannot move focus out of the popover", () => {
const onURLChange = vi.fn();
renderInProvider(
<RemoteRepoChip
row={row()}
branches={[]}
branchesLoading={false}
accessibleRepos={makeAccessible()}
onURLChange={onURLChange}
onBranchChange={noopBranch}
onRemove={noopRemove}
/>,
);
fireEvent.click(screen.getByTestId(TRIGGER_TID));
const input = screen.getByTestId(INPUT_TID) as HTMLInputElement;
fireEvent.change(input, { target: { value: URL_FOO_BAR_PR } });
fireEvent.keyDown(input, { key: "Tab" });
expect(onURLChange).toHaveBeenCalledWith(URL_FOO_BAR_PR, "paste");
});
it("surfaces an inline error when a URL-shaped non-GitHub value is submitted", () => {
const onURLChange = vi.fn();
renderInProvider(
<RemoteRepoChip
row={row()}
branches={[]}
branchesLoading={false}
accessibleRepos={makeAccessible()}
onURLChange={onURLChange}
onBranchChange={noopBranch}
onRemove={noopRemove}
/>,
);
fireEvent.click(screen.getByTestId(TRIGGER_TID));
const input = screen.getByTestId(INPUT_TID) as HTMLInputElement;
fireEvent.change(input, { target: { value: "https://gitlab.com/acme/api" } });
fireEvent.keyDown(input, { key: "Enter" });
expect(input.getAttribute("aria-invalid")).toBe("true");
expect(screen.getByRole("alert").textContent).toContain("Enter a GitHub repository URL");
expect(onURLChange).not.toHaveBeenCalled();
});
});
describe("RemoteRepoChip — unified search", () => {
it("uses ordinary text as repository search without committing it on blur", () => {
const onURLChange = vi.fn();
const search = vi.fn();
renderInProvider(
<RemoteRepoChip
row={row()}
branches={[]}
branchesLoading={false}
accessibleRepos={makeAccessible({ search })}
onURLChange={onURLChange}
onBranchChange={noopBranch}
onRemove={noopRemove}
/>,
);
fireEvent.click(screen.getByTestId(TRIGGER_TID));
const input = screen.getByTestId(INPUT_TID) as HTMLInputElement;
fireEvent.change(input, { target: { value: "acme" } });
fireEvent.blur(input);
expect(search).toHaveBeenLastCalledWith("acme");
expect(input.getAttribute("aria-invalid")).toBeNull();
expect(screen.queryByRole("alert")).toBeNull();
expect(onURLChange).not.toHaveBeenCalled();
});
it("picker click after searching commits only the selected repository", () => {
const accessibleRepos = makeAccessible({
repos: [
{
@@ -176,10 +264,8 @@ describe("RemoteRepoChip — paste/picker race", () => {
/>,
);
fireEvent.click(screen.getByTestId(TRIGGER_TID));
const input = screen.getByTestId("remote-paste-url-input") as HTMLInputElement;
fireEvent.change(input, { target: { value: "https://github.com/typed/value" } });
// Simulate focus moving to the picker option before the click lands —
// this is the exact ordering the browser produces (blur → click).
const input = screen.getByTestId(INPUT_TID) as HTMLInputElement;
fireEvent.change(input, { target: { value: "acme" } });
const option = screen.getByText(FULL_NAME).closest("button") as HTMLButtonElement;
fireEvent.blur(input, { relatedTarget: option });
fireEvent.click(option);
@@ -192,6 +278,48 @@ describe("RemoteRepoChip — paste/picker race", () => {
});
});
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,
},
],
});
const onURLChange = vi.fn();
renderInProvider(
<RemoteRepoChip
row={row()}
branches={[]}
branchesLoading={false}
accessibleRepos={accessibleRepos}
onURLChange={onURLChange}
onBranchChange={noopBranch}
onRemove={noopRemove}
/>,
);
fireEvent.click(screen.getByTestId(TRIGGER_TID));
const input = screen.getByTestId(INPUT_TID) as HTMLInputElement;
fireEvent.change(input, { target: { value: URL_ACME_SITE } });
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, "picker", {
provider: "github",
fullName: FULL_NAME,
defaultBranch: "main",
});
});
});
describe("RemoteRepoChip — branch pill", () => {
it("is disabled when the URL is empty", () => {
renderInProvider(
@@ -305,6 +433,7 @@ describe("RemoteRepoChip — picker loading state", () => {
fireEvent.click(screen.getByTestId(TRIGGER_TID));
const loadingNode = screen.getByTestId("remote-repo-picker-loading");
expect(loadingNode.textContent).toContain("Loading repositories");
expect(loadingNode.parentElement?.className).toContain("h-56");
});
it("does NOT render the spinner once repos have loaded (even if loading flips true again later)", () => {
@@ -335,9 +464,67 @@ describe("RemoteRepoChip — picker loading state", () => {
expect(screen.queryByTestId("remote-repo-picker-loading")).toBeNull();
expect(screen.getByText(FULL_NAME)).toBeTruthy();
});
it("does not render a loading spinner with the GitHub connection banner", () => {
renderInProvider(
<RemoteRepoChip
row={row()}
branches={[]}
branchesLoading={false}
accessibleRepos={makeAccessible({ unavailable: true, loading: true })}
onURLChange={vi.fn()}
onBranchChange={noopBranch}
onRemove={noopRemove}
/>,
);
fireEvent.click(screen.getByTestId(TRIGGER_TID));
expect(screen.getByText(/Connect a GitHub account/i)).toBeTruthy();
expect(screen.queryByTestId("remote-repo-picker-loading")).toBeNull();
});
it("shows only the connection banner for invalid URL input when GitHub is unavailable", () => {
renderInProvider(
<RemoteRepoChip
row={row()}
branches={[]}
branchesLoading={false}
accessibleRepos={makeAccessible({ unavailable: true })}
onURLChange={vi.fn()}
onBranchChange={noopBranch}
onRemove={noopRemove}
/>,
);
fireEvent.click(screen.getByTestId(TRIGGER_TID));
const input = screen.getByTestId(INPUT_TID) as HTMLInputElement;
fireEvent.change(input, { target: { value: "https://gitlab.com/acme/api" } });
fireEvent.keyDown(input, { key: "Enter" });
expect(screen.getByText(/Connect a GitHub account/i)).toBeTruthy();
expect(screen.queryByRole("alert")).toBeNull();
expect(input.getAttribute("aria-invalid")).toBeNull();
});
});
describe("RemoteRepoChip — popover content", () => {
it("renders one top-level input for both repository search and GitHub URLs", () => {
renderInProvider(
<RemoteRepoChip
row={row()}
branches={[]}
branchesLoading={false}
accessibleRepos={makeAccessible()}
onURLChange={vi.fn()}
onBranchChange={noopBranch}
onRemove={noopRemove}
/>,
);
fireEvent.click(screen.getByTestId(TRIGGER_TID));
const input = screen.getByTestId(INPUT_TID) as HTMLInputElement;
expect(input.placeholder).toBe("Search repositories or paste a GitHub URL");
expect(screen.queryByTestId("remote-repo-search")).toBeNull();
expect(screen.queryByTestId("remote-paste-url-input")).toBeNull();
});
it("portals and constrains the repo picker so dialog overflow cannot clip it", () => {
renderInProvider(
<div data-testid="clipping-host" className="overflow-hidden">
@@ -357,7 +544,7 @@ describe("RemoteRepoChip — popover content", () => {
expect(screen.getByTestId("clipping-host").contains(content)).toBe(false);
expect(content.className).toContain("max-w-[calc(100vw-2rem)]");
expect(content.className).toContain("max-h-[min(420px,calc(100vh-12rem))]");
expect(content.className).toContain("overflow-y-auto");
expect(content.className).toContain("overflow-hidden");
});
it("renders the 'Connect GitHub' banner when accessibleRepos.unavailable=true", () => {
@@ -444,44 +631,3 @@ describe("RemoteRepoChip — trigger label", () => {
expect(screen.getByTestId(TRIGGER_TID).textContent).toContain("github.com/foo/bar");
});
});
describe("computeTriggerLabel", () => {
it("returns the empty-state placeholder when url is empty", () => {
expect(computeTriggerLabel(row())).toBe("Pick or paste a repo");
});
it("returns picker fullName when source is 'picker' and metadata is present", () => {
expect(
computeTriggerLabel(
row({
url: "https://github.com/octocat/hello-world",
source: "picker",
provider: "github",
fullName: "octocat/hello-world",
}),
),
).toBe("octocat/hello-world");
});
it("returns short paste URLs verbatim (no ellipsis under threshold)", () => {
const label = computeTriggerLabel(row({ url: "github.com/x/y", source: "paste" }));
expect(label).toBe("github.com/x/y");
expect(label).not.toContain("…");
});
it("middle-truncates long paste URLs while preserving first and last chars", () => {
const long = "github.com/some-very-long-org/some-very-long-repo-name";
const stripped = "github.com/some-very-long-org/some-very-long-repo-name"; // no scheme to strip
const label = computeTriggerLabel(row({ url: long, source: "paste" }));
expect(label).toContain("…");
expect(label.length).toBeLessThan(stripped.length);
expect(label.startsWith(stripped[0]!)).toBe(true);
expect(label.endsWith(stripped[stripped.length - 1]!)).toBe(true);
});
it("strips https:// and www. prefixes from paste labels", () => {
expect(
computeTriggerLabel(row({ url: "https://www.github.com/foo/bar", source: "paste" })),
).toBe("github.com/foo/bar");
});
});
@@ -18,7 +18,7 @@ import {
import { scoreBranch } from "@/lib/utils/branch-filter";
import type { UseAccessibleReposResult } from "@/hooks/domains/github/use-accessible-repos";
import type { AccessibleRepo } from "@/lib/api/domains/github-api";
import type { PRInfo } from "@/hooks/domains/github/use-pr-info-by-url";
import { parseGitHubAnyUrl, type PRInfo } from "@/hooks/domains/github/use-pr-info-by-url";
import type { TaskRemoteRepoRow } from "@/components/task-create-dialog-types";
import { useTaskCreateDialogPopoverContainer } from "@/hooks/use-task-create-dialog-popover-container";
@@ -79,8 +79,8 @@ export type RemoteRepoChipProps = {
*
* [ repo pill ] [ branch pill ] [X]
*
* The repo pill opens a custom popover with two sections (an autocomplete
* search over the user's accessible GitHub repos, and a paste-a-URL input).
* The repo pill opens a custom popover with one input that searches the
* user's accessible GitHub repos or accepts a pasted GitHub URL.
* The branch pill is the shared `Pill` primitive over the per-URL branches
* the parent loads via `branchesByUrl`.
*/
@@ -220,7 +220,7 @@ function RemoteRepoPill({
</PopoverTrigger>
<PopoverContent
data-testid="remote-repo-popover-content"
className="w-[380px] max-w-[calc(100vw-2rem)] max-h-[min(420px,calc(100vh-12rem))] overflow-y-auto p-0"
className="w-[380px] max-w-[calc(100vw-2rem)] max-h-[min(420px,calc(100vh-12rem))] overflow-hidden p-0"
align="start"
portalContainer={portalContainer}
>
@@ -278,70 +278,110 @@ function RemoteRepoPopoverContent({
onPick: (repo: AccessibleRepo) => void;
onPaste: (value: string) => void;
}) {
const [search, setSearch] = useState("");
// Destructure the stable `search` callback so the effect's dep array is
// not invalidated on every render of the parent (the hook's result object
// identity changes each render, but `search` is a useCallback). The hook
// itself owns the 250ms debounce so we just forward the latest value.
const [value, setValue] = useState("");
const [urlError, setUrlError] = useState<string | null>(null);
const { search: triggerSearch } = accessible;
useEffect(() => {
triggerSearch(search);
}, [search, triggerSearch]);
triggerSearch(value);
}, [value, triggerSearch]);
const commitURL = (candidate: string) => {
const trimmed = candidate.trim();
if (!parseGitHubAnyUrl(trimmed)) {
if (looksLikeURL(trimmed)) {
setUrlError("Enter a GitHub repository URL, such as github.com/owner/repo.");
}
return false;
}
setUrlError(null);
onPaste(trimmed);
return true;
};
const visibleUrlError = accessible.unavailable ? null : urlError;
return (
<div className="flex flex-col">
<PickerSection
accessible={accessible}
search={search}
onSearchChange={setSearch}
onPick={onPick}
<input
autoFocus
type="text"
value={value}
onChange={(event) => {
setValue(event.target.value);
setUrlError(null);
}}
onBlur={(event) => {
const popoverContent = event.currentTarget.closest('[data-slot="popover-content"]');
if (
popoverContent &&
event.relatedTarget instanceof Node &&
popoverContent.contains(event.relatedTarget)
) {
return;
}
commitURL(value);
}}
onPaste={(event) => {
const pasted = event.clipboardData.getData("text");
const isURL = looksLikeURL(pasted.trim());
if (!commitURL(pasted) && !isURL) return;
event.preventDefault();
setValue(pasted.trim());
}}
onKeyDown={(event) => {
if (event.key === "Tab") {
commitURL(value);
return;
}
if (event.key !== "Enter") return;
const isURL = looksLikeURL(value.trim());
if (commitURL(value) || isURL) event.preventDefault();
}}
placeholder="Search repositories or paste a GitHub URL"
aria-label="Search repositories or paste a GitHub URL"
aria-invalid={visibleUrlError ? true : undefined}
data-testid="remote-repo-input"
data-legacy-testid="remote-paste-url-input"
className={cn(
"h-11 sm:h-9 mx-2 mt-2 rounded-md px-2 text-xs bg-muted/30 border border-border/60",
"outline-none focus:bg-muted focus:border-border placeholder:text-muted-foreground",
visibleUrlError && "border-destructive focus:border-destructive",
)}
/>
<div className="border-t" />
<PasteSection onPaste={onPaste} />
<PickerList accessible={accessible} onPick={onPick} urlError={visibleUrlError} />
</div>
);
}
function PickerSection({
accessible,
search,
onSearchChange,
onPick,
}: {
accessible: UseAccessibleReposResult;
search: string;
onSearchChange: (v: string) => void;
onPick: (repo: AccessibleRepo) => void;
}) {
if (accessible.unavailable) return <ConnectGitHubBanner />;
return (
<div className="flex flex-col">
<input
type="text"
value={search}
onChange={(e) => onSearchChange(e.target.value)}
placeholder="Search your GitHub repos…"
data-testid="remote-repo-search"
className={cn(
"h-9 mx-2 mt-2 rounded-md px-2 text-xs bg-muted/30 border border-border/60",
"outline-none focus:bg-muted focus:border-border placeholder:text-muted-foreground",
)}
/>
<PickerList accessible={accessible} onPick={onPick} />
</div>
);
function looksLikeURL(value: string): boolean {
if (!value) return false;
const withScheme = /^[a-z][a-z\d+.-]*:\/\//i.test(value) ? value : `https://${value}`;
try {
const parsed = new URL(withScheme);
return parsed.hostname.includes(".") && value.includes("/");
} catch {
return false;
}
}
function PickerList({
accessible,
onPick,
urlError,
}: {
accessible: UseAccessibleReposResult;
onPick: (repo: AccessibleRepo) => void;
urlError: string | null;
}) {
const { repos, loading, error } = accessible;
return (
<div className="max-h-56 overflow-y-auto p-1">
{loading && repos.length === 0 ? (
<div className="h-56 max-h-[calc(100vh-16rem)] overflow-y-auto p-1">
{urlError ? (
<div role="alert" className="px-2 py-3 text-xs text-destructive">
{urlError}
</div>
) : null}
{accessible.unavailable ? <ConnectGitHubBanner /> : null}
{!accessible.unavailable && loading && repos.length === 0 ? (
<div
className="flex items-center gap-2 px-2 py-3 text-xs text-muted-foreground"
data-testid="remote-repo-picker-loading"
@@ -350,7 +390,7 @@ function PickerList({
<span>Loading repositories…</span>
</div>
) : null}
{!loading && repos.length === 0 && !error ? (
{!accessible.unavailable && !loading && repos.length === 0 && !error ? (
<div className="px-2 py-3 text-xs text-muted-foreground">No repositories found.</div>
) : null}
{error ? (
@@ -378,7 +418,7 @@ function RepoOption({
onClick={() => onPick(repo)}
data-testid="remote-repo-option"
className={cn(
"flex w-full items-center justify-between gap-2 rounded-sm px-2 py-1.5 text-xs",
"flex min-h-11 sm:min-h-8 w-full items-center justify-between gap-2 rounded-sm px-2 py-1.5 text-xs",
"hover:bg-muted cursor-pointer text-left",
)}
>
@@ -407,55 +447,6 @@ function ConnectGitHubBanner() {
);
}
function PasteSection({ onPaste }: { onPaste: (value: string) => void }) {
const [value, setValue] = useState("");
const commit = () => {
const trimmed = value.trim();
if (!trimmed) return;
onPaste(trimmed);
};
return (
<div className="flex flex-col gap-1 p-2">
<label className="text-[11px] text-muted-foreground" htmlFor="remote-paste-url-input">
…or paste a URL/PR/issue
</label>
<input
id="remote-paste-url-input"
type="text"
value={value}
onChange={(e) => setValue(e.target.value)}
onBlur={(e) => {
// If focus moved to another element inside this popover (e.g., a
// picker option click), let that handler run instead of committing
// the paste — otherwise the popover would close before the click
// lands and the user's pick would be lost.
const popoverContent = e.currentTarget.closest('[data-slot="popover-content"]');
if (
popoverContent &&
e.relatedTarget instanceof Node &&
popoverContent.contains(e.relatedTarget)
) {
return;
}
commit();
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
commit();
}
}}
placeholder="github.com/owner/repo, .../pull/123, or .../issues/123"
data-testid="remote-paste-url-input"
className={cn(
"h-8 rounded-md px-2 text-xs bg-muted/30 border border-border/60",
"outline-none focus:bg-muted focus:border-border placeholder:text-muted-foreground",
)}
/>
</div>
);
}
// --- Branch pill -------------------------------------------------------------
function RemoteBranchPill({
@@ -161,7 +161,7 @@ test.describe("Branch selector behavior with executor types", () => {
// Switch to Remote tab and paste via the chip popover.
await testPage.getByTestId("source-mode-remote").click();
await testPage.getByTestId("remote-repo-chip-trigger").first().click();
const pasteInput = testPage.getByTestId("remote-paste-url-input");
const pasteInput = testPage.getByTestId("remote-repo-input");
await pasteInput.fill("https://github.com/branch-test-owner/branch-test-repo");
await pasteInput.press("Enter");
@@ -433,7 +433,7 @@ test.describe("Fresh-branch flow", () => {
// Switch to Remote tab and paste via the chip popover.
await testPage.getByTestId("source-mode-remote").click();
await testPage.getByTestId("remote-repo-chip-trigger").first().click();
const pasteInput = testPage.getByTestId("remote-paste-url-input");
const pasteInput = testPage.getByTestId("remote-repo-input");
await pasteInput.fill("https://github.com/branch-test-owner/branch-test-repo");
await pasteInput.press("Enter");
await testPage.getByTestId("executor-profile-selector").click();
@@ -8,7 +8,7 @@ import { SessionPage } from "../../pages/session-page";
useRegularMode();
/**
* Helper: switch the create-task dialog to the Remote tab and paste a URL
* Helper: switch the create-task dialog to the Remote tab and type a URL
* into the (newly nested) chip popover. Replaces the old "click
* toggle-github-url + fill github-url-input" pair after Task 5/8 swapped the
* top-level URL input for a per-row chip + popover.
@@ -17,10 +17,10 @@ async function openRemoteAndPasteURL(testPage: Page, url: string): Promise<void>
await testPage.getByTestId("source-mode-remote").click();
// The chip popover holds the paste input. Open the first chip.
await testPage.getByTestId("remote-repo-chip-trigger").first().click();
const pasteInput = testPage.getByTestId("remote-paste-url-input");
await expect(pasteInput).toBeVisible();
await pasteInput.fill(url);
await pasteInput.press("Enter");
const urlInput = testPage.getByTestId("remote-repo-input");
await expect(urlInput).toBeVisible();
await urlInput.fill(url);
await urlInput.press("Tab");
}
test.describe("Task creation from GitHub URL", () => {
@@ -8,8 +8,8 @@ import { KanbanPage } from "../../pages/kanban-page";
// rows, add/remove rows, the "GitHub not configured" banner, and mode-switch
// preservation. Modeled on create-task-github-url.spec.ts but exercises the
// new chip-popover UI (testids: source-mode-remote, remote-repo-chip,
// remote-repo-chip-trigger, remote-paste-url-input, remote-add-row,
// remote-chip-remove, remote-repo-search, remote-repo-option).
// remote-repo-chip-trigger, remote-repo-input, remote-add-row,
// remote-chip-remove, remote-repo-option).
type TaskRepoAPI = {
repositories?: Array<{
@@ -90,7 +90,7 @@ async function pasteUrlInChip(testPage: Page, url: string, chipIndex = 0): Promi
// multiple chips have popovers open (or Radix is mid-transition between
// opens), the same testid can briefly resolve to more than one element,
// so target the last (currently focused) one.
const pasteInput = testPage.getByTestId("remote-paste-url-input").last();
const pasteInput = testPage.getByTestId("remote-repo-input").last();
await expect(pasteInput).toBeVisible();
await pasteInput.fill(url);
await pasteInput.press("Enter");
@@ -117,6 +117,55 @@ test.describe("Task creation from Remote tab (chip picker)", () => {
await apiClient.mockGitHubReset();
});
test("keeps the unified input fixed while repositories load", async ({ testPage, apiClient }) => {
await seedAccessibleRepos(apiClient);
let releaseRepos = () => undefined;
const reposGate = new Promise<void>((resolve) => {
releaseRepos = resolve;
});
await testPage.route("**/api/v1/github/repos?*", async (route) => {
await reposGate;
await route.continue();
});
const kanban = new KanbanPage(testPage);
await openCreateDialog(testPage, kanban);
await clickRemoteMode(testPage);
await testPage.getByTestId("remote-repo-chip-trigger").first().click();
const popover = testPage.getByTestId("remote-repo-popover-content");
const input = testPage.getByTestId("remote-repo-input");
await expect(testPage.getByTestId("remote-repo-picker-loading")).toBeVisible();
// Measure the loading state after the finite popover entrance animation.
// Otherwise its scale transform makes the first box a few pixels smaller
// than the loaded-state box even though the layout itself never shifts.
await popover.evaluate(async (element) => {
await Promise.all(
element.getAnimations().map((animation) => animation.finished.catch(() => undefined)),
);
});
const [loadingPopoverBox, loadingInputBox] = await Promise.all([
popover.boundingBox(),
input.boundingBox(),
]);
releaseRepos();
await expect(
testPage.getByTestId("remote-repo-option").filter({ hasText: "mock-user/alpha" }),
).toBeVisible();
const [loadedPopoverBox, loadedInputBox] = await Promise.all([
popover.boundingBox(),
input.boundingBox(),
]);
expect(loadingPopoverBox).not.toBeNull();
expect(loadingInputBox).not.toBeNull();
expect(loadedPopoverBox).not.toBeNull();
expect(loadedInputBox).not.toBeNull();
expect(Math.abs(loadedPopoverBox!.height - loadingPopoverBox!.height)).toBeLessThanOrEqual(2);
expect(Math.abs(loadedInputBox!.y - loadingInputBox!.y)).toBeLessThanOrEqual(2);
});
test("scenario 1: picker selection submits one repo", async ({
testPage,
apiClient,
@@ -238,7 +287,7 @@ test.describe("Task creation from Remote tab (chip picker)", () => {
await testPage.getByTestId("remote-repo-chip-trigger").first().click();
await expectPopoverFitsDialog(testPage);
const pasteInput = testPage.getByTestId("remote-paste-url-input").last();
const pasteInput = testPage.getByTestId("remote-repo-input").last();
await pasteInput.fill("https://github.com/issue-owner/issue-repo/issues/1456");
await pasteInput.press("Enter");
@@ -407,7 +456,7 @@ test.describe("Task creation from Remote tab (chip picker)", () => {
await expect(settingsLink).toBeVisible();
// The paste input is still rendered and usable — paste a URL.
const pasteInput = testPage.getByTestId("remote-paste-url-input");
const pasteInput = testPage.getByTestId("remote-repo-input");
await expect(pasteInput).toBeVisible();
await pasteInput.fill("https://github.com/banner-owner/banner-repo");
await pasteInput.press("Enter");
@@ -59,7 +59,7 @@ test.describe("Create-task URL flow - branches after reopen", () => {
// inside the chip in Task 5/8).
await testPage.getByTestId("source-mode-remote").click();
await testPage.getByTestId("remote-repo-chip-trigger").first().click();
const pasteInput = testPage.getByTestId("remote-paste-url-input");
const pasteInput = testPage.getByTestId("remote-repo-input");
await pasteInput.fill(`https://github.com/${repoFullName}`);
await pasteInput.press("Enter");
@@ -13,12 +13,19 @@ async function openRemotePicker(testPage: Page): Promise<void> {
async function expectPopoverFitsViewport(testPage: Page): Promise<void> {
const viewport = testPage.viewportSize();
const box = await testPage.getByTestId("remote-repo-popover-content").boundingBox();
const input = testPage.getByTestId("remote-repo-input");
const [box, inputBox] = await Promise.all([
testPage.getByTestId("remote-repo-popover-content").boundingBox(),
input.boundingBox(),
]);
expect(viewport).not.toBeNull();
expect(box).not.toBeNull();
expect(inputBox).not.toBeNull();
expect(box!.x).toBeGreaterThanOrEqual(0);
expect(box!.x + box!.width).toBeLessThanOrEqual(viewport!.width);
expect(box!.y + box!.height).toBeLessThanOrEqual(viewport!.height);
expect(inputBox!.y - box!.y).toBeLessThan(16);
await expect(input).toHaveCSS("height", "44px");
}
test.describe("Create task Remote repo picker on mobile", () => {
@@ -43,7 +50,7 @@ test.describe("Create task Remote repo picker on mobile", () => {
await openRemotePicker(testPage);
await expectPopoverFitsViewport(testPage);
const pasteInput = testPage.getByTestId("remote-paste-url-input").last();
const pasteInput = testPage.getByTestId("remote-repo-input").last();
await pasteInput.fill("https://github.com/issue-owner/issue-repo/issues/1456");
await pasteInput.press("Enter");
+1 -1
View File
@@ -656,7 +656,7 @@ test.describe("Subtask dialog feature parity", () => {
const chipTrigger = testPage.getByTestId("remote-repo-chip-trigger").first();
await expect(chipTrigger).toBeVisible({ timeout: 5_000 });
await chipTrigger.click();
const urlInput = testPage.getByTestId("remote-paste-url-input").last();
const urlInput = testPage.getByTestId("remote-repo-input").last();
await expect(urlInput).toBeVisible({ timeout: 5_000 });
await urlInput.fill("https://github.com/subtask-owner/subtask-repo");
await urlInput.press("Enter");