Files
PaperClipAI/ui/src/components/NewIssueDialog.tsx
T
DottaandPaperclip 39e5ccd1a8 fix(ui): remove New Task heading from composer (#18604)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - People use the task composer to describe work for an agent.
> - The Coordinator workspace added a visible New Task heading above the
editor.
> - This heading adds text and space that the task form does not need.
> - This pull request removes the heading and its empty header space.
> - The composer keeps its task fields and creation controls.

## Linked Issues or Issue Description

**What happened?**

The task composer shows a New Task heading when Coordinator is on.
Commit 71f0c0c99e added this text in
#17983 on October 28, 2026.

**Expected behavior**

The task composer opens without the extra visible heading or blank
header space.

**Steps to reproduce**

1. Enable Coordinator in Settings > Experimental.
2. Open the new task composer.
3. Look above the description editor.

**Paperclip version or commit**

Verified from source based on 06bc8db75.

**Deployment mode**

Source checkout. This is a UI change.

Related work: #17983 introduced the heading. A search found no open PR
that removes it.

## What Changed

- Remove the Coordinator-only New Task paragraph from `NewIssueDialog`.
- Remove Coordinator from the header condition to prevent empty header
space.
- Update the existing creation test to check that the heading is absent
with Coordinator on and off.

## Verification

- `pnpm exec vitest run ui/src/components/NewIssueDialog.test.tsx`: 111
tests passed. This includes description-only creation with Coordinator
on and off, saved titles, sub-tasks, and pending creation states.
- `pnpm check:token-gates`: passed.
- `pnpm --filter @paperclipai/ui typecheck`: passed.
- `pnpm --filter @paperclipai/ui build`: passed when run separately.
- Full workspace typecheck and build could not complete because the
sandbox has no `cargo` command. Runner TypeScript checks completed
before this limit.
- The first UI build was killed with exit 137 while the full test suite
ran. The 8 GiB sandbox reports one OOM kill. The full local suite was
stopped without a verdict to free memory. The separate UI build then
passed. CI runs the required checks on this commit.
- Greptile reviewed commit `82d90c97e018f1677fcbf85b9e0713a4eebe4115`:
5/5, no review comments.
- Browser and release suites were not run. This change removes one
paragraph and its header condition. Existing component tests cover the
changed states.

## Risks

- Low risk. The header still shows saved titles, parent task context,
pending sends, and retry errors under their existing conditions.
- No API, database, permission, or design token changes.
- No documentation changes are needed for this text removal.

## Model Used

- OpenAI Codex, GPT-6 family, with reasoning, tool use, and code
execution. The runtime does not expose the exact model ID or context
window size.

## 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 not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-11-02 08:54:35 -06:00

1809 lines
85 KiB
TypeScript

import { usePrimaryAgentPresentation } from "./primary-agent/PrimaryAgentPresentation";
import { useWorkspaceIsolationControls } from "@/hooks/useWorkspaceIsolationControls";
import { useState, useEffect, useRef, useCallback, useMemo, type CSSProperties, type DragEvent } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import type { AgentEnvConfig, EnvBinding, IssueWorkMode } from "@paperclipai/shared";
import { useDialog } from "../context/DialogContext";
import { useCompany } from "../context/CompanyContext";
import { executionWorkspacesApi } from "../api/execution-workspaces";
import { issuesApi } from "../api/issues";
import { MissingUserSecretsBanner } from "../pages/secrets/MissingUserSecretsBanner";
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 { useNavigate } from "../lib/router";
import {
defaultExecutionWorkspaceModeForProject,
defaultProjectWorkspaceIdForProject,
issueExecutionWorkspaceModeForExistingWorkspace,
} from "../lib/project-workspace-defaults";
import { useProjectOrder } from "../hooks/useProjectOrder";
import { useStreamlinedUiEnabled } from "../hooks/useStreamlinedUiEnabled";
import { useCoordinatorWorkspaceEnabled } from "../hooks/useCoordinatorWorkspaceEnabled";
import { getRecentAssigneeIds, getRecentAssigneeSelectionIds, sortAgentsByRecency, trackRecentAssignee, trackRecentAssigneeUser } from "../lib/recent-assignees";
import { getLastProjectId, getRecentProjectIds, trackRecentProject } from "../lib/recent-projects";
import { recordRecentTask } from "../lib/recent-tasks";
import { buildExecutionPolicy } from "../lib/issue-execution-policy";
import { createUuid } from "../lib/uuid";
import { isIssueWorkMode, nextWorkMode } from "../lib/work-mode-meta";
import { useToastActions } from "../context/ToastContext";
import { assigneeValueFromSelection, currentUserAssigneeOption, parseAssigneeValue } from "../lib/assignees";
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Paperclip, FileText, Flag, PauseCircle, ListTree, X, ShieldAlert, Folder, ChevronDown, Lock } from "lucide-react";
import { cn } from "../lib/utils";
import type { MentionOption } from "./MarkdownEditor";
import { TaskChatComposer } from "./task-chat/TaskChatComposer";
import { ComposerWorktreePicker } from "./task-chat/ComposerWorktreePicker";
import { TaskChatPresentationProvider } from "./task-chat/presentation-mode";
import { mergeComposerRunSettings, type ComposerRunSettings } from "./task-chat/composer-run-settings";
import { useSidebar } from "../context/SidebarContext";
import { InlineBanner } from "./InlineBanner";
import { PendingSendNotice } from "./PendingSendNotice";
import { useDurableSubmit } from "../hooks/useDurableSubmit";
import { describeError, type ErrorDescription } from "../api/errors";
import { clearPendingSend, pendingSendKeyFor, readPendingSend, writePendingSend, PendingSendAccountChangedError } from "../lib/pending-send";
import { useDraftOwner, useDraftOwnerReader } from "../hooks/useDraftOwner";
import { InlineEntitySelector, type InlineEntityOption } from "./InlineEntitySelector";
import { getTrustPreset } from "../lib/trust-policy-ui";
const DRAFT_KEY = "paperclip:issue-draft";
/** A create that is waiting to resend: its request body and idempotency key. */
const PENDING_CREATE_KEY = "paperclip:issue-draft:pending-create";
/**
* The create request. Staged files cannot be stored, so they ride in memory and
* only their names are saved: a create restored after reload knows it needs them.
*/
type PendingCreate = { fingerprint: string; data: Record<string, unknown>; stagedFileNames?: string[] };
function isPendingCreate(value: unknown): value is PendingCreate {
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
const row = value as Record<string, unknown>;
return (
typeof row.fingerprint === "string" &&
!!row.data && typeof row.data === "object" && !Array.isArray(row.data) &&
typeof (row.data as Record<string, unknown>).companyId === "string" &&
(row.stagedFileNames === undefined ||
(Array.isArray(row.stagedFileNames) && row.stagedFileNames.every((name) => typeof name === "string")))
);
}
/** Thrown before sending a restored create whose staged files are gone from memory. */
class StagedFilesMissingError extends Error {
constructor(names: string[]) {
super(
`This page no longer has ${names.length === 1 ? "the file" : `the ${names.length} files`} staged for this task (${names.join(", ")}). Attach ${names.length === 1 ? "it" : "them"} again, then create the task.`,
);
this.name = "StagedFilesMissingError";
}
}
/**
* A restored create that could not be sent for lack of its files. Its key is
* kept until a create succeeds or the user cancels, so reattaching after any
* number of reloads replays a create that already reached the server.
*/
const CREATE_AWAITING_FILES_KEY = "paperclip:issue-draft:create-awaiting-files";
function stagedFilesMissingDescription(names: string[]): ErrorDescription {
return describeError(new StagedFilesMissingError(names), { action: "create the task" });
}
/** Identifies a staged file across reselection from disk, unlike its per-staging `id`. */
function stagedFileIdentity(file: StagedIssueFile): string {
return [file.kind, file.documentKey ?? "", file.file.name, file.file.size, file.file.lastModified].join(":");
}
function restoreStagedDocumentKeys(files: StagedIssueFile[], fingerprint: string | undefined): StagedIssueFile[] {
let required: unknown;
try { required = JSON.parse(fingerprint ?? "{}").stagedFiles; } catch { return files; }
if (!Array.isArray(required)) return files;
return files.map(file => {
if (file.kind !== "document") return file;
const suffix = `:${file.file.name}:${file.file.size}:${file.file.lastModified}`;
const matches = required.filter((identity): identity is string => typeof identity === "string" && identity.startsWith("document:") && identity.endsWith(suffix));
if (matches.length !== 1) return file;
return { ...file, documentKey: matches[0].slice("document:".length, -suffix.length) };
});
}
const DEBOUNCE_MS = 800;
type VisualViewportLayout = {
height: number;
offsetTop: number;
constrained: boolean;
};
type NewIssueDialogViewportStyle = CSSProperties & {
"--new-issue-visual-viewport-height"?: string;
"--new-issue-dialog-top"?: string;
"--new-issue-dialog-height"?: string;
};
type MobileEntityPickerViewportStyle = CSSProperties & {
"--mobile-entity-picker-visual-viewport-height"?: string;
};
function readVisualViewportLayout(): VisualViewportLayout | null {
if (typeof window === "undefined" || !window.visualViewport) return null;
const { height, offsetTop } = window.visualViewport;
// Mobile browsers can briefly report unusable geometry while the visual
// viewport initializes or animates. Applying it collapses the dialog.
if (!Number.isFinite(height) || height <= 0 || !Number.isFinite(offsetTop) || offsetTop < 0) {
return null;
}
return {
height,
offsetTop,
constrained: height < window.innerHeight,
};
}
function useVisualViewportLayout(enabled: boolean) {
const [layout, setLayout] = useState<VisualViewportLayout | null>(() =>
enabled ? readVisualViewportLayout() : null,
);
useEffect(() => {
if (!enabled) {
setLayout(null);
return;
}
const viewport = window.visualViewport;
if (!viewport) return;
const updateLayout = () => {
const nextLayout = readVisualViewportLayout();
// Keep the last valid keyboard geometry during transient invalid readings.
if (nextLayout) setLayout(nextLayout);
};
updateLayout();
viewport.addEventListener("resize", updateLayout);
viewport.addEventListener("scroll", updateLayout);
window.addEventListener("resize", updateLayout);
return () => {
viewport.removeEventListener("resize", updateLayout);
viewport.removeEventListener("scroll", updateLayout);
window.removeEventListener("resize", updateLayout);
};
}, [enabled]);
return layout;
}
interface IssueDraft {
isPrivate?: boolean;
title: string;
description: string;
status: string;
priority: string;
assigneeValue: string;
reviewerValue: string;
approverValue: string;
watchdogAgentId?: string;
watchdogInstructions?: string;
assigneeId?: string;
projectId: string;
projectWorkspaceId?: string;
assigneeModelLane?: IssueModelLane;
assigneeModelOverride: string;
assigneeThinkingEffort: string;
assigneeChrome: boolean;
executionWorkspaceMode?: string;
selectedExecutionWorkspaceId?: string;
useIsolatedExecutionWorkspace?: boolean;
workMode?: IssueWorkMode;
composerSettings?: ComposerRunSettings | null;
}
type StagedIssueFile = {
id: string;
file: File;
kind: "document" | "attachment";
documentKey?: string;
title?: string | null;
};
/**
* Files a keyed create has already added to its task. A replayed create (same
* key, after a reload mid-upload) gets the existing task back from the
* server, and must not add those files a second time.
*/
const CREATE_UPLOAD_RECEIPTS_PREFIX = "paperclip:issue-draft:create-uploads:";
function loadCreateUploadReceipts(idempotencyKey: string): Set<string> {
try {
const stored = JSON.parse(localStorage.getItem(CREATE_UPLOAD_RECEIPTS_PREFIX + idempotencyKey) ?? "[]");
return new Set(Array.isArray(stored) ? stored.filter((item): item is string => typeof item === "string") : []);
} catch {
return new Set();
}
}
function saveCreateUploadReceipts(idempotencyKey: string, receipts: Set<string>) {
try {
localStorage.setItem(CREATE_UPLOAD_RECEIPTS_PREFIX + idempotencyKey, JSON.stringify([...receipts]));
} catch {
// Without storage a replay after reload may add a file twice; nothing is lost.
}
}
export function clearCreateUploadReceipts(idempotencyKey: string) {
try {
localStorage.removeItem(CREATE_UPLOAD_RECEIPTS_PREFIX + idempotencyKey);
} catch {
// Unavailable storage holds nothing to clear.
}
}
/** Add staged files to a created task, skipping any this create already added. Returns the names that failed. */
export async function addStagedFilesToIssue(input: {
companyId: string;
issueId: string;
stagedFiles: StagedIssueFile[];
idempotencyKey?: string;
assertCurrentOwner?: () => void;
}): Promise<string[]> {
const { companyId, issueId, stagedFiles, idempotencyKey } = input;
const receipts = idempotencyKey ? loadCreateUploadReceipts(idempotencyKey) : new Set<string>();
const failures: string[] = [];
for (const stagedFile of stagedFiles) {
input.assertCurrentOwner?.();
let identity: string | undefined;
if (idempotencyKey && globalThis.crypto?.subtle) {
try {
const bytes = await new Promise<ArrayBuffer>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as ArrayBuffer);
reader.onerror = () => reject(reader.error);
reader.onabort = () => reject(new Error("File read cancelled"));
reader.readAsArrayBuffer(stagedFile.file);
});
const digest = await crypto.subtle.digest("SHA-256", bytes);
const hash = Array.from(new Uint8Array(digest), byte => byte.toString(16).padStart(2, "0")).join("");
identity = JSON.stringify([stagedFileIdentity(stagedFile), hash]);
} catch {
identity = undefined;
}
}
input.assertCurrentOwner?.();
if (identity && receipts.has(identity)) continue;
try {
if (stagedFile.kind === "document") {
const body = await stagedFile.file.text();
input.assertCurrentOwner?.();
await issuesApi.upsertDocument(issueId, stagedFile.documentKey ?? "document", {
title: stagedFile.documentKey === "plan" ? null : (stagedFile.title ?? null),
format: "markdown",
body,
baseRevisionId: null,
});
} else {
await issuesApi.uploadAttachment(companyId, issueId, stagedFile.file);
}
if (identity && idempotencyKey) {
receipts.add(identity);
saveCreateUploadReceipts(idempotencyKey, receipts);
}
input.assertCurrentOwner?.();
} catch {
input.assertCurrentOwner?.();
failures.push(stagedFile.file.name);
}
}
return failures;
}
import { Badge } from "@/components/ui/badge";
import { buildAssigneeAdapterOverrides, type IssueModelLane } from "../lib/issue-assignee-overrides";
function loadDraft(owner: string | undefined): IssueDraft | null {
if (owner === undefined) return null;
try {
const raw = localStorage.getItem(pendingSendKeyFor(DRAFT_KEY, owner));
if (!raw) return null;
const { successCriteria, ...draft } = JSON.parse(raw) as IssueDraft & { successCriteria?: string };
// Keep text entered in the retired field visible and editable in the brief.
const criteria = typeof successCriteria === "string" ? successCriteria.trim() : "";
return {
...draft,
description: criteria
? [draft.description, `## Success criteria\n${criteria}`].filter(Boolean).join("\n\n")
: draft.description,
};
} catch {
return null;
}
}
function saveDraft(owner: string, draft: IssueDraft) {
try { localStorage.setItem(pendingSendKeyFor(DRAFT_KEY, owner), JSON.stringify(draft)); } catch {}
}
function clearDraft(owner: string | undefined) {
try { if (owner !== undefined) localStorage.removeItem(pendingSendKeyFor(DRAFT_KEY, owner)); } catch {}
}
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`;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function isRequiredUserSecretBinding(value: unknown): value is Extract<EnvBinding, { type: "user_secret_ref" }> {
return (
isRecord(value) &&
value.type === "user_secret_ref" &&
typeof value.key === "string" &&
value.key.trim().length > 0 &&
value.required !== false &&
value.allowMissingOverride !== true
);
}
function collectRequiredUserSecretKeysFromEnv(
env: AgentEnvConfig | Record<string, unknown> | null | undefined,
): string[] {
if (!isRecord(env)) return [];
return Object.values(env).flatMap((binding) => (isRequiredUserSecretBinding(binding) ? [binding.key.trim()] : []));
}
function uniqueRequiredUserSecretKeys(
inputs: Array<AgentEnvConfig | Record<string, unknown> | null | undefined>,
): string[] {
return [...new Set(inputs.flatMap(collectRequiredUserSecretKeysFromEnv))];
}
function shouldWarnAboutRunUserSecrets(status: string, assigneeAgentId: string | null | undefined) {
return Boolean(assigneeAgentId) && (status === "todo" || status === "in_progress");
}
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);
}
function isWorkModePeriodShortcut(e: Pick<React.KeyboardEvent, "code" | "ctrlKey" | "key" | "metaKey">) {
const isPeriod = e.code === "Period" || e.key === ".";
return (e.metaKey || e.ctrlKey) && isPeriod;
}
function isWorkModeEscapeShortcut(e: Pick<KeyboardEvent, "key" | "metaKey">) {
return e.metaKey && e.key === "Escape";
}
export function NewIssueDialog() {
const owner = useDraftOwner();
const { newIssueDefaults } = useDialog();
const [scope, setScope] = useState({ owner, generation: 0, ignoredDefaults: null as typeof newIssueDefaults | null });
const changed = scope.owner !== owner;
const knownChange = changed && scope.owner !== undefined;
const next = changed ? { owner, generation: scope.generation + (knownChange ? 1 : 0), ignoredDefaults: knownChange ? newIssueDefaults : scope.ignoredDefaults } : scope;
if (changed) setScope(next);
return <AccountNewIssueDialog key={next.generation} ignoreDefaults={next.ignoredDefaults === newIssueDefaults} />;
}
function AccountNewIssueDialog({ ignoreDefaults }: { ignoreDefaults: boolean }) {
const { visible: workspaceIsolationControlsVisible } = useWorkspaceIsolationControls();
const { newIssueOpen, newIssueDefaults: sharedDefaults, closeNewIssue } = useDialog();
const newIssueDefaults = useMemo(() => ignoreDefaults ? {} as typeof sharedDefaults : sharedDefaults, [ignoreDefaults, sharedDefaults]);
const readOwner = useDraftOwnerReader();
const active = useRef(true);
useEffect(() => { active.current = true; return () => { active.current = false; }; }, []);
const unboundFormDraft = useRef<IssueDraft | null>(null);
const visualViewportLayout = useVisualViewportLayout(newIssueOpen);
const dialogBodyRef = useRef<HTMLDivElement>(null);
const { companies, selectedCompanyId: effectiveCompanyId } = useCompany();
const queryClient = useQueryClient();
const navigate = useNavigate();
const { pushToast } = useToastActions();
const { enabled: streamlinedUiEnabled } = useStreamlinedUiEnabled();
const { enabled: coordinatorEnabled } = useCoordinatorWorkspaceEnabled();
const [title, setTitle] = useState("");
const [hasTitle, setHasTitle] = useState(false);
const [description, setDescription] = useState("");
const titleRef = useRef("");
const descriptionRef = useRef("");
const [status, setStatus] = useState("todo");
const [priority, setPriority] = useState("");
const [assigneeValue, setAssigneeValue] = useState("");
const [reviewerValue, setReviewerValue] = useState("");
const [approverValue, setApproverValue] = useState("");
const [watchdogAgentId, setWatchdogAgentId] = useState("");
const [watchdogInstructions, setWatchdogInstructions] = useState("");
const [projectId, setProjectId] = useState("");
const [projectWorkspaceId, setProjectWorkspaceId] = useState("");
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 [isPrivate, setIsPrivate] = useState(false);
const { isMobile } = useSidebar();
const [composerSettings, setComposerSettings] = useState<ComposerRunSettings | 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 defaultAssigneePendingRef = useRef(false);
const defaultProjectPendingRef = useRef(false);
// `recovering`: a create restored without its files keeps its key until it is
// sent or cancelled, whatever the files and fields look like when resubmitted.
const createRequestRef = useRef<{ fingerprint: string; idempotencyKey: string; recovering?: boolean; payload?: PendingCreate } | null>(null);
const draftOwner = useDraftOwner();
const [restoredLaunch, setRestoredLaunch] = useState<{ owner: string | undefined; parentId?: string; goalId?: string } | null>(null);
const savedLaunch = restoredLaunch?.owner === draftOwner ? restoredLaunch : null;
const parentId = savedLaunch ? savedLaunch.parentId : newIssueDefaults.parentId;
const goalId = savedLaunch ? savedLaunch.goalId : newIssueDefaults.goalId;
const restoreLaunch = (payload: PendingCreate) => setRestoredLaunch({ owner: draftOwner,
...(typeof payload.data.parentId === "string" ? { parentId: payload.data.parentId } : {}),
...(typeof payload.data.goalId === "string" ? { goalId: payload.data.goalId } : {}),
});
const awaitingFilesKey = draftOwner !== undefined ? pendingSendKeyFor(CREATE_AWAITING_FILES_KEY, draftOwner) : null;
const primaryAgent = usePrimaryAgentPresentation(effectiveCompanyId);
const isSubIssueMode = Boolean(parentId);
const parentIssueLabel =
(newIssueDefaults.parentId === parentId ? newIssueDefaults.parentIdentifier : undefined) ?? (parentId ? parentId.slice(0, 8) : "");
const parentExecutionWorkspaceId = newIssueDefaults.executionWorkspaceId ?? "";
const parentExecutionWorkspaceLabel = newIssueDefaults.parentExecutionWorkspaceLabel ?? parentExecutionWorkspaceId;
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: mentionIssues } = useQuery({
queryKey: queryKeys.issues.mentionPool(effectiveCompanyId!),
queryFn: () => issuesApi.list(effectiveCompanyId!, {
limit: 100,
sortField: "updated",
sortDir: "desc",
}),
enabled: Boolean(effectiveCompanyId) && newIssueOpen,
staleTime: 60_000,
});
const { data: session, isFetched: sessionFetched } = useQuery({
queryKey: queryKeys.auth.session,
queryFn: () => authApi.getSession(),
});
const { data: companyMembers, isFetched: membersFetched } = 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,
});
const currentUserId = session?.user?.id ?? session?.session?.userId ?? null;
const activeProjects = useMemo(() => projects ?? [], [projects]);
const { orderedProjects } = useProjectOrder({
projects: activeProjects,
companyId: effectiveCompanyId,
userId: currentUserId,
});
const currentProject = orderedProjects.find((project) => project.id === projectId);
const currentProjectExecutionWorkspacePolicy =
experimentalSettings?.enableIsolatedWorkspaces === true ? (currentProject?.executionWorkspacePolicy ?? null) : null;
const currentProjectSupportsExecutionWorkspace = Boolean(currentProjectExecutionWorkspacePolicy?.enabled);
const canChooseWorktrees = workspaceIsolationControlsVisible && currentProjectSupportsExecutionWorkspace;
const {
data: reusableExecutionWorkspaces,
isPending: worktreesLoading,
isError: worktreesError,
refetch: refetchWorktrees,
} = useQuery({
queryKey: queryKeys.executionWorkspaces.summaryList(effectiveCompanyId!, {
projectId,
reuseEligible: true,
}),
queryFn: () => executionWorkspacesApi.listSummaries(effectiveCompanyId!, {
projectId,
reuseEligible: true,
}),
enabled: Boolean(effectiveCompanyId) && newIssueOpen && canChooseWorktrees,
});
const { data: privacyParent, isError: parentPrivacyError, refetch: refetchParentPrivacy } = useQuery({
queryKey: queryKeys.issues.detail(parentId ?? ""),
queryFn: () => issuesApi.get(parentId!),
enabled: newIssueOpen && Boolean(parentId),
});
const parentPrivacyUnresolved = isSubIssueMode && !privacyParent;
const inheritsPrivateAccess = privacyParent?.visibility === "private" || privacyParent?.project?.visibility === "private";
const privateParentProject = privacyParent?.project?.visibility === "private" ? privacyParent.project : null;
const inheritedPrivateProject = privateParentProject ?? (!isPrivate && currentProject?.visibility === "private" ? currentProject : null);
const inheritedPrivacyReason = privacyParent?.visibility === "private"
? `Subtask of private task ${privacyParent.title || newIssueDefaults.parentTitle || parentIssueLabel}`
: inheritedPrivateProject ? `In private project ${inheritedPrivateProject.name}` : undefined;
const effectivePrivate = isPrivate || inheritsPrivateAccess
|| orderedProjects.some(project => project.id === projectId && project.visibility === "private");
const selectedAssignee = useMemo(() => parseAssigneeValue(assigneeValue), [assigneeValue]);
const selectedAssigneeAgentId = selectedAssignee.assigneeAgentId;
const selectedAssigneeUserId = selectedAssignee.assigneeUserId;
const selectedAssigneeAgent = useMemo(
() => (agents ?? []).find((agent) => agent.id === selectedAssigneeAgentId) ?? null,
[agents, selectedAssigneeAgentId],
);
const assigneeAdapterType = selectedAssigneeAgent?.adapterType ?? null;
const mentionOptions = useMemo<MentionOption[]>(() => {
return buildMarkdownMentionOptions({
agents,
projects: orderedProjects,
members: companyMembers?.users,
issues: mentionIssues,
});
}, [agents, companyMembers?.users, orderedProjects, mentionIssues]);
const assertCurrentOwner = () => {
if (!active.current || draftOwner === undefined || readOwner() !== draftOwner) throw new PendingSendAccountChangedError();
};
const createIssue = useMutation({
mutationFn: async ({
companyId,
stagedFiles: pendingStagedFiles,
navigateOnCreate,
...data
}: { companyId: string; stagedFiles: StagedIssueFile[]; navigateOnCreate?: boolean } & Record<string, unknown>) => {
assertCurrentOwner();
const issue = await issuesApi.create(companyId, data);
assertCurrentOwner();
const failures = await addStagedFilesToIssue({
companyId,
issueId: issue.id,
stagedFiles: pendingStagedFiles,
assertCurrentOwner,
idempotencyKey: typeof data.idempotencyKey === "string" ? data.idempotencyKey : undefined,
});
assertCurrentOwner();
return { issue, companyId, failures, navigateOnCreate };
},
onSuccess: ({ issue, companyId, failures, navigateOnCreate }) => {
assertCurrentOwner();
trackRecentProject(issue.projectId ?? "", companyId);
if (issue.assigneeAgentId) trackRecentAssignee(issue.assigneeAgentId, companyId);
if (issue.assigneeUserId) trackRecentAssigneeUser(issue.assigneeUserId, companyId);
if (streamlinedUiEnabled) recordRecentTask(issue, currentUserId);
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);
const prefix = (companies.find((company) => company.id === companyId)?.issuePrefix ?? "").trim();
const issueRef = issue.identifier ?? issue.id;
const openIssueAction = prefix
? { label: `Open ${issueRef}`, href: `/${prefix}/issues/${issueRef}` }
: undefined;
if (failures.length > 0) {
pushToast({
title: `Created ${issueRef} with upload warnings`,
body: `${failures.length} staged ${failures.length === 1 ? "file" : "files"} could not be added.`,
tone: "warn",
action: openIssueAction,
});
} else {
pushToast({
title: `Created ${issueRef}`,
tone: "success",
action: openIssueAction,
});
}
clearDraft(draftOwner);
reset();
closeNewIssue();
if (navigateOnCreate) navigate(openIssueAction?.href ?? `/issues/${issueRef}`);
},
});
// Issue create is keyed: the server replays the original issue for a known
// idempotency key, so an outage or a lost response resends safely.
const stagedFilesByKey = useRef(new Map<string, StagedIssueFile[]>());
// The composer shows a rejection of the first attempt; later (automatic or
// Resend now) rejections show here.
const firstAttemptKey = useRef<string | null>(null);
const [resendError, setResendError] = useState<ErrorDescription | null>(null);
// Once the account is known, a create still waiting for its files keeps its
// key and asks for the files again.
useEffect(() => {
if (!awaitingFilesKey) return;
const awaiting = readPendingSend(awaitingFilesKey, isPendingCreate);
if (!awaiting) return;
createRequestRef.current ??= {
fingerprint: awaiting.payload.fingerprint,
idempotencyKey: awaiting.idempotencyKey,
recovering: true,
payload: awaiting.payload,
};
restoreLaunch(awaiting.payload);
const names = awaiting.payload.stagedFileNames;
if (names?.length) setResendError((current) => current ?? stagedFilesMissingDescription(names));
}, [awaitingFilesKey]);
const durableCreate = useDurableSubmit<PendingCreate, unknown>({
sourceId: "new-issue-dialog",
storageKey: PENDING_CREATE_KEY,
isPayload: isPendingCreate,
action: "create the task",
send: async (payload, idempotencyKey) => {
const stagedFiles = stagedFilesByKey.current.get(idempotencyKey);
// Never create a restored task without the files it was submitted with.
if (!stagedFiles && payload.stagedFileNames?.length) {
throw new StagedFilesMissingError(payload.stagedFileNames);
}
const result = await createIssue.mutateAsync({
...(payload.data as { companyId: string }),
stagedFiles: stagedFiles ?? [],
idempotencyKey,
});
stagedFilesByKey.current.delete(idempotencyKey);
clearCreateUploadReceipts(idempotencyKey);
return result;
},
onRejected: (error, description, pending) => {
if (error instanceof StagedFilesMissingError) {
// Reattaching the same files resubmits under the original key, so a
// create that already reached the server is replayed, not duplicated.
createRequestRef.current = {
fingerprint: pending.payload.fingerprint,
idempotencyKey: pending.idempotencyKey,
recovering: true,
payload: pending.payload,
};
restoreLaunch(pending.payload);
if (awaitingFilesKey) writePendingSend(awaitingFilesKey, pending);
}
if (pending.idempotencyKey !== firstAttemptKey.current) setResendError(description);
},
});
// A create restored after reload without its files cannot be sent; say so
// now rather than after the first resend delay. Nothing reaches the network.
const pendingCreate = durableCreate.pending;
const { resendNow: resendCreateNow } = durableCreate;
useEffect(() => {
if (pendingCreate?.payload.stagedFileNames?.length && !stagedFilesByKey.current.has(pendingCreate.idempotencyKey)) {
resendCreateNow();
}
}, [pendingCreate, resendCreateNow]);
// Debounced draft saving
const scheduleSave = useCallback((draft: IssueDraft) => {
if (draftOwner === undefined && (unboundFormDraft.current || draft.title.trim() || draft.description.trim())) unboundFormDraft.current = draft;
if (draftTimer.current) clearTimeout(draftTimer.current);
draftTimer.current = setTimeout(() => {
const owner = readOwner();
if (owner !== undefined && (draftOwner === undefined || owner === draftOwner)) {
if (draft.title.trim() || draft.description.trim()) saveDraft(owner, draft);
else clearDraft(owner);
}
}, DEBOUNCE_MS);
}, [draftOwner, readOwner]);
const setIssueText = useCallback((nextTitle: string, nextDescription: string) => {
setHasTitle(Boolean(nextTitle));
titleRef.current = nextTitle;
descriptionRef.current = nextDescription;
setTitle(nextTitle);
setDescription(nextDescription);
}, []);
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,
watchdogAgentId,
watchdogInstructions,
projectId,
projectWorkspaceId,
assigneeModelLane,
assigneeModelOverride,
assigneeThinkingEffort,
assigneeChrome,
executionWorkspaceMode,
selectedExecutionWorkspaceId,
workMode,
isPrivate,
composerSettings,
});
},
[
newIssueOpen,
scheduleSave,
status,
priority,
assigneeValue,
reviewerValue,
approverValue,
watchdogAgentId,
watchdogInstructions,
projectId,
projectWorkspaceId,
assigneeModelOverride,
assigneeThinkingEffort,
assigneeChrome,
executionWorkspaceMode,
selectedExecutionWorkspaceId,
workMode,
isPrivate,
composerSettings,
],
);
const handleDescriptionChange = useCallback(
(nextDescription: string) => {
descriptionRef.current = nextDescription;
setDescription(nextDescription);
queueDraftSave({ description: nextDescription });
},
[queueDraftSave],
);
// Save draft on meaningful changes
useEffect(() => {
if (!newIssueOpen) return;
queueDraftSave();
}, [
status,
priority,
assigneeValue,
reviewerValue,
approverValue,
watchdogAgentId,
watchdogInstructions,
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;
defaultAssigneePendingRef.current = false;
defaultProjectPendingRef.current = false;
return;
}
const initializationKey = `${draftOwner ?? "unknown"}:${effectiveCompanyId ?? ""}:${JSON.stringify(newIssueDefaults)}`;
if (initializationKeyRef.current === initializationKey) return;
initializationKeyRef.current = initializationKey;
if (draftTimer.current) {
clearTimeout(draftTimer.current);
draftTimer.current = null;
}
executionWorkspaceDefaultProjectId.current = null;
if (draftOwner !== undefined && unboundFormDraft.current) {
if (unboundFormDraft.current.title.trim() || unboundFormDraft.current.description.trim()) saveDraft(draftOwner, unboundFormDraft.current);
else clearDraft(draftOwner);
unboundFormDraft.current = null;
return;
}
const draft = loadDraft(draftOwner);
defaultProjectPendingRef.current = newIssueDefaults.projectId === undefined && !newIssueDefaults.parentId;
defaultAssigneePendingRef.current = !newIssueDefaults.assigneeAgentId && !newIssueDefaults.assigneeUserId;
setComposerSettings(null);
setIsPrivate(false);
createIssue.reset();
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 || newIssueDefaults.description) {
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("");
setWatchdogAgentId("");
setWatchdogInstructions("");
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() || draft.description.trim())) {
defaultAssigneePendingRef.current = false;
defaultProjectPendingRef.current = false;
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;
setIsPrivate(draft.isPrivate ?? false);
setIssueText(draft.title, draft.description);
setComposerSettings(draft.composerSettings ?? null);
setStatus(draft.status || "todo");
setPriority(draft.priority);
setAssigneeValue(
newIssueDefaults.assigneeAgentId || newIssueDefaults.assigneeUserId
? assigneeValueFromSelection(newIssueDefaults)
: (draft.assigneeValue ?? draft.assigneeId ?? ""),
);
setReviewerValue(draft.reviewerValue ?? "");
setApproverValue(draft.approverValue ?? "");
setWatchdogAgentId(draft.watchdogAgentId ?? "");
setWatchdogInstructions(draft.watchdogInstructions ?? "");
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(isIssueWorkMode(newIssueDefaults.workMode) ? newIssueDefaults.workMode : "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("");
setWatchdogAgentId("");
setWatchdogInstructions("");
setAssigneeModelOverride("");
setAssigneeThinkingEffort("");
setAssigneeChrome(false);
setExecutionWorkspaceMode(defaultExecutionWorkspaceModeForIssueDefaults(newIssueDefaults, defaultProject));
setSelectedExecutionWorkspaceId(newIssueDefaults.executionWorkspaceId ?? "");
executionWorkspaceDefaultProjectId.current =
hasExplicitProjectWorkspaceId || newIssueDefaults.executionWorkspaceId || defaultProject
? defaultProjectId || null
: null;
}
}, [newIssueOpen, newIssueDefaults, orderedProjects, effectiveCompanyId, setIssueText, draftOwner]);
// Cleanup timer on unmount
useEffect(() => {
return () => {
if (draftTimer.current) clearTimeout(draftTimer.current);
};
}, []);
function reset() {
unboundFormDraft.current = null;
createRequestRef.current = null; setRestoredLaunch(null);
if (awaitingFilesKey) clearPendingSend(awaitingFilesKey);
setIssueText("", "");
setStatus("todo");
setPriority("");
setAssigneeValue("");
setReviewerValue("");
setApproverValue("");
setWatchdogAgentId("");
setWatchdogInstructions("");
setProjectId("");
setProjectWorkspaceId("");
setAssigneeModelLane("primary");
setAssigneeModelOverride("");
setAssigneeThinkingEffort("");
setAssigneeChrome(false);
setExecutionWorkspaceMode("shared_workspace");
setSelectedExecutionWorkspaceId("");
setWorkMode("standard");
setComposerSettings(null);
setIsPrivate(false);
setStagedFiles([]);
setIsFileDragOver(false);
executionWorkspaceDefaultProjectId.current = null;
initializationKeyRef.current = null;
}
async function handleSubmit(body: string, mode: IssueWorkMode, settings: ComposerRunSettings | null) {
const currentTitle = titleRef.current.trim();
const currentDescription = body.trim();
if (!effectiveCompanyId || (!currentTitle && !currentDescription) || createIssue.isPending || durableCreate.pending || worktreeSelectionIncomplete || parentPrivacyUnresolved) return;
const inheritedOverrides = buildAssigneeAdapterOverrides({
adapterType: assigneeAdapterType,
lane: assigneeChrome ? "custom" : assigneeModelLane,
modelOverride: assigneeModelOverride,
thinkingEffortOverride: assigneeThinkingEffort,
chrome: assigneeChrome,
});
const selectedProject = orderedProjects.find((project) => project.id === projectId);
// Hidden selectors must not submit a restored draft over the managed default.
const executionWorkspacePolicy =
workspaceIsolationControlsVisible && experimentalSettings?.enableIsolatedWorkspaces === true
? (selectedProject?.executionWorkspacePolicy ?? null)
: null;
const selectedReusableExecutionWorkspace = selectableReusableWorkspaces.find(
(workspace) => workspace.id === selectedExecutionWorkspaceId,
);
const requestedExecutionWorkspaceMode =
executionWorkspaceMode === "reuse_existing"
? issueExecutionWorkspaceModeForExistingWorkspace(selectedReusableExecutionWorkspace?.mode)
: executionWorkspaceMode;
const executionWorkspaceSettings = executionWorkspacePolicy?.enabled
? { mode: requestedExecutionWorkspaceMode }
: null;
const requestedProjectWorkspaceId = !workspaceIsolationControlsVisible
? (newIssueDefaults.projectId === projectId ? newIssueDefaults.projectWorkspaceId : undefined)
: canChooseWorktrees && executionWorkspaceMode === "reuse_existing" && selectedReusableExecutionWorkspace
? selectedReusableExecutionWorkspace.projectWorkspaceId
: projectWorkspaceId;
// A task launched from a workspace (or its parent task) keeps that explicit
// context. Draft-only choices are ignored while the selector is hidden.
const contextualWorkspaceId =
!workspaceIsolationControlsVisible && newIssueDefaults.projectId === projectId
? newIssueDefaults.executionWorkspaceId
: undefined;
const executionPolicy = buildExecutionPolicy({
reviewerValues: reviewerValue ? [reviewerValue] : [],
approverValues: approverValue ? [approverValue] : [],
});
const assigneeAdapterOverrides = settings
? mergeComposerRunSettings(inheritedOverrides, assigneeAdapterType ?? undefined, settings)
: inheritedOverrides;
const createData = {
companyId: effectiveCompanyId,
...(currentTitle ? { title: currentTitle } : {}),
description: currentDescription || undefined,
status,
priority: priority || "medium",
workMode: mode,
...(effectivePrivate ? { visibility: "private" } : {}),
...(selectedAssigneeAgentId ? { assigneeAgentId: selectedAssigneeAgentId } : {}),
...(selectedAssigneeUserId ? { assigneeUserId: selectedAssigneeUserId } : {}),
...(parentId ? { parentId } : {}),
...(goalId ? { goalId } : {}),
...(projectId ? { projectId } : {}),
...(requestedProjectWorkspaceId ? { projectWorkspaceId: requestedProjectWorkspaceId } : {}),
...(assigneeAdapterOverrides ? { assigneeAdapterOverrides } : {}),
...(executionWorkspacePolicy?.enabled ? { executionWorkspacePreference: executionWorkspaceMode } : {}),
...(canChooseWorktrees &&
executionWorkspaceMode === "reuse_existing" &&
selectedExecutionWorkspaceId
? { executionWorkspaceId: selectedExecutionWorkspaceId }
: {}),
...(executionWorkspaceSettings ? { executionWorkspaceSettings } : {}),
...(contextualWorkspaceId
? { executionWorkspaceId: contextualWorkspaceId, executionWorkspacePreference: "reuse_existing" }
: {}),
...(executionPolicy ? { executionPolicy } : {}),
...(watchdogAgentId
? { watchdog: { agentId: watchdogAgentId, instructions: watchdogInstructions.trim() || null } }
: {}),
};
// An explicit board create is a new task even when its title already exists.
// Reuse the request key only for retries of the same submitted draft, and
// always for a create waiting for its files: it may already have reached
// the server, and its key then replays that task instead of a second one.
// The file set is compared without regard to selection order.
let request = createRequestRef.current;
const createFiles = request?.recovering ? restoreStagedDocumentKeys(stagedFiles, request.payload?.fingerprint) : stagedFiles;
const fingerprint = JSON.stringify({ ...createData, stagedFiles: createFiles.map(stagedFileIdentity).sort() });
if (!request || (!request.recovering && request.fingerprint !== fingerprint)) {
request = { fingerprint, idempotencyKey: createUuid() };
createRequestRef.current = request;
}
let createPayload: PendingCreate = {
fingerprint, data: { ...createData, allowDuplicate: true, navigateOnCreate: newIssueDefaults.navigateOnCreate === true },
...(stagedFiles.length ? { stagedFileNames: stagedFiles.map(file => file.file.name) } : {}),
};
if (request.recovering) {
const original = request.payload;
const rejectRecovery = (message: string): never => {
setResendError({ title: "Saved request needs attention", body: message, retryable: false });
throw new Error(message);
};
if (!original) rejectRecovery("The saved request is unavailable. Cancel it before creating another task.");
let originalFingerprint: Record<string, unknown> = {};
try { originalFingerprint = JSON.parse(original!.fingerprint); } catch {}
const requiredFiles = originalFingerprint.stagedFiles;
if (original!.stagedFileNames?.length && (!Array.isArray(requiredFiles) ||
JSON.stringify([...requiredFiles].sort()) !== JSON.stringify(createFiles.map(stagedFileIdentity).sort()))) {
rejectRecovery(`Attach the original files again: ${original!.stagedFileNames.join(", ")}.`);
}
const normalize = (_key: string, value: unknown) => value && typeof value === "object" && !Array.isArray(value)
? Object.fromEntries(Object.entries(value).sort(([a], [b]) => a.localeCompare(b))) : value;
const fieldsChanged = Object.entries(original!.data).some(([key, value]) =>
!["allowDuplicate", "navigateOnCreate"].includes(key) && JSON.stringify(value, normalize) !== JSON.stringify((createData as Record<string, unknown>)[key], normalize));
if (fieldsChanged || (typeof originalFingerprint.companyId === "string" &&
JSON.stringify(originalFingerprint, normalize) !== JSON.stringify(JSON.parse(fingerprint), normalize))) {
rejectRecovery("Restore the saved task's original fields before retrying, or cancel the saved request to start another task.");
}
createPayload = original!;
}
request.payload = createPayload;
const { idempotencyKey } = request;
stagedFilesByKey.current.set(idempotencyKey, createFiles);
firstAttemptKey.current = idempotencyKey;
setResendError(null);
let result;
try {
result = await durableCreate.submit(createPayload, { idempotencyKey });
} finally {
firstAttemptKey.current = null;
}
// A transient failure keeps the request and shows the pending notice; a
// rejection goes back to the composer with readable copy.
if (result.kind === "rejected") throw result.error;
}
function handleKeyDown(e: React.KeyboardEvent) {
if (isWorkModePeriodShortcut(e)) {
e.preventDefault();
setWorkMode((current) => nextWorkMode(current));
return;
}
}
function stageFiles(files: File[]) {
if (files.length === 0 || createIssue.isPending || durableCreate.pending) 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 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 currentAssignee = selectedAssigneeAgentId ? (agents ?? []).find((a) => a.id === selectedAssigneeAgentId) : null;
const currentAssigneeLowTrust = getTrustPreset(currentAssignee?.permissions) === "low_trust_review";
const neededUserSecretKeys = useMemo(() => {
if (!shouldWarnAboutRunUserSecrets(status, selectedAssigneeAgentId)) return [];
return uniqueRequiredUserSecretKeys([
isRecord(currentAssignee?.adapterConfig) ? (currentAssignee.adapterConfig.env as Record<string, unknown>) : null,
currentProject?.env ?? null,
]);
}, [currentAssignee?.adapterConfig, currentProject?.env, selectedAssigneeAgentId, status]);
const selectableReusableWorkspaces = reusableExecutionWorkspaces ?? [];
const selectedReusableWorktree = selectableReusableWorkspaces.find((workspace) => workspace.id === selectedExecutionWorkspaceId);
const worktreeSelectionIncomplete = canChooseWorktrees && executionWorkspaceMode === "reuse_existing"
&& (worktreesLoading || worktreesError || !selectedReusableWorktree);
const isUsingParentExecutionWorkspace =
isSubIssueMode && parentExecutionWorkspaceId
? executionWorkspaceMode === "reuse_existing" && selectedExecutionWorkspaceId === parentExecutionWorkspaceId
: false;
const showParentWorkspaceWarning =
isSubIssueMode &&
currentProjectSupportsExecutionWorkspace &&
Boolean(parentExecutionWorkspaceId) &&
!isUsingParentExecutionWorkspace;
const recentAssigneeIds = useMemo(() => getRecentAssigneeIds(), [newIssueOpen]);
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],
);
// Resolve once after the directory loads, without resetting text typed while
// the queries were in flight or replacing an explicit/restored assignee.
useEffect(() => {
if (!newIssueOpen || !effectiveCompanyId || !defaultAssigneePendingRef.current
|| !agents || !sessionFetched || !membersFetched || primaryAgent?.loading) return;
defaultAssigneePendingRef.current = false;
const available = new Set(assigneeOptions.map((option) => option.id));
const scopedRecents = getRecentAssigneeSelectionIds(effectiveCompanyId);
// Older history has no company key. Agent IDs identify their company;
// human IDs can belong to several companies and cannot be migrated safely.
const recents = scopedRecents.length ? scopedRecents
: getRecentAssigneeSelectionIds().filter((value) => value.startsWith("agent:"));
const recent = recents.find((value) => available.has(value));
const targets = agents.filter(isAgentTaskTarget);
const fallback = targets.find((agent) => agent.id === primaryAgent?.primaryAgentId)
?? targets.find((agent) => agent.role === "ceo") ?? targets[0];
setAssigneeValue(recent ?? (fallback ? assigneeValueFromSelection({ assigneeAgentId: fallback.id }) : ""));
}, [newIssueOpen, effectiveCompanyId, agents, assigneeOptions, sessionFetched, membersFetched, primaryAgent?.loading, primaryAgent?.primaryAgentId]);
useEffect(() => {
if (!newIssueOpen || !effectiveCompanyId || !defaultProjectPendingRef.current || !projects) return;
defaultProjectPendingRef.current = false;
const available = new Set(orderedProjects.filter((project) => !project.archivedAt).map((project) => project.id));
const last = getLastProjectId(effectiveCompanyId);
const remembered = last !== undefined
? (available.has(last) ? last : "")
: (getRecentProjectIds().find((id) => available.has(id)) ?? "");
setProjectId(remembered);
}, [newIssueOpen, effectiveCompanyId, projects, orderedProjects]);
const projectOptions = useMemo<InlineEntityOption[]>(
() =>
orderedProjects.map((project) => ({
id: project.id,
label: project.name,
searchText: project.description ?? "",
})),
[orderedProjects],
);
const stagedDocuments = stagedFiles.filter((file) => file.kind === "document");
const stagedAttachments = stagedFiles.filter((file) => file.kind === "attachment");
const handleProjectChange = useCallback(
(nextProjectId: string) => {
defaultProjectPendingRef.current = false;
trackRecentProject(nextProjectId, effectiveCompanyId ?? undefined);
setProjectId(nextProjectId);
const nextProject = orderedProjects.find((project) => project.id === nextProjectId);
executionWorkspaceDefaultProjectId.current = nextProjectId || null;
setProjectWorkspaceId(defaultProjectWorkspaceIdForProject(nextProject));
setExecutionWorkspaceMode(defaultExecutionWorkspaceModeForProject(nextProject));
setSelectedExecutionWorkspaceId("");
},
[orderedProjects, effectiveCompanyId],
);
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 dialogViewportStyle = useMemo<NewIssueDialogViewportStyle>(() => {
const dialogGeometry = {
// Fixed-position coordinates are relative to Safari's visual viewport.
// Adding visualViewport.offsetTop here places the dialog below that
// viewport after the software keyboard pans the page.
"--new-issue-dialog-top": "var(--new-issue-dialog-top-gap)",
"--new-issue-dialog-height":
"calc(var(--new-issue-visual-viewport-height) - var(--new-issue-dialog-top-gap) - var(--new-issue-dialog-bottom-gap))",
};
if (!visualViewportLayout) return dialogGeometry;
return {
...dialogGeometry,
"--new-issue-visual-viewport-height": `${visualViewportLayout.height}px`,
...(visualViewportLayout.constrained
? {
top: "var(--new-issue-dialog-top)",
maxHeight: "var(--new-issue-dialog-height)",
translate: "var(--pct-neg-50)",
}
: {}),
};
}, [visualViewportLayout]);
const entityPickerViewportStyle = useMemo<MobileEntityPickerViewportStyle>(() => {
if (!visualViewportLayout) return {};
return {
"--mobile-entity-picker-visual-viewport-height": `${visualViewportLayout.height}px`,
};
}, [visualViewportLayout]);
useEffect(() => {
if (!visualViewportLayout?.constrained) return;
const focusedElement = document.activeElement;
if (
!(focusedElement instanceof HTMLElement) ||
!dialogBodyRef.current?.contains(focusedElement) ||
typeof focusedElement.scrollIntoView !== "function"
) {
return;
}
const frame = window.requestAnimationFrame(() => {
focusedElement.scrollIntoView({ block: "nearest" });
});
return () => window.cancelAnimationFrame(frame);
}, [visualViewportLayout]);
const modelAgents = useMemo(() => new Map((agents ?? []).map((agent) => [agent.id, agent])), [agents]);
const inheritedOverrides = buildAssigneeAdapterOverrides({
adapterType: assigneeAdapterType,
lane: assigneeChrome ? "custom" : assigneeModelLane,
modelOverride: assigneeModelOverride,
thinkingEffortOverride: assigneeThinkingEffort,
chrome: assigneeChrome,
});
return (
<Dialog
open={newIssueOpen}
onOpenChange={(open) => {
if (!open && !createIssue.isPending) closeNewIssue();
}}
>
<DialogContent
showCloseButton={false}
aria-describedby={undefined}
style={dialogViewportStyle}
className="flex max-h-(--new-issue-dialog-height) flex-col gap-0 overflow-hidden rounded-(--radius-task-composer) border-0 bg-transparent p-0 shadow-none sm:max-w-2xl"
onKeyDown={handleKeyDown}
onOpenAutoFocus={(event) => {
event.preventDefault();
dialogBodyRef.current?.querySelector<HTMLElement>('[contenteditable="true"]')?.focus();
}}
onEscapeKeyDown={(event) => {
if (event.defaultPrevented) return;
if (isWorkModeEscapeShortcut(event)) {
event.preventDefault();
setWorkMode((current) => nextWorkMode(current));
} else if (createIssue.isPending) event.preventDefault();
}}
onPointerDownOutside={(event) => {
const target = event.detail.originalEvent.target as HTMLElement | null;
if (
createIssue.isPending ||
target?.closest("[data-radix-popper-content-wrapper], [data-paperclip-floating-ui]")
) {
event.preventDefault();
}
}}
>
<DialogTitle className="sr-only">{isSubIssueMode ? "New sub-task" : "New task"}</DialogTitle>
<div
ref={dialogBodyRef}
className={cn("min-h-0 overflow-y-auto overscroll-contain", isFileDragOver && "bg-accent/20")}
onDragEnter={handleFileDragEnter}
onDragOver={handleFileDragOver}
onDragLeave={handleFileDragLeave}
onDrop={handleFileDrop}
>
<TaskChatPresentationProvider mode={streamlinedUiEnabled || coordinatorEnabled ? "streamlined" : "production"}>
<TaskChatComposer
workMode={workMode}
onWorkModeChange={setWorkMode}
disabled={createIssue.isPending || !effectiveCompanyId}
placeholder="Describe a task…"
mobile={isMobile}
mentions={mentionOptions}
onImageUpload={async (file) => {
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,
privacy: parentPrivacyUnresolved ? undefined : {
private: effectivePrivate,
inherited: inheritedPrivacyReason,
onChange: (checked) => {
setIsPrivate(checked);
if (!checked && currentProject?.visibility === "private") handleProjectChange("");
if (checked && !projectId && currentUserId) {
const personalProject = orderedProjects.find(project => project.personalOwnerUserId === currentUserId);
if (personalProject) handleProjectChange(personalProject.id);
}
},
},
submitLabel: isSubIssueMode ? "Create sub-task" : "Create task",
canSubmitWithoutBody: Boolean(title.trim()),
onSelectFiles: stageFiles,
runSettings: composerSettings,
onRunSettingsChange: setComposerSettings,
header:
hasTitle || isSubIssueMode || durableCreate.pending || resendError ? (
<div className="mb-3 flex flex-col gap-2 text-xs">
{durableCreate.pending && durableCreate.status !== "sending" ? (
<PendingSendNotice
noun="task"
persisted={durableCreate.persisted}
resending={durableCreate.resending}
stalled={durableCreate.status === "stalled"}
onResendNow={durableCreate.resendNow}
onCancel={() => {
// The draft (title and description) is still saved; only the send stops.
const cancelledKey = durableCreate.pending?.idempotencyKey;
const cancelled = durableCreate.cancel();
if (cancelled) {
createRequestRef.current = null; setRestoredLaunch(null);
if (awaitingFilesKey) clearPendingSend(awaitingFilesKey, cancelledKey);
if (cancelledKey) clearCreateUploadReceipts(cancelledKey);
}
}}
/>
) : null}
{resendError && !durableCreate.pending ? (
<div>
<p role="alert" className="text-sm text-destructive">{resendError.title}. {resendError.body}</p>
{createRequestRef.current?.recovering ? <Button type="button" size="sm" variant="ghost" onClick={() => {
const key = createRequestRef.current?.idempotencyKey;
if (awaitingFilesKey && key) clearPendingSend(awaitingFilesKey, key);
if (key) { stagedFilesByKey.current.delete(key); clearCreateUploadReceipts(key); }
createRequestRef.current = null; setRestoredLaunch(null); setResendError(null);
}}>Cancel saved request</Button> : null}
</div>
) : null}
{hasTitle ? (
<input
aria-label="Task title"
value={title}
// The waiting request is what gets created; later edits would be lost.
disabled={createIssue.isPending || Boolean(durableCreate.pending)}
className="w-full bg-transparent text-sm text-foreground outline-none"
onChange={(event) => {
titleRef.current = event.target.value;
setTitle(event.target.value);
queueDraftSave({ title: event.target.value });
}}
/>
) : null}
{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}
</div>
) : undefined,
details: (
<>
{parentPrivacyUnresolved ? (
<div role={parentPrivacyError ? "alert" : "status"} className="mb-2 flex items-center gap-2 text-xs text-muted-foreground">
<span>{parentPrivacyError ? "Couldn't check parent access." : "Checking parent access…"}</span>
{parentPrivacyError ? <Button variant="ghost" size="sm" onClick={() => void refetchParentPrivacy()}>Retry</Button> : null}
</div>
) : null}
{worktreeSelectionIncomplete && !worktreesLoading ? (
<p role="alert" className="mb-2 text-xs text-destructive">
{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."}
</p>
) : null}
{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">
<Badge
variant="outline"
className="border-border font-mono text-(length:--text-nano) uppercase tracking-(--tracking-eyebrow) text-muted-foreground"
>
{file.documentKey}
</Badge>
<span className="truncate text-sm">{file.file.name}</span>
</div>
<div className="mt-1 flex items-center gap-2 text-(length:--text-micro) 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-(length:--text-micro) 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}
{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}
{selectedAssigneeAgent?.status === "paused" ? (
<div data-testid="new-issue-paused-assignee-note" className="mx-4 mb-2">
<InlineBanner tone="warning" icon={PauseCircle} compact>
<span className="font-medium">{selectedAssigneeAgent.name}</span> 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.
</InlineBanner>
</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; task, project, or
run policy defines the concrete scope.
</span>
</div>
) : null}
{showParentWorkspaceWarning ? (
<div className="mb-2">
<InlineBanner tone="warning" compact>
This sub-task will no longer use the parent task workspace
{parentExecutionWorkspaceLabel ? ` (${parentExecutionWorkspaceLabel})` : ""}.
</InlineBanner>
</div>
) : null}
{effectiveCompanyId && neededUserSecretKeys.length > 0 ? (
<MissingUserSecretsBanner companyId={effectiveCompanyId} definitionKeys={neededUserSecretKeys} />
) : null}
</>
),
submitDisabled: worktreeSelectionIncomplete || parentPrivacyUnresolved || Boolean(durableCreate.pending),
readOnly: Boolean(durableCreate.pending),
contextBar: (
<>
<InlineEntitySelector
value={projectId}
options={projectOptions}
recentOptionIds={recentProjectIds}
placeholder="Project"
mobileTitle="Select project"
modal
className="h-8 min-w-0 flex-1 gap-1.5 border-0 bg-transparent px-2 text-xs shadow-none hover:bg-accent focus-visible:bg-accent focus-visible:ring-0 sm:max-w-64 sm:flex-none"
disabled={createIssue.isPending}
triggerDataSlot="new-issue-compact-control"
contentStyle={entityPickerViewportStyle}
noneLabel="No project"
noneAtTop
searchPlaceholder="Search projects..."
emptyMessage="No projects found."
onChange={handleProjectChange}
renderTriggerValue={(option) =>
option && currentProject ? (
<>
<Folder className="size-3.5 shrink-0" style={{ color: currentProject.color ?? "var(--project-seed)" }} aria-hidden />
<span className="truncate">{option.label}</span>
{currentProject.visibility === "private" ? <Lock className="size-3 shrink-0 text-muted-foreground" aria-label="Private project" /> : null}
<ChevronDown className="size-3 shrink-0 text-muted-foreground" aria-hidden />
</>
) : (
<>
<Folder className="size-3.5 shrink-0 text-muted-foreground" aria-hidden />
<span className="truncate text-muted-foreground">Project</span>
<ChevronDown className="size-3 shrink-0 text-muted-foreground" aria-hidden />
</>
)
}
renderOption={(option) => {
if (!option.id) return <><Folder className="size-4 shrink-0 text-muted-foreground" aria-hidden /><span className="truncate">{option.label}</span></>;
const project = orderedProjects.find((item) => item.id === option.id);
return (
<>
<Folder className="size-4 shrink-0" style={{ color: project?.color ?? "var(--project-seed)" }} aria-hidden />
<span className="min-w-0 flex-1 truncate">{option.label}</span>
{project?.visibility === "private" ? <Lock className="size-3.5 shrink-0 text-muted-foreground" aria-label="Private project" /> : null}
</>
);
}}
/>
{canChooseWorktrees ? (
<ComposerWorktreePicker
mode={executionWorkspaceMode}
workspaceId={selectedExecutionWorkspaceId}
selectedWorkspaceLabel={selectedReusableWorktree?.name ?? (selectedExecutionWorkspaceId === parentExecutionWorkspaceId ? newIssueDefaults.parentExecutionWorkspaceLabel : undefined)}
workspaces={selectableReusableWorkspaces}
onChange={(mode, workspaceId) => {
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}
</>
),
}}
/>
</TaskChatPresentationProvider>
</div>
</DialogContent>
</Dialog>
);
}