71a8464fee
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - The control plane owns agent lifecycle, issue assignment, routine dispatch, heartbeat wakeups, and recovery paths > - Terminated, paused, pending-approval, or otherwise invalid agents should not receive new work or new execution attempts > - The old behavior left eligibility checks spread across routes and services, so assignment and run paths could drift apart > - This pull request centralizes agent lifecycle eligibility and applies it consistently to assignment, invocation, routines, recovery, and UI affordances > - The benefit is safer autonomy: terminated agents stay paused, invalid org-chain agents are surfaced, and active agents keep receiving valid work ## Linked Issues or Issue Description Refs #5103 Related: #1864 Bug fix context: - What happened: agent assignment and heartbeat/run paths did not share one eligibility contract, so invalid lifecycle states could still be considered in some paths. - Expected behavior: terminated agents must never receive new assignments or heartbeat runs, and paused or otherwise invalid agents should be treated as non-invokable consistently. - Steps to reproduce: create or select an agent in an invalid lifecycle state, then attempt assignment, routine dispatch, or heartbeat/recovery wake paths. - Paperclip version/commit: fixed on top of `paperclipai/paperclip` `master` at the PR base. - Deployment mode: applies to the server control plane in local and authenticated deployments. ## What Changed - Added shared agent lifecycle eligibility helpers and exported the related shared types. - Centralized server-side assignability and invokability checks for issue assignment, agent routes, heartbeat dispatch, routines, recovery, and liveness logic. - Hardened issue assignment so invalid assignees are rejected instead of queued for work. - Hardened heartbeat/routine/recovery paths so terminated and otherwise invalid agents are not woken for new runs. - Updated board UI affordances to disable invalid agent actions and surface org-chain warnings where relevant. - Added targeted shared, server, and UI tests for the new eligibility behavior. ## Verification - `pnpm exec vitest run packages/shared/src/agent-eligibility.test.ts server/src/__tests__/agent-invokability.test.ts server/src/__tests__/heartbeat-archived-company-guard.test.ts server/src/__tests__/issue-liveness.test.ts server/src/__tests__/issues-service.test.ts server/src/__tests__/routines-service.test.ts ui/src/lib/company-members.test.ts ui/src/pages/Agents.test.tsx` — 8 files, 144 tests passed. - `pnpm --filter @paperclipai/shared typecheck && pnpm --filter @paperclipai/server typecheck && pnpm --filter @paperclipai/ui typecheck` — passed. - Checked the PR diff does not include `pnpm-lock.yaml` or `.github/workflows` changes. - Checked `ROADMAP.md`; this is a targeted control-plane safety fix and does not duplicate a planned core feature. - Searched GitHub for duplicate or related PRs/issues; closest related items are linked above. - CI and Greptile verification are pending on the opened PR and will be followed up before requesting merge. ## Risks Low to moderate risk. The intended behavioral shift is that invalid agents are refused earlier and more consistently, which could expose existing data with paused, pending, terminated, or broken org-chain assignees. The added tests cover the critical assignment, heartbeat, routine, recovery, shared helper, and UI paths. No database migrations are included. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used OpenAI GPT-5 Codex via the Paperclip `codex_local` adapter, with shell/git/GitHub CLI tool use. Reasoning mode and context window are managed by the adapter runtime and not exposed in this environment. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] If this change affects the UI, I have included before/after screenshots (not applicable: no design screenshots requested; UI behavior is covered by tests) - [x] I have updated relevant documentation to reflect my changes (not applicable: no user-facing command or schema docs changed) - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green (pending CI) - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups (pending Greptile) - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
2091 lines
87 KiB
TypeScript
2091 lines
87 KiB
TypeScript
import { memo, useState, useEffect, useRef, useCallback, useMemo, type ChangeEvent, type CSSProperties, type DragEvent, type RefObject } from "react";
|
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
|
import type { IssueWorkMode } from "@paperclipai/shared";
|
|
import { pickTextColorForSolidBg } from "@/lib/color-contrast";
|
|
import { useDialog } from "../context/DialogContext";
|
|
import { useCompany } from "../context/CompanyContext";
|
|
import { useAdapterCapabilities } from "../adapters/use-adapter-capabilities";
|
|
import { executionWorkspacesApi } from "../api/execution-workspaces";
|
|
import { issuesApi } from "../api/issues";
|
|
import { instanceSettingsApi } from "../api/instanceSettings";
|
|
import { projectsApi } from "../api/projects";
|
|
import { agentsApi } from "../api/agents";
|
|
import { accessApi } from "../api/access";
|
|
import { authApi } from "../api/auth";
|
|
import { assetsApi } from "../api/assets";
|
|
import { buildCompanyUserInlineOptions, buildMarkdownMentionOptions, isAgentTaskTarget } from "../lib/company-members";
|
|
import { queryKeys } from "../lib/queryKeys";
|
|
import { orderReusableExecutionWorkspaces } from "../lib/reusable-execution-workspaces";
|
|
import { useProjectOrder } from "../hooks/useProjectOrder";
|
|
import { getRecentAssigneeIds, sortAgentsByRecency, trackRecentAssignee } from "../lib/recent-assignees";
|
|
import { getRecentProjectIds, trackRecentProject } from "../lib/recent-projects";
|
|
import { buildExecutionPolicy } from "../lib/issue-execution-policy";
|
|
import { useToastActions } from "../context/ToastContext";
|
|
import {
|
|
assigneeValueFromSelection,
|
|
currentUserAssigneeOption,
|
|
parseAssigneeValue,
|
|
} from "../lib/assignees";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
} from "@/components/ui/dialog";
|
|
import { Button } from "@/components/ui/button";
|
|
import { ToggleSwitch } from "@/components/ui/toggle-switch";
|
|
import {
|
|
Popover,
|
|
PopoverContent,
|
|
PopoverTrigger,
|
|
} from "@/components/ui/popover";
|
|
import {
|
|
Maximize2,
|
|
Minimize2,
|
|
MoreHorizontal,
|
|
ChevronRight,
|
|
ChevronDown,
|
|
CircleDot,
|
|
ClipboardList,
|
|
Hammer,
|
|
Minus,
|
|
ArrowUp,
|
|
ArrowDown,
|
|
AlertTriangle,
|
|
Tag,
|
|
Calendar,
|
|
Paperclip,
|
|
FileText,
|
|
Flag,
|
|
Loader2,
|
|
ListTree,
|
|
X,
|
|
Eye,
|
|
ShieldAlert,
|
|
ShieldCheck,
|
|
} from "lucide-react";
|
|
import { cn } from "../lib/utils";
|
|
import { extractProviderIdWithFallback } from "../lib/model-utils";
|
|
import { issueStatusText, issueStatusTextDefault, priorityColor, priorityColorDefault } from "../lib/status-colors";
|
|
import { MarkdownEditor, type MarkdownEditorRef, type MentionOption } from "./MarkdownEditor";
|
|
import { AgentIcon } from "./AgentIconPicker";
|
|
import { InlineEntitySelector, type InlineEntityOption } from "./InlineEntitySelector";
|
|
import { getTrustPreset } from "../lib/trust-policy-ui";
|
|
|
|
const DRAFT_KEY = "paperclip:issue-draft";
|
|
const DEBOUNCE_MS = 800;
|
|
const MOBILE_DIALOG_HEIGHT = "calc(100dvh - max(1rem, env(safe-area-inset-top)) - max(1rem, env(safe-area-inset-bottom)))";
|
|
|
|
|
|
interface IssueDraft {
|
|
title: string;
|
|
description: string;
|
|
status: string;
|
|
priority: string;
|
|
assigneeValue: string;
|
|
reviewerValue: string;
|
|
approverValue: string;
|
|
assigneeId?: string;
|
|
projectId: string;
|
|
projectWorkspaceId?: string;
|
|
assigneeModelLane?: IssueModelLane;
|
|
assigneeModelOverride: string;
|
|
assigneeThinkingEffort: string;
|
|
assigneeChrome: boolean;
|
|
executionWorkspaceMode?: string;
|
|
selectedExecutionWorkspaceId?: string;
|
|
useIsolatedExecutionWorkspace?: boolean;
|
|
workMode?: IssueWorkMode;
|
|
}
|
|
|
|
type StagedIssueFile = {
|
|
id: string;
|
|
file: File;
|
|
kind: "document" | "attachment";
|
|
documentKey?: string;
|
|
title?: string | null;
|
|
};
|
|
|
|
import {
|
|
buildAssigneeAdapterOverrides,
|
|
ISSUE_OVERRIDE_ADAPTER_TYPES,
|
|
type IssueModelLane,
|
|
} from "../lib/issue-assignee-overrides";
|
|
|
|
const STAGED_FILE_ACCEPT = "image/*,application/pdf,text/plain,text/markdown,application/json,text/csv,text/html,.md,.markdown";
|
|
|
|
const ISSUE_THINKING_EFFORT_OPTIONS = {
|
|
claude_local: [
|
|
{ value: "", label: "Default" },
|
|
{ value: "low", label: "Low" },
|
|
{ value: "medium", label: "Medium" },
|
|
{ value: "high", label: "High" },
|
|
],
|
|
codex_local: [
|
|
{ value: "", label: "Default" },
|
|
{ value: "minimal", label: "Minimal" },
|
|
{ value: "low", label: "Low" },
|
|
{ value: "medium", label: "Medium" },
|
|
{ value: "high", label: "High" },
|
|
{ value: "xhigh", label: "X-High" },
|
|
],
|
|
opencode_local: [
|
|
{ value: "", label: "Default" },
|
|
{ value: "minimal", label: "Minimal" },
|
|
{ value: "low", label: "Low" },
|
|
{ value: "medium", label: "Medium" },
|
|
{ value: "high", label: "High" },
|
|
{ value: "xhigh", label: "X-High" },
|
|
{ value: "max", label: "Max" },
|
|
],
|
|
} as const;
|
|
|
|
function isIssueWorkMode(value: unknown): value is IssueWorkMode {
|
|
return value === "standard" || value === "planning";
|
|
}
|
|
|
|
const ISSUE_WORK_MODE_OPTIONS: ReadonlyArray<{
|
|
value: IssueWorkMode;
|
|
label: string;
|
|
icon: typeof Hammer;
|
|
}> = [
|
|
{ value: "standard", label: "Standard", icon: Hammer },
|
|
{ value: "planning", label: "Planning", icon: ClipboardList },
|
|
];
|
|
|
|
function loadDraft(): IssueDraft | null {
|
|
try {
|
|
const raw = localStorage.getItem(DRAFT_KEY);
|
|
if (!raw) return null;
|
|
return JSON.parse(raw) as IssueDraft;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function saveDraft(draft: IssueDraft) {
|
|
localStorage.setItem(DRAFT_KEY, JSON.stringify(draft));
|
|
}
|
|
|
|
function clearDraft() {
|
|
localStorage.removeItem(DRAFT_KEY);
|
|
}
|
|
|
|
function isTextDocumentFile(file: File) {
|
|
const name = file.name.toLowerCase();
|
|
return (
|
|
name.endsWith(".md") ||
|
|
name.endsWith(".markdown") ||
|
|
name.endsWith(".txt") ||
|
|
file.type === "text/markdown" ||
|
|
file.type === "text/plain"
|
|
);
|
|
}
|
|
|
|
function fileBaseName(filename: string) {
|
|
return filename.replace(/\.[^.]+$/, "");
|
|
}
|
|
|
|
function slugifyDocumentKey(input: string) {
|
|
const slug = input
|
|
.trim()
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9]+/g, "-")
|
|
.replace(/^-+|-+$/g, "");
|
|
return slug || "document";
|
|
}
|
|
|
|
function titleizeFilename(input: string) {
|
|
return input
|
|
.split(/[-_ ]+/g)
|
|
.filter(Boolean)
|
|
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
|
.join(" ");
|
|
}
|
|
|
|
function createUniqueDocumentKey(baseKey: string, stagedFiles: StagedIssueFile[]) {
|
|
const existingKeys = new Set(
|
|
stagedFiles
|
|
.filter((file) => file.kind === "document")
|
|
.map((file) => file.documentKey)
|
|
.filter((key): key is string => Boolean(key)),
|
|
);
|
|
if (!existingKeys.has(baseKey)) return baseKey;
|
|
let suffix = 2;
|
|
while (existingKeys.has(`${baseKey}-${suffix}`)) {
|
|
suffix += 1;
|
|
}
|
|
return `${baseKey}-${suffix}`;
|
|
}
|
|
|
|
function formatFileSize(file: File) {
|
|
if (file.size < 1024) return `${file.size} B`;
|
|
if (file.size < 1024 * 1024) return `${(file.size / 1024).toFixed(1)} KB`;
|
|
return `${(file.size / (1024 * 1024)).toFixed(1)} MB`;
|
|
}
|
|
|
|
const statuses: ReadonlyArray<{ value: string; label: string; color: string; description?: string }> = [
|
|
{
|
|
value: "backlog",
|
|
label: "Backlog",
|
|
color: issueStatusText.backlog ?? issueStatusTextDefault,
|
|
description: "Parked — assignee will not be woken",
|
|
},
|
|
{
|
|
value: "todo",
|
|
label: "Todo",
|
|
color: issueStatusText.todo ?? issueStatusTextDefault,
|
|
description: "Executable — assignee will be woken",
|
|
},
|
|
{ value: "in_progress", label: "In Progress", color: issueStatusText.in_progress ?? issueStatusTextDefault },
|
|
{ value: "in_review", label: "In Review", color: issueStatusText.in_review ?? issueStatusTextDefault },
|
|
{ value: "done", label: "Done", color: issueStatusText.done ?? issueStatusTextDefault },
|
|
];
|
|
|
|
const priorities = [
|
|
{ value: "critical", label: "Critical", icon: AlertTriangle, color: priorityColor.critical ?? priorityColorDefault },
|
|
{ value: "high", label: "High", icon: ArrowUp, color: priorityColor.high ?? priorityColorDefault },
|
|
{ value: "medium", label: "Medium", icon: Minus, color: priorityColor.medium ?? priorityColorDefault },
|
|
{ value: "low", label: "Low", icon: ArrowDown, color: priorityColor.low ?? priorityColorDefault },
|
|
];
|
|
|
|
const EXECUTION_WORKSPACE_MODES = [
|
|
{ value: "shared_workspace", label: "Project default" },
|
|
{ value: "isolated_workspace", label: "New isolated workspace" },
|
|
{ value: "reuse_existing", label: "Reuse existing workspace" },
|
|
] as const;
|
|
|
|
function defaultProjectWorkspaceIdForProject(project: { workspaces?: Array<{ id: string; isPrimary: boolean }>; executionWorkspacePolicy?: { defaultProjectWorkspaceId?: string | null } | null } | null | undefined) {
|
|
if (!project) return "";
|
|
return project.executionWorkspacePolicy?.defaultProjectWorkspaceId
|
|
?? project.workspaces?.find((workspace) => workspace.isPrimary)?.id
|
|
?? project.workspaces?.[0]?.id
|
|
?? "";
|
|
}
|
|
|
|
function defaultExecutionWorkspaceModeForProject(project: { executionWorkspacePolicy?: { enabled?: boolean; defaultMode?: string | null } | null } | null | undefined) {
|
|
const defaultMode = project?.executionWorkspacePolicy?.enabled ? project.executionWorkspacePolicy.defaultMode : null;
|
|
if (
|
|
defaultMode === "isolated_workspace" ||
|
|
defaultMode === "operator_branch" ||
|
|
defaultMode === "adapter_default"
|
|
) {
|
|
return defaultMode === "adapter_default" ? "agent_default" : defaultMode;
|
|
}
|
|
return "shared_workspace";
|
|
}
|
|
|
|
function defaultExecutionWorkspaceModeForIssueDefaults(
|
|
defaults: {
|
|
executionWorkspaceId?: unknown;
|
|
executionWorkspaceMode?: unknown;
|
|
},
|
|
project: { executionWorkspacePolicy?: { enabled?: boolean; defaultMode?: string | null } | null } | null | undefined,
|
|
) {
|
|
if (typeof defaults.executionWorkspaceId === "string" && defaults.executionWorkspaceId.length > 0) {
|
|
return "reuse_existing";
|
|
}
|
|
return typeof defaults.executionWorkspaceMode === "string" && defaults.executionWorkspaceMode.length > 0
|
|
? defaults.executionWorkspaceMode
|
|
: defaultExecutionWorkspaceModeForProject(project);
|
|
}
|
|
|
|
const IssueTitleTextarea = memo(function IssueTitleTextarea({
|
|
value,
|
|
pending,
|
|
assigneeValue,
|
|
projectId,
|
|
descriptionEditorRef,
|
|
assigneeSelectorRef,
|
|
projectSelectorRef,
|
|
onChange,
|
|
}: {
|
|
value: string;
|
|
pending: boolean;
|
|
assigneeValue: string;
|
|
projectId: string;
|
|
descriptionEditorRef: RefObject<MarkdownEditorRef | null>;
|
|
assigneeSelectorRef: RefObject<HTMLButtonElement | null>;
|
|
projectSelectorRef: RefObject<HTMLButtonElement | null>;
|
|
onChange: (value: string) => void;
|
|
}) {
|
|
const [draftValue, setDraftValue] = useState(value);
|
|
|
|
useEffect(() => {
|
|
setDraftValue(value);
|
|
}, [value]);
|
|
|
|
return (
|
|
<textarea
|
|
className="w-full text-lg font-semibold bg-transparent outline-none resize-none overflow-hidden placeholder:text-muted-foreground/50"
|
|
placeholder="Task title"
|
|
rows={1}
|
|
value={draftValue}
|
|
onChange={(e) => {
|
|
const nextValue = e.target.value;
|
|
setDraftValue(nextValue);
|
|
onChange(nextValue);
|
|
e.target.style.height = "auto";
|
|
e.target.style.height = `${e.target.scrollHeight}px`;
|
|
}}
|
|
readOnly={pending}
|
|
onKeyDown={(e) => {
|
|
if (
|
|
e.key === "Enter" &&
|
|
!e.metaKey &&
|
|
!e.ctrlKey &&
|
|
!e.nativeEvent.isComposing
|
|
) {
|
|
e.preventDefault();
|
|
descriptionEditorRef.current?.focus();
|
|
}
|
|
if (e.key === "Tab" && !e.shiftKey) {
|
|
e.preventDefault();
|
|
if (assigneeValue) {
|
|
if (projectId) {
|
|
descriptionEditorRef.current?.focus();
|
|
} else {
|
|
projectSelectorRef.current?.focus();
|
|
}
|
|
} else {
|
|
assigneeSelectorRef.current?.focus();
|
|
}
|
|
}
|
|
}}
|
|
autoFocus
|
|
/>
|
|
);
|
|
});
|
|
|
|
const IssueDescriptionEditor = memo(function IssueDescriptionEditor({
|
|
value,
|
|
expanded,
|
|
mentions,
|
|
descriptionEditorRef,
|
|
imageUploadHandler,
|
|
onChange,
|
|
}: {
|
|
value: string;
|
|
expanded: boolean;
|
|
mentions: MentionOption[];
|
|
descriptionEditorRef: RefObject<MarkdownEditorRef | null>;
|
|
imageUploadHandler: (file: File) => Promise<string>;
|
|
onChange: (value: string) => void;
|
|
}) {
|
|
const [draftValue, setDraftValue] = useState(value);
|
|
|
|
useEffect(() => {
|
|
setDraftValue(value);
|
|
}, [value]);
|
|
|
|
return (
|
|
<MarkdownEditor
|
|
ref={descriptionEditorRef}
|
|
value={draftValue}
|
|
onChange={(nextValue) => {
|
|
setDraftValue(nextValue);
|
|
onChange(nextValue);
|
|
}}
|
|
placeholder="Add description..."
|
|
bordered={false}
|
|
mentions={mentions}
|
|
contentClassName={cn("text-sm text-muted-foreground pb-12", expanded ? "min-h-[220px]" : "min-h-[120px]")}
|
|
imageUploadHandler={imageUploadHandler}
|
|
/>
|
|
);
|
|
});
|
|
|
|
function issueExecutionWorkspaceModeForExistingWorkspace(mode: string | null | undefined) {
|
|
if (mode === "isolated_workspace" || mode === "operator_branch" || mode === "shared_workspace") {
|
|
return mode;
|
|
}
|
|
if (mode === "adapter_managed" || mode === "cloud_sandbox") {
|
|
return "agent_default";
|
|
}
|
|
return "shared_workspace";
|
|
}
|
|
|
|
export function NewIssueDialog() {
|
|
const { newIssueOpen, newIssueDefaults, closeNewIssue } = useDialog();
|
|
const { companies, selectedCompanyId, selectedCompany } = useCompany();
|
|
const queryClient = useQueryClient();
|
|
const { pushToast } = useToastActions();
|
|
const [title, setTitle] = useState("");
|
|
const [description, setDescription] = useState("");
|
|
const titleRef = useRef("");
|
|
const descriptionRef = useRef("");
|
|
const [titleHasText, setTitleHasText] = useState(false);
|
|
const [draftHasText, setDraftHasText] = useState(false);
|
|
const [status, setStatus] = useState("todo");
|
|
const [priority, setPriority] = useState("");
|
|
const [assigneeValue, setAssigneeValue] = useState("");
|
|
const [reviewerValue, setReviewerValue] = useState("");
|
|
const [approverValue, setApproverValue] = useState("");
|
|
const [showReviewerRow, setShowReviewerRow] = useState(false);
|
|
const [showApproverRow, setShowApproverRow] = useState(false);
|
|
const [participantMenuOpen, setParticipantMenuOpen] = useState(false);
|
|
const [projectId, setProjectId] = useState("");
|
|
const [projectWorkspaceId, setProjectWorkspaceId] = useState("");
|
|
const [assigneeOptionsOpen, setAssigneeOptionsOpen] = useState(false);
|
|
const [assigneeModelLane, setAssigneeModelLane] = useState<IssueModelLane>("primary");
|
|
const [assigneeModelOverride, setAssigneeModelOverride] = useState("");
|
|
const [assigneeThinkingEffort, setAssigneeThinkingEffort] = useState("");
|
|
const [assigneeChrome, setAssigneeChrome] = useState(false);
|
|
const [executionWorkspaceMode, setExecutionWorkspaceMode] = useState<string>("shared_workspace");
|
|
const [selectedExecutionWorkspaceId, setSelectedExecutionWorkspaceId] = useState("");
|
|
const [workMode, setWorkMode] = useState<IssueWorkMode>("standard");
|
|
const [expanded, setExpanded] = useState(false);
|
|
const [dialogCompanyId, setDialogCompanyId] = useState<string | null>(null);
|
|
const [stagedFiles, setStagedFiles] = useState<StagedIssueFile[]>([]);
|
|
const [isFileDragOver, setIsFileDragOver] = useState(false);
|
|
const draftTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
const executionWorkspaceDefaultProjectId = useRef<string | null>(null);
|
|
const initializationKeyRef = useRef<string | null>(null);
|
|
|
|
const effectiveCompanyId = dialogCompanyId ?? selectedCompanyId;
|
|
const dialogCompany = companies.find((c) => c.id === effectiveCompanyId) ?? selectedCompany;
|
|
const isSubIssueMode = Boolean(newIssueDefaults.parentId);
|
|
const parentIssueLabel = newIssueDefaults.parentIdentifier
|
|
?? (newIssueDefaults.parentId ? newIssueDefaults.parentId.slice(0, 8) : "");
|
|
const parentExecutionWorkspaceId = newIssueDefaults.executionWorkspaceId ?? "";
|
|
const parentExecutionWorkspaceLabel = newIssueDefaults.parentExecutionWorkspaceLabel ?? parentExecutionWorkspaceId;
|
|
|
|
// Popover states
|
|
const [statusOpen, setStatusOpen] = useState(false);
|
|
const [priorityOpen, setPriorityOpen] = useState(false);
|
|
const [workModeOpen, setWorkModeOpen] = useState(false);
|
|
const [moreOpen, setMoreOpen] = useState(false);
|
|
const [companyOpen, setCompanyOpen] = useState(false);
|
|
const descriptionEditorRef = useRef<MarkdownEditorRef>(null);
|
|
const stageFileInputRef = useRef<HTMLInputElement | null>(null);
|
|
const assigneeSelectorRef = useRef<HTMLButtonElement | null>(null);
|
|
const projectSelectorRef = useRef<HTMLButtonElement | null>(null);
|
|
|
|
const { data: agents } = useQuery({
|
|
queryKey: queryKeys.agents.list(effectiveCompanyId!),
|
|
queryFn: () => agentsApi.list(effectiveCompanyId!),
|
|
enabled: !!effectiveCompanyId && newIssueOpen,
|
|
});
|
|
|
|
const { data: projects } = useQuery({
|
|
queryKey: queryKeys.projects.list(effectiveCompanyId!),
|
|
queryFn: () => projectsApi.list(effectiveCompanyId!),
|
|
enabled: !!effectiveCompanyId && newIssueOpen,
|
|
});
|
|
const { data: reusableExecutionWorkspaces } = useQuery({
|
|
queryKey: queryKeys.executionWorkspaces.summaryList(effectiveCompanyId!, {
|
|
projectId,
|
|
projectWorkspaceId: projectWorkspaceId || undefined,
|
|
reuseEligible: true,
|
|
}),
|
|
queryFn: () =>
|
|
executionWorkspacesApi.listSummaries(effectiveCompanyId!, {
|
|
projectId,
|
|
projectWorkspaceId: projectWorkspaceId || undefined,
|
|
reuseEligible: true,
|
|
}),
|
|
enabled: Boolean(effectiveCompanyId) && newIssueOpen && Boolean(projectId),
|
|
});
|
|
const { data: session } = useQuery({
|
|
queryKey: queryKeys.auth.session,
|
|
queryFn: () => authApi.getSession(),
|
|
});
|
|
const { data: companyMembers } = useQuery({
|
|
queryKey: queryKeys.access.companyUserDirectory(effectiveCompanyId!),
|
|
queryFn: () => accessApi.listUserDirectory(effectiveCompanyId!),
|
|
enabled: Boolean(effectiveCompanyId) && newIssueOpen,
|
|
});
|
|
const { data: experimentalSettings } = useQuery({
|
|
queryKey: queryKeys.instance.experimentalSettings,
|
|
queryFn: () => instanceSettingsApi.getExperimental(),
|
|
enabled: newIssueOpen,
|
|
retry: false,
|
|
});
|
|
const currentUserId = session?.user?.id ?? session?.session?.userId ?? null;
|
|
const activeProjects = useMemo(
|
|
() => (projects ?? []).filter((p) => !p.archivedAt),
|
|
[projects],
|
|
);
|
|
const { orderedProjects } = useProjectOrder({
|
|
projects: activeProjects,
|
|
companyId: effectiveCompanyId,
|
|
userId: currentUserId,
|
|
});
|
|
|
|
const selectedAssignee = useMemo(() => parseAssigneeValue(assigneeValue), [assigneeValue]);
|
|
const selectedAssigneeAgentId = selectedAssignee.assigneeAgentId;
|
|
const selectedAssigneeUserId = selectedAssignee.assigneeUserId;
|
|
|
|
const assigneeAdapterType = (agents ?? []).find((agent) => agent.id === selectedAssigneeAgentId)?.adapterType ?? null;
|
|
const supportsAssigneeOverrides = Boolean(
|
|
assigneeAdapterType && ISSUE_OVERRIDE_ADAPTER_TYPES.has(assigneeAdapterType),
|
|
);
|
|
const getAdapterCapabilities = useAdapterCapabilities();
|
|
const assigneeAdapterCapabilities = assigneeAdapterType
|
|
? getAdapterCapabilities(assigneeAdapterType)
|
|
: null;
|
|
const assigneeSupportsCheapLane = Boolean(
|
|
supportsAssigneeOverrides && assigneeAdapterCapabilities?.supportsModelProfiles,
|
|
);
|
|
|
|
const { data: assigneeCheapProfiles } = useQuery({
|
|
queryKey: effectiveCompanyId && assigneeAdapterType
|
|
? queryKeys.agents.adapterModelProfiles(effectiveCompanyId, assigneeAdapterType)
|
|
: ["agents", "none", "adapter-model-profiles", assigneeAdapterType ?? "none"],
|
|
queryFn: () => agentsApi.adapterModelProfiles(effectiveCompanyId!, assigneeAdapterType!),
|
|
enabled: Boolean(effectiveCompanyId) && newIssueOpen && assigneeSupportsCheapLane,
|
|
});
|
|
const assigneeCheapProfile = useMemo(
|
|
() => (assigneeCheapProfiles ?? []).find((profile) => profile.key === "cheap") ?? null,
|
|
[assigneeCheapProfiles],
|
|
);
|
|
const mentionOptions = useMemo<MentionOption[]>(() => {
|
|
return buildMarkdownMentionOptions({
|
|
agents,
|
|
projects: orderedProjects,
|
|
members: companyMembers?.users,
|
|
});
|
|
}, [agents, companyMembers?.users, orderedProjects]);
|
|
|
|
const { data: assigneeAdapterModels } = useQuery({
|
|
queryKey:
|
|
effectiveCompanyId && assigneeAdapterType
|
|
? queryKeys.agents.adapterModels(effectiveCompanyId, assigneeAdapterType)
|
|
: ["agents", "none", "adapter-models", assigneeAdapterType ?? "none"],
|
|
queryFn: () => agentsApi.adapterModels(effectiveCompanyId!, assigneeAdapterType!),
|
|
enabled: Boolean(effectiveCompanyId) && newIssueOpen && supportsAssigneeOverrides,
|
|
});
|
|
|
|
const createIssue = useMutation({
|
|
mutationFn: async ({
|
|
companyId,
|
|
stagedFiles: pendingStagedFiles,
|
|
...data
|
|
}: { companyId: string; stagedFiles: StagedIssueFile[] } & Record<string, unknown>) => {
|
|
const issue = await issuesApi.create(companyId, data);
|
|
const failures: string[] = [];
|
|
|
|
for (const stagedFile of pendingStagedFiles) {
|
|
try {
|
|
if (stagedFile.kind === "document") {
|
|
const body = await stagedFile.file.text();
|
|
await issuesApi.upsertDocument(issue.id, stagedFile.documentKey ?? "document", {
|
|
title: stagedFile.documentKey === "plan" ? null : stagedFile.title ?? null,
|
|
format: "markdown",
|
|
body,
|
|
baseRevisionId: null,
|
|
});
|
|
} else {
|
|
await issuesApi.uploadAttachment(companyId, issue.id, stagedFile.file);
|
|
}
|
|
} catch {
|
|
failures.push(stagedFile.file.name);
|
|
}
|
|
}
|
|
|
|
return { issue, companyId, failures };
|
|
},
|
|
onSuccess: ({ issue, companyId, failures }) => {
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(companyId) });
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.listMineByMe(companyId) });
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.listTouchedByMe(companyId) });
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.listUnreadTouchedByMe(companyId) });
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.sidebarBadges(companyId) });
|
|
if (draftTimer.current) clearTimeout(draftTimer.current);
|
|
if (failures.length > 0) {
|
|
const prefix = (companies.find((company) => company.id === companyId)?.issuePrefix ?? "").trim();
|
|
const issueRef = issue.identifier ?? issue.id;
|
|
pushToast({
|
|
title: `Created ${issueRef} with upload warnings`,
|
|
body: `${failures.length} staged ${failures.length === 1 ? "file" : "files"} could not be added.`,
|
|
tone: "warn",
|
|
action: prefix
|
|
? { label: `Open ${issueRef}`, href: `/${prefix}/issues/${issueRef}` }
|
|
: undefined,
|
|
});
|
|
}
|
|
clearDraft();
|
|
reset();
|
|
closeNewIssue();
|
|
},
|
|
});
|
|
|
|
const uploadDescriptionImage = useMutation({
|
|
mutationFn: async (file: File) => {
|
|
if (!effectiveCompanyId) throw new Error("No company selected");
|
|
return assetsApi.uploadImage(effectiveCompanyId, file, "issues/drafts");
|
|
},
|
|
});
|
|
const uploadDescriptionImageHandler = useCallback(async (file: File) => {
|
|
const asset = await uploadDescriptionImage.mutateAsync(file);
|
|
return asset.contentPath;
|
|
}, [uploadDescriptionImage.mutateAsync]);
|
|
|
|
// Debounced draft saving
|
|
const scheduleSave = useCallback(
|
|
(draft: IssueDraft) => {
|
|
if (draftTimer.current) clearTimeout(draftTimer.current);
|
|
draftTimer.current = setTimeout(() => {
|
|
if (draft.title.trim()) saveDraft(draft);
|
|
}, DEBOUNCE_MS);
|
|
},
|
|
[],
|
|
);
|
|
|
|
const setIssueText = useCallback((nextTitle: string, nextDescription: string) => {
|
|
titleRef.current = nextTitle;
|
|
descriptionRef.current = nextDescription;
|
|
setTitle(nextTitle);
|
|
setDescription(nextDescription);
|
|
setTitleHasText(nextTitle.trim().length > 0);
|
|
setDraftHasText(nextTitle.trim().length > 0 || nextDescription.trim().length > 0);
|
|
}, []);
|
|
|
|
const queueDraftSave = useCallback((overrides: { title?: string; description?: string } = {}) => {
|
|
if (!newIssueOpen) return;
|
|
const nextTitle = overrides.title ?? titleRef.current;
|
|
const nextDescription = overrides.description ?? descriptionRef.current;
|
|
scheduleSave({
|
|
title: nextTitle,
|
|
description: nextDescription,
|
|
status,
|
|
priority,
|
|
assigneeValue,
|
|
reviewerValue,
|
|
approverValue,
|
|
projectId,
|
|
projectWorkspaceId,
|
|
assigneeModelLane,
|
|
assigneeModelOverride,
|
|
assigneeThinkingEffort,
|
|
assigneeChrome,
|
|
executionWorkspaceMode,
|
|
selectedExecutionWorkspaceId,
|
|
workMode,
|
|
});
|
|
}, [
|
|
newIssueOpen,
|
|
scheduleSave,
|
|
status,
|
|
priority,
|
|
assigneeValue,
|
|
reviewerValue,
|
|
approverValue,
|
|
projectId,
|
|
projectWorkspaceId,
|
|
assigneeModelOverride,
|
|
assigneeThinkingEffort,
|
|
assigneeChrome,
|
|
executionWorkspaceMode,
|
|
selectedExecutionWorkspaceId,
|
|
workMode,
|
|
]);
|
|
|
|
const handleTitleChange = useCallback((nextTitle: string) => {
|
|
titleRef.current = nextTitle;
|
|
const nextTitleHasText = nextTitle.trim().length > 0;
|
|
const nextDraftHasText = nextTitleHasText || descriptionRef.current.trim().length > 0;
|
|
setTitleHasText((current) => current === nextTitleHasText ? current : nextTitleHasText);
|
|
setDraftHasText((current) => current === nextDraftHasText ? current : nextDraftHasText);
|
|
queueDraftSave({ title: nextTitle });
|
|
}, [queueDraftSave]);
|
|
|
|
const handleDescriptionChange = useCallback((nextDescription: string) => {
|
|
descriptionRef.current = nextDescription;
|
|
const nextDraftHasText = titleRef.current.trim().length > 0 || nextDescription.trim().length > 0;
|
|
setDraftHasText((current) => current === nextDraftHasText ? current : nextDraftHasText);
|
|
queueDraftSave({ description: nextDescription });
|
|
}, [queueDraftSave]);
|
|
|
|
// Save draft on meaningful changes
|
|
useEffect(() => {
|
|
if (!newIssueOpen) return;
|
|
queueDraftSave();
|
|
}, [
|
|
status,
|
|
priority,
|
|
assigneeValue,
|
|
reviewerValue,
|
|
approverValue,
|
|
projectId,
|
|
projectWorkspaceId,
|
|
assigneeModelLane,
|
|
assigneeModelOverride,
|
|
assigneeThinkingEffort,
|
|
assigneeChrome,
|
|
executionWorkspaceMode,
|
|
selectedExecutionWorkspaceId,
|
|
workMode,
|
|
newIssueOpen,
|
|
queueDraftSave,
|
|
]);
|
|
|
|
// Restore draft or apply defaults when dialog opens
|
|
useEffect(() => {
|
|
if (!newIssueOpen) {
|
|
initializationKeyRef.current = null;
|
|
return;
|
|
}
|
|
const initializationKey = `${selectedCompanyId ?? ""}:${JSON.stringify(newIssueDefaults)}`;
|
|
if (initializationKeyRef.current === initializationKey) return;
|
|
initializationKeyRef.current = initializationKey;
|
|
setDialogCompanyId(selectedCompanyId);
|
|
executionWorkspaceDefaultProjectId.current = null;
|
|
|
|
const draft = loadDraft();
|
|
if (newIssueDefaults.parentId) {
|
|
const nextWorkMode = isIssueWorkMode(newIssueDefaults.workMode) ? newIssueDefaults.workMode : "standard";
|
|
const defaultProjectId = newIssueDefaults.projectId ?? "";
|
|
const defaultProject = orderedProjects.find((project) => project.id === defaultProjectId);
|
|
const hasExplicitProjectWorkspaceId = newIssueDefaults.projectWorkspaceId !== undefined;
|
|
const defaultProjectWorkspaceId = newIssueDefaults.projectWorkspaceId
|
|
?? defaultProjectWorkspaceIdForProject(defaultProject);
|
|
const defaultExecutionWorkspaceMode = defaultExecutionWorkspaceModeForIssueDefaults(newIssueDefaults, defaultProject);
|
|
setIssueText(newIssueDefaults.title ?? "", newIssueDefaults.description ?? "");
|
|
setStatus(newIssueDefaults.status ?? "todo");
|
|
setPriority(newIssueDefaults.priority ?? "");
|
|
setProjectId(defaultProjectId);
|
|
setProjectWorkspaceId(defaultProjectWorkspaceId);
|
|
setAssigneeValue(assigneeValueFromSelection(newIssueDefaults));
|
|
setAssigneeModelLane("primary");
|
|
setAssigneeModelOverride("");
|
|
setAssigneeThinkingEffort("");
|
|
setAssigneeChrome(false);
|
|
setExecutionWorkspaceMode(defaultExecutionWorkspaceMode);
|
|
setWorkMode(nextWorkMode);
|
|
setSelectedExecutionWorkspaceId(newIssueDefaults.executionWorkspaceId ?? "");
|
|
executionWorkspaceDefaultProjectId.current = hasExplicitProjectWorkspaceId || defaultProject
|
|
? defaultProjectId || null
|
|
: null;
|
|
} else if (newIssueDefaults.title) {
|
|
const nextWorkMode = isIssueWorkMode(newIssueDefaults.workMode) ? newIssueDefaults.workMode : "standard";
|
|
setIssueText(newIssueDefaults.title, newIssueDefaults.description ?? "");
|
|
setStatus(newIssueDefaults.status ?? "todo");
|
|
setPriority(newIssueDefaults.priority ?? "");
|
|
const defaultProjectId = newIssueDefaults.projectId ?? "";
|
|
const defaultProject = orderedProjects.find((project) => project.id === defaultProjectId);
|
|
const hasExplicitProjectWorkspaceId = newIssueDefaults.projectWorkspaceId !== undefined;
|
|
setProjectId(defaultProjectId);
|
|
setProjectWorkspaceId(newIssueDefaults.projectWorkspaceId ?? defaultProjectWorkspaceIdForProject(defaultProject));
|
|
setAssigneeValue(assigneeValueFromSelection(newIssueDefaults));
|
|
setReviewerValue("");
|
|
setApproverValue("");
|
|
setShowReviewerRow(false);
|
|
setShowApproverRow(false);
|
|
setAssigneeModelOverride("");
|
|
setAssigneeThinkingEffort("");
|
|
setAssigneeChrome(false);
|
|
setExecutionWorkspaceMode(defaultExecutionWorkspaceModeForIssueDefaults(newIssueDefaults, defaultProject));
|
|
setWorkMode(nextWorkMode);
|
|
setSelectedExecutionWorkspaceId(newIssueDefaults.executionWorkspaceId ?? "");
|
|
executionWorkspaceDefaultProjectId.current = hasExplicitProjectWorkspaceId || newIssueDefaults.executionWorkspaceId || defaultProject
|
|
? defaultProjectId || null
|
|
: null;
|
|
} else if (draft && draft.title.trim()) {
|
|
const nextWorkMode = isIssueWorkMode(draft.workMode) ? draft.workMode : "standard";
|
|
const restoredProjectId = newIssueDefaults.projectId ?? draft.projectId;
|
|
const restoredProject = orderedProjects.find((project) => project.id === restoredProjectId);
|
|
const hasExplicitProjectWorkspaceId = newIssueDefaults.projectWorkspaceId !== undefined;
|
|
const hasExplicitExecutionWorkspaceId = newIssueDefaults.executionWorkspaceId !== undefined;
|
|
const hasExplicitExecutionWorkspaceMode = newIssueDefaults.executionWorkspaceMode !== undefined;
|
|
setIssueText(draft.title, draft.description);
|
|
setStatus(draft.status || "todo");
|
|
setPriority(draft.priority);
|
|
setAssigneeValue(
|
|
newIssueDefaults.assigneeAgentId || newIssueDefaults.assigneeUserId
|
|
? assigneeValueFromSelection(newIssueDefaults)
|
|
: (draft.assigneeValue ?? draft.assigneeId ?? ""),
|
|
);
|
|
setReviewerValue(draft.reviewerValue ?? "");
|
|
setApproverValue(draft.approverValue ?? "");
|
|
setShowReviewerRow(!!(draft.reviewerValue));
|
|
setShowApproverRow(!!(draft.approverValue));
|
|
setProjectId(restoredProjectId);
|
|
setProjectWorkspaceId(
|
|
hasExplicitProjectWorkspaceId
|
|
? (newIssueDefaults.projectWorkspaceId ?? "")
|
|
: (draft.projectWorkspaceId ?? defaultProjectWorkspaceIdForProject(restoredProject)),
|
|
);
|
|
setAssigneeModelLane(draft.assigneeModelLane ?? "primary");
|
|
setAssigneeModelOverride(draft.assigneeModelOverride ?? "");
|
|
setAssigneeThinkingEffort(draft.assigneeThinkingEffort ?? "");
|
|
setAssigneeChrome(draft.assigneeChrome ?? false);
|
|
setExecutionWorkspaceMode(
|
|
hasExplicitExecutionWorkspaceId || hasExplicitExecutionWorkspaceMode
|
|
? defaultExecutionWorkspaceModeForIssueDefaults(newIssueDefaults, restoredProject)
|
|
: (
|
|
draft.executionWorkspaceMode
|
|
?? (draft.useIsolatedExecutionWorkspace ? "isolated_workspace" : defaultExecutionWorkspaceModeForProject(restoredProject))
|
|
),
|
|
);
|
|
setWorkMode(nextWorkMode);
|
|
setSelectedExecutionWorkspaceId(
|
|
hasExplicitExecutionWorkspaceId
|
|
? (newIssueDefaults.executionWorkspaceId ?? "")
|
|
: (draft.selectedExecutionWorkspaceId ?? ""),
|
|
);
|
|
executionWorkspaceDefaultProjectId.current = hasExplicitProjectWorkspaceId || hasExplicitExecutionWorkspaceId || draft.projectWorkspaceId || restoredProject
|
|
? restoredProjectId || null
|
|
: null;
|
|
} else {
|
|
setWorkMode("standard");
|
|
const defaultProjectId = newIssueDefaults.projectId ?? "";
|
|
const defaultProject = orderedProjects.find((project) => project.id === defaultProjectId);
|
|
const hasExplicitProjectWorkspaceId = newIssueDefaults.projectWorkspaceId !== undefined;
|
|
setIssueText("", "");
|
|
setStatus(newIssueDefaults.status ?? "todo");
|
|
setPriority(newIssueDefaults.priority ?? "");
|
|
setProjectId(defaultProjectId);
|
|
setProjectWorkspaceId(newIssueDefaults.projectWorkspaceId ?? defaultProjectWorkspaceIdForProject(defaultProject));
|
|
setAssigneeValue(assigneeValueFromSelection(newIssueDefaults));
|
|
setReviewerValue("");
|
|
setApproverValue("");
|
|
setShowReviewerRow(false);
|
|
setShowApproverRow(false);
|
|
setAssigneeModelOverride("");
|
|
setAssigneeThinkingEffort("");
|
|
setAssigneeChrome(false);
|
|
setExecutionWorkspaceMode(defaultExecutionWorkspaceModeForIssueDefaults(newIssueDefaults, defaultProject));
|
|
setSelectedExecutionWorkspaceId(newIssueDefaults.executionWorkspaceId ?? "");
|
|
executionWorkspaceDefaultProjectId.current = hasExplicitProjectWorkspaceId || newIssueDefaults.executionWorkspaceId || defaultProject
|
|
? defaultProjectId || null
|
|
: null;
|
|
}
|
|
}, [newIssueOpen, newIssueDefaults, orderedProjects, selectedCompanyId, setIssueText]);
|
|
|
|
useEffect(() => {
|
|
if (!supportsAssigneeOverrides) {
|
|
setAssigneeOptionsOpen(false);
|
|
setAssigneeModelLane("primary");
|
|
setAssigneeModelOverride("");
|
|
setAssigneeThinkingEffort("");
|
|
setAssigneeChrome(false);
|
|
return;
|
|
}
|
|
if (!assigneeSupportsCheapLane && assigneeModelLane === "cheap") {
|
|
setAssigneeModelLane("primary");
|
|
}
|
|
|
|
const validThinkingValues =
|
|
assigneeAdapterType === "codex_local"
|
|
? ISSUE_THINKING_EFFORT_OPTIONS.codex_local
|
|
: assigneeAdapterType === "opencode_local"
|
|
? ISSUE_THINKING_EFFORT_OPTIONS.opencode_local
|
|
: ISSUE_THINKING_EFFORT_OPTIONS.claude_local;
|
|
if (!validThinkingValues.some((option) => option.value === assigneeThinkingEffort)) {
|
|
setAssigneeThinkingEffort("");
|
|
}
|
|
}, [
|
|
supportsAssigneeOverrides,
|
|
assigneeAdapterType,
|
|
assigneeThinkingEffort,
|
|
assigneeSupportsCheapLane,
|
|
assigneeModelLane,
|
|
]);
|
|
|
|
// Cleanup timer on unmount
|
|
useEffect(() => {
|
|
return () => {
|
|
if (draftTimer.current) clearTimeout(draftTimer.current);
|
|
};
|
|
}, []);
|
|
|
|
function reset() {
|
|
setIssueText("", "");
|
|
setStatus("todo");
|
|
setPriority("");
|
|
setAssigneeValue("");
|
|
setReviewerValue("");
|
|
setApproverValue("");
|
|
setShowReviewerRow(false);
|
|
setShowApproverRow(false);
|
|
setProjectId("");
|
|
setProjectWorkspaceId("");
|
|
setAssigneeOptionsOpen(false);
|
|
setAssigneeModelLane("primary");
|
|
setAssigneeModelOverride("");
|
|
setAssigneeThinkingEffort("");
|
|
setAssigneeChrome(false);
|
|
setExecutionWorkspaceMode("shared_workspace");
|
|
setSelectedExecutionWorkspaceId("");
|
|
setWorkMode("standard");
|
|
setExpanded(false);
|
|
setDialogCompanyId(null);
|
|
setStagedFiles([]);
|
|
setIsFileDragOver(false);
|
|
setCompanyOpen(false);
|
|
executionWorkspaceDefaultProjectId.current = null;
|
|
initializationKeyRef.current = null;
|
|
}
|
|
|
|
function handleCompanyChange(companyId: string) {
|
|
if (isSubIssueMode) return;
|
|
if (companyId === effectiveCompanyId) return;
|
|
setDialogCompanyId(companyId);
|
|
setAssigneeValue("");
|
|
setReviewerValue("");
|
|
setApproverValue("");
|
|
setShowReviewerRow(false);
|
|
setShowApproverRow(false);
|
|
setProjectId("");
|
|
setProjectWorkspaceId("");
|
|
setAssigneeModelLane("primary");
|
|
setAssigneeModelOverride("");
|
|
setAssigneeThinkingEffort("");
|
|
setAssigneeChrome(false);
|
|
setExecutionWorkspaceMode("shared_workspace");
|
|
setSelectedExecutionWorkspaceId("");
|
|
setWorkMode("standard");
|
|
}
|
|
|
|
function discardDraft() {
|
|
clearDraft();
|
|
reset();
|
|
closeNewIssue();
|
|
}
|
|
|
|
function handleSubmit() {
|
|
const currentTitle = titleRef.current.trim();
|
|
const currentDescription = descriptionRef.current.trim();
|
|
if (!effectiveCompanyId || !currentTitle || createIssue.isPending) return;
|
|
const effectiveLane = assigneeSupportsCheapLane
|
|
? assigneeModelLane
|
|
: assigneeModelLane === "cheap"
|
|
? "primary"
|
|
: assigneeModelLane;
|
|
const assigneeAdapterOverrides = buildAssigneeAdapterOverrides({
|
|
adapterType: assigneeAdapterType,
|
|
lane: effectiveLane,
|
|
modelOverride: assigneeModelOverride,
|
|
thinkingEffortOverride: assigneeThinkingEffort,
|
|
chrome: assigneeChrome,
|
|
});
|
|
const selectedProject = orderedProjects.find((project) => project.id === projectId);
|
|
const executionWorkspacePolicy =
|
|
experimentalSettings?.enableIsolatedWorkspaces === true
|
|
? selectedProject?.executionWorkspacePolicy ?? null
|
|
: null;
|
|
const selectedReusableExecutionWorkspace = deduplicatedReusableWorkspaces.find(
|
|
(workspace) => workspace.id === selectedExecutionWorkspaceId,
|
|
);
|
|
const requestedExecutionWorkspaceMode =
|
|
executionWorkspaceMode === "reuse_existing"
|
|
? issueExecutionWorkspaceModeForExistingWorkspace(selectedReusableExecutionWorkspace?.mode)
|
|
: executionWorkspaceMode;
|
|
const executionWorkspaceSettings = executionWorkspacePolicy?.enabled
|
|
? { mode: requestedExecutionWorkspaceMode }
|
|
: null;
|
|
const executionPolicy = buildExecutionPolicy({
|
|
reviewerValues: reviewerValue ? [reviewerValue] : [],
|
|
approverValues: approverValue ? [approverValue] : [],
|
|
});
|
|
createIssue.mutate({
|
|
companyId: effectiveCompanyId,
|
|
stagedFiles,
|
|
title: currentTitle,
|
|
description: currentDescription || undefined,
|
|
status,
|
|
priority: priority || "medium",
|
|
workMode,
|
|
...(selectedAssigneeAgentId ? { assigneeAgentId: selectedAssigneeAgentId } : {}),
|
|
...(selectedAssigneeUserId ? { assigneeUserId: selectedAssigneeUserId } : {}),
|
|
...(newIssueDefaults.parentId ? { parentId: newIssueDefaults.parentId } : {}),
|
|
...(newIssueDefaults.goalId ? { goalId: newIssueDefaults.goalId } : {}),
|
|
...(projectId ? { projectId } : {}),
|
|
...(projectWorkspaceId ? { projectWorkspaceId } : {}),
|
|
...(assigneeAdapterOverrides ? { assigneeAdapterOverrides } : {}),
|
|
...(executionWorkspacePolicy?.enabled ? { executionWorkspacePreference: executionWorkspaceMode } : {}),
|
|
...(executionWorkspaceMode === "reuse_existing" && selectedExecutionWorkspaceId
|
|
? { executionWorkspaceId: selectedExecutionWorkspaceId }
|
|
: {}),
|
|
...(executionWorkspaceSettings ? { executionWorkspaceSettings } : {}),
|
|
...(executionPolicy ? { executionPolicy } : {}),
|
|
});
|
|
}
|
|
|
|
function handleKeyDown(e: React.KeyboardEvent) {
|
|
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
|
|
e.preventDefault();
|
|
handleSubmit();
|
|
}
|
|
}
|
|
|
|
function stageFiles(files: File[]) {
|
|
if (files.length === 0) return;
|
|
setStagedFiles((current) => {
|
|
const next = [...current];
|
|
for (const file of files) {
|
|
if (isTextDocumentFile(file)) {
|
|
const baseName = fileBaseName(file.name);
|
|
const documentKey = createUniqueDocumentKey(slugifyDocumentKey(baseName), next);
|
|
next.push({
|
|
id: `${file.name}:${file.size}:${file.lastModified}:${documentKey}`,
|
|
file,
|
|
kind: "document",
|
|
documentKey,
|
|
title: titleizeFilename(baseName),
|
|
});
|
|
continue;
|
|
}
|
|
next.push({
|
|
id: `${file.name}:${file.size}:${file.lastModified}`,
|
|
file,
|
|
kind: "attachment",
|
|
});
|
|
}
|
|
return next;
|
|
});
|
|
}
|
|
|
|
function handleStageFilesPicked(evt: ChangeEvent<HTMLInputElement>) {
|
|
stageFiles(Array.from(evt.target.files ?? []));
|
|
if (stageFileInputRef.current) {
|
|
stageFileInputRef.current.value = "";
|
|
}
|
|
}
|
|
|
|
function handleFileDragEnter(evt: DragEvent<HTMLDivElement>) {
|
|
if (!evt.dataTransfer.types.includes("Files")) return;
|
|
evt.preventDefault();
|
|
setIsFileDragOver(true);
|
|
}
|
|
|
|
function handleFileDragOver(evt: DragEvent<HTMLDivElement>) {
|
|
if (!evt.dataTransfer.types.includes("Files")) return;
|
|
evt.preventDefault();
|
|
evt.dataTransfer.dropEffect = "copy";
|
|
setIsFileDragOver(true);
|
|
}
|
|
|
|
function handleFileDragLeave(evt: DragEvent<HTMLDivElement>) {
|
|
if (evt.currentTarget.contains(evt.relatedTarget as Node | null)) return;
|
|
setIsFileDragOver(false);
|
|
}
|
|
|
|
function handleFileDrop(evt: DragEvent<HTMLDivElement>) {
|
|
if (!evt.dataTransfer.files.length) return;
|
|
evt.preventDefault();
|
|
setIsFileDragOver(false);
|
|
stageFiles(Array.from(evt.dataTransfer.files));
|
|
}
|
|
|
|
function removeStagedFile(id: string) {
|
|
setStagedFiles((current) => current.filter((file) => file.id !== id));
|
|
}
|
|
|
|
const hasDraft = draftHasText || stagedFiles.length > 0;
|
|
const currentStatus = statuses.find((s) => s.value === status) ?? statuses[1]!;
|
|
const currentPriority = priorities.find((p) => p.value === priority);
|
|
const currentAssignee = selectedAssigneeAgentId
|
|
? (agents ?? []).find((a) => a.id === selectedAssigneeAgentId)
|
|
: null;
|
|
const currentAssigneeLowTrust = getTrustPreset(currentAssignee?.permissions) === "low_trust_review";
|
|
const currentProject = orderedProjects.find((project) => project.id === projectId);
|
|
const currentProjectExecutionWorkspacePolicy =
|
|
experimentalSettings?.enableIsolatedWorkspaces === true
|
|
? currentProject?.executionWorkspacePolicy ?? null
|
|
: null;
|
|
const currentProjectSupportsExecutionWorkspace = Boolean(currentProjectExecutionWorkspacePolicy?.enabled);
|
|
const deduplicatedReusableWorkspaces = useMemo(() => {
|
|
return orderReusableExecutionWorkspaces(reusableExecutionWorkspaces ?? []);
|
|
}, [reusableExecutionWorkspaces]);
|
|
const selectedReusableExecutionWorkspace = deduplicatedReusableWorkspaces.find(
|
|
(workspace) => workspace.id === selectedExecutionWorkspaceId,
|
|
);
|
|
const isUsingParentExecutionWorkspace = isSubIssueMode && parentExecutionWorkspaceId
|
|
? executionWorkspaceMode === "reuse_existing" && selectedExecutionWorkspaceId === parentExecutionWorkspaceId
|
|
: false;
|
|
const showParentWorkspaceWarning = isSubIssueMode
|
|
&& currentProjectSupportsExecutionWorkspace
|
|
&& Boolean(parentExecutionWorkspaceId)
|
|
&& !isUsingParentExecutionWorkspace;
|
|
const assigneeOptionsTitle =
|
|
assigneeAdapterType === "claude_local"
|
|
? "Claude options"
|
|
: assigneeAdapterType === "codex_local"
|
|
? "Codex options"
|
|
: assigneeAdapterType === "opencode_local"
|
|
? "OpenCode options"
|
|
: "Agent options";
|
|
const thinkingEffortOptions =
|
|
assigneeAdapterType === "codex_local"
|
|
? ISSUE_THINKING_EFFORT_OPTIONS.codex_local
|
|
: assigneeAdapterType === "opencode_local"
|
|
? ISSUE_THINKING_EFFORT_OPTIONS.opencode_local
|
|
: ISSUE_THINKING_EFFORT_OPTIONS.claude_local;
|
|
const recentAssigneeIds = useMemo(() => getRecentAssigneeIds(), [newIssueOpen]);
|
|
const recentAssigneeOptionIds = useMemo(
|
|
() => recentAssigneeIds.map((id) => assigneeValueFromSelection({ assigneeAgentId: id })),
|
|
[recentAssigneeIds],
|
|
);
|
|
const recentProjectIds = useMemo(() => getRecentProjectIds(), [newIssueOpen]);
|
|
const assigneeOptions = useMemo<InlineEntityOption[]>(
|
|
() => [
|
|
...currentUserAssigneeOption(currentUserId),
|
|
...buildCompanyUserInlineOptions(companyMembers?.users, { excludeUserIds: [currentUserId] }),
|
|
...sortAgentsByRecency(
|
|
(agents ?? []).filter(isAgentTaskTarget),
|
|
recentAssigneeIds,
|
|
).map((agent) => ({
|
|
id: assigneeValueFromSelection({ assigneeAgentId: agent.id }),
|
|
label: agent.name,
|
|
searchText: `${agent.name} ${agent.role} ${agent.title ?? ""}`,
|
|
})),
|
|
],
|
|
[agents, companyMembers?.users, currentUserId, recentAssigneeIds],
|
|
);
|
|
const projectOptions = useMemo<InlineEntityOption[]>(
|
|
() =>
|
|
orderedProjects.map((project) => ({
|
|
id: project.id,
|
|
label: project.name,
|
|
searchText: project.description ?? "",
|
|
})),
|
|
[orderedProjects],
|
|
);
|
|
const savedDraft = useMemo(() => newIssueOpen ? loadDraft() : null, [newIssueOpen]);
|
|
const hasSavedDraft = Boolean(savedDraft?.title.trim() || savedDraft?.description.trim());
|
|
const canDiscardDraft = hasDraft || hasSavedDraft;
|
|
const createIssueErrorMessage =
|
|
createIssue.error instanceof Error ? createIssue.error.message : "Failed to create task. Try again.";
|
|
const stagedDocuments = stagedFiles.filter((file) => file.kind === "document");
|
|
const stagedAttachments = stagedFiles.filter((file) => file.kind === "attachment");
|
|
|
|
const handleProjectChange = useCallback((nextProjectId: string) => {
|
|
if (nextProjectId) trackRecentProject(nextProjectId);
|
|
setProjectId(nextProjectId);
|
|
const nextProject = orderedProjects.find((project) => project.id === nextProjectId);
|
|
executionWorkspaceDefaultProjectId.current = nextProjectId || null;
|
|
setProjectWorkspaceId(defaultProjectWorkspaceIdForProject(nextProject));
|
|
setExecutionWorkspaceMode(defaultExecutionWorkspaceModeForProject(nextProject));
|
|
setSelectedExecutionWorkspaceId("");
|
|
}, [orderedProjects]);
|
|
|
|
useEffect(() => {
|
|
if (
|
|
!newIssueOpen ||
|
|
!projectId ||
|
|
selectedExecutionWorkspaceId ||
|
|
executionWorkspaceDefaultProjectId.current === projectId
|
|
) {
|
|
return;
|
|
}
|
|
const project = orderedProjects.find((entry) => entry.id === projectId);
|
|
if (!project) return;
|
|
executionWorkspaceDefaultProjectId.current = projectId;
|
|
setProjectWorkspaceId(defaultProjectWorkspaceIdForProject(project));
|
|
setExecutionWorkspaceMode(defaultExecutionWorkspaceModeForProject(project));
|
|
setSelectedExecutionWorkspaceId("");
|
|
}, [newIssueOpen, orderedProjects, projectId, selectedExecutionWorkspaceId]);
|
|
const modelOverrideOptions = useMemo<InlineEntityOption[]>(
|
|
() => {
|
|
return [...(assigneeAdapterModels ?? [])]
|
|
.sort((a, b) => {
|
|
const providerA = extractProviderIdWithFallback(a.id);
|
|
const providerB = extractProviderIdWithFallback(b.id);
|
|
const byProvider = providerA.localeCompare(providerB);
|
|
if (byProvider !== 0) return byProvider;
|
|
return a.id.localeCompare(b.id);
|
|
})
|
|
.map((model) => ({
|
|
id: model.id,
|
|
label: model.label,
|
|
searchText: `${model.id} ${extractProviderIdWithFallback(model.id)}`,
|
|
}));
|
|
},
|
|
[assigneeAdapterModels],
|
|
);
|
|
const currentWorkMode = ISSUE_WORK_MODE_OPTIONS[workMode === "planning" ? 1 : 0]!;
|
|
const CurrentWorkModeIcon = currentWorkMode.icon;
|
|
|
|
return (
|
|
<Dialog
|
|
open={newIssueOpen}
|
|
onOpenChange={(open) => {
|
|
if (!open && !createIssue.isPending) closeNewIssue();
|
|
}}
|
|
>
|
|
<DialogContent
|
|
showCloseButton={false}
|
|
aria-describedby={undefined}
|
|
style={{ "--new-issue-dialog-height": MOBILE_DIALOG_HEIGHT } as CSSProperties}
|
|
className={cn(
|
|
"flex h-[var(--new-issue-dialog-height)] max-h-[var(--new-issue-dialog-height)] flex-col gap-0 overflow-hidden p-0 sm:h-auto",
|
|
expanded
|
|
? "sm:max-w-2xl sm:h-[var(--new-issue-dialog-height)]"
|
|
: "sm:max-w-lg"
|
|
)}
|
|
onKeyDown={handleKeyDown}
|
|
onEscapeKeyDown={(event) => {
|
|
if (createIssue.isPending) {
|
|
event.preventDefault();
|
|
}
|
|
}}
|
|
onPointerDownOutside={(event) => {
|
|
if (createIssue.isPending) {
|
|
event.preventDefault();
|
|
return;
|
|
}
|
|
// Radix Dialog's modal DismissableLayer calls preventDefault() on
|
|
// pointerdown events that originate outside the Dialog DOM tree.
|
|
// Popover and editor autocomplete portals render at the body level
|
|
// (outside the Dialog), so touch/click events on their content get
|
|
// their default prevented. Telling Radix "this event is handled" skips
|
|
// that preventDefault, restoring popover scroll and autocomplete taps.
|
|
const target = event.detail.originalEvent.target as HTMLElement | null;
|
|
if (target?.closest("[data-radix-popper-content-wrapper], [data-paperclip-floating-ui]")) {
|
|
event.preventDefault();
|
|
}
|
|
}}
|
|
>
|
|
{/* Header bar */}
|
|
<div className="flex items-center justify-between px-4 py-2.5 border-b border-border shrink-0">
|
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
|
<Popover open={companyOpen} onOpenChange={setCompanyOpen}>
|
|
<PopoverTrigger asChild>
|
|
<button
|
|
className={cn(
|
|
"px-1.5 py-0.5 rounded text-xs font-semibold cursor-pointer hover:opacity-80 transition-opacity",
|
|
!dialogCompany?.brandColor && "bg-muted",
|
|
)}
|
|
disabled={isSubIssueMode}
|
|
style={
|
|
dialogCompany?.brandColor
|
|
? {
|
|
backgroundColor: dialogCompany.brandColor,
|
|
color: pickTextColorForSolidBg(dialogCompany.brandColor),
|
|
}
|
|
: undefined
|
|
}
|
|
>
|
|
{(dialogCompany?.name ?? "").slice(0, 3).toUpperCase()}
|
|
</button>
|
|
</PopoverTrigger>
|
|
<PopoverContent className="w-48 p-1" align="start">
|
|
{companies.filter((c) => c.status !== "archived").map((c) => (
|
|
<button
|
|
key={c.id}
|
|
className={cn(
|
|
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
|
|
c.id === effectiveCompanyId && "bg-accent",
|
|
)}
|
|
onClick={() => {
|
|
handleCompanyChange(c.id);
|
|
setCompanyOpen(false);
|
|
}}
|
|
>
|
|
<span
|
|
className={cn(
|
|
"px-1 py-0.5 rounded text-[10px] font-semibold leading-none",
|
|
!c.brandColor && "bg-muted",
|
|
)}
|
|
style={
|
|
c.brandColor
|
|
? {
|
|
backgroundColor: c.brandColor,
|
|
color: pickTextColorForSolidBg(c.brandColor),
|
|
}
|
|
: undefined
|
|
}
|
|
>
|
|
{c.name.slice(0, 3).toUpperCase()}
|
|
</span>
|
|
<span className="truncate">{c.name}</span>
|
|
</button>
|
|
))}
|
|
</PopoverContent>
|
|
</Popover>
|
|
<span className="text-muted-foreground/60">›</span>
|
|
<span>{isSubIssueMode ? "New sub-task" : "New task"}</span>
|
|
</div>
|
|
<div className="flex items-center gap-1">
|
|
<Button
|
|
variant="ghost"
|
|
size="icon-xs"
|
|
className="text-muted-foreground"
|
|
onClick={() => setExpanded(!expanded)}
|
|
disabled={createIssue.isPending}
|
|
>
|
|
{expanded ? <Minimize2 className="h-3.5 w-3.5" /> : <Maximize2 className="h-3.5 w-3.5" />}
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon-xs"
|
|
className="text-muted-foreground"
|
|
onClick={() => closeNewIssue()}
|
|
disabled={createIssue.isPending}
|
|
>
|
|
<span className="text-lg leading-none">×</span>
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain">
|
|
{/* Title */}
|
|
<div className="px-4 pt-4 pb-2">
|
|
<IssueTitleTextarea
|
|
value={title}
|
|
pending={createIssue.isPending}
|
|
assigneeValue={assigneeValue}
|
|
projectId={projectId}
|
|
descriptionEditorRef={descriptionEditorRef}
|
|
assigneeSelectorRef={assigneeSelectorRef}
|
|
projectSelectorRef={projectSelectorRef}
|
|
onChange={handleTitleChange}
|
|
/>
|
|
</div>
|
|
|
|
<div className="px-4 pb-2">
|
|
<div className="overflow-x-auto overscroll-x-contain">
|
|
<div className="inline-flex items-center gap-2 text-sm text-muted-foreground flex-wrap sm:flex-nowrap sm:min-w-max">
|
|
<span className="w-6 shrink-0 text-center">For</span>
|
|
<InlineEntitySelector
|
|
ref={assigneeSelectorRef}
|
|
value={assigneeValue}
|
|
options={assigneeOptions}
|
|
recentOptionIds={recentAssigneeOptionIds}
|
|
placeholder="Assignee"
|
|
disablePortal
|
|
noneLabel="No assignee"
|
|
searchPlaceholder="Search assignees..."
|
|
emptyMessage="No assignees found."
|
|
onChange={(value) => {
|
|
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 ? (
|
|
<>
|
|
<AgentIcon icon={currentAssignee.icon} className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
|
<span className="truncate">{option.label}</span>
|
|
</>
|
|
) : (
|
|
<span className="truncate">{option.label}</span>
|
|
)
|
|
) : (
|
|
<span className="text-muted-foreground">Assignee</span>
|
|
)
|
|
}
|
|
renderOption={(option) => {
|
|
if (!option.id) return <span className="truncate">{option.label}</span>;
|
|
const assignee = parseAssigneeValue(option.id).assigneeAgentId
|
|
? (agents ?? []).find((agent) => agent.id === parseAssigneeValue(option.id).assigneeAgentId)
|
|
: null;
|
|
return (
|
|
<>
|
|
{assignee ? <AgentIcon icon={assignee.icon} className="h-3.5 w-3.5 shrink-0 text-muted-foreground" /> : null}
|
|
<span className="truncate">{option.label}</span>
|
|
{assignee && getTrustPreset(assignee.permissions) === "low_trust_review" ? (
|
|
<ShieldAlert className="ml-auto h-3.5 w-3.5 shrink-0 text-amber-600 dark:text-amber-300" aria-label="Low-trust review agent" />
|
|
) : null}
|
|
</>
|
|
);
|
|
}}
|
|
/>
|
|
<span>in</span>
|
|
<InlineEntitySelector
|
|
ref={projectSelectorRef}
|
|
value={projectId}
|
|
options={projectOptions}
|
|
recentOptionIds={recentProjectIds}
|
|
placeholder="Project"
|
|
disablePortal
|
|
noneLabel="No project"
|
|
searchPlaceholder="Search projects..."
|
|
emptyMessage="No projects found."
|
|
onChange={handleProjectChange}
|
|
onConfirm={() => {
|
|
descriptionEditorRef.current?.focus();
|
|
}}
|
|
renderTriggerValue={(option) =>
|
|
option && currentProject ? (
|
|
<>
|
|
<span
|
|
className="h-3.5 w-3.5 shrink-0 rounded-sm"
|
|
style={{ backgroundColor: currentProject.color ?? "#6366f1" }}
|
|
/>
|
|
<span className="truncate">{option.label}</span>
|
|
</>
|
|
) : (
|
|
<span className="text-muted-foreground">Project</span>
|
|
)
|
|
}
|
|
renderOption={(option) => {
|
|
if (!option.id) return <span className="truncate">{option.label}</span>;
|
|
const project = orderedProjects.find((item) => item.id === option.id);
|
|
return (
|
|
<>
|
|
<span
|
|
className="h-3.5 w-3.5 shrink-0 rounded-sm"
|
|
style={{ backgroundColor: project?.color ?? "#6366f1" }}
|
|
/>
|
|
<span className="truncate">{option.label}</span>
|
|
</>
|
|
);
|
|
}}
|
|
/>
|
|
|
|
{/* Three-dot menu to add Reviewer / Approver rows */}
|
|
<Popover open={participantMenuOpen} onOpenChange={setParticipantMenuOpen}>
|
|
<PopoverTrigger asChild>
|
|
<button
|
|
type="button"
|
|
className="inline-flex items-center justify-center rounded-md p-1 text-muted-foreground hover:bg-accent/50 transition-colors"
|
|
title="Add reviewer or approver"
|
|
>
|
|
<MoreHorizontal className="h-4 w-4" />
|
|
</button>
|
|
</PopoverTrigger>
|
|
<PopoverContent className="w-44 p-1" align="start">
|
|
<button
|
|
className={cn(
|
|
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
|
|
showReviewerRow && "bg-accent",
|
|
)}
|
|
onClick={() => {
|
|
setShowReviewerRow((v) => !v);
|
|
if (showReviewerRow) setReviewerValue("");
|
|
setParticipantMenuOpen(false);
|
|
}}
|
|
>
|
|
<Eye className="h-3 w-3" />
|
|
Reviewer
|
|
</button>
|
|
<button
|
|
className={cn(
|
|
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
|
|
showApproverRow && "bg-accent",
|
|
)}
|
|
onClick={() => {
|
|
setShowApproverRow((v) => !v);
|
|
if (showApproverRow) setApproverValue("");
|
|
setParticipantMenuOpen(false);
|
|
}}
|
|
>
|
|
<ShieldCheck className="h-3 w-3" />
|
|
Approver
|
|
</button>
|
|
</PopoverContent>
|
|
</Popover>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Reviewer row */}
|
|
{showReviewerRow && (
|
|
<div className="flex items-center gap-2 text-sm text-muted-foreground mt-1">
|
|
<span className="w-6 shrink-0 flex items-center justify-center"><Eye className="h-3.5 w-3.5" /></span>
|
|
<InlineEntitySelector
|
|
value={reviewerValue}
|
|
options={assigneeOptions}
|
|
recentOptionIds={recentAssigneeOptionIds}
|
|
placeholder="Reviewer"
|
|
disablePortal
|
|
noneLabel="No reviewer"
|
|
searchPlaceholder="Search reviewers..."
|
|
emptyMessage="No reviewers found."
|
|
onChange={setReviewerValue}
|
|
renderTriggerValue={(option) =>
|
|
option ? (
|
|
<>
|
|
{(() => {
|
|
const reviewer = parseAssigneeValue(option.id).assigneeAgentId
|
|
? (agents ?? []).find((a) => a.id === parseAssigneeValue(option.id).assigneeAgentId)
|
|
: null;
|
|
return reviewer ? <AgentIcon icon={reviewer.icon} className="h-3.5 w-3.5 shrink-0 text-muted-foreground" /> : null;
|
|
})()}
|
|
<span className="truncate">{option.label}</span>
|
|
</>
|
|
) : (
|
|
<span className="text-muted-foreground">Reviewer</span>
|
|
)
|
|
}
|
|
renderOption={(option) => {
|
|
if (!option.id) return <span className="truncate">{option.label}</span>;
|
|
const reviewer = parseAssigneeValue(option.id).assigneeAgentId
|
|
? (agents ?? []).find((agent) => agent.id === parseAssigneeValue(option.id).assigneeAgentId)
|
|
: null;
|
|
return (
|
|
<>
|
|
{reviewer ? <AgentIcon icon={reviewer.icon} className="h-3.5 w-3.5 shrink-0 text-muted-foreground" /> : null}
|
|
<span className="truncate">{option.label}</span>
|
|
</>
|
|
);
|
|
}}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* Approver row */}
|
|
{showApproverRow && (
|
|
<div className="flex items-center gap-2 text-sm text-muted-foreground mt-1">
|
|
<span className="w-6 shrink-0 flex items-center justify-center"><ShieldCheck className="h-3.5 w-3.5" /></span>
|
|
<InlineEntitySelector
|
|
value={approverValue}
|
|
options={assigneeOptions}
|
|
recentOptionIds={recentAssigneeOptionIds}
|
|
placeholder="Approver"
|
|
disablePortal
|
|
noneLabel="No approver"
|
|
searchPlaceholder="Search approvers..."
|
|
emptyMessage="No approvers found."
|
|
onChange={setApproverValue}
|
|
renderTriggerValue={(option) =>
|
|
option ? (
|
|
<>
|
|
{(() => {
|
|
const approver = parseAssigneeValue(option.id).assigneeAgentId
|
|
? (agents ?? []).find((a) => a.id === parseAssigneeValue(option.id).assigneeAgentId)
|
|
: null;
|
|
return approver ? <AgentIcon icon={approver.icon} className="h-3.5 w-3.5 shrink-0 text-muted-foreground" /> : null;
|
|
})()}
|
|
<span className="truncate">{option.label}</span>
|
|
</>
|
|
) : (
|
|
<span className="text-muted-foreground">Approver</span>
|
|
)
|
|
}
|
|
renderOption={(option) => {
|
|
if (!option.id) return <span className="truncate">{option.label}</span>;
|
|
const approver = parseAssigneeValue(option.id).assigneeAgentId
|
|
? (agents ?? []).find((agent) => agent.id === parseAssigneeValue(option.id).assigneeAgentId)
|
|
: null;
|
|
return (
|
|
<>
|
|
{approver ? <AgentIcon icon={approver.icon} className="h-3.5 w-3.5 shrink-0 text-muted-foreground" /> : null}
|
|
<span className="truncate">{option.label}</span>
|
|
</>
|
|
);
|
|
}}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{isSubIssueMode ? (
|
|
<div className="px-4 pb-2">
|
|
<div className="max-w-full rounded-md border border-border bg-muted/30 px-2.5 py-1.5 text-xs text-muted-foreground">
|
|
<div className="flex items-center gap-1.5">
|
|
<ListTree className="h-3.5 w-3.5 shrink-0" />
|
|
<span className="shrink-0">Sub-task of</span>
|
|
<span className="font-medium text-foreground">{parentIssueLabel}</span>
|
|
</div>
|
|
{newIssueDefaults.parentTitle ? (
|
|
<div className="pl-5 text-foreground/80 truncate">
|
|
{newIssueDefaults.parentTitle}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
|
|
{currentProject && currentProjectSupportsExecutionWorkspace && (
|
|
<div className="px-4 py-3 space-y-2">
|
|
<div className="space-y-1.5">
|
|
<div className="text-xs font-medium">Execution workspace</div>
|
|
<div className="text-[11px] text-muted-foreground">
|
|
Control whether this task runs in the shared workspace, a new isolated workspace, or an existing one.
|
|
</div>
|
|
<select
|
|
className="w-full rounded border border-border bg-transparent px-2 py-1.5 text-xs outline-none"
|
|
value={executionWorkspaceMode}
|
|
onChange={(e) => {
|
|
setExecutionWorkspaceMode(e.target.value);
|
|
if (e.target.value !== "reuse_existing") {
|
|
setSelectedExecutionWorkspaceId("");
|
|
}
|
|
}}
|
|
>
|
|
{EXECUTION_WORKSPACE_MODES.map((option) => (
|
|
<option key={option.value} value={option.value}>
|
|
{option.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
{executionWorkspaceMode === "reuse_existing" && (
|
|
<select
|
|
className="w-full rounded border border-border bg-transparent px-2 py-1.5 text-xs outline-none"
|
|
value={selectedExecutionWorkspaceId}
|
|
onChange={(e) => setSelectedExecutionWorkspaceId(e.target.value)}
|
|
>
|
|
<option value="">Choose an existing workspace</option>
|
|
{deduplicatedReusableWorkspaces.map((workspace) => (
|
|
<option key={workspace.id} value={workspace.id}>
|
|
{workspace.name} · {workspace.status} · {workspace.branchName ?? workspace.cwd ?? workspace.id.slice(0, 8)}
|
|
</option>
|
|
))}
|
|
</select>
|
|
)}
|
|
{executionWorkspaceMode === "reuse_existing" && selectedReusableExecutionWorkspace && (
|
|
<div className="text-[11px] text-muted-foreground">
|
|
Reusing {selectedReusableExecutionWorkspace.name} from {selectedReusableExecutionWorkspace.branchName ?? selectedReusableExecutionWorkspace.cwd ?? "existing execution workspace"}.
|
|
</div>
|
|
)}
|
|
{showParentWorkspaceWarning ? (
|
|
<div className="rounded-md border border-amber-300/60 bg-amber-50 px-2 py-1.5 text-[11px] text-amber-900 dark:border-amber-800/70 dark:bg-amber-950/30 dark:text-amber-100">
|
|
Warning: this sub-task will no longer use the parent task workspace{parentExecutionWorkspaceLabel ? ` (${parentExecutionWorkspaceLabel})` : ""}.
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{supportsAssigneeOverrides && (
|
|
<div className="px-4 pb-2">
|
|
<button
|
|
className="inline-flex items-center gap-1.5 text-xs font-medium text-muted-foreground hover:text-foreground transition-colors"
|
|
onClick={() => setAssigneeOptionsOpen((open) => !open)}
|
|
>
|
|
{assigneeOptionsOpen ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />}
|
|
{assigneeOptionsTitle}
|
|
</button>
|
|
{assigneeOptionsOpen && (
|
|
<div className="mt-2 rounded-md border border-border p-3 bg-muted/20 space-y-3">
|
|
<div className="space-y-1.5">
|
|
<div className="text-xs text-muted-foreground">Model lane</div>
|
|
<div
|
|
className="flex w-full overflow-hidden rounded-md border border-border"
|
|
role="radiogroup"
|
|
aria-label="Model lane"
|
|
>
|
|
{(["primary", ...(assigneeSupportsCheapLane ? (["cheap"] as const) : ([] as const)), "custom"] as const).map((lane) => (
|
|
<button
|
|
key={lane}
|
|
type="button"
|
|
role="radio"
|
|
aria-checked={assigneeModelLane === lane}
|
|
className={cn(
|
|
"flex-1 px-2 py-1 text-xs capitalize transition-colors hover:bg-accent/40",
|
|
assigneeModelLane === lane && "bg-accent text-foreground",
|
|
)}
|
|
onClick={() => setAssigneeModelLane(lane)}
|
|
>
|
|
{lane === "primary"
|
|
? "Primary"
|
|
: lane === "cheap"
|
|
? "Cheap"
|
|
: "Custom"}
|
|
</button>
|
|
))}
|
|
</div>
|
|
{assigneeModelLane === "cheap" && (
|
|
<p className="text-[11px] text-muted-foreground">
|
|
Sends <code>modelProfile: "cheap"</code>{" "}
|
|
{assigneeCheapProfile?.adapterConfig && typeof (assigneeCheapProfile.adapterConfig as Record<string, unknown>).model === "string"
|
|
? <>· adapter default <code>{String((assigneeCheapProfile.adapterConfig as Record<string, unknown>).model)}</code></>
|
|
: assigneeCheapProfile
|
|
? <>· uses the agent's configured cheap profile</>
|
|
: <>· falls back to the primary model if no cheap profile is configured</>}
|
|
</p>
|
|
)}
|
|
{assigneeModelLane === "primary" && (
|
|
<p className="text-[11px] text-muted-foreground">Runs on the agent's primary model.</p>
|
|
)}
|
|
{assigneeModelLane === "custom" && (
|
|
<p className="text-[11px] text-muted-foreground">Override the model and effort for this task only.</p>
|
|
)}
|
|
</div>
|
|
{assigneeModelLane === "custom" && (
|
|
<div className="space-y-1.5">
|
|
<div className="text-xs text-muted-foreground">Model</div>
|
|
<InlineEntitySelector
|
|
value={assigneeModelOverride}
|
|
options={modelOverrideOptions}
|
|
placeholder="Default model"
|
|
disablePortal
|
|
noneLabel="Default model"
|
|
searchPlaceholder="Search models..."
|
|
emptyMessage="No models found."
|
|
onChange={setAssigneeModelOverride}
|
|
/>
|
|
</div>
|
|
)}
|
|
{assigneeModelLane === "custom" && (
|
|
<div className="space-y-1.5">
|
|
<div className="text-xs text-muted-foreground">Thinking effort</div>
|
|
<div className="flex items-center gap-1.5 flex-wrap">
|
|
{thinkingEffortOptions.map((option) => (
|
|
<button
|
|
key={option.value || "default"}
|
|
className={cn(
|
|
"px-2 py-1 rounded-md text-xs border border-border hover:bg-accent/50 transition-colors",
|
|
assigneeThinkingEffort === option.value && "bg-accent"
|
|
)}
|
|
onClick={() => setAssigneeThinkingEffort(option.value)}
|
|
>
|
|
{option.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
{assigneeAdapterType === "claude_local" && assigneeModelLane === "custom" && (
|
|
<div className="flex items-center justify-between rounded-md border border-border px-2 py-1.5">
|
|
<div className="text-xs text-muted-foreground">Enable Chrome (--chrome)</div>
|
|
<ToggleSwitch
|
|
checked={assigneeChrome}
|
|
onCheckedChange={() => setAssigneeChrome((value) => !value)}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Description */}
|
|
<div
|
|
className="border-t border-border/60 px-4 pb-2 pt-3"
|
|
onDragEnter={handleFileDragEnter}
|
|
onDragOver={handleFileDragOver}
|
|
onDragLeave={handleFileDragLeave}
|
|
onDrop={handleFileDrop}
|
|
>
|
|
<div
|
|
className={cn(
|
|
"rounded-md transition-colors",
|
|
isFileDragOver && "bg-accent/20",
|
|
)}
|
|
>
|
|
<IssueDescriptionEditor
|
|
value={description}
|
|
expanded={expanded}
|
|
mentions={mentionOptions}
|
|
descriptionEditorRef={descriptionEditorRef}
|
|
imageUploadHandler={uploadDescriptionImageHandler}
|
|
onChange={handleDescriptionChange}
|
|
/>
|
|
</div>
|
|
{stagedFiles.length > 0 ? (
|
|
<div className="mt-4 space-y-3 rounded-lg border border-border/70 p-3">
|
|
{stagedDocuments.length > 0 ? (
|
|
<div className="space-y-2">
|
|
<div className="text-xs font-medium text-muted-foreground">Documents</div>
|
|
<div className="space-y-2">
|
|
{stagedDocuments.map((file) => (
|
|
<div key={file.id} className="flex items-start justify-between gap-3 rounded-md border border-border/70 px-3 py-2">
|
|
<div className="min-w-0">
|
|
<div className="flex items-center gap-2">
|
|
<span className="rounded-full border border-border px-2 py-0.5 font-mono text-[10px] uppercase tracking-[0.16em] text-muted-foreground">
|
|
{file.documentKey}
|
|
</span>
|
|
<span className="truncate text-sm">{file.file.name}</span>
|
|
</div>
|
|
<div className="mt-1 flex items-center gap-2 text-[11px] text-muted-foreground">
|
|
<FileText className="h-3.5 w-3.5" />
|
|
<span>{file.title || file.file.name}</span>
|
|
<span>•</span>
|
|
<span>{formatFileSize(file.file)}</span>
|
|
</div>
|
|
</div>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon-xs"
|
|
className="shrink-0 text-muted-foreground"
|
|
onClick={() => removeStagedFile(file.id)}
|
|
disabled={createIssue.isPending}
|
|
title="Remove document"
|
|
>
|
|
<X className="h-3.5 w-3.5" />
|
|
</Button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
|
|
{stagedAttachments.length > 0 ? (
|
|
<div className="space-y-2">
|
|
<div className="text-xs font-medium text-muted-foreground">Attachments</div>
|
|
<div className="space-y-2">
|
|
{stagedAttachments.map((file) => (
|
|
<div key={file.id} className="flex items-start justify-between gap-3 rounded-md border border-border/70 px-3 py-2">
|
|
<div className="min-w-0">
|
|
<div className="flex items-center gap-2">
|
|
<Paperclip className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
|
<span className="truncate text-sm">{file.file.name}</span>
|
|
</div>
|
|
<div className="mt-1 text-[11px] text-muted-foreground">
|
|
{file.file.type || "application/octet-stream"} • {formatFileSize(file.file)}
|
|
</div>
|
|
</div>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon-xs"
|
|
className="shrink-0 text-muted-foreground"
|
|
onClick={() => removeStagedFile(file.id)}
|
|
disabled={createIssue.isPending}
|
|
title="Remove attachment"
|
|
>
|
|
<X className="h-3.5 w-3.5" />
|
|
</Button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Property chips bar */}
|
|
<div className="flex items-center gap-1.5 px-4 py-2 border-t border-border flex-wrap shrink-0">
|
|
{/* Status chip */}
|
|
<Popover open={statusOpen} onOpenChange={setStatusOpen}>
|
|
<PopoverTrigger asChild>
|
|
<button className="inline-flex items-center gap-1.5 rounded-md border border-border px-2 py-1 text-xs hover:bg-accent/50 transition-colors">
|
|
<CircleDot className={cn("h-3 w-3", currentStatus.color)} />
|
|
{currentStatus.label}
|
|
</button>
|
|
</PopoverTrigger>
|
|
<PopoverContent className="w-56 p-1" align="start">
|
|
{statuses.map((s) => (
|
|
<button
|
|
key={s.value}
|
|
className={cn(
|
|
"flex w-full items-start gap-2 px-2 py-1.5 text-xs rounded hover:bg-accent/50",
|
|
s.value === status && "bg-accent"
|
|
)}
|
|
onClick={() => { setStatus(s.value); setStatusOpen(false); }}
|
|
>
|
|
<CircleDot className={cn("h-3 w-3 mt-0.5 shrink-0", s.color)} />
|
|
<span className="flex flex-col text-left leading-tight">
|
|
<span>{s.label}</span>
|
|
{s.description ? (
|
|
<span className="text-[10px] text-muted-foreground">{s.description}</span>
|
|
) : null}
|
|
</span>
|
|
</button>
|
|
))}
|
|
</PopoverContent>
|
|
</Popover>
|
|
|
|
{/* Priority chip */}
|
|
<Popover open={priorityOpen} onOpenChange={setPriorityOpen}>
|
|
<PopoverTrigger asChild>
|
|
<button
|
|
type="button"
|
|
data-testid="new-issue-priority-chip"
|
|
className="hidden items-center gap-1.5 rounded-md border border-border px-2 py-1 text-xs transition-colors hover:bg-accent/50 sm:inline-flex"
|
|
>
|
|
{currentPriority ? (
|
|
<>
|
|
<currentPriority.icon className={cn("h-3 w-3", currentPriority.color)} />
|
|
{currentPriority.label}
|
|
</>
|
|
) : (
|
|
<>
|
|
<Minus className="h-3 w-3 text-muted-foreground" />
|
|
Priority
|
|
</>
|
|
)}
|
|
</button>
|
|
</PopoverTrigger>
|
|
<PopoverContent className="w-36 p-1" align="start">
|
|
{priorities.map((p) => (
|
|
<button
|
|
key={p.value}
|
|
className={cn(
|
|
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
|
|
p.value === priority && "bg-accent"
|
|
)}
|
|
onClick={() => { setPriority(p.value); setPriorityOpen(false); }}
|
|
>
|
|
<p.icon className={cn("h-3 w-3", p.color)} />
|
|
{p.label}
|
|
</button>
|
|
))}
|
|
</PopoverContent>
|
|
</Popover>
|
|
|
|
{/* Labels chip — disabled, not wired up yet */}
|
|
{/* <button className="inline-flex items-center gap-1.5 rounded-md border border-border px-2 py-1 text-xs hover:bg-accent/50 transition-colors text-muted-foreground">
|
|
<Tag className="h-3 w-3" />
|
|
Labels
|
|
</button> */}
|
|
|
|
<input
|
|
ref={stageFileInputRef}
|
|
type="file"
|
|
accept={STAGED_FILE_ACCEPT}
|
|
className="hidden"
|
|
onChange={handleStageFilesPicked}
|
|
multiple
|
|
/>
|
|
<button
|
|
className="inline-flex items-center gap-1.5 rounded-md border border-border px-2 py-1 text-xs hover:bg-accent/50 transition-colors text-muted-foreground"
|
|
onClick={() => stageFileInputRef.current?.click()}
|
|
disabled={createIssue.isPending}
|
|
>
|
|
<Paperclip className="h-3 w-3" />
|
|
Upload
|
|
</button>
|
|
|
|
{/* Work mode chip */}
|
|
<Popover open={workModeOpen} onOpenChange={setWorkModeOpen}>
|
|
<PopoverTrigger asChild>
|
|
<button
|
|
type="button"
|
|
data-issue-work-mode-chip={workMode}
|
|
className={cn(
|
|
"inline-flex items-center gap-1.5 rounded-md border px-2 py-1 text-xs transition-colors",
|
|
workMode === "planning"
|
|
? "border-amber-500/60 bg-amber-500/15 text-amber-800 hover:bg-amber-500/25 dark:border-amber-500/50 dark:bg-amber-500/15 dark:text-amber-200 dark:hover:bg-amber-500/25"
|
|
: "border-border text-muted-foreground hover:bg-accent/50",
|
|
)}
|
|
>
|
|
<CurrentWorkModeIcon className="h-3 w-3" />
|
|
{currentWorkMode.label}
|
|
</button>
|
|
</PopoverTrigger>
|
|
<PopoverContent className="w-36 p-1" align="start">
|
|
{ISSUE_WORK_MODE_OPTIONS.map((option) => {
|
|
const Icon = option.icon;
|
|
return (
|
|
<button
|
|
key={option.value}
|
|
data-issue-work-mode={option.value}
|
|
className={cn(
|
|
"flex w-full items-center gap-2 rounded px-2 py-1.5 text-xs hover:bg-accent/50",
|
|
option.value === workMode && "bg-accent",
|
|
option.value === "planning" && "text-amber-700 dark:text-amber-300",
|
|
)}
|
|
onClick={() => {
|
|
setWorkMode(option.value);
|
|
setWorkModeOpen(false);
|
|
}}
|
|
>
|
|
<Icon className="h-3 w-3" />
|
|
{option.label}
|
|
</button>
|
|
);
|
|
})}
|
|
</PopoverContent>
|
|
</Popover>
|
|
|
|
{/* More */}
|
|
<Popover open={moreOpen} onOpenChange={setMoreOpen}>
|
|
<PopoverTrigger asChild>
|
|
<button
|
|
type="button"
|
|
data-testid="new-issue-more-menu-trigger"
|
|
className="inline-flex items-center justify-center rounded-md border border-border p-1 text-xs text-muted-foreground transition-colors hover:bg-accent/50"
|
|
>
|
|
<MoreHorizontal className="h-3 w-3" />
|
|
</button>
|
|
</PopoverTrigger>
|
|
<PopoverContent className="w-44 p-1" align="start" data-testid="new-issue-more-menu">
|
|
<div className="sm:hidden">
|
|
<div className="px-2 py-1 text-[10px] font-medium uppercase text-muted-foreground">
|
|
Priority
|
|
</div>
|
|
{priorities.map((p) => (
|
|
<button
|
|
type="button"
|
|
key={p.value}
|
|
data-testid={`new-issue-more-priority-${p.value}`}
|
|
className={cn(
|
|
"flex w-full items-center gap-2 rounded px-2 py-1.5 text-xs hover:bg-accent/50",
|
|
p.value === priority && "bg-accent",
|
|
)}
|
|
onClick={() => {
|
|
setPriority(p.value);
|
|
setMoreOpen(false);
|
|
}}
|
|
>
|
|
<p.icon className={cn("h-3 w-3", p.color)} />
|
|
{p.label}
|
|
</button>
|
|
))}
|
|
<div className="my-1 border-t border-border" />
|
|
</div>
|
|
<button className="flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50 text-muted-foreground">
|
|
<Calendar className="h-3 w-3" />
|
|
Start date
|
|
</button>
|
|
<button className="flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50 text-muted-foreground">
|
|
<Calendar className="h-3 w-3" />
|
|
Due date
|
|
</button>
|
|
</PopoverContent>
|
|
</Popover>
|
|
</div>
|
|
|
|
{assigneeValue && status === "backlog" ? (
|
|
<div
|
|
data-testid="new-issue-assigned-backlog-note"
|
|
className="mx-4 mb-2 flex items-start gap-2 rounded-md border border-amber-300/70 bg-amber-50/90 px-3 py-2 text-xs text-amber-900 dark:border-amber-500/40 dark:bg-amber-500/10 dark:text-amber-100"
|
|
>
|
|
<Flag className="mt-0.5 h-3.5 w-3.5 shrink-0 text-amber-600 dark:text-amber-300" />
|
|
<span className="leading-snug">
|
|
Assigning implies executable intent — leave status as <span className="font-medium">Backlog</span> only to deliberately park this. The assignee will not be woken until status moves to <span className="font-medium">Todo</span> or <span className="font-medium">In Progress</span>.
|
|
</span>
|
|
</div>
|
|
) : null}
|
|
|
|
{currentAssigneeLowTrust ? (
|
|
<div
|
|
data-testid="new-issue-low-trust-assignee-note"
|
|
className="mx-4 mb-2 flex items-start gap-2 rounded-md border border-amber-300/70 bg-amber-50/90 px-3 py-2 text-xs text-amber-900 dark:border-amber-500/40 dark:bg-amber-500/10 dark:text-amber-100"
|
|
>
|
|
<ShieldAlert className="mt-0.5 h-3.5 w-3.5 shrink-0 text-amber-600 dark:text-amber-300" />
|
|
<span className="leading-snug">
|
|
Low-trust review agent. It can only act inside its assigned review boundary; issue, project, or run policy defines the concrete scope.
|
|
</span>
|
|
</div>
|
|
) : null}
|
|
|
|
{/* Footer */}
|
|
<div className="flex items-center justify-between px-4 py-2.5 border-t border-border shrink-0">
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="text-muted-foreground"
|
|
onClick={discardDraft}
|
|
disabled={createIssue.isPending || !canDiscardDraft}
|
|
>
|
|
Discard Draft
|
|
</Button>
|
|
<div className="flex items-center gap-3">
|
|
<div className="min-h-5 text-right">
|
|
{createIssue.isPending ? (
|
|
<span className="inline-flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
|
|
<Loader2 className="h-3 w-3 animate-spin" />
|
|
Creating issue...
|
|
</span>
|
|
) : createIssue.isError ? (
|
|
<span className="text-xs text-destructive">{createIssueErrorMessage}</span>
|
|
) : null}
|
|
</div>
|
|
<Button
|
|
size="sm"
|
|
className="min-w-[8.5rem] disabled:opacity-100"
|
|
disabled={!titleHasText || createIssue.isPending}
|
|
onClick={handleSubmit}
|
|
aria-busy={createIssue.isPending}
|
|
>
|
|
<span className="inline-flex items-center justify-center gap-1.5">
|
|
{createIssue.isPending ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : null}
|
|
<span>{createIssue.isPending ? "Creating..." : isSubIssueMode ? "Create Sub-Task" : "Create Task"}</span>
|
|
</span>
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|