fix(spawn): stop sending branch on spawn, render API errors, wire worker name (#171)
* fix(spawn): stop sending branch on spawn, render API errors, wire worker name Three spawn-modal bugs, re-landed from the closed redesign branch (#156): - createTask no longer sends `branch`: the API field names the session's NEW worktree branch, so submitting the modal's default ("main") made the daemon 409 with BRANCH_CHECKED_OUT_ELSEWHERE on every spawn. The "Based on" pane is informational — workers branch off the project's default branch in a fresh worktree. - API errors render their envelope message instead of "[object Object]": openapi-fetch resolves non-2xx responses to a plain {code,error,message} object, not an Error; new apiErrorMessage unwraps it (message + code). - The "Worker name" field is actually used: after spawn, a best-effort PATCH rename sets the displayName (a failed rename must not look like a failed spawn — the worker is already running). Also revives the renderer test suite, which made these tests (and 7 of 9 suite files) impossible to run on main: - vitest.config.ts re-exports vite.renderer.config so `vitest run` actually loads the jsdom environment + setup file. Forge's per-target vite.*.config.ts names are invisible to vitest, so the existing `test` block was dead config and every DOM-touching test died on "window is not defined". - vite.renderer.config.ts imports defineConfig from vitest/config so its `test` key typechecks. - routeTree.gen.ts + the session route are regenerated by the pinned @tanstack/router-plugin (it runs as part of loading the renderer config; the checked-in tree predated the installed plugin version and its route-ID drift caused 3 of main's 7 typecheck errors). - App.test.tsx wraps App in TooltipProvider, mirroring routes/__root.tsx. Frontend: 9/9 test files, 99/99 tests pass (was 2/9 files). Typecheck is down from 7 errors to 3 — the survivors (forge.config notarize/maker types, update-electron-app call signature) predate this branch and are untouched. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore: format with prettier [skip ci] * fix(gitworktree): base new session branches on the local default branch when no remote exists Re-lands the remoteless fallback from the closed redesign branch (archived in 641b712). Creating a session worktree resolved the base for a NEW branch only via the remote-tracking ref (origin/<defaultBranch>), so a registered repo with no remote failed every spawn with BRANCH_NOT_FETCHED — an error that misleadingly names the new session branch and suggests `git fetch`, which is impossible without a remote. refs/heads/<defaultBranch> now follows origin/<defaultBranch> in the candidate list: remote-tracking still wins whenever it exists, and a remoteless repo bases session branches on its local default branch. Verified live: a plain `git init` repo (no remote) that previously failed now spawns, and the integration suite covers it (TestWorkspaceIntegrationCreateInRemotelessRepo). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
This commit is contained in:
parent
40bd2dfb2b
commit
d60c49f5fb
|
|
@ -45,9 +45,14 @@ func worktreeListPorcelainArgs(repo string) []string {
|
||||||
func baseRefCandidates(branch, defaultBranch string) []string {
|
func baseRefCandidates(branch, defaultBranch string) []string {
|
||||||
candidates := []string{"origin/" + branch}
|
candidates := []string{"origin/" + branch}
|
||||||
if strings.Contains(defaultBranch, "/") {
|
if strings.Contains(defaultBranch, "/") {
|
||||||
|
// A qualified default ("upstream/main") is used verbatim; git's refname
|
||||||
|
// disambiguation already falls back to refs/heads/<defaultBranch>.
|
||||||
candidates = append(candidates, defaultBranch)
|
candidates = append(candidates, defaultBranch)
|
||||||
} else {
|
} else {
|
||||||
candidates = append(candidates, "origin/"+defaultBranch)
|
// The local head comes after origin/<defaultBranch> so remote-tracking
|
||||||
|
// still wins when present, but a remoteless repo can base new branches
|
||||||
|
// on its local default branch instead of failing BRANCH_NOT_FETCHED.
|
||||||
|
candidates = append(candidates, "origin/"+defaultBranch, "refs/heads/"+defaultBranch)
|
||||||
}
|
}
|
||||||
return append(candidates, branch)
|
return append(candidates, branch)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -154,6 +154,42 @@ func TestWorkspaceIntegrationDestroyDirtyWorktree(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestWorkspaceIntegrationCreateInRemotelessRepo guards the BRANCH_NOT_FETCHED
|
||||||
|
// regression: a repo with no remote configured must still spawn worktrees for
|
||||||
|
// new branches by basing them on the local default-branch head
|
||||||
|
// (refs/heads/main) once no origin/* candidate resolves.
|
||||||
|
func TestWorkspaceIntegrationCreateInRemotelessRepo(t *testing.T) {
|
||||||
|
git := requireGit(t)
|
||||||
|
tmp := t.TempDir()
|
||||||
|
repo := filepath.Join(tmp, "repo")
|
||||||
|
run(t, git, "init", repo)
|
||||||
|
runGit(t, git, repo, "config", "user.email", "ao@example.com")
|
||||||
|
runGit(t, git, repo, "config", "user.name", "Ao Agents")
|
||||||
|
if err := os.WriteFile(filepath.Join(repo, "README.md"), []byte("seed\n"), 0o644); err != nil {
|
||||||
|
t.Fatalf("write seed: %v", err)
|
||||||
|
}
|
||||||
|
runGit(t, git, repo, "add", "README.md")
|
||||||
|
runGit(t, git, repo, "commit", "-m", "seed")
|
||||||
|
runGit(t, git, repo, "branch", "-M", "main")
|
||||||
|
|
||||||
|
root := filepath.Join(tmp, "managed")
|
||||||
|
ws, err := New(Options{Binary: git, ManagedRoot: root, RepoResolver: StaticRepoResolver{"proj": repo}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("new: %v", err)
|
||||||
|
}
|
||||||
|
ctx := context.Background()
|
||||||
|
info, err := ws.Create(ctx, ports.WorkspaceConfig{ProjectID: "proj", SessionID: "sess", Branch: "feature/remoteless"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create in remoteless repo: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(info.Path, "README.md")); err != nil {
|
||||||
|
t.Fatalf("created worktree missing seed file: %v", err)
|
||||||
|
}
|
||||||
|
if err := ws.Destroy(ctx, info); err != nil {
|
||||||
|
t.Fatalf("destroy: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func requireGit(t *testing.T) string {
|
func requireGit(t *testing.T) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
git, err := exec.LookPath("git")
|
git, err := exec.LookPath("git")
|
||||||
|
|
|
||||||
|
|
@ -46,7 +46,7 @@ func TestCommandArgs(t *testing.T) {
|
||||||
|
|
||||||
func TestBaseRefCandidates(t *testing.T) {
|
func TestBaseRefCandidates(t *testing.T) {
|
||||||
got := baseRefCandidates("feature/test", "main")
|
got := baseRefCandidates("feature/test", "main")
|
||||||
want := []string{"origin/feature/test", "origin/main", "feature/test"}
|
want := []string{"origin/feature/test", "origin/main", "refs/heads/main", "feature/test"}
|
||||||
if !reflect.DeepEqual(got, want) {
|
if !reflect.DeepEqual(got, want) {
|
||||||
t.Fatalf("candidates = %#v, want %#v", got, want)
|
t.Fatalf("candidates = %#v, want %#v", got, want)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,10 +3,12 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { beforeEach, expect, test, vi } from "vitest";
|
import { beforeEach, expect, test, vi } from "vitest";
|
||||||
import { App } from "./App";
|
import { App } from "./App";
|
||||||
|
import { TooltipProvider } from "./components/ui/tooltip";
|
||||||
import { useUiStore } from "./stores/ui-store";
|
import { useUiStore } from "./stores/ui-store";
|
||||||
|
|
||||||
const { postMock, mockData } = vi.hoisted(() => ({
|
const { postMock, patchMock, mockData } = vi.hoisted(() => ({
|
||||||
postMock: vi.fn(),
|
postMock: vi.fn(),
|
||||||
|
patchMock: vi.fn(),
|
||||||
mockData: {
|
mockData: {
|
||||||
projectsError: undefined as Error | undefined,
|
projectsError: undefined as Error | undefined,
|
||||||
projects: [] as { id: string; name: string; path: string; sessionPrefix: string }[],
|
projects: [] as { id: string; name: string; path: string; sessionPrefix: string }[],
|
||||||
|
|
@ -38,6 +40,7 @@ vi.mock("./lib/api-client", () => ({
|
||||||
return { data: undefined, error: new Error(`unexpected GET ${url}`) };
|
return { data: undefined, error: new Error(`unexpected GET ${url}`) };
|
||||||
}),
|
}),
|
||||||
POST: postMock,
|
POST: postMock,
|
||||||
|
PATCH: patchMock,
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|
@ -47,15 +50,19 @@ vi.mock("./components/TerminalPane", () => ({
|
||||||
|
|
||||||
function renderApp() {
|
function renderApp() {
|
||||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
|
// TooltipProvider mirrors routes/__root.tsx, which wraps App in production.
|
||||||
return render(
|
return render(
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<App />
|
<TooltipProvider>
|
||||||
|
<App />
|
||||||
|
</TooltipProvider>
|
||||||
</QueryClientProvider>,
|
</QueryClientProvider>,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
postMock.mockReset();
|
postMock.mockReset();
|
||||||
|
patchMock.mockReset();
|
||||||
mockData.projectsError = undefined;
|
mockData.projectsError = undefined;
|
||||||
mockData.projects = [];
|
mockData.projects = [];
|
||||||
mockData.sessions = [];
|
mockData.sessions = [];
|
||||||
|
|
@ -158,15 +165,51 @@ test("spawns a worker from the New worker modal", async () => {
|
||||||
await user.type(await screen.findByLabelText("Prompt"), "Make task creation work");
|
await user.type(await screen.findByLabelText("Prompt"), "Make task creation work");
|
||||||
await user.click(screen.getByRole("button", { name: /Spawn worker/ }));
|
await user.click(screen.getByRole("button", { name: /Spawn worker/ }));
|
||||||
|
|
||||||
|
// No `branch` field: it names the worktree branch, and sending the default
|
||||||
|
// base ("main") makes the daemon 409 with BRANCH_CHECKED_OUT_ELSEWHERE.
|
||||||
expect(postMock).toHaveBeenCalledWith("/api/v1/sessions", {
|
expect(postMock).toHaveBeenCalledWith("/api/v1/sessions", {
|
||||||
body: {
|
body: {
|
||||||
projectId: "proj-1",
|
projectId: "proj-1",
|
||||||
kind: "worker",
|
kind: "worker",
|
||||||
harness: "claude-code",
|
harness: "claude-code",
|
||||||
prompt: "Make task creation work",
|
prompt: "Make task creation work",
|
||||||
branch: "main",
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
// No worker name given, so no rename round-trip.
|
||||||
|
expect(patchMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("renames the spawned worker when a name is given", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
mockData.projects = [{ id: "proj-1", name: "my-app", path: "/home/me/my-app", sessionPrefix: "" }];
|
||||||
|
postMock.mockResolvedValueOnce({
|
||||||
|
data: {
|
||||||
|
session: {
|
||||||
|
id: "new-task",
|
||||||
|
projectId: "proj-1",
|
||||||
|
harness: "claude-code",
|
||||||
|
isTerminated: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
patchMock.mockResolvedValueOnce({
|
||||||
|
data: { ok: true, sessionId: "new-task", displayName: "fix-login" },
|
||||||
|
});
|
||||||
|
|
||||||
|
renderApp();
|
||||||
|
|
||||||
|
await screen.findByRole("button", { name: "Select my-app" });
|
||||||
|
|
||||||
|
await user.click(screen.getByRole("button", { name: "New worker" }));
|
||||||
|
await user.type(await screen.findByLabelText("Worker name"), "fix-login");
|
||||||
|
await user.type(screen.getByLabelText("Prompt"), "Fix the login bug");
|
||||||
|
await user.click(screen.getByRole("button", { name: /Spawn worker/ }));
|
||||||
|
|
||||||
|
expect(patchMock).toHaveBeenCalledWith("/api/v1/sessions/{sessionId}", {
|
||||||
|
params: { path: { sessionId: "new-task" } },
|
||||||
|
body: { displayName: "fix-login" },
|
||||||
|
});
|
||||||
|
expect(await screen.findByRole("button", { name: "fix-login" })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("surfaces an error when spawning fails", async () => {
|
test("surfaces an error when spawning fails", async () => {
|
||||||
|
|
@ -188,8 +231,33 @@ test("surfaces an error when spawning fails", async () => {
|
||||||
kind: "worker",
|
kind: "worker",
|
||||||
harness: "claude-code",
|
harness: "claude-code",
|
||||||
prompt: "Failing task",
|
prompt: "Failing task",
|
||||||
branch: "main",
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(await screen.findByText("Failed to fetch")).toBeInTheDocument();
|
expect(await screen.findByText("Failed to fetch")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("surfaces the daemon error envelope message, not [object Object]", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
mockData.projects = [{ id: "proj-1", name: "my-app", path: "/home/me/my-app", sessionPrefix: "" }];
|
||||||
|
// openapi-fetch resolves non-2xx bodies as a plain APIError envelope.
|
||||||
|
postMock.mockResolvedValueOnce({
|
||||||
|
error: {
|
||||||
|
code: "BRANCH_CHECKED_OUT_ELSEWHERE",
|
||||||
|
error: "Conflict",
|
||||||
|
message: "main is checked out at /home/me/my-app",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
renderApp();
|
||||||
|
|
||||||
|
await screen.findByRole("button", { name: "Select my-app" });
|
||||||
|
|
||||||
|
await user.click(screen.getByRole("button", { name: "New worker" }));
|
||||||
|
await user.type(await screen.findByLabelText("Prompt"), "Failing task");
|
||||||
|
await user.click(screen.getByRole("button", { name: /Spawn worker/ }));
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await screen.findByText("main is checked out at /home/me/my-app (BRANCH_CHECKED_OUT_ELSEWHERE)"),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("[object Object]")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,25 @@ function errorMessage(error: unknown) {
|
||||||
return error instanceof Error ? error.message : "Could not load projects";
|
return error instanceof Error ? error.message : "Could not load projects";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* openapi-fetch resolves non-2xx responses to a plain APIError envelope
|
||||||
|
* ({ code, error, message, ... }), not an Error — String() on it renders
|
||||||
|
* "[object Object]".
|
||||||
|
*/
|
||||||
|
function apiErrorMessage(error: unknown, fallback: string): string {
|
||||||
|
if (error instanceof Error) return error.message;
|
||||||
|
if (typeof error === "string" && error) return error;
|
||||||
|
if (error && typeof error === "object") {
|
||||||
|
const envelope = error as { message?: unknown; code?: unknown };
|
||||||
|
if (typeof envelope.message === "string" && envelope.message) {
|
||||||
|
return typeof envelope.code === "string" && envelope.code
|
||||||
|
? `${envelope.message} (${envelope.code})`
|
||||||
|
: envelope.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
export function App({ routeSessionId, routeWorkspaceId }: AppProps) {
|
export function App({ routeSessionId, routeWorkspaceId }: AppProps) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const {
|
const {
|
||||||
|
|
@ -106,7 +125,7 @@ export function App({ routeSessionId, routeWorkspaceId }: AppProps) {
|
||||||
const createProject = async (input: { path: string }) => {
|
const createProject = async (input: { path: string }) => {
|
||||||
const { data, error } = await apiClient.POST("/api/v1/projects", { body: { path: input.path } });
|
const { data, error } = await apiClient.POST("/api/v1/projects", { body: { path: input.path } });
|
||||||
|
|
||||||
if (error) throw error;
|
if (error) throw new Error(apiErrorMessage(error, "Could not add project"));
|
||||||
if (!data?.project) throw new Error("Project creation returned no project");
|
if (!data?.project) throw new Error("Project creation returned no project");
|
||||||
|
|
||||||
const workspace: WorkspaceSummary = {
|
const workspace: WorkspaceSummary = {
|
||||||
|
|
@ -121,23 +140,36 @@ export function App({ routeSessionId, routeWorkspaceId }: AppProps) {
|
||||||
selectWorkspace(workspace.id);
|
selectWorkspace(workspace.id);
|
||||||
};
|
};
|
||||||
|
|
||||||
const createTask = async (input: { projectId: string; prompt: string; branch?: string; harness?: AgentProvider }) => {
|
const createTask = async (input: { projectId: string; prompt: string; name?: string; harness?: AgentProvider }) => {
|
||||||
|
// No `branch` here: the API's branch field names the session's worktree
|
||||||
|
// branch (sending a checked-out branch like "main" 409s); the base branch
|
||||||
|
// comes from the project config.
|
||||||
const { data, error } = await apiClient.POST("/api/v1/sessions", {
|
const { data, error } = await apiClient.POST("/api/v1/sessions", {
|
||||||
body: {
|
body: {
|
||||||
projectId: input.projectId,
|
projectId: input.projectId,
|
||||||
kind: "worker",
|
kind: "worker",
|
||||||
harness: input.harness,
|
harness: input.harness,
|
||||||
prompt: input.prompt,
|
prompt: input.prompt,
|
||||||
branch: input.branch || undefined,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (error || !data?.session) {
|
if (error || !data?.session) {
|
||||||
throw new Error(error instanceof Error ? error.message : error ? String(error) : "No session returned");
|
throw new Error(apiErrorMessage(error, "No session returned"));
|
||||||
}
|
}
|
||||||
|
|
||||||
const session = data.session;
|
const session = data.session;
|
||||||
|
|
||||||
|
// Best-effort: the session is already running, so a failed rename should
|
||||||
|
// not surface as a failed spawn (retrying would create a duplicate).
|
||||||
|
let displayName: string | undefined;
|
||||||
|
if (input.name) {
|
||||||
|
const renamed = await apiClient.PATCH("/api/v1/sessions/{sessionId}", {
|
||||||
|
params: { path: { sessionId: session.id } },
|
||||||
|
body: { displayName: input.name },
|
||||||
|
});
|
||||||
|
displayName = renamed.data?.displayName;
|
||||||
|
}
|
||||||
|
|
||||||
updateWorkspaces((current) =>
|
updateWorkspaces((current) =>
|
||||||
current.map((item) =>
|
current.map((item) =>
|
||||||
item.id === input.projectId
|
item.id === input.projectId
|
||||||
|
|
@ -149,9 +181,9 @@ export function App({ routeSessionId, routeWorkspaceId }: AppProps) {
|
||||||
terminalHandleId: session.terminalHandleId,
|
terminalHandleId: session.terminalHandleId,
|
||||||
workspaceId: item.id,
|
workspaceId: item.id,
|
||||||
workspaceName: item.name,
|
workspaceName: item.name,
|
||||||
title: input.prompt,
|
title: displayName ?? input.prompt,
|
||||||
provider: toAgentProvider(session.harness),
|
provider: toAgentProvider(session.harness),
|
||||||
branch: input.branch ?? "",
|
branch: "",
|
||||||
status: toSessionStatus(session.status, session.isTerminated),
|
status: toSessionStatus(session.status, session.isTerminated),
|
||||||
updatedAt: "now",
|
updatedAt: "now",
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -27,12 +27,7 @@ type SpawnWorkerModalProps = {
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
workspaces: WorkspaceSummary[];
|
workspaces: WorkspaceSummary[];
|
||||||
defaultProjectId?: string;
|
defaultProjectId?: string;
|
||||||
onCreateTask: (input: {
|
onCreateTask: (input: { projectId: string; prompt: string; name?: string; harness?: AgentProvider }) => Promise<void>;
|
||||||
projectId: string;
|
|
||||||
prompt: string;
|
|
||||||
branch?: string;
|
|
||||||
harness?: AgentProvider;
|
|
||||||
}) => Promise<void>;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export function SpawnWorkerModal({
|
export function SpawnWorkerModal({
|
||||||
|
|
@ -47,7 +42,6 @@ export function SpawnWorkerModal({
|
||||||
const [projectId, setProjectId] = useState(fallbackProjectId);
|
const [projectId, setProjectId] = useState(fallbackProjectId);
|
||||||
const [agent, setAgent] = useState<AgentProvider>("claude-code");
|
const [agent, setAgent] = useState<AgentProvider>("claude-code");
|
||||||
const [basedOn, setBasedOn] = useState<BasedOn>("Branch");
|
const [basedOn, setBasedOn] = useState<BasedOn>("Branch");
|
||||||
const [branch, setBranch] = useState("main");
|
|
||||||
const [tab, setTab] = useState<"Prompt" | "Workspace">("Prompt");
|
const [tab, setTab] = useState<"Prompt" | "Workspace">("Prompt");
|
||||||
const [prompt, setPrompt] = useState("");
|
const [prompt, setPrompt] = useState("");
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
@ -62,9 +56,6 @@ export function SpawnWorkerModal({
|
||||||
}, [open, fallbackProjectId]);
|
}, [open, fallbackProjectId]);
|
||||||
|
|
||||||
const selectedWorkspace = workspaces.find((workspace) => workspace.id === projectId) ?? workspaces[0];
|
const selectedWorkspace = workspaces.find((workspace) => workspace.id === projectId) ?? workspaces[0];
|
||||||
const branchOptions = Array.from(
|
|
||||||
new Set(["main", ...(selectedWorkspace?.sessions.map((session) => session.branch).filter(Boolean) ?? [])]),
|
|
||||||
);
|
|
||||||
const nameValid = name === "" || NAME_RULE.test(name);
|
const nameValid = name === "" || NAME_RULE.test(name);
|
||||||
const canSubmit = prompt.trim().length > 0 && projectId !== "" && nameValid && !isSubmitting;
|
const canSubmit = prompt.trim().length > 0 && projectId !== "" && nameValid && !isSubmitting;
|
||||||
|
|
||||||
|
|
@ -77,12 +68,11 @@ export function SpawnWorkerModal({
|
||||||
await onCreateTask({
|
await onCreateTask({
|
||||||
projectId,
|
projectId,
|
||||||
prompt: prompt.trim(),
|
prompt: prompt.trim(),
|
||||||
branch: basedOn === "Branch" ? branch.trim() || undefined : undefined,
|
name: name || undefined,
|
||||||
harness: agent,
|
harness: agent,
|
||||||
});
|
});
|
||||||
setName("");
|
setName("");
|
||||||
setPrompt("");
|
setPrompt("");
|
||||||
setBranch("main");
|
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "Could not spawn worker");
|
setError(err instanceof Error ? err.message : "Could not spawn worker");
|
||||||
|
|
@ -169,19 +159,15 @@ export function SpawnWorkerModal({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-2.5">
|
<div className="p-2.5">
|
||||||
{basedOn === "Branch" ? (
|
<p className="px-1 py-1.5 text-[12.5px] text-passive">
|
||||||
<SelectControl
|
{/* The API has no per-spawn base branch — the worker branches off the
|
||||||
aria-label="Based on branch"
|
project's configured default branch in a fresh worktree. */}
|
||||||
className="flex w-full"
|
{basedOn === "Branch"
|
||||||
onChange={setBranch}
|
? "Branches off the project's default branch in a fresh worktree."
|
||||||
value={branch}
|
: basedOn === "Issue"
|
||||||
options={branchOptions.map((option) => ({ value: option, label: option }))}
|
? "Pick an issue to start from."
|
||||||
/>
|
: "Pick a pull request to start from."}
|
||||||
) : (
|
</p>
|
||||||
<p className="px-1 py-1.5 text-[12.5px] text-passive">
|
|
||||||
{basedOn === "Issue" ? "Pick an issue to start from." : "Pick a pull request to start from."}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,57 +1,50 @@
|
||||||
/* eslint-disable */
|
/* eslint-disable */
|
||||||
|
|
||||||
// @ts-nocheck
|
// @ts-nocheck
|
||||||
|
|
||||||
// noinspection JSUnusedGlobalSymbols
|
// noinspection JSUnusedGlobalSymbols
|
||||||
// This file is auto-generated by @tanstack/router-plugin
|
|
||||||
// Do not edit this file manually — it is regenerated on every dev/build.
|
|
||||||
|
|
||||||
// Import Routes
|
// This file was automatically generated by TanStack Router.
|
||||||
|
// You should NOT make any changes in this file as it will be overwritten.
|
||||||
|
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
|
||||||
|
|
||||||
import { Route as rootRoute } from "./routes/__root";
|
import { Route as rootRouteImport } from "./routes/__root";
|
||||||
import { Route as IndexRoute } from "./routes/index";
|
import { Route as IndexRouteImport } from "./routes/index";
|
||||||
import { Route as WorkspacesWorkspaceIdRoute } from "./routes/workspaces.$workspaceId";
|
import { Route as WorkspacesWorkspaceIdRouteImport } from "./routes/workspaces.$workspaceId";
|
||||||
import { Route as WorkspacesWorkspaceIdSessionsSessionIdRoute } from "./routes/workspaces.$workspaceId_.sessions.$sessionId";
|
import { Route as WorkspacesWorkspaceIdSessionsSessionIdRouteImport } from "./routes/workspaces.$workspaceId_.sessions.$sessionId";
|
||||||
|
|
||||||
// Create/Update Routes
|
const IndexRoute = IndexRouteImport.update({
|
||||||
|
|
||||||
const IndexRouteWithChildren = IndexRoute.update({
|
|
||||||
id: "/",
|
id: "/",
|
||||||
path: "/",
|
path: "/",
|
||||||
getParentRoute: () => rootRoute,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any);
|
} as any);
|
||||||
|
const WorkspacesWorkspaceIdRoute = WorkspacesWorkspaceIdRouteImport.update({
|
||||||
const WorkspacesWorkspaceIdRouteWithChildren = WorkspacesWorkspaceIdRoute.update({
|
|
||||||
id: "/workspaces/$workspaceId",
|
id: "/workspaces/$workspaceId",
|
||||||
path: "/workspaces/$workspaceId",
|
path: "/workspaces/$workspaceId",
|
||||||
getParentRoute: () => rootRoute,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any);
|
} as any);
|
||||||
|
const WorkspacesWorkspaceIdSessionsSessionIdRoute = WorkspacesWorkspaceIdSessionsSessionIdRouteImport.update({
|
||||||
const WorkspacesWorkspaceIdSessionsSessionIdRouteWithChildren = WorkspacesWorkspaceIdSessionsSessionIdRoute.update({
|
|
||||||
id: "/workspaces/$workspaceId_/sessions/$sessionId",
|
id: "/workspaces/$workspaceId_/sessions/$sessionId",
|
||||||
path: "/workspaces/$workspaceId/sessions/$sessionId",
|
path: "/workspaces/$workspaceId/sessions/$sessionId",
|
||||||
getParentRoute: () => rootRoute,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any);
|
} as any);
|
||||||
|
|
||||||
// Populate FileRoutes
|
|
||||||
|
|
||||||
export interface FileRoutesByFullPath {
|
export interface FileRoutesByFullPath {
|
||||||
"/": typeof IndexRoute;
|
"/": typeof IndexRoute;
|
||||||
"/workspaces/$workspaceId": typeof WorkspacesWorkspaceIdRoute;
|
"/workspaces/$workspaceId": typeof WorkspacesWorkspaceIdRoute;
|
||||||
"/workspaces/$workspaceId/sessions/$sessionId": typeof WorkspacesWorkspaceIdSessionsSessionIdRoute;
|
"/workspaces/$workspaceId/sessions/$sessionId": typeof WorkspacesWorkspaceIdSessionsSessionIdRoute;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FileRoutesByTo {
|
export interface FileRoutesByTo {
|
||||||
"/": typeof IndexRoute;
|
"/": typeof IndexRoute;
|
||||||
"/workspaces/$workspaceId": typeof WorkspacesWorkspaceIdRoute;
|
"/workspaces/$workspaceId": typeof WorkspacesWorkspaceIdRoute;
|
||||||
"/workspaces/$workspaceId/sessions/$sessionId": typeof WorkspacesWorkspaceIdSessionsSessionIdRoute;
|
"/workspaces/$workspaceId/sessions/$sessionId": typeof WorkspacesWorkspaceIdSessionsSessionIdRoute;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FileRoutesById {
|
export interface FileRoutesById {
|
||||||
__root__: typeof rootRoute;
|
__root__: typeof rootRouteImport;
|
||||||
"/": typeof IndexRoute;
|
"/": typeof IndexRoute;
|
||||||
"/workspaces/$workspaceId": typeof WorkspacesWorkspaceIdRoute;
|
"/workspaces/$workspaceId": typeof WorkspacesWorkspaceIdRoute;
|
||||||
"/workspaces/$workspaceId_/sessions/$sessionId": typeof WorkspacesWorkspaceIdSessionsSessionIdRoute;
|
"/workspaces/$workspaceId_/sessions/$sessionId": typeof WorkspacesWorkspaceIdSessionsSessionIdRoute;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FileRouteTypes {
|
export interface FileRouteTypes {
|
||||||
fileRoutesByFullPath: FileRoutesByFullPath;
|
fileRoutesByFullPath: FileRoutesByFullPath;
|
||||||
fullPaths: "/" | "/workspaces/$workspaceId" | "/workspaces/$workspaceId/sessions/$sessionId";
|
fullPaths: "/" | "/workspaces/$workspaceId" | "/workspaces/$workspaceId/sessions/$sessionId";
|
||||||
|
|
@ -60,17 +53,41 @@ export interface FileRouteTypes {
|
||||||
id: "__root__" | "/" | "/workspaces/$workspaceId" | "/workspaces/$workspaceId_/sessions/$sessionId";
|
id: "__root__" | "/" | "/workspaces/$workspaceId" | "/workspaces/$workspaceId_/sessions/$sessionId";
|
||||||
fileRoutesById: FileRoutesById;
|
fileRoutesById: FileRoutesById;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RootRouteChildren {
|
export interface RootRouteChildren {
|
||||||
IndexRoute: typeof IndexRoute;
|
IndexRoute: typeof IndexRoute;
|
||||||
WorkspacesWorkspaceIdRoute: typeof WorkspacesWorkspaceIdRoute;
|
WorkspacesWorkspaceIdRoute: typeof WorkspacesWorkspaceIdRoute;
|
||||||
WorkspacesWorkspaceIdSessionsSessionIdRoute: typeof WorkspacesWorkspaceIdSessionsSessionIdRoute;
|
WorkspacesWorkspaceIdSessionsSessionIdRoute: typeof WorkspacesWorkspaceIdSessionsSessionIdRoute;
|
||||||
}
|
}
|
||||||
|
|
||||||
const rootRouteChildren: RootRouteChildren = {
|
declare module "@tanstack/react-router" {
|
||||||
IndexRoute: IndexRouteWithChildren,
|
interface FileRoutesByPath {
|
||||||
WorkspacesWorkspaceIdRoute: WorkspacesWorkspaceIdRouteWithChildren,
|
"/": {
|
||||||
WorkspacesWorkspaceIdSessionsSessionIdRoute: WorkspacesWorkspaceIdSessionsSessionIdRouteWithChildren,
|
id: "/";
|
||||||
};
|
path: "/";
|
||||||
|
fullPath: "/";
|
||||||
|
preLoaderRoute: typeof IndexRouteImport;
|
||||||
|
parentRoute: typeof rootRouteImport;
|
||||||
|
};
|
||||||
|
"/workspaces/$workspaceId": {
|
||||||
|
id: "/workspaces/$workspaceId";
|
||||||
|
path: "/workspaces/$workspaceId";
|
||||||
|
fullPath: "/workspaces/$workspaceId";
|
||||||
|
preLoaderRoute: typeof WorkspacesWorkspaceIdRouteImport;
|
||||||
|
parentRoute: typeof rootRouteImport;
|
||||||
|
};
|
||||||
|
"/workspaces/$workspaceId_/sessions/$sessionId": {
|
||||||
|
id: "/workspaces/$workspaceId_/sessions/$sessionId";
|
||||||
|
path: "/workspaces/$workspaceId/sessions/$sessionId";
|
||||||
|
fullPath: "/workspaces/$workspaceId/sessions/$sessionId";
|
||||||
|
preLoaderRoute: typeof WorkspacesWorkspaceIdSessionsSessionIdRouteImport;
|
||||||
|
parentRoute: typeof rootRouteImport;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const routeTree = rootRoute._addFileChildren(rootRouteChildren)._addFileTypes<FileRouteTypes>();
|
const rootRouteChildren: RootRouteChildren = {
|
||||||
|
IndexRoute: IndexRoute,
|
||||||
|
WorkspacesWorkspaceIdRoute: WorkspacesWorkspaceIdRoute,
|
||||||
|
WorkspacesWorkspaceIdSessionsSessionIdRoute: WorkspacesWorkspaceIdSessionsSessionIdRoute,
|
||||||
|
};
|
||||||
|
export const routeTree = rootRouteImport._addFileChildren(rootRouteChildren)._addFileTypes<FileRouteTypes>();
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import { App } from "../App";
|
import { App } from "../App";
|
||||||
|
|
||||||
export const Route = createFileRoute("/workspaces/$workspaceId/sessions/$sessionId")({
|
export const Route = createFileRoute("/workspaces/$workspaceId_/sessions/$sessionId")({
|
||||||
component: SessionRoute,
|
component: SessionRoute,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
import { defineConfig } from "vite";
|
// defineConfig comes from vitest/config (a superset of vite's) so the `test`
|
||||||
|
// block below typechecks; the produced config is a plain vite config object.
|
||||||
|
import { defineConfig } from "vitest/config";
|
||||||
import type { Plugin } from "vite";
|
import type { Plugin } from "vite";
|
||||||
import { TanStackRouterVite } from "@tanstack/router-plugin/vite";
|
import { TanStackRouterVite } from "@tanstack/router-plugin/vite";
|
||||||
import react from "@vitejs/plugin-react";
|
import react from "@vitejs/plugin-react";
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
// `vitest run` only auto-loads vite.config.ts / vitest.config.ts — it never
|
||||||
|
// sees Forge's per-target vite.*.config.ts files, so the renderer config's
|
||||||
|
// `test` block (jsdom environment, setup file) was dead config and every
|
||||||
|
// DOM-touching test failed with "window is not defined". Re-export the
|
||||||
|
// renderer config so tests run under the same plugins and test settings.
|
||||||
|
export { default } from "./vite.renderer.config";
|
||||||
Loading…
Reference in New Issue