e.stopPropagation()}
>
{listing.entries.map((entry) => (
@@ -240,7 +407,10 @@ function Entries({
key={entry.path}
type="button"
onClick={() => onDescend(entry.path)}
- className="group flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs hover:bg-accent cursor-pointer"
+ className={cn(
+ "group flex w-full items-center gap-2 px-3 text-left text-xs hover:bg-accent cursor-pointer",
+ touchRows ? "min-h-12 py-2" : "py-1.5",
+ )}
data-testid="folder-picker-entry"
>
diff --git a/apps/web/components/task-create-dialog-handlers.test.ts b/apps/web/components/task-create-dialog-handlers.test.ts
index 40fe086ee7..0d7800731a 100644
--- a/apps/web/components/task-create-dialog-handlers.test.ts
+++ b/apps/web/components/task-create-dialog-handlers.test.ts
@@ -1,11 +1,129 @@
-import { beforeEach, describe, expect, it } from "vitest";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import type { Executor, Repository } from "@/lib/types/http";
import {
+ applyCreatedLocalRepository,
+ findDirectLocalExecutorProfile,
queueTaskCreateLastUsedFromPayload,
readQueuedTaskCreateLastUsedState,
resetTaskCreateLastUsedSync,
syncTaskCreateLastUsed,
} from "./task-create-dialog-handlers";
+const WORKTREE_PROFILE_ID = "worktree-profile";
+const LOCAL_PROFILE_ID = "local-profile";
+
+function executor(
+ id: string,
+ type: Executor["type"],
+ profiles: Array<{ id: string; name: string; executor_type?: Executor["type"] }>,
+): Executor {
+ return {
+ id,
+ type,
+ name: id,
+ profiles: profiles.map((profile) => ({
+ ...profile,
+ executor_id: id,
+ prepare_script: "",
+ cleanup_script: "",
+ created_at: "",
+ updated_at: "",
+ })),
+ status: "active",
+ is_system: false,
+ created_at: "",
+ updated_at: "",
+ };
+}
+
+function repository(id: string): Repository {
+ return {
+ id,
+ workspace_id: "ws-1",
+ name: "alpha",
+ source_type: "local",
+ local_path: "/work/alpha",
+ default_branch: "main",
+ created_at: "",
+ updated_at: "",
+ } as Repository;
+}
+
+describe("local repository creation selection", () => {
+ it("keeps the selected profile when it already runs directly on the local host", () => {
+ const selection = findDirectLocalExecutorProfile(
+ [
+ executor("worktree", "worktree", [{ id: WORKTREE_PROFILE_ID, name: "Worktree" }]),
+ executor("local", "local", [{ id: LOCAL_PROFILE_ID, name: "Local" }]),
+ ],
+ LOCAL_PROFILE_ID,
+ );
+
+ expect(selection).toEqual({
+ executorId: "local",
+ executorProfileId: LOCAL_PROFILE_ID,
+ executorProfileName: "Local",
+ requiresSwitch: false,
+ });
+ });
+
+ it("chooses a direct-local profile when the selected profile is incompatible", () => {
+ const selection = findDirectLocalExecutorProfile(
+ [
+ executor("worktree", "worktree", [{ id: WORKTREE_PROFILE_ID, name: "Worktree" }]),
+ executor("local", "local_pc", [{ id: LOCAL_PROFILE_ID, name: "This computer" }]),
+ ],
+ WORKTREE_PROFILE_ID,
+ );
+
+ expect(selection).toMatchObject({
+ executorId: "local",
+ executorProfileId: LOCAL_PROFILE_ID,
+ requiresSwitch: true,
+ });
+ });
+
+ it("returns null when no direct-local profile exists", () => {
+ expect(
+ findDirectLocalExecutorProfile(
+ [executor("worktree", "worktree", [{ id: WORKTREE_PROFILE_ID, name: "Worktree" }])],
+ WORKTREE_PROFILE_ID,
+ ),
+ ).toBeNull();
+ });
+
+ it("patches only the originating row and switches executor state", () => {
+ const updateRepository = vi.fn();
+ const setExecutorId = vi.fn();
+ const setExecutorProfileId = vi.fn();
+ const upsertWorkspaceRepository = vi.fn();
+ const created = repository("repo-new");
+
+ applyCreatedLocalRepository({
+ fs: { updateRepository, setExecutorId, setExecutorProfileId },
+ rowKey: "row-2",
+ repository: created,
+ workspaceId: "ws-1",
+ upsertWorkspaceRepository,
+ executorSelection: {
+ executorId: "local",
+ executorProfileId: LOCAL_PROFILE_ID,
+ executorProfileName: "Local",
+ requiresSwitch: true,
+ },
+ });
+
+ expect(updateRepository).toHaveBeenCalledWith("row-2", {
+ repositoryId: "repo-new",
+ localPath: undefined,
+ branch: "main",
+ });
+ expect(setExecutorId).toHaveBeenCalledWith("local");
+ expect(setExecutorProfileId).toHaveBeenCalledWith(LOCAL_PROFILE_ID);
+ expect(upsertWorkspaceRepository).toHaveBeenCalledWith("ws-1", created);
+ });
+});
+
describe("syncTaskCreateLastUsed", () => {
beforeEach(() => {
window.localStorage.clear();
diff --git a/apps/web/components/task-create-dialog-handlers.ts b/apps/web/components/task-create-dialog-handlers.ts
index 7eaf9204da..682a8698a6 100644
--- a/apps/web/components/task-create-dialog-handlers.ts
+++ b/apps/web/components/task-create-dialog-handlers.ts
@@ -1,7 +1,7 @@
"use client";
import { useCallback } from "react";
-import type { Repository } from "@/lib/types/http";
+import type { Executor, Repository } from "@/lib/types/http";
import type { DialogFormState, TaskRepoRow } from "@/components/task-create-dialog-types";
import { createDebugLogger } from "@/lib/debug/log";
import type { TaskCreateLastUsedState } from "@/lib/state/slices/settings/types";
@@ -24,6 +24,73 @@ type TaskCreateLastUsedPayload = {
executor_profile_id?: string;
};
+export type DirectLocalExecutorSelection = {
+ executorId: string;
+ executorProfileId: string;
+ executorProfileName: string;
+ requiresSwitch: boolean;
+};
+
+function isDirectLocalExecutorType(type: string | undefined): boolean {
+ return type === "local" || type === "local_pc";
+}
+
+export function findDirectLocalExecutorProfile(
+ executors: Executor[],
+ currentProfileId: string,
+): DirectLocalExecutorSelection | null {
+ const candidates = executors.flatMap((executor) =>
+ (executor.profiles ?? [])
+ .filter((profile) => isDirectLocalExecutorType(profile.executor_type ?? executor.type))
+ .map((profile) => ({
+ executorId: executor.id,
+ executorProfileId: profile.id,
+ executorProfileName: profile.name,
+ requiresSwitch: profile.id !== currentProfileId,
+ })),
+ );
+ return (
+ candidates.find((candidate) => candidate.executorProfileId === currentProfileId) ??
+ candidates[0] ??
+ null
+ );
+}
+
+type CreatedLocalRepositoryForm = Pick<
+ DialogFormState,
+ "updateRepository" | "setExecutorId" | "setExecutorProfileId"
+>;
+
+export function applyCreatedLocalRepository({
+ fs,
+ rowKey,
+ repository,
+ workspaceId,
+ upsertWorkspaceRepository,
+ executorSelection,
+}: {
+ fs: CreatedLocalRepositoryForm;
+ rowKey: string;
+ repository: Repository;
+ workspaceId: string;
+ upsertWorkspaceRepository: (workspaceId: string, repository: Repository) => void;
+ executorSelection: DirectLocalExecutorSelection;
+}) {
+ fs.updateRepository(rowKey, {
+ repositoryId: repository.id,
+ localPath: undefined,
+ branch: "main",
+ });
+ fs.setExecutorId(executorSelection.executorId);
+ fs.setExecutorProfileId(executorSelection.executorProfileId);
+ upsertWorkspaceRepository(workspaceId, repository);
+ syncTaskCreateLastUsed({
+ repository_id: repository.id,
+ branch: "main",
+ executor_profile_id: executorSelection.executorProfileId,
+ });
+}
+
let lastQueuedLastUsed: Partial
= {};
const lastUsedDebug = createDebugLogger("task-create:last-used");
@@ -292,13 +359,42 @@ function useGitHubAndFreshBranchHandlers(fs: DialogFormState) {
};
}
-export function useDialogHandlers(fs: DialogFormState, repositories: Repository[]) {
+export function useDialogHandlers(
+ fs: DialogFormState,
+ repositories: Repository[],
+ context?: {
+ workspaceId: string | null;
+ executors: Executor[];
+ upsertWorkspaceRepository: (workspaceId: string, repository: Repository) => void;
+ },
+) {
const repo = useRepositoryHandlers(fs, repositories);
const profile = useProfileAndNameHandlers(fs);
const gh = useGitHubAndFreshBranchHandlers(fs);
+ const directLocalExecutorSelection = findDirectLocalExecutorProfile(
+ context?.executors ?? [],
+ fs.executorProfileId,
+ );
+ const handleLocalRepositoryCreated = useCallback(
+ (rowKey: string, repository: Repository) => {
+ if (!context?.workspaceId || !directLocalExecutorSelection) return;
+ applyCreatedLocalRepository({
+ fs,
+ rowKey,
+ repository,
+ workspaceId: context.workspaceId,
+ upsertWorkspaceRepository: context.upsertWorkspaceRepository,
+ executorSelection: directLocalExecutorSelection,
+ });
+ clearFreshBranch(fs);
+ },
+ [context, directLocalExecutorSelection, fs],
+ );
return {
...repo,
...profile,
...gh,
+ directLocalExecutorSelection,
+ handleLocalRepositoryCreated,
};
}
diff --git a/apps/web/components/task-create-dialog-pill.test.tsx b/apps/web/components/task-create-dialog-pill.test.tsx
index 4b77dc07fb..3d43e4730e 100644
--- a/apps/web/components/task-create-dialog-pill.test.tsx
+++ b/apps/web/components/task-create-dialog-pill.test.tsx
@@ -38,6 +38,8 @@ import {
Pill,
} from "./task-create-dialog-pill";
+const CREATE_REPOSITORY = "Create new repository";
+
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
@@ -195,4 +197,46 @@ describe("Pill popover", () => {
expect(content).not.toBeNull();
expect(screen.getByTestId("clipping-host").contains(content)).toBe(false);
});
+
+ it("activates an optional toolbar action with a pointer", () => {
+ const onAction = vi.fn();
+ render(
+ }
+ value=""
+ placeholder="repository"
+ options={[]}
+ onSelect={vi.fn()}
+ searchPlaceholder="Search repositories..."
+ emptyMessage="No repositories"
+ action={{ label: CREATE_REPOSITORY, onSelect: onAction }}
+ />,
+ );
+
+ fireEvent.click(screen.getByText("repository"));
+ fireEvent.click(screen.getByRole("button", { name: CREATE_REPOSITORY }));
+
+ expect(onAction).toHaveBeenCalledOnce();
+ });
+
+ it("renders an optional toolbar action outside the option list", () => {
+ const onAction = vi.fn();
+ render(
+ }
+ value=""
+ placeholder="repository"
+ options={[]}
+ onSelect={vi.fn()}
+ searchPlaceholder="Search repositories..."
+ emptyMessage="No repositories"
+ action={{ label: CREATE_REPOSITORY, onSelect: onAction }}
+ />,
+ );
+
+ fireEvent.click(screen.getByText("repository"));
+ expect(screen.getByRole("button", { name: CREATE_REPOSITORY })).toBeTruthy();
+ expect(screen.queryByRole("option", { name: CREATE_REPOSITORY })).toBeNull();
+ expect(onAction).not.toHaveBeenCalled();
+ });
});
diff --git a/apps/web/components/task-create-dialog-pill.tsx b/apps/web/components/task-create-dialog-pill.tsx
index 5d944e8e9b..1395a135e9 100644
--- a/apps/web/components/task-create-dialog-pill.tsx
+++ b/apps/web/components/task-create-dialog-pill.tsx
@@ -25,6 +25,12 @@ export type PillOption = {
renderLabel?: () => React.ReactNode;
};
+export type PillAction = {
+ label: string;
+ icon?: React.ReactNode;
+ onSelect: () => void;
+};
+
/**
* `Pill` wraps cmdk's `Command` / `CommandInput` / `CommandList`. Its popover
* body only supports cmdk children (`CommandItem`, etc.) — keyboard nav and
@@ -49,6 +55,8 @@ type PillProps = {
onRefresh?: () => void;
/** Show the refresh icon as spinning + disabled while a refresh is in flight. */
refreshing?: boolean;
+ /** Accessible label used for the optional refresh action. */
+ refreshLabel?: string;
/**
* Render without its own border/bg so the pill blends into a wrapping
* grouped container (used by RepoChip to draw one rectangle around
@@ -66,6 +74,8 @@ type PillProps = {
* base) without depending on the user reading a tooltip.
*/
prefix?: string;
+ /** Optional icon action rendered beside the search input. */
+ action?: PillAction;
};
/** Returns the active-state hover classes for the pill trigger button. */
@@ -150,28 +160,65 @@ function PillPopoverContent({
searchPlaceholder,
onRefresh,
refreshing,
+ refreshLabel,
options,
onSelect,
setOpen,
emptyMessage,
portalContainer,
+ action,
}: {
filter?: PillProps["filter"];
searchPlaceholder: string;
onRefresh?: () => void;
refreshing?: boolean;
+ refreshLabel?: string;
options: PillOption[];
onSelect: (value: string) => void;
setOpen: (open: boolean) => void;
emptyMessage: string;
portalContainer: HTMLElement | null;
+ action?: PillAction;
}) {
return (
-
+
-
-
- {onRefresh &&
}
+
+
+
+
+ {onRefresh ? (
+
+ ) : null}
+ {action ? (
+
+
+
+
+ {action.label}
+
+ ) : null}
);
diff --git a/apps/web/components/task-create-dialog-prop-builders.test.ts b/apps/web/components/task-create-dialog-prop-builders.test.ts
index f6d97f5fcd..a1469df789 100644
--- a/apps/web/components/task-create-dialog-prop-builders.test.ts
+++ b/apps/web/components/task-create-dialog-prop-builders.test.ts
@@ -1,83 +1,87 @@
import { describe, expect, it } from "vitest";
-import { computeHasAllBranches } from "./task-create-dialog-prop-builders";
-import type { DialogFormState } from "@/components/task-create-dialog-types";
+import type { DialogFormState } from "./task-create-dialog-types";
+import {
+ computeHasAllBranches,
+ localRepositoryCreationEnabled,
+} from "./task-create-dialog-prop-builders";
-const URL_AB = "github.com/a/b";
-
-// Minimal fs stub for computeHasAllBranches. The function only reads
-// `noRepository`, `useRemote`, `remoteRepos`, and `repositories[].branch`,
-// so we cast a partial through `unknown` to avoid having to materialise the
-// full DialogFormState surface in tests.
-function fsStub(overrides: {
- noRepository?: boolean;
- useRemote?: boolean;
- remoteRepos?: Array<{ url?: string; branch?: string }>;
- repositories?: Array<{ branch?: string }>;
-}): DialogFormState {
+function formState(overrides: Partial
): DialogFormState {
return {
noRepository: false,
useRemote: false,
- remoteRepos: [],
repositories: [],
+ remoteRepos: [],
...overrides,
- } as unknown as DialogFormState;
+ } as DialogFormState;
}
describe("computeHasAllBranches", () => {
- it("returns true when the task is in no-repository mode (short-circuits the rest)", () => {
- // noRepository mode doesn't need a branch — the backend skips the
- // worktree/clone step entirely, so it always satisfies hasAllBranches.
- expect(computeHasAllBranches(fsStub({ noRepository: true, repositories: [] }))).toBe(true);
+ it("accepts no-repository tasks", () => {
+ expect(computeHasAllBranches(formState({ noRepository: true }))).toBe(true);
});
- it("Remote mode: every non-empty row needs a branch", () => {
- // Empty rows (URL not yet filled in) are skipped — the user can leave a
- // half-filled chip without blocking submit, mirroring the workspace path.
- expect(computeHasAllBranches(fsStub({ useRemote: true, remoteRepos: [] }))).toBe(false);
+ it("requires a branch on every populated remote row", () => {
expect(
computeHasAllBranches(
- fsStub({ useRemote: true, remoteRepos: [{ url: URL_AB, branch: "" }] }),
- ),
- ).toBe(false);
- expect(
- computeHasAllBranches(
- fsStub({ useRemote: true, remoteRepos: [{ url: URL_AB, branch: "main" }] }),
- ),
- ).toBe(true);
- expect(
- computeHasAllBranches(
- fsStub({
+ formState({
useRemote: true,
remoteRepos: [
- { url: URL_AB, branch: "main" },
- { url: "", branch: "" },
- ],
- }),
- ),
- ).toBe(true);
- expect(
- computeHasAllBranches(
- fsStub({
- useRemote: true,
- remoteRepos: [
- { url: URL_AB, branch: "main" },
- { url: "github.com/c/d", branch: "" },
+ { key: "one", url: "https://example.com/one.git", branch: "main", source: "paste" },
+ { key: "two", url: "https://example.com/two.git", branch: "", source: "paste" },
],
}),
),
).toBe(false);
});
- it("requires every selected repository row to carry a branch", () => {
- expect(computeHasAllBranches(fsStub({ repositories: [] }))).toBe(false);
- expect(computeHasAllBranches(fsStub({ repositories: [{ branch: "main" }] }))).toBe(true);
- expect(
- computeHasAllBranches(fsStub({ repositories: [{ branch: "main" }, { branch: "" }] })),
- ).toBe(false);
+ it("rejects remote mode without a populated row", () => {
+ expect(computeHasAllBranches(formState({ useRemote: true }))).toBe(false);
+ });
+
+ it("accepts a branched remote row and ignores an empty trailing row", () => {
expect(
computeHasAllBranches(
- fsStub({ repositories: [{ branch: "main" }, { branch: "feature/x" }] }),
+ formState({
+ useRemote: true,
+ remoteRepos: [
+ { key: "one", url: "https://example.com/one.git", branch: "main", source: "paste" },
+ { key: "two", url: "", branch: "", source: "paste" },
+ ],
+ }),
+ ),
+ ).toBe(true);
+ });
+
+ it("rejects local mode without a repository row", () => {
+ expect(computeHasAllBranches(formState({}))).toBe(false);
+ });
+
+ it("rejects a local repository row without a branch", () => {
+ expect(
+ computeHasAllBranches(
+ formState({ repositories: [{ key: "one", repositoryId: "repo-one", branch: "" }] }),
+ ),
+ ).toBe(false);
+ });
+
+ it("requires a branch on every local repository row", () => {
+ expect(
+ computeHasAllBranches(
+ formState({
+ repositories: [
+ { key: "one", repositoryId: "repo-one", branch: "main" },
+ { key: "two", repositoryId: "repo-two", branch: "develop" },
+ ],
+ }),
),
).toBe(true);
});
});
+
+describe("localRepositoryCreationEnabled", () => {
+ it("allows repository creation only for an unlocked new-task form", () => {
+ expect(localRepositoryCreationEnabled(true, false)).toBe(true);
+ expect(localRepositoryCreationEnabled(false, false)).toBe(false);
+ expect(localRepositoryCreationEnabled(true, true)).toBe(false);
+ });
+});
diff --git a/apps/web/components/task-create-dialog-prop-builders.ts b/apps/web/components/task-create-dialog-prop-builders.ts
index 25bc14205c..3a5d5091bd 100644
--- a/apps/web/components/task-create-dialog-prop-builders.ts
+++ b/apps/web/components/task-create-dialog-prop-builders.ts
@@ -23,6 +23,10 @@ export function computeHasAllBranches(fs: DialogFormState): boolean {
return fs.repositories.length > 0 && fs.repositories.every((r) => !!r.branch);
}
+export function localRepositoryCreationEnabled(isCreateMode: boolean, repoLocked: boolean) {
+ return isCreateMode && !repoLocked;
+}
+
export function buildDialogFormBodyProps(
setup: ReturnType,
props: TaskCreateDialogProps,
@@ -59,9 +63,17 @@ export function buildDialogFormBodyProps(
onToggleFreshBranch: handlers.handleToggleFreshBranch,
onToggleNoRepository: repoLocked ? undefined : handlers.handleToggleNoRepository,
onWorkspacePathChange: handlers.handleWorkspacePathChange,
+ localRepositoryCreation: localRepositoryCreationEnabled(setup.isCreateMode, repoLocked)
+ ? {
+ executorSelection: handlers.directLocalExecutorSelection,
+ onCreated: handlers.handleLocalRepositoryCreated,
+ }
+ : undefined,
enhance: setup.enhance,
workflowAgentLocked: computed.workflowAgentLocked,
repositories: setup.repositories,
+ onRefreshRepositories: setup.refreshRepositories,
+ repositoriesRefreshing: setup.repositoriesLoading,
lastUsedBranch: setup.taskCreateLastUsed.branch,
userSettingsLoaded: setup.userSettingsLoaded,
freshBranchAvailable: setup.freshBranchAvailable,
diff --git a/apps/web/components/task-create-dialog-repo-branch-autoselect.ts b/apps/web/components/task-create-dialog-repo-branch-autoselect.ts
index 8a1df1125b..b2090e501c 100644
--- a/apps/web/components/task-create-dialog-repo-branch-autoselect.ts
+++ b/apps/web/components/task-create-dialog-repo-branch-autoselect.ts
@@ -26,7 +26,8 @@ export function useRepoBranchAutoselect({
userSettingsLoaded?: boolean;
}) {
useEffect(() => {
- if (!branchSource || branchesLoading || branches.length === 0 || rowBranch) return;
+ const hasSelectableBranch = branches.length > 0 || !!preferredDefaultBranch;
+ if (!branchSource || branchesLoading || !hasSelectableBranch || rowBranch) return;
runAutoselect({
branches,
preferredDefaultBranch,
diff --git a/apps/web/components/task-create-dialog-repo-chips.test.tsx b/apps/web/components/task-create-dialog-repo-chips.test.tsx
index d89456d253..54aec465dd 100644
--- a/apps/web/components/task-create-dialog-repo-chips.test.tsx
+++ b/apps/web/components/task-create-dialog-repo-chips.test.tsx
@@ -4,10 +4,8 @@ import type { Branch, Repository } from "@/lib/types/http";
import type { DialogFormState, TaskRepoRow } from "./task-create-dialog-types";
import { TooltipProvider } from "@kandev/ui/tooltip";
-// One mocked branches hook now — id-based and path-based rows go through
-// the same loader. Tests can override the return value when they need
-// branch-specific assertions; the mockBySource handle lets a test prove
-// which kind of source the chip passed in.
+// The unified branch mock lets tests override results and inspect whether
+// chips select an id-based or path-based source.
const lastBranchSource = vi.hoisted((): { value: unknown } => ({ value: null }));
const mockBranches = vi.hoisted((): { value: { branches: Branch[]; isLoading: boolean } } => ({
value: { branches: [], isLoading: false },
@@ -58,11 +56,9 @@ function row(overrides: Partial = {}): TaskRepoRow {
}
function makeFs(overrides: Partial): DialogFormState {
- // Only the fields RepoChipsRow actually reads/sets need to be real.
- const remoteUrl = (overrides.remoteRepos?.[0]?.url ?? "") as string;
const branchesByUrl = {
branches: (url: string) =>
- url === remoteUrl
+ url === (overrides.remoteRepos?.[0]?.url ?? "")
? ((overrides as Partial).branchesByUrl?.branches(url) ?? [])
: [],
loading: () => false,
@@ -200,6 +196,28 @@ describe("RepoChipsRow", () => {
expect(onRowBranchChange).toHaveBeenCalledWith("r0", "feature/x");
});
+ it("uses the symbolic current branch for an unborn local repository", () => {
+ mockBranches.value = { branches: [], isLoading: false };
+ const onRowBranchChange = vi.fn();
+ renderInProvider(
+ ,
+ );
+
+ expect(onRowBranchChange).toHaveBeenCalledWith("r0", "main");
+ });
+
it("local-executor row shows the loading placeholder while resolving the current branch", () => {
mockBranches.value = { branches: [], isLoading: false };
renderInProvider(
@@ -243,8 +261,7 @@ describe("RepoChipsRow", () => {
onRowBranchChange={NOOP}
/>,
);
- const btn = screen.getByTestId("add-repository") as HTMLButtonElement;
- expect(btn.disabled).toBe(false);
+ expect((screen.getByTestId("add-repository") as HTMLButtonElement).disabled).toBe(false);
});
it("calls fs.addRepository when the + button is clicked", () => {
@@ -403,7 +420,6 @@ describe("RepoChipsRow", () => {
fireEvent.click(screen.getByTestId("branch-chip-trigger"));
expect(screen.getByText("main")).toBeTruthy();
expect(screen.getByText("origin/main")).toBeTruthy();
- // Reset for sibling tests.
mockBranches.value = { branches: [], isLoading: false };
});
});
@@ -457,6 +473,78 @@ describe("WorkspaceRepoChips duplicate policy", () => {
expect(screen.getByRole("option", { name: /^frontend/ })).toBeTruthy();
expect(screen.getByRole("option", { name: /^backend/ })).toBeTruthy();
});
+
+ it("does not expose repository creation when the caller does not opt in", () => {
+ renderWorkspaceChips(false);
+
+ fireEvent.click(screen.getAllByTestId(REPO_CHIP_TRIGGER)[1]);
+
+ expect(screen.queryByText("Create new repository")).toBeNull();
+ });
+
+ it("routes repository creation to the only row", () => {
+ const onCreateRepository = vi.fn();
+ renderInProvider(
+ ,
+ );
+
+ fireEvent.click(screen.getByTestId(REPO_CHIP_TRIGGER));
+ fireEvent.click(screen.getByTestId("create-local-repository-button"));
+
+ expect(onCreateRepository).toHaveBeenCalledWith("r0");
+ });
+
+ it("refreshes repositories from the selector toolbar", () => {
+ const onRefreshRepositories = vi.fn();
+ renderInProvider(
+ ,
+ );
+
+ fireEvent.click(screen.getByTestId(REPO_CHIP_TRIGGER));
+ fireEvent.click(screen.getByTestId("repo-refresh-button"));
+
+ expect(onRefreshRepositories).toHaveBeenCalledOnce();
+ });
+
+ it("does not expose repository creation for multi-repository tasks", () => {
+ renderInProvider(
+ ,
+ );
+
+ fireEvent.click(screen.getAllByTestId(REPO_CHIP_TRIGGER)[1]);
+
+ expect(screen.queryByText("Create new repository")).toBeNull();
+ });
});
describe("WorkspaceRepoChips workspace markers", () => {
diff --git a/apps/web/components/task-create-dialog-repo-chips.tsx b/apps/web/components/task-create-dialog-repo-chips.tsx
index 9d99769c95..1dfb1c230c 100644
--- a/apps/web/components/task-create-dialog-repo-chips.tsx
+++ b/apps/web/components/task-create-dialog-repo-chips.tsx
@@ -1,5 +1,6 @@
"use client";
+import { useRef, useState } from "react";
import { IconGitFork } from "@tabler/icons-react";
import { cn } from "@/lib/utils";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
@@ -9,6 +10,8 @@ import { RemoteRepoChipsRow } from "@/components/task-create-dialog-remote-repo-
import { FolderPicker } from "@/components/folder-picker";
import { SourceModeSwitch } from "@/components/task-create-dialog-source-mode";
import { WorkspaceRepoChips } from "@/components/task-create-dialog-workspace-repo-chips";
+import { CreateLocalRepositorySurface } from "@/components/create-local-repository-surface";
+import type { DirectLocalExecutorSelection } from "@/components/task-create-dialog-handlers";
type RepoChipsRowProps = {
fs: DialogFormState;
@@ -49,6 +52,12 @@ type RepoChipsRowProps = {
onWorkspacePathChange?: (value: string) => void;
lastUsedBranch?: string | null;
userSettingsLoaded?: boolean;
+ localRepositoryCreation?: {
+ executorSelection: DirectLocalExecutorSelection | null;
+ onCreated: (rowKey: string, repository: Repository) => void;
+ };
+ onRefreshRepositories?: () => void;
+ repositoriesRefreshing?: boolean;
};
export function RepoChipsRow({
@@ -67,7 +76,23 @@ export function RepoChipsRow({
onWorkspacePathChange,
lastUsedBranch,
userSettingsLoaded,
+ localRepositoryCreation,
+ onRefreshRepositories,
+ repositoriesRefreshing,
}: RepoChipsRowProps) {
+ const chipRowRef = useRef(null);
+ const [creatingForRowKey, setCreatingForRowKey] = useState(null);
+ const handleCreationOpenChange = (open: boolean) => {
+ if (open || creatingForRowKey === null) return;
+ const rowKey = creatingForRowKey;
+ setCreatingForRowKey(null);
+ requestAnimationFrame(() => {
+ const row = Array.from(
+ chipRowRef.current?.querySelectorAll("[data-repo-row-key]") ?? [],
+ ).find((candidate) => candidate.dataset.repoRowKey === rowKey);
+ row?.querySelector("[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)
@@ -95,7 +120,11 @@ export function RepoChipsRow({
// modal doesn't jump when the user toggles between Repo / URL / None
// (None renders a single pill, Repo can render chips + branch + add and
// sometimes wraps when the segmented control crowds the row).
-
+
+ {localRepositoryCreation ? (
+ {
+ if (creatingForRowKey) localRepositoryCreation.onCreated(creatingForRowKey, repository);
+ }}
+ />
+ ) : null}
);
}
@@ -139,6 +182,9 @@ function ModeBody({
onWorkspacePathChange,
lastUsedBranch,
userSettingsLoaded,
+ onCreateRepository,
+ onRefreshRepositories,
+ repositoriesRefreshing,
}: {
fs: DialogFormState;
repositories: Repository[];
@@ -155,6 +201,9 @@ function ModeBody({
onWorkspacePathChange?: (value: string) => void;
lastUsedBranch?: string | null;
userSettingsLoaded?: boolean;
+ onCreateRepository?: (key: string) => void;
+ onRefreshRepositories?: () => void;
+ repositoriesRefreshing?: boolean;
}) {
if (fs.noRepository) {
return (
@@ -195,6 +244,9 @@ function ModeBody({
onRowBranchChange={onRowBranchChange}
lastUsedBranch={lastUsedBranch}
userSettingsLoaded={userSettingsLoaded}
+ onCreateRepository={onCreateRepository}
+ onRefreshRepositories={onRefreshRepositories}
+ repositoriesRefreshing={repositoriesRefreshing}
freshBranchToggle={
// Multi-repo runs use worktrees, so the existing-vs-fork choice
// is irrelevant — only surface the toggle for single-repo flows.
diff --git a/apps/web/components/task-create-dialog-state.ts b/apps/web/components/task-create-dialog-state.ts
index 9344128e2c..c4b9615e09 100644
--- a/apps/web/components/task-create-dialog-state.ts
+++ b/apps/web/components/task-create-dialog-state.ts
@@ -582,7 +582,11 @@ export function useTaskCreateDialogData(
const taskCreateUserSettings = useEnsureUserSettings(open);
useSettingsData(open);
- const { repositories, isLoading: repositoriesLoading } = useRepositories(workspaceId, open);
+ const {
+ repositories,
+ isLoading: repositoriesLoading,
+ refresh: refreshRepositories,
+ } = useRepositories(workspaceId, open);
// Per-repo branch loading lives in each chip now (RepoChipsRow). No
// global branch query is needed here — the chip uses useRepositoryBranches
// for its own row, and the store dedupes by repositoryId.
@@ -613,6 +617,7 @@ export function useTaskCreateDialogData(
snapshots,
repositories,
repositoriesLoading,
+ refreshRepositories,
branchesLoading,
taskCreateLastUsed: taskCreateUserSettings.userSettings.taskCreateLastUsed,
userSettingsLoaded: taskCreateUserSettings.loaded,
diff --git a/apps/web/components/task-create-dialog-types.ts b/apps/web/components/task-create-dialog-types.ts
index 2f79d0ec89..faea9f2b56 100644
--- a/apps/web/components/task-create-dialog-types.ts
+++ b/apps/web/components/task-create-dialog-types.ts
@@ -433,10 +433,18 @@ export type DialogFormBodyProps = {
onToggleFreshBranch: (enabled: boolean) => void;
onToggleNoRepository?: () => void;
onWorkspacePathChange: (value: string) => void;
+ localRepositoryCreation?: {
+ executorSelection:
+ | import("@/components/task-create-dialog-handlers").DirectLocalExecutorSelection
+ | null;
+ onCreated: (rowKey: string, repository: Repository) => void;
+ };
enhance?: DialogPromptEnhance;
workflowAgentLocked: boolean;
/** Workspace repositories — driven into the chip row for repo + branch picks. */
repositories: Repository[];
+ onRefreshRepositories?: () => void;
+ repositoriesRefreshing?: boolean;
lastUsedBranch?: string | null;
userSettingsLoaded?: boolean;
/** Computed in the parent: single-row + local executor + not URL mode. */
diff --git a/apps/web/components/task-create-dialog-workspace-repo-chips.tsx b/apps/web/components/task-create-dialog-workspace-repo-chips.tsx
index e9f212eb42..990c05346a 100644
--- a/apps/web/components/task-create-dialog-workspace-repo-chips.tsx
+++ b/apps/web/components/task-create-dialog-workspace-repo-chips.tsx
@@ -1,7 +1,14 @@
"use client";
import { useMemo } from "react";
-import { IconPlus, IconX, IconCode, IconGitBranch, IconCheck } from "@tabler/icons-react";
+import {
+ IconPlus,
+ IconX,
+ IconCode,
+ IconGitBranch,
+ IconFolderPlus,
+ IconCheck,
+} from "@tabler/icons-react";
import { Badge } from "@kandev/ui/badge";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import { useBranches, type BranchSource } from "@/hooks/domains/workspace/use-repository-branches";
@@ -15,6 +22,7 @@ import {
sortBranches,
branchToOption,
computeBranchPlaceholder,
+ type PillAction,
type PillOption,
} from "@/components/task-create-dialog-pill";
import {
@@ -24,6 +32,32 @@ import {
} from "@/components/task-create-dialog-branch-utils";
import { useRepoBranchAutoselect } from "@/components/task-create-dialog-repo-branch-autoselect";
+type WorkspaceRepoChipsProps = {
+ rows: TaskRepoRow[];
+ repositories: Repository[];
+ discoveredRepositories?: LocalRepository[];
+ workspaceId: string | null;
+ branchLocked?: boolean;
+ isLocalExecutor?: boolean;
+ currentLocalBranch?: string;
+ currentLocalBranchLoading?: boolean;
+ freshBranchEnabled?: boolean;
+ canAddMore: boolean;
+ addHint?: string;
+ addLabel?: string;
+ allowDuplicateRepositories?: boolean;
+ freshBranchToggle?: React.ReactNode;
+ onAdd: () => void;
+ onRemove: (key: string) => void;
+ onRowRepositoryChange: (key: string, value: string) => void;
+ onRowBranchChange: (key: string, value: string) => void;
+ onCreateRepository?: (key: string) => void;
+ onRefreshRepositories?: () => void;
+ repositoriesRefreshing?: boolean;
+ lastUsedBranch?: string | null;
+ userSettingsLoaded?: boolean;
+};
+
/**
* Renders the list of repo chips plus the trailing "+ add repository"
* button. Extracted from RepoChipsRow so the parent stays under the
@@ -48,30 +82,12 @@ export function WorkspaceRepoChips({
onRemove,
onRowRepositoryChange,
onRowBranchChange,
+ onCreateRepository,
+ onRefreshRepositories,
+ repositoriesRefreshing,
lastUsedBranch,
userSettingsLoaded,
-}: {
- rows: TaskRepoRow[];
- repositories: Repository[];
- discoveredRepositories?: LocalRepository[];
- workspaceId: string | null;
- branchLocked?: boolean;
- isLocalExecutor?: boolean;
- currentLocalBranch?: string;
- currentLocalBranchLoading?: boolean;
- freshBranchEnabled?: boolean;
- canAddMore: boolean;
- addHint?: string;
- addLabel?: string;
- allowDuplicateRepositories?: boolean;
- freshBranchToggle?: React.ReactNode;
- onAdd: () => void;
- onRemove: (key: string) => void;
- onRowRepositoryChange: (key: string, value: string) => void;
- onRowBranchChange: (key: string, value: string) => void;
- lastUsedBranch?: string | null;
- userSettingsLoaded?: boolean;
-}) {
+}: WorkspaceRepoChipsProps) {
return (
<>
{rows.map((row) => (
@@ -102,6 +118,11 @@ export function WorkspaceRepoChips({
})}
onRepositoryChange={(value) => onRowRepositoryChange(row.key, value)}
onBranchChange={(value) => onRowBranchChange(row.key, value)}
+ onCreateRepository={
+ rows.length === 1 && onCreateRepository ? () => onCreateRepository(row.key) : undefined
+ }
+ onRefreshRepositories={rows.length === 1 ? onRefreshRepositories : undefined}
+ repositoriesRefreshing={repositoriesRefreshing}
onRemove={() => onRemove(row.key)}
/>
))}
@@ -230,6 +251,9 @@ type RepoChipProps = {
onRepositoryChange: (value: string) => void;
onBranchChange: (value: string) => void;
onRemove: () => void;
+ onCreateRepository?: () => void;
+ onRefreshRepositories?: () => void;
+ repositoriesRefreshing?: boolean;
};
function useRepoChipData({
@@ -348,6 +372,15 @@ function computeRepoChipDisplay(
return { repoLabel, repoTooltip };
}
+function buildCreateRepositoryAction(onSelect?: () => void): PillAction | undefined {
+ if (!onSelect) return undefined;
+ return {
+ label: "Create new repository",
+ icon:
,
+ onSelect,
+ };
+}
+
function RepoChip({
row,
workspaceId,
@@ -364,6 +397,9 @@ function RepoChip({
onRepositoryChange,
onBranchChange,
onRemove,
+ onCreateRepository,
+ onRefreshRepositories,
+ repositoriesRefreshing,
}: RepoChipProps) {
const { repoOptions, branchOptions, branchesLoading, refreshBranches } = useRepoChipData({
row,
@@ -395,6 +431,7 @@ function RepoChip({
className="inline-flex items-center rounded-md border border-input bg-input/20 dark:bg-input/30 pr-0.5"
data-testid="repo-chip"
data-repository-id={row.repositoryId || row.localPath || ""}
+ data-repo-row-key={row.key}
>
}
@@ -407,6 +444,10 @@ function RepoChip({
testId="repo-chip-trigger"
tooltip={repoTooltip}
filter={scoreRepo}
+ action={buildCreateRepositoryAction(onCreateRepository)}
+ onRefresh={onRefreshRepositories}
+ refreshing={repositoriesRefreshing}
+ refreshLabel="repositories"
flat
/>
{showTaskName && (
state.upsertRepository);
const { toast } = useToast();
const sessionRepoName = useSessionRepoName(isSessionMode);
const {
@@ -440,6 +447,7 @@ export function useTaskCreateDialogSetup(
snapshots,
repositories,
repositoriesLoading,
+ refreshRepositories,
taskCreateLastUsed,
userSettingsLoaded,
computed,
@@ -467,7 +475,11 @@ export function useTaskCreateDialogSetup(
preserveBranch: initialValues?.checkoutBranch || initialValues?.branch,
});
useLockedFieldSync(open, workflowId, initialValues, fs);
- const handlers = useDialogHandlers(fs, repositories);
+ const handlers = useDialogHandlers(fs, repositories, {
+ workspaceId,
+ executors,
+ upsertWorkspaceRepository,
+ });
const submitHandlers = useSubmitHandlersWiring({
props,
fs,
@@ -502,6 +514,7 @@ export function useTaskCreateDialogSetup(
snapshots,
repositories,
repositoriesLoading,
+ refreshRepositories,
computed,
handlers,
submitHandlers,
diff --git a/apps/web/e2e/tests/task/create-task-new-local-repository.spec.ts b/apps/web/e2e/tests/task/create-task-new-local-repository.spec.ts
new file mode 100644
index 0000000000..40b59767e4
--- /dev/null
+++ b/apps/web/e2e/tests/task/create-task-new-local-repository.spec.ts
@@ -0,0 +1,203 @@
+import { execFileSync, spawnSync } from "node:child_process";
+import fs from "node:fs";
+import path from "node:path";
+import type { Page } from "@playwright/test";
+import { test, expect } from "../../fixtures/test-base";
+import type { ApiClient } from "../../helpers/api-client";
+import { useRegularMode } from "../../helpers/regular-mode";
+import { KanbanPage } from "../../pages/kanban-page";
+
+useRegularMode();
+
+type PersistedRepository = {
+ id: string;
+ name: string;
+ local_path: string;
+ default_branch: string;
+ source_type: string;
+};
+
+async function listRepositories(
+ apiClient: ApiClient,
+ workspaceId: string,
+): Promise {
+ const response = await apiClient.rawRequest(
+ "GET",
+ `/api/v1/workspaces/${workspaceId}/repositories`,
+ );
+ expect(response.ok).toBe(true);
+ return ((await response.json()) as { repositories: PersistedRepository[] }).repositories;
+}
+
+async function openCreateTask(page: Page): Promise {
+ const kanban = new KanbanPage(page);
+ await kanban.goto();
+ await kanban.createTaskButton.first().click();
+ await expect(page.getByTestId("create-task-dialog")).toBeVisible();
+}
+
+async function openRepositoryCreation(page: Page): Promise {
+ await page.getByTestId("repo-chip-trigger").click();
+ const search = page.getByPlaceholder("Search repositories...");
+ const refresh = page.getByTestId("repo-refresh-button");
+ const action = page.getByTestId("create-local-repository-button");
+ await expect(search).toBeVisible();
+ await expect(refresh).toBeVisible();
+ await expect(action).toBeVisible();
+ await expect(refresh).toBeEnabled();
+ await refresh.hover();
+ const refreshTooltip = page
+ .locator('[data-slot="tooltip-content"][data-state]')
+ .getByText("Refresh repositories", { exact: true })
+ .filter({ visible: true });
+ await expect(refreshTooltip).toBeVisible();
+ const [searchBox, refreshBox, actionBox] = await Promise.all([
+ search.boundingBox(),
+ refresh.boundingBox(),
+ action.boundingBox(),
+ ]);
+ expect(searchBox).not.toBeNull();
+ expect(refreshBox).not.toBeNull();
+ expect(actionBox).not.toBeNull();
+ expect(searchBox!.width).toBeGreaterThanOrEqual(280);
+ expect(searchBox!.x + searchBox!.width).toBeLessThanOrEqual(refreshBox!.x);
+ expect(refreshBox!.x + refreshBox!.width).toBeLessThanOrEqual(actionBox!.x);
+ await action.click();
+ await expect(page.getByTestId("create-local-repository-dialog")).toBeVisible();
+}
+
+async function createRepository(page: Page, name: string, targetPath: string): Promise {
+ await page.getByRole("textbox", { name: "Parent directory" }).fill(path.dirname(targetPath));
+ await page.getByRole("textbox", { name: "Repository name" }).fill(name);
+ await expect(page.getByTitle(targetPath)).toBeVisible();
+ await page.getByRole("button", { name: "Create repository" }).click();
+ await expect(page.getByTestId("create-local-repository-dialog")).not.toBeVisible();
+}
+
+function expectUnbornMainRepository(repositoryPath: string): void {
+ expect(fs.statSync(path.join(repositoryPath, ".git")).isDirectory()).toBe(true);
+ expect(
+ execFileSync("git", ["symbolic-ref", "--short", "HEAD"], {
+ cwd: repositoryPath,
+ encoding: "utf8",
+ }).trim(),
+ ).toBe("main");
+ const head = spawnSync("git", ["rev-parse", "--verify", "HEAD"], { cwd: repositoryPath });
+ expect(head.error).toBeUndefined();
+ expect(head.status).not.toBe(0);
+}
+
+function taskIdFromUrl(page: Page): string {
+ const match = page.url().match(/\/t\/([^/?]+)/);
+ if (!match) throw new Error(`Task route missing from ${page.url()}`);
+ return match[1];
+}
+
+test.describe("Create task with a new local repository", () => {
+ test("initializes, registers, selects, and starts from an unborn main repository", async ({
+ testPage,
+ apiClient,
+ seedData,
+ backend,
+ }) => {
+ const repositoryName = "desktop-unborn-main";
+ const repositoryPath = path.join(
+ backend.tmpDir,
+ "missing-manual-parent",
+ "nested",
+ repositoryName,
+ );
+ const { executors } = await apiClient.listExecutors();
+ const directExecutor = executors.find((executor) =>
+ ["local", "local_pc"].includes(executor.type),
+ );
+ const directProfile = directExecutor?.profiles?.[0];
+ expect(directExecutor, "a direct local executor is required by the fixture").toBeDefined();
+ expect(
+ directProfile,
+ "a direct local executor profile is required by the fixture",
+ ).toBeDefined();
+
+ await openCreateTask(testPage);
+ await testPage.getByTestId("task-title-input").fill("Task on a new local repository");
+ await testPage.getByTestId("task-description-input").fill("/e2e:simple-message");
+ await testPage.getByTestId("executor-profile-selector").click();
+ await testPage.getByRole("option", { name: /Worktree/i }).click();
+
+ await openRepositoryCreation(testPage);
+ await expect(testPage.getByTestId("create-local-repository-dialog")).toContainText(
+ /will switch to/i,
+ );
+ await createRepository(testPage, repositoryName, repositoryPath);
+ expect(fs.statSync(path.dirname(repositoryPath)).isDirectory()).toBe(true);
+
+ await expect(testPage.getByTestId("repo-chip-trigger")).toContainText(repositoryName);
+ await expect(testPage.getByTestId("branch-chip-trigger").first()).toContainText("main");
+ await expect(testPage.getByTestId("executor-profile-selector")).toContainText(
+ directExecutor!.name,
+ );
+ await expect(testPage.getByTestId("submit-start-agent")).toBeEnabled({ timeout: 30_000 });
+ await testPage.getByTestId("submit-start-agent").click();
+ await expect(testPage).toHaveURL(/\/t\//, { timeout: 15_000 });
+
+ const persisted = (await listRepositories(apiClient, seedData.workspaceId)).find(
+ (repository) => repository.local_path === repositoryPath,
+ );
+ expect(persisted).toMatchObject({
+ name: repositoryName,
+ local_path: repositoryPath,
+ default_branch: "main",
+ source_type: "local",
+ });
+ const taskId = taskIdFromUrl(testPage);
+ const task = await apiClient.getTask(taskId);
+ expect(task.repositories).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({ repository_id: persisted!.id, base_branch: "main" }),
+ ]),
+ );
+ await expect
+ .poll(async () => await apiClient.getTaskEnvironment(taskId), { timeout: 20_000 })
+ .toMatchObject({
+ executor_type: expect.stringMatching(/^(local|local_pc)$/),
+ executor_profile_id: directProfile!.id,
+ });
+ expectUnbornMainRepository(repositoryPath);
+ });
+
+ test("leaves a conflicting target untouched and allows retry with a new name", async ({
+ testPage,
+ apiClient,
+ seedData,
+ backend,
+ }) => {
+ const conflictName = "desktop-existing-target";
+ const retryName = "desktop-conflict-retry";
+ const conflictPath = path.join(backend.tmpDir, conflictName);
+ const retryPath = path.join(backend.tmpDir, retryName);
+ const sentinelPath = path.join(conflictPath, "keep.txt");
+ fs.mkdirSync(conflictPath);
+ fs.writeFileSync(sentinelPath, "do not modify\n");
+ const repositoriesBefore = await listRepositories(apiClient, seedData.workspaceId);
+
+ await openCreateTask(testPage);
+ await openRepositoryCreation(testPage);
+ const nameInput = testPage.getByRole("textbox", { name: "Repository name" });
+ await nameInput.fill(conflictName);
+ await testPage.getByRole("button", { name: "Create repository" }).click();
+
+ await expect(testPage.getByTestId("create-local-repository-dialog")).toBeVisible();
+ await expect(testPage.getByRole("alert")).toContainText(/exist|conflict/i);
+ await expect(nameInput).toHaveValue(conflictName);
+ expect(fs.readFileSync(sentinelPath, "utf8")).toBe("do not modify\n");
+ expect(fs.existsSync(path.join(conflictPath, ".git"))).toBe(false);
+ expect(await listRepositories(apiClient, seedData.workspaceId)).toHaveLength(
+ repositoriesBefore.length,
+ );
+
+ await nameInput.fill(retryName);
+ await createRepository(testPage, retryName, retryPath);
+ await expect(testPage.getByTestId("repo-chip-trigger")).toContainText(retryName);
+ expectUnbornMainRepository(retryPath);
+ });
+});
diff --git a/apps/web/e2e/tests/task/mobile-create-task-new-local-repository.spec.ts b/apps/web/e2e/tests/task/mobile-create-task-new-local-repository.spec.ts
new file mode 100644
index 0000000000..718897b8d9
--- /dev/null
+++ b/apps/web/e2e/tests/task/mobile-create-task-new-local-repository.spec.ts
@@ -0,0 +1,209 @@
+import { spawnSync } from "node:child_process";
+import fs from "node:fs";
+import path from "node:path";
+import type { Locator, Page } from "@playwright/test";
+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";
+
+useRegularMode();
+
+type RepositoryRecord = { id: string; local_path: string; default_branch: string };
+
+async function persistedRepository(
+ apiClient: ApiClient,
+ workspaceId: string,
+ repositoryPath: string,
+): Promise {
+ const response = await apiClient.rawRequest(
+ "GET",
+ `/api/v1/workspaces/${workspaceId}/repositories`,
+ );
+ expect(response.ok).toBe(true);
+ const body = (await response.json()) as { repositories: RepositoryRecord[] };
+ return body.repositories.find((repository) => repository.local_path === repositoryPath);
+}
+
+async function openCreateTask(page: Page): Promise {
+ const mobile = new MobileKanbanPage(page);
+ await mobile.goto();
+ await mobile.mobileFab.click();
+ await expect(page.getByTestId("create-task-dialog")).toBeVisible();
+}
+
+async function openCreationDrawer(page: Page): Promise {
+ const trigger = page.getByTestId("repo-chip-trigger");
+ await trigger.click();
+ const search = page.getByPlaceholder("Search repositories...");
+ const refresh = page.getByTestId("repo-refresh-button");
+ const action = page.getByTestId("create-local-repository-button");
+ await expect(search).toBeVisible();
+ await expect(refresh).toBeVisible();
+ await expect(action).toBeVisible();
+ const searchControl = search.locator("..");
+ const [searchControlBox, refreshBox, actionBox] = await Promise.all([
+ searchControl.boundingBox(),
+ refresh.boundingBox(),
+ action.boundingBox(),
+ ]);
+ expect(searchControlBox).not.toBeNull();
+ expect(refreshBox).not.toBeNull();
+ expect(actionBox).not.toBeNull();
+ expect(searchControlBox!.width).toBeGreaterThan(refreshBox!.width + actionBox!.width);
+ const refreshGap = refreshBox!.x - (searchControlBox!.x + searchControlBox!.width);
+ expect(refreshGap).toBeGreaterThanOrEqual(0);
+ expect(refreshGap).toBeLessThanOrEqual(8);
+ expect(refreshBox!.height).toBeGreaterThanOrEqual(44);
+ expect(actionBox!.height).toBeGreaterThanOrEqual(44);
+ await action.click();
+ const drawer = page.getByTestId("create-local-repository-drawer");
+ await expect(drawer).toBeVisible();
+ return drawer;
+}
+
+async function expectDrawerGeometry(page: Page, drawer: Locator): Promise {
+ const viewport = page.viewportSize();
+ expect(viewport).not.toBeNull();
+ await expect
+ .poll(async () => {
+ const box = await drawer.boundingBox();
+ return !!box && box.y >= 0 && box.y + box.height <= viewport!.height;
+ })
+ .toBe(true);
+ const drawerBox = await drawer.boundingBox();
+ expect(drawerBox).not.toBeNull();
+ expect(drawerBox!.x).toBeGreaterThanOrEqual(0);
+ expect(drawerBox!.x + drawerBox!.width).toBeLessThanOrEqual(viewport!.width);
+ expect(drawerBox!.y).toBeGreaterThanOrEqual(0);
+ expect(drawerBox!.y + drawerBox!.height).toBeLessThanOrEqual(viewport!.height);
+
+ const scrollOwners = await drawer.locator("*").evaluateAll((elements) =>
+ elements
+ .filter((element) => ["auto", "scroll"].includes(getComputedStyle(element).overflowY))
+ .map((element) => ({
+ clientHeight: element.clientHeight,
+ scrollHeight: element.scrollHeight,
+ })),
+ );
+ expect(scrollOwners).toHaveLength(1);
+ expect(scrollOwners[0].scrollHeight).toBeGreaterThan(scrollOwners[0].clientHeight);
+
+ const entryBox = await drawer.getByTestId("folder-picker-entry").first().boundingBox();
+ const createButton = drawer.getByRole("button", { name: "Create repository" });
+ const [createBox, footerBox] = await Promise.all([
+ createButton.boundingBox(),
+ createButton.locator("..").boundingBox(),
+ ]);
+ expect(entryBox).not.toBeNull();
+ expect(createBox).not.toBeNull();
+ expect(footerBox).not.toBeNull();
+ expect(entryBox!.height).toBeGreaterThanOrEqual(44);
+ expect(createBox!.height).toBeGreaterThanOrEqual(44);
+ expect(footerBox!.y + footerBox!.height).toBeLessThanOrEqual(drawerBox!.y + drawerBox!.height);
+ expect(
+ await page.evaluate(
+ () => document.documentElement.scrollWidth > document.documentElement.clientWidth,
+ ),
+ ).toBe(false);
+}
+
+function expectUnbornMainRepository(repositoryPath: string): void {
+ const symbolicRef = spawnSync("git", ["symbolic-ref", "--short", "HEAD"], {
+ cwd: repositoryPath,
+ encoding: "utf8",
+ });
+ expect(symbolicRef.error).toBeUndefined();
+ expect(symbolicRef.status).toBe(0);
+ expect(String(symbolicRef.stdout).trim()).toBe("main");
+
+ const head = spawnSync("git", ["rev-parse", "--verify", "HEAD"], { cwd: repositoryPath });
+ expect(head.error).toBeUndefined();
+ expect(head.status).not.toBe(0);
+}
+
+test.describe("Create task with a new local repository on mobile", () => {
+ test("creates and selects the repository in a contained, scrollable drawer", async ({
+ testPage,
+ apiClient,
+ seedData,
+ backend,
+ }) => {
+ const repositoryName = "mobile-unborn-main";
+ const parentName = "mobile-created-parent";
+ const parentPath = path.join(backend.tmpDir, parentName);
+ const repositoryPath = path.join(parentPath, repositoryName);
+ for (let index = 0; index < 18; index += 1) {
+ fs.mkdirSync(path.join(backend.tmpDir, `mobile-parent-${String(index).padStart(2, "0")}`));
+ }
+ const { executors } = await apiClient.listExecutors();
+ const directExecutor = executors.find((executor) =>
+ ["local", "local_pc"].includes(executor.type),
+ );
+ const directProfile = directExecutor?.profiles?.[0];
+ expect(
+ directProfile,
+ "a direct local executor profile is required by the fixture",
+ ).toBeDefined();
+
+ await openCreateTask(testPage);
+ await testPage.getByTestId("task-title-input").fill("Mobile task on a new repository");
+ await testPage.getByTestId("task-description-input").fill("/e2e:simple-message");
+ let drawer = await openCreationDrawer(testPage);
+ await expect(testPage.getByRole("textbox", { name: "Repository name" })).toBeFocused();
+ await testPage.getByRole("textbox", { name: "Repository name" }).fill("dismissed-name");
+ await testPage.keyboard.press("Escape");
+ await expect(drawer).not.toBeVisible();
+ await expect(testPage.getByTestId("repo-chip-trigger")).toBeFocused();
+ expect(fs.existsSync(path.join(backend.tmpDir, "dismissed-name"))).toBe(false);
+ await expect(testPage.getByTestId("task-title-input")).toHaveValue(
+ "Mobile task on a new repository",
+ );
+
+ drawer = await openCreationDrawer(testPage);
+ await expectDrawerGeometry(testPage, drawer);
+ const parentInput = drawer.getByRole("textbox", { name: "Parent directory" });
+ await parentInput.fill(backend.tmpDir);
+ await parentInput.press("Enter");
+ await drawer.getByRole("button", { name: "New folder" }).click();
+ await drawer.getByRole("textbox", { name: "New folder name" }).fill(parentName);
+ await drawer.getByRole("button", { name: "Create folder" }).click();
+ await expect(parentInput).toHaveValue(parentPath);
+ const nameInput = testPage.getByRole("textbox", { name: "Repository name" });
+ await nameInput.fill(repositoryName);
+ await expect(testPage.getByTitle(repositoryPath)).toBeVisible();
+ await drawer.getByRole("button", { name: "Create repository" }).click();
+ await expect(drawer).not.toBeVisible();
+
+ await expect(testPage.getByTestId("repo-chip-trigger")).toContainText(repositoryName);
+ await expect(testPage.getByTestId("branch-chip-trigger").first()).toContainText("main");
+ await expect(testPage.getByTestId("executor-profile-selector")).toContainText(
+ directExecutor!.name,
+ );
+ await expect(testPage.getByTestId("submit-start-agent")).toBeEnabled({ timeout: 30_000 });
+ const taskResponsePromise = testPage.waitForResponse(
+ (response) =>
+ response.url().endsWith("/api/v1/tasks") && response.request().method() === "POST",
+ );
+ await testPage.getByTestId("submit-start-agent").click();
+ const taskResponse = await taskResponsePromise;
+ expect(taskResponse.ok()).toBe(true);
+ const createdTask = (await taskResponse.json()) as { id: string };
+ await expect(testPage.getByTestId("create-task-dialog")).not.toBeVisible();
+
+ const repository = await persistedRepository(apiClient, seedData.workspaceId, repositoryPath);
+ expect(repository).toMatchObject({ local_path: repositoryPath, default_branch: "main" });
+ const taskId = createdTask.id;
+ expect((await apiClient.getTask(taskId)).repositories).toEqual(
+ expect.arrayContaining([expect.objectContaining({ repository_id: repository!.id })]),
+ );
+ await expect
+ .poll(async () => await apiClient.getTaskEnvironment(taskId), { timeout: 20_000 })
+ .toMatchObject({
+ executor_type: expect.stringMatching(/^(local|local_pc)$/),
+ executor_profile_id: directProfile!.id,
+ });
+ expect(fs.statSync(path.join(repositoryPath, ".git")).isDirectory()).toBe(true);
+ expectUnbornMainRepository(repositoryPath);
+ });
+});
diff --git a/apps/web/hooks/domains/workspace/use-repositories.test.ts b/apps/web/hooks/domains/workspace/use-repositories.test.ts
index bad3e17fce..dba316b38f 100644
--- a/apps/web/hooks/domains/workspace/use-repositories.test.ts
+++ b/apps/web/hooks/domains/workspace/use-repositories.test.ts
@@ -75,4 +75,26 @@ describe("useRepositories", () => {
await Promise.resolve();
expect(mockListRepositories).not.toHaveBeenCalled();
});
+
+ it("refreshes an already-loaded workspace when requested", async () => {
+ setup(/* loaded */ true);
+ const { result } = renderHook(() => useRepositories("ws-1"));
+
+ await result.current.refresh();
+
+ expect(mockListRepositories).toHaveBeenCalledWith("ws-1", undefined, { cache: "no-store" });
+ expect(mockSetRepositories).toHaveBeenCalledWith("ws-1", [{ id: "r1", name: "Repo One" }]);
+ });
+
+ it("keeps cached repositories when a manual refresh fails", async () => {
+ setup(/* loaded */ true);
+ mockListRepositories.mockRejectedValueOnce(new Error("Network unavailable"));
+ const { result } = renderHook(() => useRepositories("ws-1"));
+
+ await expect(result.current.refresh()).resolves.toBeUndefined();
+
+ expect(mockSetRepositories).not.toHaveBeenCalled();
+ expect(mockSetRepositoriesLoading).toHaveBeenNthCalledWith(1, "ws-1", true);
+ expect(mockSetRepositoriesLoading).toHaveBeenLastCalledWith("ws-1", false);
+ });
});
diff --git a/apps/web/hooks/domains/workspace/use-repositories.ts b/apps/web/hooks/domains/workspace/use-repositories.ts
index ae893ebcd1..38cb524d7d 100644
--- a/apps/web/hooks/domains/workspace/use-repositories.ts
+++ b/apps/web/hooks/domains/workspace/use-repositories.ts
@@ -1,4 +1,4 @@
-import { useEffect, useRef } from "react";
+import { useCallback, useEffect, useRef } from "react";
import { useAppStore } from "@/components/state-provider";
import type { Repository } from "@/lib/types/http";
import { listRepositories } from "@/lib/api";
@@ -33,6 +33,19 @@ export function useRepositories(workspaceId: string | null, enabled = true, forc
// on workspace switch, and forcedRef/isLoaded gate re-fetches after success.
const forcedRef = useRef(null);
+ const refresh = useCallback(async () => {
+ if (!enabled || !workspaceId) return;
+ setRepositoriesLoading(workspaceId, true);
+ try {
+ const response = await listRepositories(workspaceId, undefined, { cache: "no-store" });
+ setRepositories(workspaceId, response.repositories);
+ } catch {
+ // Keep the existing cached repositories when a manual refresh fails.
+ } finally {
+ setRepositoriesLoading(workspaceId, false);
+ }
+ }, [enabled, setRepositories, setRepositoriesLoading, workspaceId]);
+
useEffect(() => {
if (!enabled || !workspaceId) return;
if (isLoaded && isLoading) {
@@ -88,5 +101,5 @@ export function useRepositories(workspaceId: string | null, enabled = true, forc
};
}, [enabled, forceRefresh, isLoaded, setRepositories, setRepositoriesLoading, workspaceId]);
- return { repositories, isLoading };
+ return { repositories, isLoading, refresh };
}
diff --git a/apps/web/lib/api/domains/fs-api.test.ts b/apps/web/lib/api/domains/fs-api.test.ts
new file mode 100644
index 0000000000..a383b5be25
--- /dev/null
+++ b/apps/web/lib/api/domains/fs-api.test.ts
@@ -0,0 +1,37 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { createDirectory } from "./fs-api";
+
+const originalFetch = global.fetch;
+
+afterEach(() => {
+ global.fetch = originalFetch;
+});
+
+describe("createDirectory", () => {
+ it("posts the folder name and parent path", async () => {
+ const fetchSpy = vi.fn(
+ async () =>
+ new Response(
+ JSON.stringify({
+ path: "/work/projects",
+ parent: "/work",
+ entries: [],
+ choosable: true,
+ }),
+ { status: 201, headers: { "Content-Type": "application/json" } },
+ ),
+ );
+ global.fetch = fetchSpy as typeof global.fetch;
+
+ const listing = await createDirectory("/work", "projects");
+
+ expect(fetchSpy).toHaveBeenCalledWith(
+ "http://localhost:3000/api/v1/fs/create-dir",
+ expect.objectContaining({
+ method: "POST",
+ body: JSON.stringify({ parent_path: "/work", name: "projects" }),
+ }),
+ );
+ expect(listing).toMatchObject({ path: "/work/projects", choosable: true });
+ });
+});
diff --git a/apps/web/lib/api/domains/fs-api.ts b/apps/web/lib/api/domains/fs-api.ts
index 3a98951a22..6f66ee5274 100644
--- a/apps/web/lib/api/domains/fs-api.ts
+++ b/apps/web/lib/api/domains/fs-api.ts
@@ -21,3 +21,18 @@ export async function listDirectory(path: string, options?: ApiRequestOptions) {
const qs = path ? `?path=${encodeURIComponent(path)}` : "";
return fetchJson(`/api/v1/fs/list-dir${qs}`, options);
}
+
+export async function createDirectory(
+ parentPath: string,
+ name: string,
+ options?: ApiRequestOptions,
+) {
+ return fetchJson("/api/v1/fs/create-dir", {
+ ...options,
+ init: {
+ method: "POST",
+ body: JSON.stringify({ parent_path: parentPath, name }),
+ ...(options?.init ?? {}),
+ },
+ });
+}
diff --git a/apps/web/lib/api/domains/workspace-api.test.ts b/apps/web/lib/api/domains/workspace-api.test.ts
new file mode 100644
index 0000000000..c3081126a5
--- /dev/null
+++ b/apps/web/lib/api/domains/workspace-api.test.ts
@@ -0,0 +1,42 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { initializeLocalRepository } from "./workspace-api";
+
+const originalFetch = global.fetch;
+
+afterEach(() => {
+ global.fetch = originalFetch;
+});
+
+describe("initializeLocalRepository", () => {
+ it("posts the backend's snake-case initialization payload", async () => {
+ const fetchSpy = vi.fn(
+ async () =>
+ new Response(
+ JSON.stringify({
+ id: "repo-1",
+ workspace_id: "ws-1",
+ name: "alpha",
+ source_type: "local",
+ local_path: "/work/alpha",
+ default_branch: "main",
+ }),
+ { status: 201, headers: { "Content-Type": "application/json" } },
+ ),
+ );
+ global.fetch = fetchSpy as typeof global.fetch;
+
+ const repository = await initializeLocalRepository("ws-1", {
+ name: "alpha",
+ parentPath: "/work",
+ });
+
+ expect(fetchSpy).toHaveBeenCalledWith(
+ "http://localhost:3000/api/v1/workspaces/ws-1/repositories/initialize-local",
+ expect.objectContaining({
+ method: "POST",
+ body: JSON.stringify({ name: "alpha", parent_path: "/work" }),
+ }),
+ );
+ expect(repository).toMatchObject({ id: "repo-1", default_branch: "main" });
+ });
+});
diff --git a/apps/web/lib/api/domains/workspace-api.ts b/apps/web/lib/api/domains/workspace-api.ts
index 878027672f..4c43e79d38 100644
--- a/apps/web/lib/api/domains/workspace-api.ts
+++ b/apps/web/lib/api/domains/workspace-api.ts
@@ -5,6 +5,7 @@ import type {
RepositoryBranchesResponse,
ListRepositoryScriptsResponse,
Workspace,
+ Repository,
} from "@/lib/types/http";
// Workspace operations
@@ -37,6 +38,21 @@ export async function listRepositories(
return fetchJson(url, options);
}
+export async function initializeLocalRepository(
+ workspaceId: string,
+ payload: { name: string; parentPath: string },
+ options?: ApiRequestOptions,
+) {
+ return fetchJson(`/api/v1/workspaces/${workspaceId}/repositories/initialize-local`, {
+ ...options,
+ init: {
+ method: "POST",
+ body: JSON.stringify({ name: payload.name, parent_path: payload.parentPath }),
+ ...(options?.init ?? {}),
+ },
+ });
+}
+
/**
* Lists git branches for a workspace repo. Pass exactly one of `repositoryId`
* (an imported workspace repo) or `path` (an on-machine folder discovered
diff --git a/apps/web/lib/state/slices/workspace/types.ts b/apps/web/lib/state/slices/workspace/types.ts
index 1392848f64..686ecb60db 100644
--- a/apps/web/lib/state/slices/workspace/types.ts
+++ b/apps/web/lib/state/slices/workspace/types.ts
@@ -50,6 +50,7 @@ export type WorkspaceSliceActions = {
setActiveWorkspace: (workspaceId: string | null) => void;
setWorkspaces: (workspaces: WorkspaceState["items"]) => void;
setRepositories: (workspaceId: string, repositories: Repository[]) => void;
+ upsertRepository: (workspaceId: string, repository: Repository) => void;
setRepositoriesLoading: (workspaceId: string, loading: boolean) => void;
setRepositoryBranches: (
repositoryId: string,
diff --git a/apps/web/lib/state/slices/workspace/workspace-slice.test.ts b/apps/web/lib/state/slices/workspace/workspace-slice.test.ts
index 80733114a1..cc31376bd3 100644
--- a/apps/web/lib/state/slices/workspace/workspace-slice.test.ts
+++ b/apps/web/lib/state/slices/workspace/workspace-slice.test.ts
@@ -3,7 +3,7 @@ import { create } from "zustand";
import { immer } from "zustand/middleware/immer";
import { createWorkspaceSlice } from "./workspace-slice";
import type { WorkspaceSlice } from "./types";
-import type { Branch } from "@/lib/types/http";
+import type { Branch, Repository } from "@/lib/types/http";
function makeStore() {
return create()(
@@ -13,9 +13,44 @@ function makeStore() {
}
const REPO = "repo-1";
+const CREATED_REPOSITORY = "repo-created";
const BRANCHES: Branch[] = [{ name: "main", type: "local" }];
const FETCHED_AT = "2026-04-30T10:00:00Z";
+function repository(id: string): Repository {
+ return { id, name: id, workspace_id: "ws-1" } as Repository;
+}
+
+describe("upsertRepository", () => {
+ it("preserves an unloaded cache so a later fetch includes existing repositories", () => {
+ const s = makeStore();
+
+ s.getState().upsertRepository("ws-1", repository(CREATED_REPOSITORY));
+
+ expect(s.getState().repositories.loadedByWorkspaceId["ws-1"]).toBeUndefined();
+ expect(s.getState().repositories.itemsByWorkspaceId["ws-1"]).toEqual([
+ repository(CREATED_REPOSITORY),
+ ]);
+ });
+
+ it("merges against the current store state without dropping concurrent repositories", () => {
+ const s = makeStore();
+ s.getState().setRepositories("ws-1", [repository("repo-before")]);
+ s.getState().setRepositories("ws-1", [
+ repository("repo-before"),
+ repository("repo-concurrent"),
+ ]);
+
+ s.getState().upsertRepository("ws-1", repository(CREATED_REPOSITORY));
+
+ expect(s.getState().repositories.itemsByWorkspaceId["ws-1"].map((repo) => repo.id)).toEqual([
+ "repo-before",
+ "repo-concurrent",
+ CREATED_REPOSITORY,
+ ]);
+ });
+});
+
describe("setRepositoryBranches", () => {
it("stores branches and marks loaded without meta", () => {
const s = makeStore();
diff --git a/apps/web/lib/state/slices/workspace/workspace-slice.ts b/apps/web/lib/state/slices/workspace/workspace-slice.ts
index f5b5ab3d7f..7dc1fee71a 100644
--- a/apps/web/lib/state/slices/workspace/workspace-slice.ts
+++ b/apps/web/lib/state/slices/workspace/workspace-slice.ts
@@ -46,6 +46,21 @@ export const createWorkspaceSlice: StateCreator<
draft.repositories.loadingByWorkspaceId[workspaceId] = false;
draft.repositories.loadedByWorkspaceId[workspaceId] = true;
}),
+ upsertRepository: (workspaceId, repository) =>
+ set((draft) => {
+ const repositories = draft.repositories.itemsByWorkspaceId[workspaceId] ?? [];
+ const loaded = draft.repositories.loadedByWorkspaceId[workspaceId];
+ const existingIndex = repositories.findIndex((candidate) => candidate.id === repository.id);
+ if (existingIndex === -1) {
+ repositories.push(repository);
+ } else {
+ repositories[existingIndex] = repository;
+ }
+ draft.repositories.itemsByWorkspaceId[workspaceId] = repositories;
+ if (loaded !== undefined) {
+ draft.repositories.loadedByWorkspaceId[workspaceId] = loaded;
+ }
+ }),
setRepositoriesLoading: (workspaceId, loading) =>
set((draft) => {
draft.repositories.loadingByWorkspaceId[workspaceId] = loading;
diff --git a/apps/web/lib/state/store.ts b/apps/web/lib/state/store.ts
index d04099cfc8..f11e2b8d38 100644
--- a/apps/web/lib/state/store.ts
+++ b/apps/web/lib/state/store.ts
@@ -326,6 +326,7 @@ export type AppState = {
appendInstallOutput: (agentName: string, chunk: string) => void;
clearInstallJob: (agentName: string) => void;
setRepositories: (workspaceId: string, repositories: Repository[]) => void;
+ upsertRepository: (workspaceId: string, repository: Repository) => void;
setRepositoriesLoading: (workspaceId: string, loading: boolean) => void;
setRepositoryBranches: (
repositoryId: string,
diff --git a/docs/plans/create-local-repository/plan.md b/docs/plans/create-local-repository/plan.md
new file mode 100644
index 0000000000..d1ea56775b
--- /dev/null
+++ b/docs/plans/create-local-repository/plan.md
@@ -0,0 +1,168 @@
+---
+spec: docs/specs/create-local-repository/spec.md
+created: 2026-07-21
+status: completed
+---
+
+# Implementation Plan: Create a Local Repository During Task Creation
+
+## Overview
+
+Add an explicit backend operation that owns directory creation, commitless Git initialization, and
+workspace repository persistence. Then add a selector action that opens a shared directory-browser
+form, merges the returned repository into workspace state, selects it in the originating task row,
+and moves the first task to a compatible direct local executor. Desktop and mobile use the same
+mutation/state logic with purpose-built Dialog and Drawer shells.
+
+## Backend
+
+### Initialization service
+
+- Add `InitializeLocalRepositoryRequest` and `Service.InitializeLocalRepository` in
+ `apps/backend/internal/task/service/service_requests.go` and a focused new
+ `apps/backend/internal/task/service/local_repository_initialization.go`.
+- Validate that `Name` is a single non-empty host path segment and `ParentPath` is an existing,
+ writable absolute directory. Canonicalize the parent before joining the target, atomically create
+ the absent target, and never modify an existing path.
+- Run `git init --initial-branch=main` without creating files, configuring an author, or committing.
+- Register the canonical target through the existing `CreateRepository` path with `source_type=local`
+ and `default_branch=main`, preserving validation and `repository.created` publication.
+- On a partial failure, leave no repository row and remove only the target created by this request
+ when cleanup is safe; log cleanup failure without masking the primary error.
+
+### HTTP contract
+
+- Register `POST /api/v1/workspaces/:id/repositories/initialize-local` in
+ `apps/backend/internal/task/handlers/repository_handlers.go`.
+- Bind `{name,parent_path}`, verify workspace ownership/existence before filesystem mutation, map
+ validation to `400`, target existence to `409`, and return the existing repository DTO with `201`.
+- Add handler coverage in `apps/backend/internal/task/handlers/repository_handlers_test.go`.
+
+## Frontend
+
+### API and shared directory browser
+
+- Add `initializeLocalRepository(workspaceId, { name, parentPath })` to
+ `apps/web/lib/api/domains/workspace-api.ts`, returning `Repository`.
+- Refactor `apps/web/components/folder-picker.tsx` so its directory-listing, breadcrumb, loading,
+ error, navigation, and current-folder selection are exported as a reusable browser body. Keep the
+ existing **None** mode trigger behavior unchanged.
+- Add focused tests for target-path derivation, validation, error retention, and successful API
+ response handling in `apps/web/components/create-local-repository-surface.test.tsx`.
+
+### Repository selector and creation surface
+
+- Extend the cmdk-only `Pill` contract in `apps/web/components/task-create-dialog-pill.tsx` with an
+ optional command action rendered as a `CommandItem`; do not insert arbitrary interactive markup
+ into `Command`. Cover keyboard and pointer activation in
+ `apps/web/components/task-create-dialog-pill.test.tsx`.
+- Add `apps/web/components/create-local-repository-surface.tsx`. It owns shared form state and renders
+ a desktop `Dialog` or mobile `Drawer` selected with `useResponsiveBreakpoint`.
+- Thread a task-create-only **Create new repository** action through
+ `apps/web/components/task-create-dialog-repo-chips.tsx` and
+ `apps/web/components/task-create-dialog-workspace-repo-chips.tsx`. Expose it only for a single
+ repository row. Quick Chat callers of `WorkspaceRepoChips` do not receive the opt-in action.
+- Add a repository-created handler in `apps/web/components/task-create-dialog-handlers.ts` and its
+ prop/type wiring in `apps/web/components/task-create-dialog-types.ts`,
+ `apps/web/components/task-create-dialog-prop-builders.ts`, and
+ `apps/web/components/task-create-dialog.tsx`. It directly patches the originating row with the new
+ repository ID and `main`, selects a compatible direct local executor/profile, updates task-create
+ last-used state, and leaves sibling rows untouched. Surface the automatic executor change in the
+ form; block confirmation without filesystem mutation when no direct local profile is available.
+- Merge the returned DTO into `repositories.itemsByWorkspaceId[workspaceId]` through the existing
+ workspace slice without waiting for an asynchronous refetch; deduplicate by repository ID.
+- Extend `apps/web/components/task-create-dialog-repo-chips.test.tsx` and
+ `apps/web/components/task-create-dialog-handlers.test.ts` for action visibility, row targeting,
+ cache merge, success, conflict, retry, and cancel behavior.
+
+## Mobile Design Contract
+
+- Desktop keeps the repository search popover and opens a compact creation Dialog from its visible
+ command action.
+- Mobile keeps the repository selector entry point but opens an inset bottom Drawer modeled on
+ `apps/web/components/task/mobile/mobile-picker-sheet.tsx`.
+- The drawer has fixed name/target context, one internally scrolling directory list, and a fixed
+ safe-area-aware primary footer. Directory and action rows are at least 44px high.
+- Shared form state, validation, listing requests, creation request, cache merge, row selection, and
+ direct-local executor selection are viewport-independent. Dismissal creates nothing and returns
+ focus to the originating selector.
+- The mobile E2E proves creation and selection, internal scrolling/viewport containment, touch target
+ size, safe-area footer visibility, and absence of document horizontal overflow.
+
+## Tests
+
+- **Initialization success:**
+ `apps/backend/internal/task/service/local_repository_initialization_test.go` uses `t.TempDir()` and
+ real Git to assert canonical path, unborn `main`, the absence of `HEAD` commits, persisted workspace
+ record, and repository-created event.
+- **Input and conflict safety:** the same service test table covers invalid names, relative/missing/
+ non-directory parents, existing empty/non-empty targets, and no mutation or persistence.
+- **Partial failure cleanup:** service tests inject or induce Git/persistence failures and assert no
+ repository row plus request-owned target cleanup behavior.
+- **HTTP mapping:** `apps/backend/internal/task/handlers/repository_handlers_test.go` covers `201`,
+ `400`, `404`, and `409`, including no filesystem mutation for an unknown workspace.
+- **Frontend form:** `apps/web/components/create-local-repository-surface.test.tsx` covers validation,
+ target preview, in-flight disablement, error retention/retry, success callback, and responsive shell.
+- **Selector wiring:** existing pill/repo-chip/handler tests prove the action is task-create-only, a
+ returned repository updates only the originating row and active-workspace cache, a worktree
+ selection changes to a direct local profile, missing local profiles prevent the mutation, and the
+ action is absent from multi-repository tasks.
+
+## E2E Tests
+
+- Desktop: add `apps/web/e2e/tests/task/create-task-new-local-repository.spec.ts`. Open **New Task**,
+ create a repository under the isolated backend home, assert it is selected with `main`, submit the
+ task, and verify the task is bound to the persisted repository, uses a direct local executor, and
+ the filesystem has no commit. Add a target-conflict case that proves the existing directory remains
+ unchanged.
+- Mobile: add `apps/web/e2e/tests/task/mobile-create-task-new-local-repository.spec.ts`. Enter through
+ `MobileKanbanPage.mobileFab`, complete the same create-and-submit outcome, and assert Drawer
+ containment, a scrollable internal list, 44px action rows, visible safe-area footer, focus/dismiss
+ behavior, and zero document horizontal overflow.
+
+## Implementation Waves
+
+Wave 1:
+
+- [x] [task-01-backend-initialization](task-01-backend-initialization.md) - done
+
+Wave 2 (after Wave 1):
+
+- [x] [task-02-task-create-selector](task-02-task-create-selector.md) - done
+
+Wave 3 (after integrated backend and frontend):
+
+- [x] [task-03-e2e-and-verification](task-03-e2e-and-verification.md) - done
+
+The frontend task is sequential because the shared picker, task-create handlers, and state wiring are
+one behavior and edit overlapping files. E2E follows the integrated product path.
+
+## Verification
+
+```bash
+make fmt
+make typecheck
+(cd apps/backend && go test ./internal/task/service ./internal/task/handlers)
+(cd apps && pnpm --filter @kandev/web test -- --run \
+ components/create-local-repository-surface.test.tsx \
+ components/task-create-dialog-pill.test.tsx \
+ components/task-create-dialog-repo-chips.test.tsx \
+ components/task-create-dialog-handlers.test.ts)
+(cd apps/web && pnpm e2e:run tests/task/create-task-new-local-repository.spec.ts)
+(cd apps/web && pnpm e2e:run --no-build --project mobile-chrome \
+ tests/task/mobile-create-task-new-local-repository.spec.ts)
+make test
+make lint
+```
+
+## Risks
+
+- Filesystem and database persistence cannot share a transaction. The service must narrowly own and
+ test partial-failure cleanup without ever deleting a pre-existing target.
+- An unborn Git repository cannot support the existing branch/worktree flow. The form must switch
+ the first task to a direct local executor and must not silently fall back to worktree, container, or
+ remote execution.
+- The repository list may already be hydrated while the event arrives asynchronously. Selection must
+ use the returned DTO directly and cache insertion must be idempotent.
+- Nested overlays inside the full-height mobile task dialog can break scroll and focus ownership. The
+ directory browser must render inside the creation Drawer rather than opening another phone popover.
diff --git a/docs/plans/create-local-repository/task-01-backend-initialization.md b/docs/plans/create-local-repository/task-01-backend-initialization.md
new file mode 100644
index 0000000000..6b7ed43733
--- /dev/null
+++ b/docs/plans/create-local-repository/task-01-backend-initialization.md
@@ -0,0 +1,52 @@
+---
+id: "01-backend-initialization"
+title: "Backend local repository initialization"
+status: done
+wave: 1
+depends_on: []
+plan: "plan.md"
+spec: "../../specs/create-local-repository/spec.md"
+---
+
+# Task 01: Backend Local Repository Initialization
+
+## Acceptance
+
+- The workspace-scoped endpoint creates an absent `/` repository with unborn branch
+ `main`, no files, and no commits, then returns its persisted repository DTO.
+- Invalid names/parents, unknown workspaces, and existing targets fail with the specified status and
+ do not mutate the target or persist a repository.
+- Git or persistence failure leaves no repository row and applies the request-owned cleanup contract.
+
+## Verification
+
+```bash
+make -C apps/backend fmt
+(cd apps/backend && go test ./internal/task/service ./internal/task/handlers)
+make -C apps/backend lint
+```
+
+## Files Likely Touched
+
+- `apps/backend/internal/task/service/service_requests.go`
+- `apps/backend/internal/task/service/local_repository_initialization.go`
+- `apps/backend/internal/task/service/local_repository_initialization_test.go`
+- `apps/backend/internal/task/handlers/repository_handlers.go`
+- `apps/backend/internal/task/handlers/repository_handlers_test.go`
+
+## Dependencies
+
+None.
+
+## Inputs
+
+- Spec: What, API Surface, Permissions, Failure Modes, and Persistence Guarantees.
+- Existing exact-path validation: `apps/backend/internal/task/service/repository_discovery.go`.
+- Existing persistence/event path: `Service.CreateRepository` in
+ `apps/backend/internal/task/service/service_resources.go`.
+- Security boundary: `docs/decisions/2026-07-20-explicit-local-repository-trust.md`.
+
+## Output Contract
+
+Report endpoint/service behavior, rollback strategy, tests run, files touched, blockers, and residual
+filesystem race risks. Mark this file `done` and update the plan entry only after targeted tests pass.
diff --git a/docs/plans/create-local-repository/task-02-task-create-selector.md b/docs/plans/create-local-repository/task-02-task-create-selector.md
new file mode 100644
index 0000000000..06c5792dbf
--- /dev/null
+++ b/docs/plans/create-local-repository/task-02-task-create-selector.md
@@ -0,0 +1,71 @@
+---
+id: "02-task-create-selector"
+title: "Task-create local repository selector"
+status: done
+wave: 2
+depends_on: ["01-backend-initialization"]
+plan: "plan.md"
+spec: "../../specs/create-local-repository/spec.md"
+---
+
+# Task 02: Task-Create Local Repository Selector
+
+## Acceptance
+
+- A task-create repository picker exposes **Create new repository**, and success selects the returned
+ repository with `main` only in the originating row, moves the first task to a compatible direct
+ local executor, explains that change, and preserves the rest of the task draft.
+- Multi-repository tasks do not expose the action because they require worktree execution.
+- The shared directory browser drives name/parent validation, target preview, retryable errors, and
+ idempotent active-workspace cache insertion without changing the **None** mode picker.
+- Desktop Dialog and mobile Drawer satisfy the spec's focus, scroll, safe-area, touch-target, and
+ no-hover mobile contract; Quick Chat does not gain the action, and no compatible direct local
+ profile blocks creation before the API call.
+
+## Verification
+
+```bash
+make fmt
+make typecheck
+(cd apps && pnpm --filter @kandev/web test -- --run \
+ components/create-local-repository-surface.test.tsx \
+ components/task-create-dialog-pill.test.tsx \
+ components/task-create-dialog-repo-chips.test.tsx \
+ components/task-create-dialog-handlers.test.ts)
+(cd apps/web && pnpm run lint)
+```
+
+## Files Likely Touched
+
+- `apps/web/lib/api/domains/workspace-api.ts`
+- `apps/web/components/folder-picker.tsx`
+- `apps/web/components/create-local-repository-surface.tsx`
+- `apps/web/components/create-local-repository-surface.test.tsx`
+- `apps/web/components/task-create-dialog-pill.tsx`
+- `apps/web/components/task-create-dialog-pill.test.tsx`
+- `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/components/task-create-dialog-handlers.ts`
+- `apps/web/components/task-create-dialog-handlers.test.ts`
+- `apps/web/components/task-create-dialog-types.ts`
+- `apps/web/components/task-create-dialog-prop-builders.ts`
+- `apps/web/components/task-create-dialog.tsx`
+
+## Dependencies
+
+Task 01's endpoint and response/error contract.
+
+## Inputs
+
+- Spec: What, API Surface, Mobile Design Contract, Scenarios.
+- Shared browser pattern: `apps/web/components/folder-picker.tsx`.
+- Mobile geometry exemplar: `apps/web/components/task/mobile/mobile-picker-sheet.tsx`.
+- Selector constraint: the cmdk-only note in `apps/web/components/task-create-dialog-pill.tsx`.
+- Repository cache: `apps/web/lib/state/slices/workspace/workspace-slice.ts` and
+ `apps/web/hooks/domains/workspace/use-repositories.ts`.
+
+## Output Contract
+
+Report desktop/mobile behavior, state and focus handling, tests run, files touched, blockers, and
+remaining visual risks. Mark this file `done` and update the plan entry only after targeted tests pass.
diff --git a/docs/plans/create-local-repository/task-03-e2e-and-verification.md b/docs/plans/create-local-repository/task-03-e2e-and-verification.md
new file mode 100644
index 0000000000..80a53f2043
--- /dev/null
+++ b/docs/plans/create-local-repository/task-03-e2e-and-verification.md
@@ -0,0 +1,67 @@
+---
+id: "03-e2e-and-verification"
+title: "Local repository creation E2E verification"
+status: done
+wave: 3
+depends_on: ["01-backend-initialization", "02-task-create-selector"]
+plan: "plan.md"
+spec: "../../specs/create-local-repository/spec.md"
+---
+
+# Task 03: Local Repository Creation E2E Verification
+
+## Acceptance
+
+- Desktop Playwright proves commitless repository initialization, automatic row/branch and direct
+ local executor selection, task binding, durable filesystem/DB results, and non-destructive conflict
+ handling through the real backend.
+- Mobile Playwright proves the same user outcome plus Drawer containment, internal scroll ownership,
+ 44px actions, safe-area footer visibility, focus/dismiss behavior, and no horizontal overflow.
+- Repository-wide format, typecheck, test, and lint checks pass, or exact unrelated blockers are
+ recorded.
+
+## Verification
+
+```bash
+make fmt
+make typecheck
+(cd apps/web && pnpm e2e:run tests/task/create-task-new-local-repository.spec.ts)
+(cd apps/web && pnpm e2e:run --no-build --project mobile-chrome \
+ tests/task/mobile-create-task-new-local-repository.spec.ts)
+make test
+make lint
+```
+
+## Files Likely Touched
+
+- `apps/web/e2e/tests/task/create-task-new-local-repository.spec.ts`
+- `apps/web/e2e/tests/task/mobile-create-task-new-local-repository.spec.ts`
+- `apps/web/e2e/helpers/api-client.ts` only if a typed read/assertion helper is missing.
+
+## Dependencies
+
+Tasks 01 and 02 must be integrated and their targeted tests green.
+
+## Inputs
+
+- All spec Scenarios and Mobile Design Contract.
+- `apps/web/e2e/tests/task/create-task.spec.ts` for desktop task creation.
+- `apps/web/e2e/tests/task/mobile-create-task-remote-repo.spec.ts` for mobile picker geometry.
+- `apps/web/e2e/fixtures/test-base.ts` for isolated `HOME`, real Git, and backend paths.
+
+## Output Contract
+
+Report scenario coverage, exact commands and results, screenshots/geometry observations, files
+touched, blockers, and residual risks. Mark this file `done` and update the plan only after E2E and
+required verification complete.
+
+## Results
+
+- Backend service and handler tests: `720` passed across both packages.
+- Focused frontend tests: `82` passed across seven files.
+- Desktop Playwright: `2` passed, covering creation/task binding and non-destructive conflict retry.
+- Mobile Playwright: `1` passed, covering creation/task binding plus drawer layout, scrolling, focus,
+ touch targets, and viewport containment.
+- Repository-wide `make fmt`, `make typecheck`, `make test`, and `make lint`: passed.
+- Public documentation tests and validation: passed for all `40` published pages.
+- Code and security reviews: no actionable blockers remain.
diff --git a/docs/public/tasks-and-workflows.md b/docs/public/tasks-and-workflows.md
index 5ee85d68f4..74d67c0bbe 100644
--- a/docs/public/tasks-and-workflows.md
+++ b/docs/public/tasks-and-workflows.md
@@ -27,7 +27,7 @@ A new workspace created by a user does not automatically receive a workflow.
1. Open **Settings → Workspaces** and select **Add Workspace**.
2. Enter the required workspace name.
-3. Open the workspace's **Repositories** page and add the local repositories the workspace needs. Remote URLs are not registered on this page; enter them through **New Task → Remote**.
+3. Open the workspace's **Repositories** page and add existing local repositories the workspace needs. You can also initialize a new empty repository while creating a task. Remote URLs are not registered on this page; enter them through **New Task → Remote**.
4. Open its **Workflows** page and create, import, or synchronize a workflow.
5. On **Workspace Settings**, optionally choose a **Default Executor** and **Default Agent Profile**. Both default to **No default** unless configured.
@@ -51,7 +51,7 @@ Use **New Task** in the sidebar. In an open task, the **Task** split button also
| Source | Use it for | Important behavior |
|---|---|---|
- | **Repo** | A configured or discovered local repository | Select a base branch for each repository row. Add more rows for a multi-repository task. |
+ | **Repo** | A configured, discovered, or new local repository | Select a base branch for each repository row. For a single-row new task, **Create new repository** initializes an empty `main` repository in a parent folder you choose. Add more rows for a multi-repository task. |
| **Remote** | A remote repository | Search configured GitHub, GitLab, or Azure DevOps repositories, or paste a supported URL; then select the branch. Clone and fetch require valid credentials. |
| **None** | Planning, research, or work outside Git | Use a scratch workspace or an optional folder on the Kandev host. Git worktree execution and repository-aware Changes, branch, and pull-request features are unavailable. |
@@ -68,6 +68,8 @@ Use **New Task** in the sidebar. In an open task, the **Task** split button also
Kandev remembers draft or recently used repository, branch, executor, and profile choices. Review the restored values before submitting, especially after changing workspace.
+Creating a repository is available only in an unlocked, single-repository **New Task** form. Kandev rejects an existing target path, creates no initial files or commit, registers the repository in the workspace, and switches the task to a direct **Local** executor profile. If no direct Local profile is available, repository creation stays disabled. Add more repository rows only after selecting existing repositories; empty multi-repository worktrees are not supported.
+
### Choose the profile for tasks created by agents
Open **Settings → General → Task Actions → Profile for Tasks Created by Agents** to choose which agent profile Kandev assigns when an agent calls a Kandev MCP tool that creates a task without choosing `agent_profile_id`. The profile determines the agent, model, and setup used when the new task starts:
diff --git a/docs/public/use-kandev.md b/docs/public/use-kandev.md
index 083386bc9c..e02f4c482f 100644
--- a/docs/public/use-kandev.md
+++ b/docs/public/use-kandev.md
@@ -83,14 +83,14 @@ Remote repository and issue/PR URLs are not added from this settings page. Use t
Detected capabilities come from the installed CLI and can change after an agent upgrade or login. A model or mode shown in documentation is not guaranteed for every account. If no built-in adapter fits, **Add TUI Agent** creates a passthrough integration; passthrough has a different resume, usage, and MCP contract from an ACP-capable agent.
-For the first task, keep the seeded **Worktree** executor profile. It creates a separate Git checkout so concurrent Kandev tasks do not edit the same working tree. A worktree is Git isolation, not operating-system isolation. Choose **Local** only when direct edits in the selected checkout are intentional. See [Agents and profiles](agents-and-profiles.md) and [Executors](executors.md) before using Docker, SSH, Sprites, custom scripts, or shared infrastructure.
+For the first task on an existing repository, keep the seeded **Worktree** executor profile. It creates a separate Git checkout so concurrent Kandev tasks do not edit the same working tree. A worktree is Git isolation, not operating-system isolation. Choose **Local** only when direct edits in the selected checkout are intentional. A repository initialized from **New Task** is the exception: it has no commit to support a worktree, so Kandev switches that task to a direct Local profile. See [Agents and profiles](agents-and-profiles.md) and [Executors](executors.md) before using Docker, SSH, Sprites, custom scripts, or shared infrastructure.
## Create and start the first task
1. Select **New Task** from the sidebar or task board.
2. Enter a specific title and a description with the expected outcome, constraints, and validation. A non-empty description enables the normal start and create-only actions.
-3. Under **Repo**, select the workspace repository and base branch. Select **None** only for work that genuinely needs a plain workspace directory.
-4. Choose the Development workflow, an agent profile, and the Worktree executor profile. Kandev remembers compatible recent choices, so re-check them after changing repository, agent, or trust boundary.
+3. Under **Repo**, select the workspace repository and base branch. To start a new project, open the repository selector, choose **Create new repository**, enter one folder name, and select its parent folder. Kandev creates an empty `main` repository with no initial commit. Select **None** only for work that genuinely needs a plain workspace directory.
+4. Choose the Development workflow and an agent profile. For an existing repository, choose the Worktree executor profile. For a newly initialized repository, keep the Local profile that Kandev selects automatically. Kandev remembers compatible recent choices, so re-check them after changing repository, agent, or trust boundary.
5. Select **Start task**. Its menu also offers **Start task in plan mode** and **Create without starting agent**. On an empty description, the primary action is **Start Plan Mode**.
Starting creates the task and its initial session. Workflow step configuration determines whether entry actions or later transitions start another session, inject a prompt, or stop at a human gate. With a structured ACP profile, create-only prepares the session/workspace without starting an agent turn. A passthrough/TUI profile is the exception: the backend upgrades prepare-only to a full launch because the native PTY must exist.
@@ -98,6 +98,7 @@ Starting creates the task and its initial session. Workflow step configuration d
The dialog also supports multiple repositories, remote GitHub rows, and a single-repository **Fork a new branch** option when the Local executor is selected. Important boundaries:
- Multi-repository tasks require the Worktree executor in the current task-create path.
+- Creating an empty repository is available only for a single repository row and switches the task to a direct Local profile.
- No-repository tasks cannot use the Worktree executor.
- The local fork option is off by default and requires explicit consent before Kandev discards dirty source-checkout changes.
- Agent and executor choices can be disabled when their remote credential requirements are incompatible.
diff --git a/docs/specs/INDEX.md b/docs/specs/INDEX.md
index edbe21e25d..15167976ab 100644
--- a/docs/specs/INDEX.md
+++ b/docs/specs/INDEX.md
@@ -142,6 +142,7 @@ System pages (Radarr/Sonarr-style) for status, disk usage, database maintenance,
| Spec | Status |
|---|---|
+| [create-local-repository](create-local-repository/spec.md) | shipped |
| [workflow-cycle-guardrails](workflow-cycle-guardrails/spec.md) | building |
| [improve-kandev](improve-kandev/spec.md) | draft |
| [homebrew-core](homebrew-core/spec.md) | draft |
diff --git a/docs/specs/create-local-repository/spec.md b/docs/specs/create-local-repository/spec.md
new file mode 100644
index 0000000000..2b41bbf67d
--- /dev/null
+++ b/docs/specs/create-local-repository/spec.md
@@ -0,0 +1,204 @@
+---
+status: shipped
+created: 2026-07-21
+owner: kandev
+---
+
+# Create a Local Repository During Task Creation
+
+## Why
+
+Users can create tasks from an existing local or remote repository, but a new project requires
+leaving the task flow to initialize and register a Git repository first. Users need to create that
+repository on the machine running Kandev, select it, and continue composing the same task.
+
+## What
+
+- The local repository selector in **Create New Task** exposes a visible **Create new repository**
+ action while the task has exactly one repository row.
+- The creation form asks for a repository name and an absolute parent path. It shows the resulting
+ target path before confirmation. The name is one platform-native path segment, not a path.
+- A manually entered parent path may contain missing trailing directories. Confirmation creates
+ those directories beneath the nearest accessible, trusted ancestor before initializing the
+ repository.
+- The parent-folder browser lists the filesystem of the machine running Kandev and shares the same
+ directory browsing behavior as the **None** source mode's starting-folder picker. It also lets
+ the user create and enter one new child folder in the currently displayed directory.
+- Confirmation creates `/` only when that target does not already exist, initializes a
+ local Git repository with unborn default branch `main`, and does not create files or commits.
+- A successfully initialized repository is persisted as a local repository in the active workspace,
+ added to the in-memory workspace repository list, selected in the originating row, and displays
+ `main` as that row's branch. Other task fields and repository rows are unchanged.
+- A commitless repository can run only with a direct local executor. Confirmation automatically
+ selects a compatible `local` or `local_pc` executor profile for the first task and explains the
+ change. If no direct local profile is available, confirmation is blocked before filesystem
+ mutation. After the repository has a commit, later tasks may use any otherwise compatible
+ executor through the normal task flow.
+- Multi-repository task creation does not expose the action because those tasks require the worktree
+ executor. Users can add the repository to a multi-repository task after it has its first commit.
+- Initialization is an explicit local-path trust grant for the exact canonical repository path. It
+ does not widen automatic discovery roots or grant access to sibling paths.
+- The form remains open with the entered name and parent folder when initialization fails, surfaces
+ an actionable error, and does not select a repository. Editing either input or navigating to
+ another folder clears the stale submission error immediately.
+- Closing the creation form before confirmation does not create a directory or repository record.
+- Desktop and mobile provide the same capability. Mobile uses a touch-native picker surface with no
+ hover-only action and keeps repository name, target path, errors, and the primary action reachable.
+
+The explicit local repository trust contract remains defined by
+[Local Workspace Repositories](../workspaces/local-repositories.md) and
+[ADR-2026-07-20](../../decisions/2026-07-20-explicit-local-repository-trust.md).
+
+## Data Model
+
+No new database entity is introduced. A successful initialization creates one existing
+`repositories` record:
+
+| Field | Contract |
+| --- | --- |
+| `workspace_id` | The active task-creation workspace. |
+| `name` | The user-supplied repository name after trimming. |
+| `source_type` | `local`. |
+| `local_path` | The canonical absolute `/` path. |
+| `default_branch` | `main`. |
+
+The filesystem repository and the `repositories` row are created as one product operation when the
+repository form is confirmed, before the task is submitted. A successful response means both exist;
+a failed response must not leave a repository row. The filesystem cleanup behavior for a partial
+failure is described under Failure modes.
+
+## API Surface
+
+`POST /api/v1/workspaces/:id/repositories/initialize-local`
+
+Request:
+
+```json
+{
+ "name": "new-project",
+ "parent_path": "/home/user/projects"
+}
+```
+
+Response: `201 Created` with the existing `Repository` DTO. The response has `source_type: "local"`,
+the canonical `local_path`, and `default_branch: "main"`.
+
+The backend, not the browser, is authoritative for validation and filesystem changes:
+
+- `400 Bad Request`: missing/invalid name, relative parent path, inaccessible parent, parent is not a
+ directory, or invalid request body.
+- `404 Not Found`: workspace does not exist.
+- `409 Conflict`: `/` already exists. Existing directories are never converted or
+ overwritten by this operation.
+- `500 Internal Server Error`: Git initialization or repository persistence fails.
+
+`POST /api/v1/fs/create-dir`
+
+Request:
+
+```json
+{
+ "parent_path": "/home/user/projects",
+ "name": "team"
+}
+```
+
+Response: `201 Created` with the same directory-listing shape as `GET /api/v1/fs/list-dir`, rooted
+at the new folder. Invalid names or inaccessible parents return `400 Bad Request`; an existing child
+returns `409 Conflict`.
+
+## Permissions
+
+This follows Kandev's trusted-local-user model. The user may initialize a repository under any
+explicitly selected parent path whose nearest existing ancestor the Kandev process can trust and
+write. Missing trailing directories are created as process-owned directories. The grant is scoped
+to the created canonical target path and the active workspace.
+
+## Failure Modes
+
+| Condition | Observable behavior |
+| --- | --- |
+| Name is empty, `.`/`..`, or contains a host path separator | Confirmation is disabled client-side and the backend rejects a forged request. |
+| The nearest existing parent ancestor cannot be trusted, read, or written | The form stays open, shows an error, and no repository is selected or persisted. |
+| Target already exists, including an empty directory | The request returns conflict and does not modify the target. |
+| `git` is unavailable or initialization fails | The request fails, no repository row is persisted, and Kandev removes only the target directory created by this request when cleanup is safe. A cleanup failure is logged and the error remains visible. |
+| Repository persistence fails after Git initialization | The request fails and performs the same best-effort cleanup of the request-owned target; no repository row is returned. |
+| Frontend state refresh fails after a successful response | The returned repository is still selected directly and merged into the active workspace cache without waiting for a second list request. |
+| No direct local executor profile is available | The form explains that an empty repository requires local execution and does not call the initialization endpoint. |
+
+## Persistence Guarantees
+
+The initialized Git repository is ordinary user-owned filesystem content and is never tied to task
+deletion. Its existing `repositories` record survives backend restart. Canceling, archiving, or
+deleting the task does not delete the repository or its workspace registration.
+
+## Mobile Design Contract
+
+- **Entry and outcome:** the repository chip opens the same local repository picker as desktop; a
+ visible, at-least-44px **Create new repository** row opens the creation flow and returns the new
+ selection to the originating chip.
+- **Nearest exemplar:** `MobilePickerSheet` supplies the inset bottom-drawer geometry, fixed header,
+ internal scrolling, and predictable dismissal used by task-workbench pickers.
+- **Hierarchy and primary action:** repository name and current target path stay above the directory
+ list; **Create repository** is the single primary action in the safe-area-aware footer.
+- **Presentation rationale:** repository creation is a short temporary choice inside task creation,
+ so mobile uses an inset bottom drawer rather than a new route or a compressed desktop dialog.
+- **Geometry:** the drawer uses one `min-h-0` vertical scroll owner, dynamic viewport sizing, bottom
+ safe-area clearance, no document horizontal overflow, and 44px directory/action rows. Keyboard
+ focus remains visible and dismissal returns focus to the repository selector.
+- **Shared logic:** validation, target-path derivation, directory listing, API mutation, workspace
+ cache update, row selection, and compatible local-executor selection are shared. Only
+ Dialog-versus-Drawer composition is responsive.
+
+## Scenarios
+
+- **GIVEN** the task dialog has a local repository row, **WHEN** the user chooses **Create new
+ repository**, selects `/work/projects`, enters `alpha`, and confirms, **THEN**
+ `/work/projects/alpha` contains a commitless Git repository whose unborn branch is `main`, the
+ workspace has a matching local repository record, the originating task row selects `alpha` /
+ `main`, and the first task uses a direct local executor.
+- **GIVEN** a worktree or container executor profile is selected and a direct local profile is
+ available, **WHEN** repository initialization succeeds, **THEN** the form selects the compatible
+ direct local profile and tells the user why the executor changed.
+- **GIVEN** no direct local executor profile is available, **WHEN** the user opens the creation form,
+ **THEN** the primary action explains the requirement and no directory or repository is created.
+- **GIVEN** a task with multiple repository rows, **WHEN** the user opens any repository picker,
+ **THEN** **Create new repository** is not offered because an unborn repository cannot use the
+ required worktree executor.
+- **GIVEN** `/work/projects/alpha` already exists, **WHEN** the user tries to create `alpha` under
+ `/work/projects`, **THEN** the UI reports the conflict and Kandev does not modify or register the
+ existing path.
+- **GIVEN** `/work` exists but `/work/team/projects` does not, **WHEN** the user enters
+ `/work/team/projects` as the parent and creates `alpha`, **THEN** Kandev creates both missing
+ parent directories and initializes `/work/team/projects/alpha`.
+- **GIVEN** the folder navigator displays `/work`, **WHEN** the user creates a child named `team`,
+ **THEN** `/work/team` is created and the navigator enters that folder on desktop and mobile.
+- **GIVEN** an initialization error is visible, **WHEN** the user edits the name or parent path or
+ navigates to another folder, **THEN** the previous error disappears before the next submission.
+- **GIVEN** an invalid name or unwritable parent, **WHEN** the user confirms, **THEN** the form stays
+ open with its inputs intact and no repository is selected.
+- **GIVEN** the creation form is open, **WHEN** the user dismisses it without confirming, **THEN** no
+ filesystem or database change occurs and their task draft remains intact.
+- **GIVEN** a repository was initialized and selected, **WHEN** the user later cancels or deletes the
+ task, **THEN** the repository directory and workspace repository record remain.
+- **GIVEN** a Pixel 5 viewport, **WHEN** the user completes the same flow, **THEN** all actions are
+ reachable by touch, the directory list scrolls inside the drawer, the footer clears the safe area,
+ and the page has no horizontal overflow.
+
+## Out of Scope
+
+- Creating or publishing a remote Git hosting repository.
+- Cloning, importing, or converting an existing directory; existing paths continue through the
+ existing local repository selection and settings flows.
+- README, license, `.gitignore`, language template, remote, visibility, or hosting-provider setup.
+- Choosing the initial branch name or creating an initial commit.
+- Worktree, container, or remote execution before the repository has its first commit.
+- Creating an unborn repository as part of a multi-repository task.
+- Adding the creation action to Quick Chat, repository settings, project repository pickers, or MCP
+ tools in this iteration.
+- Automatically deleting a successfully created repository when its originating task is canceled or
+ deleted.
+
+## Implementation Plan
+
+See [Create Local Repository plan](../../plans/create-local-repository/plan.md).