-
-
For
-
{
- const nextAssignee = parseAssigneeValue(value);
- if (nextAssignee.assigneeAgentId) {
- trackRecentAssignee(nextAssignee.assigneeAgentId);
- }
- setAssigneeValue(value);
- const hasAssignee = Boolean(nextAssignee.assigneeAgentId || nextAssignee.assigneeUserId);
- if (hasAssignee && status === "backlog") {
- setStatus("todo");
- }
- }}
- onConfirm={() => {
- if (projectId) {
- descriptionEditorRef.current?.focus();
- } else {
- projectSelectorRef.current?.focus();
- }
- }}
- renderTriggerValue={(option) =>
- option ? (
- currentAssignee ? (
- <>
-
- {option.label}
- >
- ) : (
- {option.label}
- )
- ) : (
- Assignee
- )
- }
- renderOption={(option) => {
- if (!option.id) return {option.label};
- const assignee = parseAssigneeValue(option.id).assigneeAgentId
- ? (agents ?? []).find((agent) => agent.id === parseAssigneeValue(option.id).assigneeAgentId)
- : null;
- return (
- <>
- {assignee ? : null}
- {option.label}
- {assignee && getTrustPreset(assignee.permissions) === "low_trust_review" ? (
-
+ {isSubIssueMode ? "New sub-task" : "New task"}
+
+
+ {
+ if (!effectiveCompanyId) throw new Error("No organization selected");
+ const asset = await assetsApi.uploadImage(effectiveCompanyId, file, "issues/drafts");
+ return asset.contentPath;
+ }}
+ companyId={effectiveCompanyId}
+ enableReassign
+ reassignOptions={assigneeOptions}
+ modelAgents={modelAgents}
+ agentMap={modelAgents}
+ currentAssigneeValue={assigneeValue}
+ assigneeAdapterOverrides={inheritedOverrides}
+ onPendingAssigneeChange={(value) => {
+ if (value === null) return;
+ defaultAssigneePendingRef.current = false;
+ const next = parseAssigneeValue(value);
+ if (next.assigneeAgentId) trackRecentAssignee(next.assigneeAgentId, effectiveCompanyId ?? undefined);
+ if (next.assigneeUserId) trackRecentAssigneeUser(next.assigneeUserId, effectiveCompanyId ?? undefined);
+ setAssigneeValue(value);
+ setComposerSettings(null);
+ setAssigneeModelLane("primary");
+ setAssigneeModelOverride("");
+ setAssigneeThinkingEffort("");
+ setAssigneeChrome(false);
+ if (value && status === "backlog") setStatus("todo");
+ }}
+ creation={{
+ value: description,
+ onChange: handleDescriptionChange,
+ onSubmit: handleSubmit,
+ submitLabel: isSubIssueMode ? "Create sub-task" : "Create task",
+ canSubmitWithoutBody: Boolean(title.trim()),
+ onSelectFiles: stageFiles,
+ runSettings: composerSettings,
+ onRunSettingsChange: setComposerSettings,
+ header:
+ hasTitle || isSubIssueMode ? (
+
+ {hasTitle ? (
+
{
+ titleRef.current = event.target.value;
+ setTitle(event.target.value);
+ queueDraftSave({ title: event.target.value });
+ }}
+ />
) : null}
- >
- );
- }}
- />
-
in
-
{
- descriptionEditorRef.current?.focus();
- }}
- renderTriggerValue={(option) =>
- option && currentProject ? (
- <>
-
- {option.label}
- >
- ) : (
- Project
- )
- }
- renderOption={(option) => {
- if (!option.id) return {option.label};
- const project = orderedProjects.find((item) => item.id === option.id);
- return (
- <>
-
- {option.label}
- >
- );
- }}
- />
-
- {/* Three-dot menu to add Reviewer / Approver rows */}
-
-
-
-
-
-
-
-
-
-
-
-
-
- {/* Reviewer row */}
- {showReviewerRow && (
-
-
-
- option ? (
- <>
- {(() => {
- const reviewer = parseAssigneeValue(option.id).assigneeAgentId
- ? (agents ?? []).find((a) => a.id === parseAssigneeValue(option.id).assigneeAgentId)
- : null;
- return reviewer ? : null;
- })()}
- {option.label}
- >
- ) : (
- Reviewer
- )
- }
- renderOption={(option) => {
- if (!option.id) return {option.label};
- const reviewer = parseAssigneeValue(option.id).assigneeAgentId
- ? (agents ?? []).find((agent) => agent.id === parseAssigneeValue(option.id).assigneeAgentId)
- : null;
- return (
- <>
- {reviewer ? : null}
- {option.label}
- >
- );
- }}
- />
-
- )}
-
- {/* Approver row */}
- {showApproverRow && (
-
-
-
- option ? (
- <>
- {(() => {
- const approver = parseAssigneeValue(option.id).assigneeAgentId
- ? (agents ?? []).find((a) => a.id === parseAssigneeValue(option.id).assigneeAgentId)
- : null;
- return approver ? : null;
- })()}
- {option.label}
- >
- ) : (
- Approver
- )
- }
- renderOption={(option) => {
- if (!option.id) return {option.label};
- const approver = parseAssigneeValue(option.id).assigneeAgentId
- ? (agents ?? []).find((agent) => agent.id === parseAssigneeValue(option.id).assigneeAgentId)
- : null;
- return (
- <>
- {approver ? : null}
- {option.label}
- >
- );
- }}
- />
-
- )}
-
- {/* Watchdog row */}
- {showWatchdogRow && (
-
-
-
-
-
-
-
-
-
Watchdog agent
-
- option ? (
- <>
- {selectedWatchdogAgent ? (
-
- ) : null}
- {option.label}
- >
- ) : (
- Select agent
- )
- }
- renderOption={(option) => {
- const agent = (agents ?? []).find((a) => a.id === option.id);
- return (
- <>
- {agent ? : null}
- {option.label}
- >
- );
- }}
- />
-
-
-
Instructions (optional)
-
-
-
-
-
-
-
-
- )}
-
-
- {isSubIssueMode ? (
-
-
-
-
- Sub-task of
- {parentIssueLabel}
-
- {newIssueDefaults.parentTitle ? (
-
- {newIssueDefaults.parentTitle}
-
- ) : null}
-
-
- ) : null}
-
- {workspaceIsolationControlsVisible && currentProject && currentProjectSupportsExecutionWorkspace && (
-
-
-
Execution workspace
-
- Control whether this task runs in the shared workspace, a new isolated workspace, or an existing one.
-
-
- {executionWorkspaceMode === "reuse_existing" && (
-
setSelectedExecutionWorkspaceId(workspaceId)}
- loading={reusableExecutionWorkspacesLoading}
- error={reusableExecutionWorkspacesError}
- disablePortal
- />
- )}
- {/*
- The label used to fall back to the workspace working directory,
- a path on the execution host. It now falls back to a neutral
- phrase, so the dialog never renders a host path.
- */}
- {executionWorkspaceMode === "reuse_existing" && selectedReusableExecutionWorkspace && (
-
- Reusing {selectedReusableExecutionWorkspace.name} from {selectedReusableExecutionWorkspace.branchName ?? "existing execution workspace"}.
-
- )}
- {showParentWorkspaceWarning ? (
-
- Warning: this sub-task will no longer use the parent task workspace{parentExecutionWorkspaceLabel ? ` (${parentExecutionWorkspaceLabel})` : ""}.
-
- ) : null}
-
-
- )}
-
- {supportsAssigneeOverrides && (
-
-
- {assigneeOptionsOpen && (
-
-
-
Model lane
-
- {(["primary", "custom"] as const).map((lane) => (
-
- ))}
-
- {assigneeModelLane === "primary" && (
-
Runs on the agent's primary model.
- )}
- {assigneeModelLane === "custom" && (
-
Override the model and effort for this task only.
- )}
-
- {assigneeModelLane === "custom" && (
-
- )}
- {assigneeModelLane === "custom" && (
-
-
Thinking effort
-
- {thinkingEffortOptions.map((option) => (
-
- ))}
-
-
- )}
- {assigneeAdapterType === "claude_local" && assigneeModelLane === "custom" && (
-
-
Enable Chrome (--chrome)
-
setAssigneeChrome((value) => !value)}
- />
-
- )}
-
- )}
-
- )}
-
- {/* Description */}
-
-
-
-
- {stagedFiles.length > 0 ? (
-
- {stagedDocuments.length > 0 ? (
-
-
Documents
-
- {stagedDocuments.map((file) => (
-
-
-
-
- {file.documentKey}
-
- {file.file.name}
-
-
-
-
{file.title || file.file.name}
-
•
-
{formatFileSize(file.file)}
+ {isSubIssueMode ? (
+
+
+
+
+ Sub-task of
+ {parentIssueLabel}
+
+ {newIssueDefaults.parentTitle ? (
+
{newIssueDefaults.parentTitle}
+ ) : null}
-
-
- ))}
-
-
- ) : null}
-
- {stagedAttachments.length > 0 ? (
-
-
Attachments
-
- {stagedAttachments.map((file) => (
-
-
-
-
- {file.file.type || "application/octet-stream"} • {formatFileSize(file.file)}
-
-
-
-
- ))}
-
-
- ) : null}
-
- ) : null}
-
-
-
- {/* Property chips bar */}
-
- {/* Status chip */}
-
-
-
-
-
- {statuses.map((s) => (
-
+ ) : undefined,
+ details: (
+ <>
+ {worktreeSelectionIncomplete && !worktreesLoading ? (
+
+ {worktreesError ? "Couldn't check the selected worktree. Retry in Worktrees or choose New worktree."
+ : "The selected worktree is no longer available. Choose another worktree or start a new one."}
+
) : null}
-
-
- ))}
-
-
+ {stagedFiles.length > 0 ? (
+
+ {stagedDocuments.length > 0 ? (
+
+
Documents
+
+ {stagedDocuments.map((file) => (
+
+
+
+
+ {file.documentKey}
+
+ {file.file.name}
+
+
+
+ {file.title || file.file.name}
+ •
+ {formatFileSize(file.file)}
+
+
+
+
+ ))}
+
+
+ ) : null}
- {/* Priority chip — PAP-411: hidden behind SHOW_TASK_PRIORITY_UI. */}
- {SHOW_TASK_PRIORITY_UI && (
-
-
-
+ ) : null}
+ {assigneeValue && status === "backlog" ? (
+
+
+
+ Assigning implies executable intent - leave status as{" "}
+ Backlog only to deliberately park this. The assignee will
+ not be woken until status moves to Todo or{" "}
+ In Progress.
+
+
+ ) : null}
+
+ {selectedAssigneeAgent?.status === "paused" ? (
+
+
+ {selectedAssigneeAgent.name} is paused and will not start
+ work on this task until it is resumed
+ {selectedAssigneeAgent.pauseReason === "import"
+ ? " — it arrived paused from an organization import"
+ : ""}
+ . You can resume it from the task page after creating the task.
+
+
+ ) : null}
+
+ {currentAssigneeLowTrust ? (
+
+
+
+ Low-trust review agent. It can only act inside its assigned review boundary; task, project, or
+ run policy defines the concrete scope.
+
+
+ ) : null}
+
+ {showParentWorkspaceWarning ? (
+
+
+ This sub-task will no longer use the parent task workspace
+ {parentExecutionWorkspaceLabel ? ` (${parentExecutionWorkspaceLabel})` : ""}.
+
+
+ ) : null}
+ {effectiveCompanyId && neededUserSecretKeys.length > 0 ? (
+
+ ) : null}
>
- ) : (
+ ),
+ submitDisabled: worktreeSelectionIncomplete,
+ contextBar: (
<>
-
- Priority
+
+ option && currentProject ? (
+ <>
+
+ {option.label}
+
+ >
+ ) : (
+ <>
+
+ Project
+
+ >
+ )
+ }
+ renderOption={(option) => {
+ if (!option.id) return <>{option.label}>;
+ const project = orderedProjects.find((item) => item.id === option.id);
+ return (
+ <>
+
+ {option.label}
+ >
+ );
+ }}
+ />
+ {canChooseWorktrees ? (
+ {
+ setExecutionWorkspaceMode(mode);
+ setSelectedExecutionWorkspaceId(workspaceId);
+ if (workspaceId) {
+ const workspace = selectableReusableWorkspaces.find((entry) => entry.id === workspaceId);
+ if (workspace) setProjectWorkspaceId(workspace.projectWorkspaceId ?? "");
+ } else {
+ setProjectWorkspaceId(defaultProjectWorkspaceIdForProject(currentProject));
+ }
+ }}
+ loading={worktreesLoading}
+ error={worktreesError}
+ onRetry={() => void refetchWorktrees()}
+ disabled={createIssue.isPending}
+ mobile={isMobile}
+ contentStyle={entityPickerViewportStyle}
+ />
+ ) : null}
>
- )}
-
-
-
- {priorities.map((p) => (
- { setPriority(p.value); setPriorityOpen(false); }}
- >
-
- {p.label}
-
- ))}
-
-
- )}
-
- {/* Labels chip — disabled, not wired up yet */}
- {/*
-
- Labels
- */}
-
-
- stageFileInputRef.current?.click()}
- disabled={createIssue.isPending}
- >
-
- Upload
-
-
- {/* Work mode chip */}
-
-
-
-
- {currentWorkMode.label}
-
-
-
- {workModeOptions.map((option) => {
- const Icon = option.icon;
- return (
- {
- setWorkMode(option.value);
- setWorkModeOpen(false);
- }}
- >
-
- {option.label}
- {option.value === workMode ? : null}
-
- );
- })}
-
-
-
- {/* More */}
-
-
-
-
-
-
-
- {/* PAP-411: mobile priority section hidden behind SHOW_TASK_PRIORITY_UI. */}
- {SHOW_TASK_PRIORITY_UI && (
-
-
- Priority
-
- {priorities.map((p) => (
-
{
- setPriority(p.value);
- setMoreOpen(false);
- }}
- >
-
- {p.label}
-
- ))}
-
-
- )}
-
-
- Start date
-
-
-
- Due date
-
-
-
-
-
- {assigneeValue && status === "backlog" ? (
-
-
-
- Assigning implies executable intent - leave status as Backlog only to deliberately park this. The assignee will not be woken until status moves to Todo or In Progress.
-
-
- ) : null}
-
- {selectedAssigneeAgent?.status === "paused" ? (
-
-
- {selectedAssigneeAgent.name} is paused and will not start work on this task until it is resumed
- {selectedAssigneeAgent.pauseReason === "import" ? " — it arrived paused from an organization import" : ""}. You can resume it from the task page after creating the task.
-
-
- ) : null}
-
- {currentAssigneeLowTrust ? (
-
-
-
- Low-trust review agent. It can only act inside its assigned review boundary; task, project, or run policy defines the concrete scope.
-
-
- ) : null}
-
- {/* Footer */}
-
-
- Discard Draft
-
-
- {createIssue.isError ? (
-
- {createIssueErrorMessage}
-
- ) : null}
-
-
- {createIssue.isPending ? : null}
- {createIssue.isPending ? "Creating..." : isSubIssueMode ? "Create Sub-Task" : "Create Task"}
-
-
-
+ ),
+ }}
+ />
+
diff --git a/ui/src/components/NewProjectDialog.test.tsx b/ui/src/components/NewProjectDialog.test.tsx
new file mode 100644
index 00000000000..f7dc764db16
--- /dev/null
+++ b/ui/src/components/NewProjectDialog.test.tsx
@@ -0,0 +1,72 @@
+// @vitest-environment jsdom
+
+import { act, type ReactNode } from "react";
+import { createRoot } from "react-dom/client";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { PROJECT_COLORS } from "@paperclipai/shared";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { projectsApi } from "../api/projects";
+import { getLastProjectId } from "../lib/recent-projects";
+import { NewProjectForm } from "./NewProjectDialog";
+
+vi.mock("../api/projects", () => ({ projectsApi: { create: vi.fn() } }));
+vi.mock("./ProjectRepositoryInput", () => ({
+ ProjectRepositoryInput: () => null,
+ repositoryOptionsKey: (companyId: string) => ["repositories", companyId],
+}));
+vi.mock("@/features/connections/ConnectionSetupFlow", () => ({ ConnectionSetupFlow: () => null }));
+vi.mock("./ui/dialog", () => ({
+ Dialog: ({ children }: { children: ReactNode }) => <>{children}>,
+ DialogContent: ({ children }: { children: ReactNode }) =>
{children}
,
+ DialogTitle: ({ children }: { children: ReactNode }) =>
{children}
,
+}));
+
+let root: ReturnType
;
+let container: HTMLDivElement;
+beforeEach(() => {
+ localStorage.clear();
+ vi.mocked(projectsApi.create).mockReset();
+ container = document.createElement("div");
+ document.body.appendChild(container);
+ root = createRoot(container);
+});
+afterEach(() => {
+ act(() => root.unmount());
+ container.remove();
+ vi.restoreAllMocks();
+});
+
+async function settle() {
+ await act(async () => { await new Promise((resolve) => setTimeout(resolve, 10)); });
+}
+
+describe("new project colors", () => {
+ it("previews a palette color, keeps it through a retry, and remembers the created project", async () => {
+ vi.mocked(projectsApi.create)
+ .mockRejectedValueOnce(new Error("Try again"))
+ .mockResolvedValueOnce({ id: "project-created" } as Awaited>);
+ const onClose = vi.fn();
+ act(() => root.render(
+
+ ));
+ const previewColor = container.querySelector('span[style]')!.style.backgroundColor;
+ const input = container.querySelector('input[aria-label="Project name"]')!;
+ act(() => {
+ Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call(input, "Colorful project");
+ input.dispatchEvent(new Event("input", { bubbles: true }));
+ });
+ act(() => container.querySelector("form")!.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true })));
+ await settle();
+ expect(container.querySelector('[role="alert"]')?.textContent).toBe("Try again");
+ const firstColor = vi.mocked(projectsApi.create).mock.calls[0][1].color;
+ expect(PROJECT_COLORS).toContain(firstColor);
+ const expected = document.createElement("span");
+ expected.style.backgroundColor = String(firstColor);
+ expect(previewColor).toBe(expected.style.backgroundColor);
+ act(() => container.querySelector("form")!.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true })));
+ await settle();
+ expect(vi.mocked(projectsApi.create).mock.calls[1][1]).toEqual({ name: "Colorful project", color: firstColor, status: "planned", repositoryIds: [] });
+ expect(getLastProjectId("company-1")).toBe("project-created");
+ expect(onClose).toHaveBeenCalledOnce();
+ });
+});
diff --git a/ui/src/components/NewProjectDialog.tsx b/ui/src/components/NewProjectDialog.tsx
index 80813664b43..f0c922546bb 100644
--- a/ui/src/components/NewProjectDialog.tsx
+++ b/ui/src/components/NewProjectDialog.tsx
@@ -1,11 +1,13 @@
import { useRef, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
-import type { ProjectRepository } from "@paperclipai/shared";
-import { Folder, X } from "lucide-react";
+import { PROJECT_COLORS, type ProjectRepository } from "@paperclipai/shared";
+import { X } from "lucide-react";
import { useDialog } from "../context/DialogContext";
import { useCompany } from "../context/CompanyContext";
import { projectsApi } from "../api/projects";
import { queryKeys } from "../lib/queryKeys";
+import { trackRecentProject } from "../lib/recent-projects";
+import { ProjectTile } from "./ProjectTile";
import { Dialog, DialogContent, DialogTitle } from "./ui/dialog";
import { Button } from "./ui/button";
import { ProjectRepositoryInput, repositoryOptionsKey } from "./ProjectRepositoryInput";
@@ -21,12 +23,14 @@ export function NewProjectDialog() {
export function NewProjectForm({ companyId, onClose }: { companyId: string; onClose: () => void }) {
const client = useQueryClient();
const [name, setName] = useState("");
+ const [color] = useState(() => PROJECT_COLORS[Math.floor(Math.random() * PROJECT_COLORS.length)]!);
const [repos, setRepos] = useState([]);
const [connecting, setConnecting] = useState(false);
const input = useRef(null);
const create = useMutation({
- mutationFn: () => projectsApi.create(companyId, { name: name.trim(), status: "planned", repositoryIds: repos.map((repo) => repo.id) }),
- onSuccess: () => {
+ mutationFn: () => projectsApi.create(companyId, { name: name.trim(), color, status: "planned", repositoryIds: repos.map((repo) => repo.id) }),
+ onSuccess: (project) => {
+ trackRecentProject(project.id, companyId);
void client.invalidateQueries({ queryKey: queryKeys.projects.all(companyId) });
onClose();
},
@@ -48,7 +52,7 @@ export function NewProjectForm({ companyId, onClose }: { companyId: string; onCl
diff --git a/ui/src/components/SearchableSelect.test.tsx b/ui/src/components/SearchableSelect.test.tsx
index ab6a2aef71b..4b3d4c5ffbf 100644
--- a/ui/src/components/SearchableSelect.test.tsx
+++ b/ui/src/components/SearchableSelect.test.tsx
@@ -5,6 +5,7 @@ import { createRoot, type Root } from "react-dom/client";
import type { ReactNode } from "react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { SearchableSelect, type SearchableSelectGroup, type SearchableSelectOption } from "./SearchableSelect";
+import { Dialog, DialogContent, DialogTitle } from "./ui/dialog";
import {
buildReusableExecutionWorkspaceOptionGroups,
reusableWorkspaceOptionMatches,
@@ -393,6 +394,55 @@ describe("SearchableSelect", () => {
expect(trigger?.getAttribute("aria-expanded")).toBe("false");
});
+ it.each([false, true])("dismisses outside a nested worktree picker (modal=%s) without reopening or closing the task", async (modal) => {
+ const onValueChange = vi.fn();
+ root = render(
+
,
+ container,
+ );
+ await flush();
+ const trigger = document.querySelector