fix(web): keep repository actions available (#3593)

This commit is contained in:
Carlos Florêncio
2026-09-12 16:29:16 +01:00
committed by GitHub
parent d8eefc5718
commit 3d3209aa1a
26 changed files with 1104 additions and 104 deletions
@@ -26,8 +26,8 @@ describe("BranchRefreshButton", () => {
const button = screen.getByTestId("repo-refresh-button");
expect(button.getAttribute("aria-label")).toBe("Refresh repositories");
expect(button.className).toContain("h-12");
expect(button.className).toContain("w-12");
expect(button.className).toContain("size-7");
expect(button.className).toContain("max-md:size-11");
});
it("refreshes on click", () => {
@@ -3,6 +3,7 @@
import { useState } from "react";
import { IconRefresh } from "@tabler/icons-react";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import { controlSizingClassName } from "@kandev/ui/control-sizing";
import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
@@ -47,7 +48,7 @@ export function BranchRefreshButton({
onMouseEnter={() => setOpen(true)}
onMouseLeave={() => setOpen(false)}
disabled={refreshing}
className={`inline-flex ${touchTarget ? "h-12 w-12" : "h-6 w-6"} items-center justify-center rounded-md hover:bg-muted/40 ${
className={`inline-flex ${touchTarget ? `${controlSizingClassName("icon")} max-md:min-h-12 max-md:min-w-12 [@media(pointer:coarse)]:min-h-12 [@media(pointer:coarse)]:min-w-12` : "h-6 w-6"} items-center justify-center rounded-md hover:bg-muted/40 ${
hasError
? "text-amber-500 hover:text-amber-600"
: "text-muted-foreground hover:text-foreground"
@@ -184,6 +184,59 @@ describe("CreateLocalRepositorySurface", () => {
});
});
describe("CreateLocalRepositorySurface async creation", () => {
it("delivers a delayed response to the current row handler", async () => {
let complete!: (repository: Repository) => void;
mocks.initialize.mockReturnValue(
new Promise<Repository>((resolve) => {
complete = resolve;
}),
);
const previousHandler = vi.fn();
const currentHandler = vi.fn();
const props = {
open: true,
onOpenChange: vi.fn(),
workspaceId: "ws-1",
executorSelection: directLocalSelection,
onCreated: previousHandler,
};
const { rerender } = render(<CreateLocalRepositorySurface {...props} />);
fireEvent.change(await screen.findByLabelText(REPOSITORY_NAME_LABEL), {
target: { value: REPOSITORY_NAME },
});
fireEvent.click(screen.getByRole("button", { name: CREATE_BUTTON_NAME }));
await waitFor(() => expect(mocks.initialize).toHaveBeenCalledOnce());
rerender(<CreateLocalRepositorySurface {...props} onCreated={currentHandler} />);
complete(createdRepository);
await waitFor(() => expect(previousHandler).toHaveBeenCalledWith(createdRepository));
expect(currentHandler).not.toHaveBeenCalled();
});
it("creates for a multi-row task without a direct-local profile", async () => {
mocks.initialize.mockResolvedValue(createdRepository);
const props = renderSurface({ context: "task-create-multi", executorSelection: null });
fireEvent.change(await screen.findByLabelText(REPOSITORY_NAME_LABEL), {
target: { value: REPOSITORY_NAME },
});
fireEvent.click(screen.getByRole("button", { name: CREATE_BUTTON_NAME }));
await waitFor(() => expect(props.onCreated).toHaveBeenCalledWith(createdRepository));
});
it("keeps the surface open when the completion is no longer current", async () => {
mocks.initialize.mockResolvedValue(createdRepository);
const onOpenChange = vi.fn();
const onCreated = vi.fn(() => false);
renderSurface({ onOpenChange, onCreated });
fireEvent.change(await screen.findByLabelText(REPOSITORY_NAME_LABEL), {
target: { value: REPOSITORY_NAME },
});
fireEvent.click(screen.getByRole("button", { name: CREATE_BUTTON_NAME }));
await waitFor(() => expect(onCreated).toHaveBeenCalledWith(createdRepository));
expect(onOpenChange).not.toHaveBeenCalled();
});
});
describe("CreateLocalRepositorySurface submission", () => {
it("does not submit the parent task form", async () => {
mocks.initialize.mockResolvedValue(createdRepository);
@@ -1,6 +1,6 @@
"use client";
import { FormEvent, useEffect, useState } from "react";
import { FormEvent, useEffect, useRef, useState } from "react";
import {
IconAlertCircle,
IconFolder,
@@ -60,8 +60,8 @@ export type CreateLocalRepositorySurfaceProps = {
onOpenChange: (open: boolean) => void;
workspaceId: string | null;
executorSelection: DirectLocalExecutorSelection | null;
context?: "task-create" | "workspace";
onCreated: (repository: Repository) => void;
context?: "task-create" | "task-create-multi" | "workspace";
onCreated: (repository: Repository) => boolean | void;
};
// `context` and `requiresSwitch` stay logic; only the notices they select are
@@ -71,7 +71,7 @@ function executorNotice(
selection: DirectLocalExecutorSelection | null,
context: NonNullable<CreateLocalRepositorySurfaceProps["context"]>,
): string {
if (context === "workspace") {
if (!requiresDirectLocalExecutor(context)) {
return t("common:createsAnEmptyGitRepository");
}
if (!selection) {
@@ -178,7 +178,7 @@ function RepositoryFormDetails({
</div>
<div
className={
executorSelection || context === "workspace"
executorSelection || !requiresDirectLocalExecutor(context)
? "flex items-start gap-2 text-xs text-muted-foreground"
: "flex items-start gap-2 text-xs text-destructive"
}
@@ -221,6 +221,13 @@ function CreateRepositoryFooter({
);
}
function requiresDirectLocalExecutor(context: CreateLocalRepositorySurfaceProps["context"]) {
return context === "task-create";
}
// Keep async submission ownership beside the form so stale completions cannot
// mutate a newer request's state.
// eslint-disable-next-line max-lines-per-function -- form coordinates submission and its full layout
function CreateRepositoryForm({
open,
workspaceId,
@@ -235,6 +242,7 @@ function CreateRepositoryForm({
const [editingParentPath, setEditingParentPath] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null);
const submissionIdRef = useRef(0);
const { listing, loading, error: listingError, load } = useDirectoryListing(open, "");
useEffect(() => {
if (!open) {
@@ -249,13 +257,15 @@ function CreateRepositoryForm({
const nameError = validateLocalRepositoryName(name);
const targetPath =
parentPath && !nameError ? buildLocalRepositoryTargetPath(parentPath, name) : "";
const executorReady = Boolean(executorSelection || context === "workspace");
const executorReady = Boolean(executorSelection || !requiresDirectLocalExecutor(context));
const canSubmit = !!workspaceId && executorReady && !!parentPath && !nameError && !submitting;
const handleSubmit = async (event: FormEvent) => {
event.preventDefault();
event.stopPropagation();
if (!canSubmit || !workspaceId) return;
const submissionId = submissionIdRef.current + 1;
submissionIdRef.current = submissionId;
setSubmitting(true);
setSubmitError(null);
try {
@@ -263,13 +273,14 @@ function CreateRepositoryForm({
name: name.trim(),
parentPath,
});
onCreated(repository);
const shouldDismiss = onCreated(repository);
if (shouldDismiss === false) return;
setName("");
onDismiss();
} catch (err) {
setSubmitError(err instanceof Error ? err.message : t("common:failedToCreateRepository"));
} finally {
setSubmitting(false);
if (submissionIdRef.current === submissionId) setSubmitting(false);
}
};
@@ -62,6 +62,103 @@ function repository(id: string): Repository {
}
describe("local repository creation selection", () => {
it("accepts creation through the dialog handler when only a Worktree profile exists", () => {
const fs = {
repositories: [
{ key: "first", repositoryId: "existing", branch: "main" },
{ key: "second", branch: "" },
],
executorProfileId: WORKTREE_PROFILE_ID,
updateRepository: vi.fn(),
setExecutorId: vi.fn(),
setExecutorProfileId: vi.fn(),
} as unknown as Parameters<typeof useDialogHandlers>[0];
const upsertWorkspaceRepository = vi.fn();
const { result } = renderHook(() =>
useDialogHandlers(fs, [], {
workspaceId: "ws-1",
upsertWorkspaceRepository,
executors: [
executor("worktree", "worktree", [{ id: WORKTREE_PROFILE_ID, name: "Worktree" }]),
],
}),
);
const created = repository("new");
result.current.handleLocalRepositoryCreated("second", created);
expect(upsertWorkspaceRepository).toHaveBeenCalledWith("ws-1", created);
expect(fs.updateRepository).toHaveBeenCalledWith(
"second",
expect.objectContaining({ repositoryId: "new", branch: "main" }),
);
expect(fs.setExecutorId).not.toHaveBeenCalled();
});
it.each([
null,
{
executorId: "local",
executorProfileId: LOCAL_PROFILE_ID,
executorProfileName: "Local",
requiresSwitch: true,
},
])(
"preserves executor and sibling rows when creating into a multi-row task (%j)",
(executorSelection) => {
const rows = [
{ key: "first", repositoryId: "existing", branch: "develop" },
{ key: "second", branch: "", branchPolicyId: "old-policy" },
];
const fs = {
repositories: rows,
updateRepository: (key: string, patch: object) =>
Object.assign(rows.find((r) => r.key === key)!, patch),
setExecutorId: vi.fn(),
setExecutorProfileId: vi.fn(),
};
applyCreatedLocalRepository({
fs,
rowKey: "second",
repository: repository("new"),
workspaceId: "ws-1",
upsertWorkspaceRepository: vi.fn(),
executorSelection,
});
expect(rows[0]).toEqual({ key: "first", repositoryId: "existing", branch: "develop" });
expect(rows[1]).toMatchObject({
repositoryId: "new",
branch: "main",
branchPolicyId: undefined,
});
expect(fs.setExecutorId).not.toHaveBeenCalled();
expect(fs.setExecutorProfileId).not.toHaveBeenCalled();
},
);
it("retains the created repository without changing another row after its target is removed", () => {
const fs = {
repositories: [{ key: "remaining", branch: "develop" }],
updateRepository: vi.fn(),
setExecutorId: vi.fn(),
setExecutorProfileId: vi.fn(),
};
const upsertWorkspaceRepository = vi.fn();
const created = repository("new");
applyCreatedLocalRepository({
fs,
rowKey: "removed",
repository: created,
workspaceId: "ws-1",
upsertWorkspaceRepository,
executorSelection: null,
});
expect(upsertWorkspaceRepository).toHaveBeenCalledWith("ws-1", created);
expect(fs.updateRepository).not.toHaveBeenCalled();
expect(fs.setExecutorId).not.toHaveBeenCalled();
expect(fs.setExecutorProfileId).not.toHaveBeenCalled();
});
});
describe("local repository executor selection", () => {
it("keeps the selected profile when it already runs directly on the local host", () => {
const selection = findDirectLocalExecutorProfile(
[
@@ -112,7 +209,12 @@ describe("local repository creation selection", () => {
const created = repository("repo-new");
applyCreatedLocalRepository({
fs: { updateRepository, setExecutorId, setExecutorProfileId },
fs: {
repositories: [{ key: "row-2", branch: "" }],
updateRepository,
setExecutorId,
setExecutorProfileId,
},
rowKey: "row-2",
repository: created,
workspaceId: "ws-1",
@@ -129,6 +231,7 @@ describe("local repository creation selection", () => {
repositoryId: "repo-new",
localPath: undefined,
branch: "main",
branchPolicyId: undefined,
});
expect(setExecutorId).toHaveBeenCalledWith("local");
expect(setExecutorProfileId).toHaveBeenCalledWith(LOCAL_PROFILE_ID);
@@ -63,7 +63,7 @@ export function findDirectLocalExecutorProfile(
type CreatedLocalRepositoryForm = Pick<
DialogFormState,
"updateRepository" | "setExecutorId" | "setExecutorProfileId"
"repositories" | "updateRepository" | "setExecutorId" | "setExecutorProfileId"
>;
export function applyCreatedLocalRepository({
@@ -79,20 +79,25 @@ export function applyCreatedLocalRepository({
repository: Repository;
workspaceId: string;
upsertWorkspaceRepository: (workspaceId: string, repository: Repository) => void;
executorSelection: DirectLocalExecutorSelection;
executorSelection: DirectLocalExecutorSelection | null;
}) {
upsertWorkspaceRepository(workspaceId, repository);
if (!fs.repositories.some((row) => row.key === rowKey)) return;
const selection = fs.repositories.length === 1 ? executorSelection : null;
fs.updateRepository(rowKey, {
repositoryId: repository.id,
localPath: undefined,
branch: "main",
branchPolicyId: undefined,
});
fs.setExecutorId(executorSelection.executorId);
fs.setExecutorProfileId(executorSelection.executorProfileId);
upsertWorkspaceRepository(workspaceId, repository);
if (selection) {
fs.setExecutorId(selection.executorId);
fs.setExecutorProfileId(selection.executorProfileId);
}
syncTaskCreateLastUsed({
repository_id: repository.id,
branch: "main",
executor_profile_id: executorSelection.executorProfileId,
...(selection ? { executor_profile_id: selection.executorProfileId } : {}),
});
}
@@ -446,7 +451,7 @@ export function useDialogHandlers(
);
const handleLocalRepositoryCreated = useCallback(
(rowKey: string, repository: Repository) => {
if (!context?.workspaceId || !directLocalExecutorSelection) return;
if (!context?.workspaceId) return;
applyCreatedLocalRepository({
fs,
rowKey,
@@ -455,7 +460,9 @@ export function useDialogHandlers(
upsertWorkspaceRepository: context.upsertWorkspaceRepository,
executorSelection: directLocalExecutorSelection,
});
clearFreshBranch(fs);
if (fs.repositories.length === 1 && fs.repositories.some((row) => row.key === rowKey)) {
clearFreshBranch(fs);
}
},
[context, directLocalExecutorSelection, fs],
);
@@ -15,6 +15,7 @@ import {
CommandList,
} from "@kandev/ui/command";
import { BranchRefreshButton } from "@/components/branch-refresh-button";
import { controlSizingClassName } from "@kandev/ui/control-sizing";
import { useTaskCreateDialogPopoverContainer } from "@/hooks/use-task-create-dialog-popover-container";
import { usePillTooltipSuppression } from "@/hooks/use-pill-tooltip-suppression";
import { useTooltipMountGate } from "@/hooks/use-tooltip-mount-gate";
@@ -280,7 +281,7 @@ function PillPopoverContent({
action.onSelect();
setOpen(false);
}}
className="inline-flex h-12 w-12 shrink-0 items-center justify-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground cursor-pointer"
className={`${controlSizingClassName("icon")} max-md:min-h-12 max-md:min-w-12 [@media(pointer:coarse)]:min-h-12 [@media(pointer:coarse)]:min-w-12 inline-flex shrink-0 items-center justify-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground cursor-pointer`}
>
{action.icon}
</button>
@@ -1,3 +1,4 @@
/* eslint-disable max-lines -- repository creation regressions share this focused row fixture. */
import { describe, it, expect, vi, afterEach } from "vitest";
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
import type { Branch, Repository, RepositoryBranchPolicy } from "@/lib/types/http";
@@ -13,6 +14,12 @@ const mockBranches = vi.hoisted(
}),
);
const mockPolicies = vi.hoisted((): { value: RepositoryBranchPolicy[] } => ({ value: [] }));
type CreationSurfaceProps = {
open: boolean;
onCreated: (repository: Repository) => boolean | void;
onOpenChange: (open: boolean) => void;
};
const creationSurface = vi.hoisted(() => ({ props: null as CreationSurfaceProps | null }));
vi.mock("@/hooks/domains/workspace/use-repository-branches", () => ({
useBranches: (source: unknown) => {
@@ -49,10 +56,18 @@ vi.mock("@/components/repository-discovery-controls", () => ({
RepositoryDiscoveryControls: () => <div data-testid="repository-discovery-controls" />,
}));
vi.mock("@/components/create-local-repository-surface", () => ({
CreateLocalRepositorySurface: (props: CreationSurfaceProps) => {
creationSurface.props = props;
return null;
},
}));
import { RepoChipsRow } from "./task-create-dialog-repo-chips";
afterEach(() => {
cleanup();
creationSurface.props = null;
mockBranches.value = { branches: [], isLoading: false, isLoaded: false };
mockPolicies.value = [];
});
@@ -193,6 +208,35 @@ describe("RepoChipsRow", () => {
expect(screen.getAllByTestId("repo-chip")).toHaveLength(2);
});
it("binds delayed creation to the originating row without closing a newer surface", () => {
const onCreated = vi.fn();
renderInProvider(
<RepoChipsRow
fs={makeFs({ repositories: [row({ key: "r0" }), row({ key: "r1" })] })}
repositories={[makeRepo(REPO_FRONT_ID, "frontend")]}
isTaskStarted={false}
workspaceId="ws-1"
onRowRepositoryChange={NOOP}
onRowBranchChange={NOOP}
localRepositoryCreation={{ executorSelection: null, onCreated }}
/>,
);
fireEvent.click(screen.getAllByTestId(REPO_CHIP_TRIGGER)[0]);
fireEvent.click(screen.getByTestId("create-local-repository-button"));
const firstCompletion = creationSurface.props!.onCreated;
creationSurface.props!.onOpenChange(false);
fireEvent.click(screen.getAllByTestId(REPO_CHIP_TRIGGER)[1]);
fireEvent.click(screen.getByTestId("create-local-repository-button"));
expect(creationSurface.props!.open).toBe(true);
const result = firstCompletion(makeRepo("repo-new", "new"));
expect(result).toBe(false);
expect(onCreated).toHaveBeenCalledWith("r0", expect.objectContaining({ id: "repo-new" }));
expect(creationSurface.props!.open).toBe(true);
});
it("renders the remote chips row in Remote mode (workspace chips suppressed)", () => {
renderInProvider(
<RepoChipsRow
@@ -1,6 +1,6 @@
"use client";
import { useRef, useState } from "react";
import { useCallback, useRef, useState } from "react";
import { IconGitFork } from "@tabler/icons-react";
import { cn } from "@/lib/utils";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
@@ -87,24 +87,6 @@ type RepoChipsRowProps = {
};
};
function useRepositoryCreationOpenChange(
chipRowRef: { current: HTMLDivElement | null },
creatingForRowKey: string | null,
setCreatingForRowKey: (value: string | null) => void,
) {
return (open: boolean) => {
if (open || creatingForRowKey === null) return;
const rowKey = creatingForRowKey;
setCreatingForRowKey(null);
requestAnimationFrame(() => {
const row = Array.from(
chipRowRef.current?.querySelectorAll<HTMLElement>("[data-repo-row-key]") ?? [],
).find((candidate) => candidate.dataset.repoRowKey === rowKey);
row?.querySelector<HTMLElement>("[data-testid='repo-chip-trigger']")?.focus();
});
};
}
function applyRowBranchChange(
fs: DialogFormState,
isLocalExecutor: boolean | undefined,
@@ -125,6 +107,59 @@ function updateSavedBaseBranch(fs: DialogFormState, key: string, value: string)
fs.updateRepository(key, { baseBranch: value || undefined });
}
type CreatingRepositoryTarget = { rowKey: string; requestId: number };
function useCreatingRepositoryTarget() {
const [target, setTarget] = useState<CreatingRepositoryTarget | null>(null);
const targetRef = useRef<CreatingRepositoryTarget | null>(null);
const nextRequestId = useRef(0);
const openForRow = useCallback((rowKey: string) => {
const nextTarget = { rowKey, requestId: ++nextRequestId.current };
targetRef.current = nextTarget;
setTarget(nextTarget);
}, []);
const clear = useCallback(() => {
const rowKey = targetRef.current?.rowKey ?? null;
targetRef.current = null;
setTarget(null);
return rowKey;
}, []);
return { target, targetRef, openForRow, clear };
}
function LocalRepositoryCreationSurface({
creation,
target,
targetRef,
workspaceId,
multiRow,
onOpenChange,
}: {
creation: RepoChipsRowProps["localRepositoryCreation"];
target: CreatingRepositoryTarget | null;
targetRef: { current: CreatingRepositoryTarget | null };
workspaceId: string | null;
multiRow: boolean;
onOpenChange: (open: boolean) => void;
}) {
if (!creation) return null;
return (
<CreateLocalRepositorySurface
open={target !== null}
onOpenChange={onOpenChange}
workspaceId={workspaceId}
executorSelection={creation.executorSelection}
context={multiRow ? "task-create-multi" : "task-create"}
onCreated={(repository) => {
if (!target) return false;
const isCurrentRequest = targetRef.current?.requestId === target.requestId;
creation.onCreated(target.rowKey, repository);
return isCurrentRequest;
}}
/>
);
}
export function RepoChipsRow({
fs,
repositories,
@@ -149,12 +184,18 @@ export function RepoChipsRow({
repositorySets,
}: RepoChipsRowProps) {
const chipRowRef = useRef<HTMLDivElement>(null);
const [creatingForRowKey, setCreatingForRowKey] = useState<string | null>(null);
const handleCreationOpenChange = useRepositoryCreationOpenChange(
chipRowRef,
creatingForRowKey,
setCreatingForRowKey,
);
const { target, targetRef, openForRow, clear } = useCreatingRepositoryTarget();
const handleCreationOpenChange = (open: boolean) => {
if (open || target === null) return;
const rowKey = clear();
if (rowKey === null) return;
requestAnimationFrame(() => {
const row = Array.from(
chipRowRef.current?.querySelectorAll<HTMLElement>("[data-repo-row-key]") ?? [],
).find((candidate) => candidate.dataset.repoRowKey === rowKey);
row?.querySelector<HTMLElement>("[data-testid='repo-chip-trigger']")?.focus();
});
};
// Local executor branch behavior:
// - chip is clickable (user can switch to any existing branch on disk)
// - row.branch seeds from the workspace's current branch (currentLocalBranch)
@@ -207,7 +248,7 @@ export function RepoChipsRow({
onWorkspacePathChange={onWorkspacePathChange}
lastUsedBranch={lastUsedBranch}
userSettingsLoaded={userSettingsLoaded}
onCreateRepository={localRepositoryCreation ? setCreatingForRowKey : undefined}
onCreateRepository={localRepositoryCreation ? openForRow : undefined}
onRefreshRepositories={onRefreshRepositories}
repositoriesRefreshing={repositoriesRefreshing}
/>
@@ -226,17 +267,14 @@ export function RepoChipsRow({
onToggleRemote={onToggleRemote}
onToggleNoRepository={onToggleNoRepository}
/>
{localRepositoryCreation ? (
<CreateLocalRepositorySurface
open={creatingForRowKey !== null}
onOpenChange={handleCreationOpenChange}
workspaceId={workspaceId}
executorSelection={localRepositoryCreation.executorSelection}
onCreated={(repository) => {
if (creatingForRowKey) localRepositoryCreation.onCreated(creatingForRowKey, repository);
}}
/>
) : null}
<LocalRepositoryCreationSurface
creation={localRepositoryCreation}
target={target}
targetRef={targetRef}
workspaceId={workspaceId}
multiRow={fs.repositories.length > 1}
onOpenChange={handleCreationOpenChange}
/>
</div>
);
}
@@ -107,7 +107,7 @@ describe("WorkspaceRepoChips duplicate policy", () => {
renderChips({ allowDuplicateRepositories: false });
fireEvent.click(screen.getAllByTestId(CHIP_TRIGGER)[1]);
expect(screen.queryByText("Create new repository")).toBeNull();
expect(screen.queryByRole("button", { name: "Create new repository" })).toBeNull();
});
it("routes repository creation to the only row", () => {
@@ -130,11 +130,30 @@ describe("WorkspaceRepoChips duplicate policy", () => {
expect(onRefreshRepositories).toHaveBeenCalledOnce();
});
it("does not expose repository creation for multi-repository tasks", () => {
renderChips({ onCreateRepository: vi.fn() });
it("offers creation and refresh from a second repository row", () => {
const onCreateRepository = vi.fn();
const onRefreshRepositories = vi.fn();
renderChips({ onCreateRepository, onRefreshRepositories });
fireEvent.click(screen.getAllByTestId(CHIP_TRIGGER)[1]);
fireEvent.click(screen.getByTestId("repo-refresh-button"));
expect(onRefreshRepositories).toHaveBeenCalledOnce();
fireEvent.change(screen.getByPlaceholderText("Search repositories..."), {
target: { value: "no-matching-repository" },
});
fireEvent.click(screen.getByRole("button", { name: "Create new repository" }));
expect(onCreateRepository).toHaveBeenCalledWith("r1");
});
expect(screen.queryByText("Create new repository")).toBeNull();
it.each([0, 1])("keeps both actions in empty lists on row %i while refreshing", (rowIndex) => {
renderChips({
repositories: [],
onCreateRepository: vi.fn(),
onRefreshRepositories: vi.fn(),
repositoriesRefreshing: true,
});
fireEvent.click(screen.getAllByTestId(CHIP_TRIGGER)[rowIndex]);
expect(screen.getByTestId("repo-refresh-button").hasAttribute("disabled")).toBe(true);
expect(screen.getByRole("button", { name: "Create new repository" })).toBeTruthy();
});
});
@@ -141,10 +141,8 @@ export function WorkspaceRepoChips({
onPolicySelected={onPolicySelected}
showBranchPolicies={showBranchPolicies}
showDiscoveryControls={showDiscoveryControls}
onCreateRepository={
rows.length === 1 && onCreateRepository ? () => onCreateRepository(row.key) : undefined
}
onRefreshRepositories={rows.length === 1 ? onRefreshRepositories : undefined}
onCreateRepository={onCreateRepository ? () => onCreateRepository(row.key) : undefined}
onRefreshRepositories={onRefreshRepositories}
repositoriesRefreshing={repositoriesRefreshing}
onRemove={() => onRemove(row.key)}
/>
@@ -176,6 +176,6 @@ describe("WatcherRepositoryFields", () => {
const optionClassName = screen.getByRole("option", { name: /^main local/ }).className;
expect(optionClassName).toContain("max-md:min-h-12");
expect(optionClassName).toContain("[@media(pointer:coarse)]:min-h-12");
expect(screen.getByTestId("branch-refresh-button").className).toContain("h-12 w-12");
expect(screen.getByTestId("branch-refresh-button").className).toContain("size-11");
});
});
@@ -7,6 +7,7 @@ import type { ApiClient } from "../../helpers/api-client";
import { waitForHttp } from "../../helpers/causal-waits";
import { useRegularMode } from "../../helpers/regular-mode";
import { KanbanPage } from "../../pages/kanban-page";
import { exerciseMultiRowCreation } from "./local-repository-multi-row-helpers";
useRegularMode();
@@ -133,6 +134,19 @@ function taskIdFromUrl(page: Page): string {
}
test.describe("Create task with a new local repository", () => {
test("refreshes and creates from a second repository row", async ({
testPage,
apiClient,
seedData,
backend,
}) => {
await openCreateTask(testPage);
await exerciseMultiRowCreation(testPage, apiClient, {
workspaceId: seedData.workspaceId,
parentPath: backend.tmpDir,
mobile: false,
});
});
test("initializes, registers, selects, and starts from a real main repository", async ({
testPage,
apiClient,
@@ -0,0 +1,132 @@
import path from "node:path";
import { expect, type Locator, type Page } from "@playwright/test";
import type { ApiClient } from "../../helpers/api-client";
import { waitForHttp } from "../../helpers/causal-waits";
export async function exerciseMultiRowCreation(
page: Page,
apiClient: ApiClient,
options: { workspaceId: string; parentPath: string; mobile: boolean },
) {
const activate = (locator: Locator) => (options.mobile ? locator.tap() : locator.click());
const name = options.mobile ? "mobile-second-repository" : "desktop-second-repository";
await page.getByTestId("task-title-input").fill(name);
await page.getByTestId("task-description-input").fill("/e2e:simple-message");
const executor = page.getByTestId("executor-profile-selector");
await activate(executor);
await activate(page.getByRole("option", { name: /Worktree/i }));
const originalExecutor = await executor.textContent();
const firstRow = page.getByTestId("repo-chip").nth(0);
await expect(firstRow).toHaveAttribute("data-repository-id", /.+/);
await expect(firstRow.getByTestId("branch-chip-trigger")).toBeEnabled();
await expect(firstRow.getByTestId("branch-chip-trigger")).not.toHaveText("branch");
const firstId = await firstRow.getAttribute("data-repository-id");
expect(firstId).toBeTruthy();
const firstBranch = await firstRow.getByTestId("branch-chip-trigger").textContent();
await activate(page.getByTestId("add-repository"));
const secondRow = page.getByTestId("repo-chip").nth(1);
await activate(secondRow.getByTestId("repo-chip-trigger"));
const refresh = page.getByTestId("repo-refresh-button");
const create = page.getByTestId("create-local-repository-button");
await expect(refresh).toBeVisible();
await expect(create).toBeVisible();
const repositoriesPath = `/api/v1/workspaces/${options.workspaceId}/repositories`;
let releaseRefresh!: () => void;
const refreshBarrier = new Promise<void>((resolve) => {
releaseRefresh = resolve;
});
await page.route(`**${repositoriesPath}`, async (route) => {
if (route.request().method() !== "GET") return route.continue();
const response = await route.fetch();
const body = await response.json();
await refreshBarrier;
await route.fulfill({
response,
json: {
...body,
repositories: [
...body.repositories,
{ ...body.repositories[0], id: "refresh-only-option", name: "Refreshed option" },
],
},
});
});
const refreshed = waitForHttp(page, "GET", new RegExp(`${repositoriesPath}$`));
try {
await activate(refresh);
await expect(refresh).toBeDisabled();
await expect(create).toBeVisible();
releaseRefresh();
await refreshed;
await expect(page.getByRole("option", { name: /Refreshed option/ })).toBeVisible();
} finally {
releaseRefresh();
await refreshed;
await page.unroute(`**${repositoriesPath}`);
}
await expect(firstRow).toHaveAttribute("data-repository-id", firstId!);
await page.getByPlaceholder("Search repositories...").fill("no-matching-option");
await expect(refresh).toBeVisible();
await expect(create).toBeVisible();
for (const control of [refresh, create]) {
const box = await control.boundingBox();
if (options.mobile) {
expect(box!.height).toBeGreaterThanOrEqual(44);
expect(box!.width).toBeGreaterThanOrEqual(44);
} else {
expect(box!.height).toBeCloseTo(28, 0);
expect(box!.width).toBeCloseTo(28, 0);
}
}
await activate(create);
const surface = page.getByTestId(
options.mobile ? "create-local-repository-drawer" : "create-local-repository-dialog",
);
await expect(surface).toBeVisible();
await surface.getByRole("textbox", { name: "Repository name" }).fill(name);
await surface.getByRole("textbox", { name: "Parent directory" }).fill(options.parentPath);
const initialized = waitForHttp(page, "POST", /\/repositories\/initialize-local$/);
await activate(surface.getByRole("button", { name: "Create repository" }));
await initialized;
await expect(surface).not.toBeVisible();
await expect(secondRow.getByTestId("repo-chip-trigger")).toContainText(name);
await expect(secondRow.getByTestId("branch-chip-trigger")).toContainText("main");
await expect(firstRow).toHaveAttribute("data-repository-id", firstId!);
await expect(firstRow.getByTestId("branch-chip-trigger")).toHaveText(firstBranch!);
await expect(executor).toHaveText(originalExecutor!);
const secondId = await secondRow.getAttribute("data-repository-id");
await activate(secondRow.getByTestId("repo-chip-trigger"));
await expect(refresh).toBeVisible();
await expect(create).toBeVisible();
await page.keyboard.press("Escape");
expect(
await page.evaluate(
() => document.documentElement.scrollWidth > document.documentElement.clientWidth,
),
).toBe(false);
const taskCreated = page.waitForResponse(
(r) => r.url().endsWith("/api/v1/tasks") && r.request().method() === "POST",
);
await activate(page.getByTestId("submit-start-agent"));
const response = await taskCreated;
expect(response.ok()).toBe(true);
const task = await response.json();
const taskRepositories = (await apiClient.getTask(task.id)).repositories;
expect(taskRepositories).toHaveLength(2);
expect(taskRepositories).toEqual(
expect.arrayContaining([
expect.objectContaining({ repository_id: firstId }),
expect.objectContaining({ repository_id: secondId, base_branch: "main" }),
]),
);
await expect
.poll(() => apiClient.getTaskEnvironment(task.id))
.toMatchObject({ executor_type: "worktree" });
const repositories = await apiClient.rawRequest("GET", repositoriesPath);
expect((await repositories.json()).repositories).toEqual(
expect.arrayContaining([
expect.objectContaining({ id: secondId, local_path: path.join(options.parentPath, name) }),
]),
);
}
@@ -6,6 +6,7 @@ import { test, expect } from "../../fixtures/test-base";
import type { ApiClient } from "../../helpers/api-client";
import { useRegularMode } from "../../helpers/regular-mode";
import { MobileKanbanPage } from "../../pages/mobile-kanban-page";
import { exerciseMultiRowCreation } from "./local-repository-multi-row-helpers";
useRegularMode();
@@ -178,6 +179,19 @@ function expectMainRepository(repositoryPath: string): void {
}
test.describe("Create task with a new local repository on mobile", () => {
test("refreshes and creates from a second repository row", async ({
testPage,
apiClient,
seedData,
backend,
}) => {
await openCreateTask(testPage);
await exerciseMultiRowCreation(testPage, apiClient, {
workspaceId: seedData.workspaceId,
parentPath: backend.tmpDir,
mobile: true,
});
});
test("creates and selects the repository in a contained, scrollable drawer", async ({
testPage,
apiClient,
@@ -100,6 +100,24 @@ describe("useRepositories", () => {
expect(mockSetRepositories).toHaveBeenCalledWith("ws-1", [{ id: "r1", name: "Repo One" }]);
});
it("keeps a cached workspace loading until its refresh completes", async () => {
setup(true);
let complete!: (response: { repositories: Repos }) => void;
mockListRepositories.mockReturnValue(
new Promise((resolve) => {
complete = resolve;
}),
);
const { result, rerender } = renderHook(() => useRepositories("ws-1"));
const pending = result.current.refresh();
mockState.repositories.loadingByWorkspaceId["ws-1"] = true;
rerender();
expect(mockSetRepositoriesLoading).not.toHaveBeenCalledWith("ws-1", false);
complete({ repositories: [{ id: "r2", name: "Refreshed" }] });
await pending;
expect(mockSetRepositoriesLoading).toHaveBeenLastCalledWith("ws-1", false);
});
it("keeps cached repositories when a manual refresh fails", async () => {
setup(/* loaded */ true);
mockListRepositories.mockRejectedValue(new Error("Network unavailable"));
@@ -112,3 +130,48 @@ describe("useRepositories", () => {
expect(mockSetRepositoriesLoading).toHaveBeenLastCalledWith("ws-1", false);
});
});
describe("useRepositories request ownership", () => {
beforeEach(() => {
mockListRepositories.mockResolvedValue({ repositories: [{ id: "r1", name: "Repo One" }] });
});
it("clears loading when a force refresh is cancelled", async () => {
setup(/* loaded */ true);
let complete!: (response: { repositories: Repos }) => void;
mockListRepositories.mockReturnValue(
new Promise((resolve) => {
complete = resolve;
}),
);
const { unmount } = renderHook(() => useRepositories("ws-1", true, true));
await waitFor(() => expect(mockSetRepositoriesLoading).toHaveBeenCalledWith("ws-1", true));
unmount();
expect(mockSetRepositoriesLoading).toHaveBeenLastCalledWith("ws-1", false);
complete({ repositories: [{ id: "stale", name: "Stale" }] });
await Promise.resolve();
expect(mockSetRepositories).not.toHaveBeenCalled();
});
it("keeps loading while another workspace request is active", async () => {
setup(/* loaded */ true);
let completeFirst!: (response: { repositories: Repos }) => void;
let completeSecond!: (response: { repositories: Repos }) => void;
mockListRepositories
.mockReturnValueOnce(new Promise((resolve) => (completeFirst = resolve)))
.mockReturnValueOnce(new Promise((resolve) => (completeSecond = resolve)));
const first = renderHook(() => useRepositories("ws-1", true, true));
const second = renderHook(() => useRepositories("ws-1", true, true));
await waitFor(() => expect(mockListRepositories).toHaveBeenCalledTimes(2));
first.unmount();
expect(mockSetRepositoriesLoading).toHaveBeenLastCalledWith("ws-1", true);
completeSecond({ repositories: [{ id: "fresh", name: "Fresh" }] });
await waitFor(() => expect(mockSetRepositoriesLoading).toHaveBeenLastCalledWith("ws-1", false));
completeFirst({ repositories: [{ id: "stale", name: "Stale" }] });
second.unmount();
});
});
@@ -5,6 +5,28 @@ import { listRepositories } from "@/lib/api";
const EMPTY_REPOSITORIES: Repository[] = [];
const REPOSITORY_LIST_RETRY_DELAYS_MS = [100, 250, 500, 1_000] as const;
const activeRepositoryRequests = new Map<string, number>();
function beginRepositoryRequest(
workspaceId: string,
setRepositoriesLoading: (workspaceId: string, loading: boolean) => void,
): () => void {
activeRepositoryRequests.set(workspaceId, (activeRepositoryRequests.get(workspaceId) ?? 0) + 1);
setRepositoriesLoading(workspaceId, true);
let released = false;
return () => {
if (released) return;
released = true;
const remaining = (activeRepositoryRequests.get(workspaceId) ?? 1) - 1;
if (remaining > 0) {
activeRepositoryRequests.set(workspaceId, remaining);
setRepositoriesLoading(workspaceId, true);
} else {
activeRepositoryRequests.delete(workspaceId);
setRepositoriesLoading(workspaceId, false);
}
};
}
async function listRepositoriesUntilSettled(workspaceId: string) {
for (const retryDelayMs of REPOSITORY_LIST_RETRY_DELAYS_MS) {
@@ -47,31 +69,24 @@ export function useRepositories(workspaceId: string | null, enabled = true, forc
const refresh = useCallback(async () => {
if (!enabled || !workspaceId) return;
setRepositoriesLoading(workspaceId, true);
const releaseRequest = beginRepositoryRequest(workspaceId, setRepositoriesLoading);
try {
const response = await listRepositoriesUntilSettled(workspaceId);
setRepositories(workspaceId, response.repositories);
} catch {
// Keep the existing cached repositories when a manual refresh fails.
} finally {
setRepositoriesLoading(workspaceId, false);
releaseRequest();
}
}, [enabled, setRepositories, setRepositoriesLoading, workspaceId]);
useEffect(() => {
if (!enabled || !workspaceId) return;
if (isLoaded && isLoading) {
setRepositoriesLoading(workspaceId, false);
}
}, [enabled, isLoaded, isLoading, setRepositoriesLoading, workspaceId]);
// Force-refresh: pull a fresh list once per workspace, bypassing the
// isLoaded cache. forcedRef is set only on success so a failed fetch retries.
useEffect(() => {
if (!enabled || !workspaceId || !forceRefresh) return;
if (forcedRef.current === workspaceId) return;
let cancelled = false;
setRepositoriesLoading(workspaceId, true);
const releaseRequest = beginRepositoryRequest(workspaceId, setRepositoriesLoading);
listRepositoriesUntilSettled(workspaceId)
.then((response) => {
if (cancelled) return;
@@ -81,12 +96,10 @@ export function useRepositories(workspaceId: string | null, enabled = true, forc
.catch(() => {
// Leave forcedRef unset so the next mount retries; keep cached repos.
})
.finally(() => {
if (cancelled) return;
setRepositoriesLoading(workspaceId, false);
});
.finally(releaseRequest);
return () => {
cancelled = true;
releaseRequest();
};
}, [enabled, forceRefresh, workspaceId, setRepositories, setRepositoriesLoading]);
@@ -94,7 +107,7 @@ export function useRepositories(workspaceId: string | null, enabled = true, forc
if (!enabled || !workspaceId || forceRefresh) return;
if (isLoaded) return;
let cancelled = false;
setRepositoriesLoading(workspaceId, true);
const releaseRequest = beginRepositoryRequest(workspaceId, setRepositoriesLoading);
listRepositoriesUntilSettled(workspaceId)
.then((response) => {
if (cancelled) return;
@@ -104,12 +117,10 @@ export function useRepositories(workspaceId: string | null, enabled = true, forc
// Keep the cache unloaded after a failed request. The next dialog
// mount can retry instead of treating an empty fallback as real.
})
.finally(() => {
if (cancelled) return;
setRepositoriesLoading(workspaceId, false);
});
.finally(releaseRequest);
return () => {
cancelled = true;
releaseRequest();
};
}, [enabled, forceRefresh, isLoaded, setRepositories, setRepositoriesLoading, workspaceId]);