import { isLockedIssueStub } from "@/components/LockedIssueChip"; import { IssuePullRequestLinks } from "../IssuePullRequestLinks"; import { useIssueWorkProducts } from "../../hooks/useIssueWorkProducts"; import { getIssuePullRequests, pullRequestHref, pullRequestIdentity } from "../../lib/issue-pull-requests"; import { useWorkspaceIsolationControls } from "@/hooks/useWorkspaceIsolationControls"; import { AgentIdentity } from "@/components/AgentIdentity"; import { AgentAvatar } from "@/components/AgentAvatar"; import { normalizeLegacyRunnerProvider } from "@paperclipai/adapter-utils"; import { useCallback, useEffect, useMemo, useRef, useState, type ComponentType } from "react"; import { createPortal } from "react-dom"; import { PROPERTIES_PANE_HEADER_SLOT_ID } from "../PropertiesPanel"; import { issueStatusText } from "@/lib/status-colors"; import { copyTextToClipboard } from "@/lib/clipboard"; import { Link } from "@/lib/router"; import { deriveOriginatingActor, isArtifactReviewDocumentKey, type ExecutionWorkspace, type Issue, type IssueLabel, } from "@paperclipai/shared"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { accessApi } from "../../api/access"; import { agentsApi } from "../../api/agents"; import { authApi } from "../../api/auth"; import { executionWorkspacesApi } from "../../api/execution-workspaces"; import { instanceSettingsApi } from "../../api/instanceSettings"; import { issuesApi } from "../../api/issues"; import { useIssuePlanDocument } from "@/hooks/useIssuePlanDocument"; import { useIssueDocuments } from "@/hooks/useIssueDocuments"; import { useStreamlinedUiEnabled } from "@/hooks/useStreamlinedUiEnabled"; import { selectArtifactAttachments } from "@/lib/issue-artifacts"; import { projectsApi } from "../../api/projects"; import { useCompany } from "../../context/CompanyContext"; import { useSidebar } from "../../context/SidebarContext"; import { queryKeys } from "../../lib/queryKeys"; import { COPILOT_MODEL_DISCOVERY_QUERY_OPTIONS, copilotTaskModelCatalogOptions } from "../../lib/copilot-task-model-catalog"; import { buildCompanyUserInlineOptions, buildCompanyUserLabelMap, buildCompanyUserProfileMap, isAgentTaskTarget } from "../../lib/company-members"; import { ISSUE_OVERRIDE_ADAPTER_TYPES, type IssueModelLane } from "../../lib/issue-assignee-overrides"; import { useProjectOrder } from "../../hooks/useProjectOrder"; import { getRecentAssigneeIds, sortAgentsByRecency, trackRecentAssignee, trackRecentAssigneeUser, } from "../../lib/recent-assignees"; import { getRecentProjectIds } from "../../lib/recent-projects"; import { orderItemsBySelectedAndRecent } from "../../lib/recent-selections"; import { formatAssigneeUserLabel, formatUserLabel } from "../../lib/assignees"; import { buildExecutionPolicy, stageParticipantValues } from "../../lib/issue-execution-policy"; import { useExecutionPolicy } from "../../hooks/useExecutionPolicy"; import { formatMonitorAbsolute, formatMonitorAbsoluteFull, formatMonitorEta, formatMonitorEtaLabel, formatMonitorOffset, useMonitorCountdown, } from "../../lib/issue-monitor"; import { extractProviderIdWithFallback } from "../../lib/model-utils"; import { formatRetryReason } from "../../lib/runRetryState"; import { useRetryNowMutation } from "../../hooks/useRetryNowMutation"; import { RetryErrorBand } from "../IssueScheduledRetryCard"; import { StatusIcon } from "../StatusIcon"; import { PriorityIcon } from "../PriorityIcon"; import { SHOW_TASK_PRIORITY_UI } from "../../lib/ui-flags"; import { Identity } from "../Identity"; import { ProjectTile } from "../ProjectTile"; import { IssueReferencePill } from "../IssueReferencePill"; import { formatDate, formatDateTime, cn, projectUrl } from "../../lib/utils"; import type { IssueExternalObjectGroup } from "../../hooks/useIssueExternalObjects"; import { timeAgo } from "../../lib/timeAgo"; import { invalidateInboxIssueQueries } from "../../lib/inboxArchiveCache"; import { Button } from "@/components/ui/button"; import { ToggleSwitch } from "@/components/ui/toggle-switch"; import { Separator } from "@/components/ui/separator"; import { Textarea } from "@/components/ui/textarea"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { IssuePropertiesPlansTab } from "./IssuePropertiesPlansTab"; import { IssuePropertiesArtifactsTab } from "./IssuePropertiesArtifactsTab"; import { User, ArrowUpRight, Plus, X, GitBranch, FolderOpen, HardDrive, Check, Clock, RotateCcw, Loader2, CheckCircle2, ArchiveRestore, ChevronLeft } from "lucide-react"; import { InlineEntitySelector, type InlineEntityOption } from "../InlineEntitySelector"; import { AssigneeRunningBanner, InterruptAssignConfirm, type HandoffChipResolvers, } from "../interrupt-handoff/InterruptHandoffViews"; import { describeReassignInterrupt } from "../../lib/interrupt-handoff"; import { buildWorkspaceRuntimeControlSections, WorkspaceRuntimeQuickControls, type WorkspaceRuntimeControlRequest, } from "../WorkspaceRuntimeControls"; import { ExternalObjectRows } from "./external-object-rows"; import { asRecord, compactRecord, isMainIssueWorkspace, overrideLane, sortAdapterModels, thinkingEffortKeyFor, thinkingEffortOptionsFor, thinkingEffortValueFor, toDateTimeLocalValue, } from "./helpers"; import { PropertyPicker } from "./property-picker"; import { PropertyChip, PropertyRow, PropertySection } from "./primitives"; import { buildWorkspaceSelectionUpdate, currentWorkspaceSelection, } from "../../lib/issue-workspace-selection"; import { buildReusableExecutionWorkspaceOptionGroups, dedupeReusableExecutionWorkspaces, reusableWorkspaceOptionMatches, } from "../../lib/reusable-execution-workspaces"; import { issueReviewPolicyBadge } from "../../lib/review-policy"; import { IssueCasesPanel } from "../IssueCasesPanel"; import { ExpandRelationListButton, RemovableIssueReferencePill } from "./relation-controls"; import { RoutineOriginRow } from "./routine-origin-row"; import { Badge } from "@/components/ui/badge"; import { TaskDetailReferencesPanel, TaskDetailSubtasksPanel, type TaskDetailRelationItem, } from "../task-detail/TaskDetailRelationsPanel"; function splitMiddleTruncation(value: string): { prefix: string; suffix: string } | null { const splitAt = Math.max(value.lastIndexOf("/"), value.lastIndexOf("\\")); if (splitAt <= 0 || splitAt >= value.length - 1) return null; return { prefix: value.slice(0, splitAt + 1), suffix: value.slice(splitAt + 1), }; } function TruncatedCopyable({ value, icon: Icon }: { value: string; icon: ComponentType<{ className?: string }> }) { const [copied, setCopied] = useState(false); const timerRef = useRef>(undefined); useEffect(() => () => clearTimeout(timerRef.current), []); const handleCopy = useCallback(async () => { try { await copyTextToClipboard(value); setCopied(true); clearTimeout(timerRef.current); timerRef.current = setTimeout(() => setCopied(false), 1500); } catch { /* noop */ } }, [value]); const { enabled: streamlinedUiEnabled } = useStreamlinedUiEnabled(); const middle = streamlinedUiEnabled ? splitMiddleTruncation(value) : null; return (
{copied && ( Copied )}
); } interface IssuePropertiesProps { issue: Issue; childIssues?: Issue[]; issueLinkState?: unknown; onAddSubIssue?: () => void; onUpdate: (data: Record) => void; inline?: boolean; /** Whether an agent run is currently in flight on this issue, so the assignee * picker can warn that reassigning will interrupt it. */ hasActiveRun?: boolean; externalObjects?: IssueExternalObjectGroup[]; externalObjectsLoading?: boolean; externalObjectsError?: boolean; onRetryExternalObjects?: () => void; onCheckMonitorNow?: () => void; checkingMonitorNow?: boolean; documentDeepLink?: IssuePropertiesDocumentDeepLink | null; /** Render only the Properties body when a parent owns the side-panel tabs. */ sidePanelContentOnly?: boolean; } export interface IssuePropertiesDocumentDeepLink { requestId: number; tab: "plans" | "artifacts" | "document"; documentKey: string; } const ISSUE_BLOCKER_SEARCH_LIMIT = 50; const ISSUE_PROPERTY_RELATION_PREVIEW_COUNT = 5; const STREAMLINED_PANE_TAB_CLASS = "task-detail-pane-tab h-7 rounded-md text-sm font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"; type IssuePaneTab = "properties" | "subtasks" | "references" | "plans" | "artifacts"; interface IssuePaneTabDescriptor { value: IssuePaneTab; label: string; count?: number; closable: boolean; } export function IssueProperties({ issue, childIssues = [], issueLinkState, onAddSubIssue, onUpdate, inline, hasActiveRun = false, externalObjects, externalObjectsLoading, externalObjectsError, onRetryExternalObjects, onCheckMonitorNow, checkingMonitorNow = false, documentDeepLink, sidePanelContentOnly = false, }: IssuePropertiesProps) { const policyResult = useExecutionPolicy(issue.executionPolicy); const policyAvailable = policyResult.success; const executionPolicy = policyResult.success ? policyResult.data : null; const { selectedCompanyId } = useCompany(); const { isMobile } = useSidebar(); const queryClient = useQueryClient(); const companyId = issue.companyId ?? selectedCompanyId; const { data: experimentalSettings } = useQuery({ queryKey: queryKeys.instance.experimentalSettings, queryFn: () => instanceSettingsApi.getExperimental(), }); // Managed-sandbox-only policy: the workspace folder is a host filesystem // path, so the Folder row disappears. The Branch row above it stays. The gate // fails closed whenever the policy is unknown — in flight and also on a failed // read — because an unresolved policy reads as "not managed" and would show // the folder the policy exists to hide. const hideHostPaths = experimentalSettings === undefined || experimentalSettings.enableManagedSandboxOnly === true; const { enabled: streamlinedUiEnabled } = useStreamlinedUiEnabled(); // Classic Task Interface: gate the Properties | Plans | Artifacts tab shell. // Flag ON renders the legacy stacked sections verbatim (no Tabs wrapper); // flag OFF — including while settings load — renders the chat-style tab // shell. This pane is always task-scoped, so the flag alone is a sufficient // gate. // Classic Task Interface alone controls the production tabbed-vs-stacked // boundary. Streamlined UI layers new relationship tabs and visual treatment // onto master's tabbed task-chat pane without changing that boundary. const taskChatShellEnabled = experimentalSettings?.enableClassicTaskInterface !== true; const streamlinedPropertiesEnabled = streamlinedUiEnabled && taskChatShellEnabled; // When hosted by the resizable PropertiesPanel, the tab strip portals into // the pane's header bar (left of the window controls). The slot only exists // once the panel has committed, hence the effect; inline hosts (mobile sheet) // keep the tab strip in place. const [paneHeaderSlot, setPaneHeaderSlot] = useState(null); useEffect(() => { if (!taskChatShellEnabled || inline) { setPaneHeaderSlot(null); return; } setPaneHeaderSlot(document.getElementById(PROPERTIES_PANE_HEADER_SLOT_ID)); }, [taskChatShellEnabled, inline]); // A Plan tab represents materialized plan content, not merely planning mode. // Same query keys as the tab bodies, so these share their cached fetches. const { data: paneTabPlanDocument } = useIssuePlanDocument( taskChatShellEnabled ? issue.id : null, ); const { data: paneTabAcceptedPlans } = useQuery({ queryKey: queryKeys.issues.acceptedPlanDecompositions(issue.id), queryFn: () => issuesApi.listAcceptedPlanDecompositions(issue.id), enabled: taskChatShellEnabled, }); const { data: paneTabAttachments } = useQuery({ queryKey: queryKeys.issues.attachments(issue.id), queryFn: () => issuesApi.listAttachments(issue.id), enabled: taskChatShellEnabled, }); const { data: paneTabWorkProducts, isError: workProductsError, refetch: refetchWorkProducts } = useIssueWorkProducts(issue.id); const pullRequests = useMemo(() => getIssuePullRequests(paneTabWorkProducts), [paneTabWorkProducts]); const remainingExternalObjects = useMemo(() => { const identities = new Set(pullRequests.map((product) => pullRequestIdentity(pullRequestHref(product))).filter(Boolean)); return externalObjects?.filter((entry) => !identities.has(pullRequestIdentity(entry.pill.url))); }, [externalObjects, pullRequests]); const { data: paneTabDocuments } = useIssueDocuments(taskChatShellEnabled ? issue.id : null); // Proxy `artifact-review-*` documents surface only through their Work // product row, so they must not summon the Plan or Documents surfaces. const paneTabStandaloneDocuments = (paneTabDocuments ?? []).filter( (doc) => !isArtifactReviewDocumentKey(doc.key), ); const hasPlanTab = Boolean(paneTabPlanDocument) || (paneTabAcceptedPlans?.length ?? 0) > 0 || paneTabStandaloneDocuments.length > 0; // Artifacts covers the same three sources the tab body composes: work // products, documents (redundant with the Plan tab, intentionally), and // attachments from agents and people. const hasArtifactsTab = (paneTabWorkProducts?.length ?? 0) > 0 || paneTabStandaloneDocuments.length > 0 || selectArtifactAttachments(paneTabAttachments, paneTabWorkProducts).length > 0; const [paneTab, setPaneTab] = useState("properties"); const [closedPaneTabs, setClosedPaneTabs] = useState>(() => new Set()); // Once a plan document exists, surface it: switch the pane to the Plan tab so // the write-up is exposed alongside the plan-approval card, instead of leaving // the user on Properties. Only auto-switch until the user picks a tab by hand — // after that their choice wins. Ref-guarded so it fires once per mount. const paneTabUserChosenRef = useRef(false); const handlePaneTabChange = useCallback((value: string) => { paneTabUserChosenRef.current = true; setPaneTab(value as IssuePaneTab); }, []); useEffect(() => { setClosedPaneTabs(new Set()); }, [issue.id]); useEffect(() => { if (hasPlanTab && !paneTabUserChosenRef.current) { setPaneTab("plans"); } }, [hasPlanTab]); useEffect(() => { if (!documentDeepLink) return; const targetTab = documentDeepLink.tab; if (targetTab === "document") return; paneTabUserChosenRef.current = true; setPaneTab(targetTab); setClosedPaneTabs((current) => { if (!current.has(targetTab)) return current; const next = new Set(current); next.delete(targetTab); return next; }); }, [documentDeepLink]); const [assigneeOpen, setAssigneeOpen] = useState(false); const [assigneeSearch, setAssigneeSearch] = useState(""); /** When a run is live, a selection is staged here until the operator confirms * the interrupt rather than applying it immediately. */ const [pendingAssignee, setPendingAssignee] = useState<{ assigneeAgentId: string | null; assigneeUserId: string | null; label: string; track?: () => void; } | null>(null); const [projectOpen, setProjectOpen] = useState(false); const [projectSearch, setProjectSearch] = useState(""); const [workspacePickerOpen, setWorkspacePickerOpen] = useState(false); const [workspacePickerStep, setWorkspacePickerStep] = useState<"mode" | "reuse">("mode"); const [workspaceSearch, setWorkspaceSearch] = useState(""); const [blockedByOpen, setBlockedByOpen] = useState(false); const [blockedBySearch, setBlockedBySearch] = useState(""); const [blockedByExpanded, setBlockedByExpanded] = useState(false); const [blockingExpanded, setBlockingExpanded] = useState(false); const [subTasksExpanded, setSubTasksExpanded] = useState(false); const [subtasksOpen, setSubtasksOpen] = useState(false); const [relatedTasksExpanded, setRelatedTasksExpanded] = useState(false); const [parentOpen, setParentOpen] = useState(false); const [parentSearch, setParentSearch] = useState(""); const [reviewersOpen, setReviewersOpen] = useState(false); const [reviewerSearch, setReviewerSearch] = useState(""); const [approversOpen, setApproversOpen] = useState(false); const [approverSearch, setApproverSearch] = useState(""); const [monitorOpen, setMonitorOpen] = useState(false); const [monitorDetailsOpen, setMonitorDetailsOpen] = useState(false); const [scheduledRetryOpen, setScheduledRetryOpen] = useState(false); const [labelsOpen, setLabelsOpen] = useState(false); const [assigneeOptionsOpen, setAssigneeOptionsOpen] = useState(false); const [labelSearch, setLabelSearch] = useState(""); const [newLabelName, setNewLabelName] = useState(""); // token-extraction: allowlisted — color-picker seed state, persisted into label-create payload; a var() string would break that payload. const [newLabelColor, setNewLabelColor] = useState("#6366f1"); const [monitorAtInput, setMonitorAtInput] = useState(() => toDateTimeLocalValue(executionPolicy?.monitor?.nextCheckAt)); const [monitorNotesInput, setMonitorNotesInput] = useState(executionPolicy?.monitor?.notes ?? ""); const [monitorServiceInput, setMonitorServiceInput] = useState(executionPolicy?.monitor?.serviceName ?? ""); const [runtimeActionMessage, setRuntimeActionMessage] = useState(null); const [runtimeActionErrorMessage, setRuntimeActionErrorMessage] = useState(null); const [unarchiveErrorMessage, setUnarchiveErrorMessage] = useState(null); const [watchdogOpen, setWatchdogOpen] = useState(false); const [watchdogAgentInput, setWatchdogAgentInput] = useState(issue.watchdog?.watchdogAgentId ?? ""); const [watchdogInstructionsInput, setWatchdogInstructionsInput] = useState(issue.watchdog?.instructions ?? ""); const normalizedBlockedBySearch = blockedBySearch.trim(); const normalizedParentSearch = parentSearch.trim(); useEffect(() => { setBlockedByExpanded(false); setBlockingExpanded(false); setSubTasksExpanded(false); setRelatedTasksExpanded(false); }, [issue.id]); const { data: session } = useQuery({ queryKey: queryKeys.auth.session, queryFn: () => authApi.getSession(), }); const currentUserId = session?.user?.id ?? session?.session?.userId; const { data: agents } = useQuery({ queryKey: queryKeys.agents.list(companyId!), queryFn: () => agentsApi.list(companyId!), enabled: !!companyId, }); const { data: companyMembers } = useQuery({ queryKey: queryKeys.access.companyUserDirectory(companyId!), queryFn: () => accessApi.listUserDirectory(companyId!), enabled: !!companyId, }); const { data: projects } = useQuery({ queryKey: queryKeys.projects.list(companyId!, { includeArchived: true }), queryFn: () => projectsApi.list(companyId!, { includeArchived: true }), enabled: !!companyId, }); const activeProjects = useMemo( () => (projects ?? []).filter((p) => !p.archivedAt || p.id === issue.projectId), [projects, issue.projectId], ); const { orderedProjects } = useProjectOrder({ projects: activeProjects, companyId, userId: currentUserId, }); const { data: labels } = useQuery({ queryKey: queryKeys.issues.labels(companyId!), queryFn: () => issuesApi.listLabels(companyId!), enabled: !!companyId, }); const { data: allIssues, isFetching: isFetchingIssuePickerIssues } = useQuery({ queryKey: queryKeys.issues.list(companyId!), queryFn: () => issuesApi.list(companyId!), enabled: !!companyId && (parentOpen || (blockedByOpen && normalizedBlockedBySearch.length === 0)), }); const { data: searchedBlockedByIssues, isFetching: isFetchingSearchedBlockedByIssues } = useQuery({ queryKey: companyId ? queryKeys.issues.search(companyId, normalizedBlockedBySearch, undefined, ISSUE_BLOCKER_SEARCH_LIMIT) : ["issues", "blocker-search", normalizedBlockedBySearch, ISSUE_BLOCKER_SEARCH_LIMIT], queryFn: () => issuesApi.list(companyId!, { q: normalizedBlockedBySearch, limit: ISSUE_BLOCKER_SEARCH_LIMIT, }), enabled: !!companyId && blockedByOpen && normalizedBlockedBySearch.length > 0, }); const { data: searchedParentIssues, isFetching: isFetchingSearchedParentIssues } = useQuery({ queryKey: companyId ? queryKeys.issues.search(companyId, normalizedParentSearch, undefined, ISSUE_BLOCKER_SEARCH_LIMIT) : ["issues", "blocker-search", normalizedParentSearch, ISSUE_BLOCKER_SEARCH_LIMIT], queryFn: () => issuesApi.list(companyId!, { q: normalizedParentSearch, limit: ISSUE_BLOCKER_SEARCH_LIMIT, }), enabled: !!companyId && parentOpen && normalizedParentSearch.length > 0, }); const createLabel = useMutation({ mutationFn: (data: { name: string; color: string }) => issuesApi.createLabel(companyId!, data), onSuccess: async (created) => { queryClient.setQueryData( queryKeys.issues.labels(companyId!), (current) => { if (!current) return [created]; if (current.some((label) => label.id === created.id)) return current; return [...current, created]; }, ); onUpdate({ labelIds: [...(issue.labelIds ?? []), created.id] }); void queryClient.invalidateQueries({ queryKey: queryKeys.issues.labels(companyId!) }); setNewLabelName(""); }, }); const unarchiveFromInbox = useMutation({ mutationFn: () => issuesApi.unarchiveFromInbox(issue.id), onMutate: () => { setUnarchiveErrorMessage(null); }, onSuccess: () => { setUnarchiveErrorMessage(null); queryClient.setQueryData(queryKeys.issues.detail(issue.id), (current) => current ? { ...current, archivedAt: null, archivedByActorType: null, archivedByAgentId: null, archivedByRunId: null } : current, ); void queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(issue.id) }); if (companyId) invalidateInboxIssueQueries(queryClient, companyId); }, onError: (error) => { setUnarchiveErrorMessage(error instanceof Error && error.message.trim().length > 0 ? error.message : "Failed to unarchive this issue. Please try again."); }, }); const toggleLabel = (labelId: string) => { const ids = issue.labelIds ?? []; const next = ids.includes(labelId) ? ids.filter((id) => id !== labelId) : [...ids, labelId]; onUpdate({ labelIds: next }); }; const agentName = (id: string | null) => { if (!id || !agents) return null; const agent = agents.find((a) => a.id === id); return agent?.name ?? id.slice(0, 8); }; const projectName = (id: string | null) => { if (!id) return id?.slice(0, 8) ?? "None"; const project = orderedProjects.find((p) => p.id === id); return project?.name ?? id.slice(0, 8); }; const currentProject = issue.projectId ? orderedProjects.find((project) => project.id === issue.projectId) ?? null : null; const issueProject = issue.project ?? currentProject; const { visible: workspaceIsolationControlsVisible } = useWorkspaceIsolationControls(); const workspacePickerEligible = workspaceIsolationControlsVisible && experimentalSettings?.enableIsolatedWorkspaces === true && Boolean(issueProject?.executionWorkspacePolicy?.enabled); const { data: reusableExecutionWorkspaces, isLoading: reusableExecutionWorkspacesLoading, isError: reusableExecutionWorkspacesError, } = useQuery({ queryKey: queryKeys.executionWorkspaces.list(companyId!, { projectId: issue.projectId ?? undefined, projectWorkspaceId: issue.projectWorkspaceId ?? undefined, reuseEligible: true, }), queryFn: () => executionWorkspacesApi.list(companyId!, { projectId: issue.projectId ?? undefined, projectWorkspaceId: issue.projectWorkspaceId ?? undefined, reuseEligible: true, }), enabled: Boolean(companyId) && Boolean(issue.projectId) && workspacePickerEligible && workspacePickerOpen, }); const effectiveWorkspaceSelection = currentWorkspaceSelection(issue, issueProject); const hasWorkspaceOverride = issue.executionWorkspacePreference != null || issue.executionWorkspaceSettings != null; const activeWorkspacePickerMode = effectiveWorkspaceSelection === "reuse_existing" ? "reuse" : !hasWorkspaceOverride ? "default" : effectiveWorkspaceSelection === "isolated_workspace" ? "isolated" : "default"; const reusableWorkspaceOptions = useMemo( () => buildReusableExecutionWorkspaceOptionGroups( dedupeReusableExecutionWorkspaces(reusableExecutionWorkspaces ?? []), ).map((group) => ({ ...group, options: group.options.filter((option) => reusableWorkspaceOptionMatches(option, workspaceSearch)), })).filter((group) => group.options.length > 0), [reusableExecutionWorkspaces, workspaceSearch], ); const boundWorkspace = (reusableExecutionWorkspaces ?? []).find( (workspace) => workspace.id === issue.executionWorkspaceId, ) ?? issue.currentExecutionWorkspace ?? null; const workspaceTriggerLabel = activeWorkspacePickerMode === "isolated" ? "New isolated workspace" : activeWorkspacePickerMode === "reuse" ? boundWorkspace?.name ?? "Reuse existing workspace" : "Default"; const workspaceTriggerTitle = activeWorkspacePickerMode === "reuse" ? boundWorkspace?.branchName ?? undefined : undefined; const closeWorkspacePicker = () => { setWorkspacePickerOpen(false); setWorkspacePickerStep("mode"); setWorkspaceSearch(""); }; const saveWorkspaceSelection = ( selection: null | "isolated_workspace" | "reuse_existing", workspace?: ExecutionWorkspace, ) => { const update = buildWorkspaceSelectionUpdate(selection, workspace?.id, workspace?.mode); if (!update) return; onUpdate(update); closeWorkspacePicker(); }; const issueUsesMainWorkspace = useMemo( () => isMainIssueWorkspace({ issue, project: issueProject }), [issue, issueProject], ); const showWorkspaceDetailLink = Boolean(issue.executionWorkspaceId && issue.currentExecutionWorkspace?.id === issue.executionWorkspaceId) && !issueUsesMainWorkspace && experimentalSettings?.enableIsolatedWorkspaces === true; const workspaceRuntimeConfig = issueUsesMainWorkspace ? null : issue.currentExecutionWorkspace?.config?.workspaceRuntime ?? null; const workspaceRuntimeServices = issue.currentExecutionWorkspace?.runtimeServices ?? []; const workspaceCanRunCommands = Boolean(issue.currentExecutionWorkspace?.cwd); const workspaceCanStartServices = Boolean(workspaceRuntimeConfig) && workspaceCanRunCommands; const workspaceRuntimeSections = useMemo(() => buildWorkspaceRuntimeControlSections({ runtimeConfig: workspaceRuntimeConfig, runtimeServices: workspaceRuntimeServices, canStartServices: workspaceCanStartServices, canRunJobs: workspaceCanRunCommands, }), [workspaceCanRunCommands, workspaceCanStartServices, workspaceRuntimeConfig, workspaceRuntimeServices]); const hasWorkspaceRuntimeControls = !issueUsesMainWorkspace && ( workspaceRuntimeSections.services.length > 0 || workspaceRuntimeSections.otherServices.length > 0 ); const controlWorkspaceRuntime = useMutation({ mutationFn: (request: WorkspaceRuntimeControlRequest) => { const workspaceId = issue.currentExecutionWorkspace?.id ?? issue.executionWorkspaceId; if (!workspaceId) throw new Error("This task is not attached to a workspace."); return executionWorkspacesApi.controlRuntimeCommands(workspaceId, request.action, request); }, onSuccess: (result, request) => { queryClient.setQueryData(queryKeys.executionWorkspaces.detail(result.workspace.id), result.workspace); void queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(issue.id) }); if (result.workspace.projectId) void queryClient.invalidateQueries({ queryKey: queryKeys.projects.detail(result.workspace.projectId) }); void queryClient.invalidateQueries({ queryKey: queryKeys.executionWorkspaces.overview(result.workspace.companyId) }); void queryClient.invalidateQueries({ queryKey: queryKeys.executionWorkspaces.workspaceOperations(result.workspace.id) }); if (companyId) { void queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(companyId) }); void queryClient.invalidateQueries({ queryKey: queryKeys.executionWorkspaces.list(companyId) }); } setRuntimeActionErrorMessage(null); setRuntimeActionMessage( request.action === "run" ? "Workspace job completed." : request.action === "stop" ? "Workspace service stopped." : request.action === "restart" ? "Workspace service restarted." : "Workspace service started.", ); }, onError: (error) => { setRuntimeActionMessage(null); setRuntimeActionErrorMessage(error instanceof Error ? error.message : "Failed to control workspace commands."); }, }); const pendingWorkspaceRuntimeAction = controlWorkspaceRuntime.isPending ? controlWorkspaceRuntime.variables ?? null : null; const referencedIssueIdentifiers = issue.referencedIssueIdentifiers ?? []; const relatedTasks = useMemo(() => { const excluded = new Set(); const addExcluded = (candidate: { id: string; identifier?: string | null }) => { excluded.add(candidate.id); if (candidate.identifier) excluded.add(candidate.identifier); }; for (const blocker of issue.blockedBy ?? []) addExcluded(blocker); for (const blocked of issue.blocks ?? []) addExcluded(blocked); for (const child of childIssues) addExcluded(child); const referencedIssues = issue.relatedWork?.outbound.map((item) => item.issue) ?? []; if (referencedIssues.length > 0) { return referencedIssues.filter((referenced) => { const label = referenced.identifier ?? referenced.id; return !excluded.has(referenced.id) && !excluded.has(label); }); } return referencedIssueIdentifiers .filter((identifier) => !excluded.has(identifier)) .map((identifier) => ({ id: identifier, identifier, title: identifier })); }, [childIssues, issue.blockedBy, issue.blocks, issue.relatedWork?.outbound, referencedIssueIdentifiers]); const panelReferencedTasks = useMemo(() => { const outbound = issue.relatedWork?.outbound.map(({ issue: referenced }) => ({ id: referenced.id, identifier: referenced.identifier, title: referenced.title, status: referenced.status, })) ?? []; if (outbound.length > 0) return outbound; return referencedIssueIdentifiers.map((identifier) => ({ id: identifier, identifier, title: identifier, })); }, [issue.relatedWork?.outbound, referencedIssueIdentifiers]); const panelMentionedInTasks = useMemo( () => issue.relatedWork?.inbound.map(({ issue: referenced }) => ({ id: referenced.id, identifier: referenced.identifier, title: referenced.title, status: referenced.status, })) ?? [], [issue.relatedWork?.inbound], ); const projectLink = (id: string | null) => { if (!id) return null; const project = projects?.find((p) => p.id === id) ?? null; return project ? projectUrl(project) : `/projects/${id}`; }; const recentAssigneeIds = useMemo(() => getRecentAssigneeIds(), [assigneeOpen]); const sortedAgents = useMemo( () => sortAgentsByRecency((agents ?? []).filter(isAgentTaskTarget), recentAssigneeIds), [agents, recentAssigneeIds], ); const recentProjectIds = useMemo(() => getRecentProjectIds(), [projectOpen]); const userLabelMap = useMemo( () => buildCompanyUserLabelMap(companyMembers?.users), [companyMembers?.users], ); const userProfileMap = useMemo( () => buildCompanyUserProfileMap(companyMembers?.users), [companyMembers?.users], ); const otherUserOptions = useMemo( () => buildCompanyUserInlineOptions(companyMembers?.users, { excludeUserIds: [currentUserId, issue.createdByUserId] }), [companyMembers?.users, currentUserId, issue.createdByUserId], ); const assignee = issue.assigneeAgentId ? agents?.find((a) => a.id === issue.assigneeAgentId) : null; const copilotCatalog = copilotTaskModelCatalogOptions(assignee); const assigneeAdapterType = assignee?.adapterType ?? null; const assigneeAdapterOverrides = issue.assigneeAdapterOverrides ?? null; const showAssigneeAdapterOptions = assigneeAdapterOverrides !== null; const supportsAssigneeOverrides = Boolean( assigneeAdapterType && (ISSUE_OVERRIDE_ADAPTER_TYPES.has(assigneeAdapterType) || copilotCatalog), ); const assigneeOverrideLane = overrideLane(assigneeAdapterOverrides); const assigneeOverrideAdapterConfig = asRecord(assigneeAdapterOverrides?.adapterConfig); const assigneeOverrideModel = typeof assigneeOverrideAdapterConfig.model === "string" ? assigneeOverrideAdapterConfig.model : ""; const assigneePrimaryAdapterConfig = asRecord(assignee?.adapterConfig); const assigneePrimaryModel = typeof assigneePrimaryAdapterConfig.model === "string" ? assigneePrimaryAdapterConfig.model : ""; const effectiveAssigneeModel = assigneeOverrideModel || assigneePrimaryModel; const assigneeOverrideThinkingEffort = thinkingEffortValueFor( assigneeAdapterType, assigneeOverrideAdapterConfig, ); const assigneeOverrideChrome = assigneeAdapterType === "claude_local" && assigneeOverrideAdapterConfig.chrome === true; const catalogProvider = assigneeAdapterType === "paperclip_runner" ? String(normalizeLegacyRunnerProvider(assigneePrimaryAdapterConfig).provider ?? "codex") : undefined; const catalogEnvironmentId = copilotCatalog ? assignee?.defaultEnvironmentId ?? null : null; const copilotCatalogScope = JSON.stringify(copilotCatalog ?? null); const requestCopilotModels = Boolean(copilotCatalog && assigneeOptionsOpen && showAssigneeAdapterOptions && supportsAssigneeOverrides); const { data: assigneeAdapterModels, refetch: refreshAssigneeModels } = useQuery({ queryKey: companyId && assigneeAdapterType ? [...queryKeys.agents.adapterModels(companyId, assigneeAdapterType, catalogEnvironmentId, catalogProvider), copilotCatalog ?? null] : ["agents", "none", "adapter-models", assigneeAdapterType ?? "none"], queryFn: () => agentsApi.adapterModels(companyId!, assigneeAdapterType!, { provider: catalogProvider, ...(copilotCatalog ? { environmentId: catalogEnvironmentId, ...copilotCatalog } : {}), }), ...(copilotCatalog ? COPILOT_MODEL_DISCOVERY_QUERY_OPTIONS : {}), enabled: Boolean(companyId) && showAssigneeAdapterOptions && supportsAssigneeOverrides && (!copilotCatalog || requestCopilotModels), }); useEffect(() => { if (requestCopilotModels) void refreshAssigneeModels(); }, [requestCopilotModels, copilotCatalogScope, catalogEnvironmentId, companyId, refreshAssigneeModels]); const modelOverrideOptions = useMemo(() => { const models = sortAdapterModels(assigneeAdapterModels ?? []); const options = models.map((model) => ({ id: model.id, label: model.label, searchText: `${model.id} ${extractProviderIdWithFallback(model.id)}`, })); if (assigneeOverrideModel && !options.some((option) => option.id === assigneeOverrideModel)) { options.unshift({ id: assigneeOverrideModel, label: assigneeOverrideModel, searchText: assigneeOverrideModel, }); } return options; }, [assigneeAdapterModels, assigneeOverrideModel]); const updateAssigneeAdapterOverrides = (next: Issue["assigneeAdapterOverrides"]) => { onUpdate({ assigneeAdapterOverrides: next }); }; const buildAssigneeOverrideWithConfig = (adapterConfig: Record) => { const nextConfig = compactRecord(adapterConfig); const next = compactRecord({ useProjectWorkspace: assigneeAdapterOverrides?.useProjectWorkspace, ...(Object.keys(nextConfig).length > 0 ? { adapterConfig: nextConfig } : {}), }); return Object.keys(next).length > 0 ? next : null; }; const updateAssigneeOverrideConfig = (patch: Record) => { updateAssigneeAdapterOverrides( buildAssigneeOverrideWithConfig({ ...assigneeOverrideAdapterConfig, ...patch, }), ); }; const updateAssigneeOverrideThinkingEffort = (nextValue: string) => { const nextConfig = { ...assigneeOverrideAdapterConfig }; delete nextConfig.modelReasoningEffort; delete nextConfig.reasoningEffort; delete nextConfig.effort; delete nextConfig.variant; if (nextValue) { nextConfig[thinkingEffortKeyFor(assigneeAdapterType)] = nextValue; } updateAssigneeAdapterOverrides(buildAssigneeOverrideWithConfig(nextConfig)); }; const updateAssigneeOverrideModel = (nextModel: string) => { const nextConfig: Record = { ...assigneeOverrideAdapterConfig, model: nextModel || undefined, }; if ( assigneeAdapterType === "codex_local" && assigneeOverrideThinkingEffort && !thinkingEffortOptionsFor(assigneeAdapterType, nextModel || assigneePrimaryModel).some( (option) => option.value === assigneeOverrideThinkingEffort, ) ) { delete nextConfig.modelReasoningEffort; delete nextConfig.reasoningEffort; delete nextConfig.effort; } updateAssigneeAdapterOverrides(buildAssigneeOverrideWithConfig(nextConfig)); }; const setAssigneeOverrideLane = (lane: IssueModelLane) => { if (lane === "primary") { updateAssigneeAdapterOverrides(null); return; } updateAssigneeAdapterOverrides(buildAssigneeOverrideWithConfig(assigneeOverrideAdapterConfig) ?? { adapterConfig: {} }); }; const assigneeOptionsTrigger = (() => { if (assigneeOverrideLane === "custom") { const details = [ assigneeOverrideModel, assigneeOverrideThinkingEffort, assigneeOverrideChrome ? "Chrome" : "", ].filter(Boolean); const summary = details.length > 0 ? `Override · ${details.join(" · ")}` : "Override · adapter options"; return ( 0 ? ` (${details.join(" · ")})` : ""}`} > {summary} ); } return Primary model; })(); const assigneeOptionsContent = supportsAssigneeOverrides ? (
Model lane
{(["primary", "custom"] as const).map((lane) => ( ))}
{assigneeOverrideLane === "custom" ? (

Task-level model override — replaces the agent's primary model for this issue.

) : null}
{assigneeOverrideLane === "custom" ? ( <>
Model
Thinking effort
{thinkingEffortOptionsFor(assigneeAdapterType, effectiveAssigneeModel).map((option) => ( ))}
{assigneeAdapterType === "claude_local" ? (
Enable Chrome (--chrome)
updateAssigneeOverrideConfig({ chrome: next ? true : undefined })} />
) : null} ) : null}
) : (

{assignee ? "This assignee's adapter does not expose editable task overrides." : "Select a compatible assignee agent to edit these overrides."}

); const reviewerValues = stageParticipantValues(issue.executionPolicy, "review"); const approverValues = stageParticipantValues(issue.executionPolicy, "approval"); const userLabel = (userId: string | null | undefined) => formatAssigneeUserLabel(userId, currentUserId, userLabelMap); const actualUserLabel = (userId: string | null | undefined) => formatUserLabel(userId, userLabelMap); const assigneeUserLabel = userLabel(issue.assigneeUserId); const creatorUserLabel = actualUserLabel(issue.createdByUserId); const originatingActor = deriveOriginatingActor(issue); const originatingUserProfile = originatingActor?.kind === "user" ? userProfileMap.get(originatingActor.id) : null; const originatingViaAgentName = originatingActor?.kind === "user" && originatingActor.viaAgentId ? agentName(originatingActor.viaAgentId) ?? originatingActor.viaAgentId.slice(0, 8) : null; const selectedAssigneeValue = issue.assigneeAgentId ? `agent:${issue.assigneeAgentId}` : issue.assigneeUserId ? `user:${issue.assigneeUserId}` : ""; // --- Interrupt-handoff clarity for the assignee picker (design surface 2) --- const handoffResolvers: HandoffChipResolvers = useMemo( () => ({ agentMap: new Map((agents ?? []).map((agent) => [agent.id, agent])), resolveUserLabel: (id) => userLabel(id), }), // userLabel closes over userLabelMap + currentUserId, both reflected here. [agents, userLabelMap, currentUserId], ); const reassignInterruptCopy = useMemo( () => describeReassignInterrupt({ runningAgentName: assignee?.name ?? null }), [assignee?.name], ); const closeAssigneePicker = () => { setAssigneeOpen(false); setAssigneeSearch(""); setPendingAssignee(null); }; const applyAssignee = (next: { assigneeAgentId: string | null; assigneeUserId: string | null }, track?: () => void) => { track?.(); onUpdate(next); closeAssigneePicker(); }; /** Apply a selection immediately, or stage it for confirmation while a run is live. */ const selectAssignee = ( next: { assigneeAgentId: string | null; assigneeUserId: string | null }, label: string, track?: () => void, ) => { const nextValue = next.assigneeAgentId ? `agent:${next.assigneeAgentId}` : next.assigneeUserId ? `user:${next.assigneeUserId}` : ""; if (nextValue === selectedAssigneeValue) { closeAssigneePicker(); return; } if (hasActiveRun) { setPendingAssignee({ ...next, label, track }); return; } applyAssignee(next, track); }; const updateExecutionPolicy = (nextReviewers: string[], nextApprovers: string[]) => { if (!policyAvailable) return; onUpdate({ executionPolicy: buildExecutionPolicy({ existingPolicy: issue.executionPolicy ?? null, reviewerValues: nextReviewers, approverValues: nextApprovers, }), }); }; const toggleExecutionParticipant = (stageType: "review" | "approval", value: string) => { const currentValues = stageType === "review" ? reviewerValues : approverValues; const nextValues = currentValues.includes(value) ? currentValues.filter((candidate) => candidate !== value) : [...currentValues, value]; updateExecutionPolicy( stageType === "review" ? nextValues : reviewerValues, stageType === "approval" ? nextValues : approverValues, ); }; const executionParticipantLabel = (value: string) => { if (value.startsWith("agent:")) { return agentName(value.slice("agent:".length)) ?? value.slice("agent:".length, "agent:".length + 8); } if (value.startsWith("user:")) { return userLabel(value.slice("user:".length)) ?? "User"; } return value; }; const reviewerLabel = reviewerValues.map((value) => executionParticipantLabel(value)).join(", "); const approverLabel = approverValues.map((value) => executionParticipantLabel(value)).join(", "); const reviewerTrigger = reviewerValues.length > 0 ? {reviewerLabel} : None; const approverTrigger = approverValues.length > 0 ? {approverLabel} : None; // PAP-16506 P4: who may give the `in_review` verdict. Only an agent sets this, // and only the two opt-in constraints are worth a row — the default (`null` ≡ // "anyone can approve") is what every issue already does, so it shows nothing. const reviewPolicyBadge = issueReviewPolicyBadge(issue.reviewPolicy); const nextRunnableExecutionStage = (() => { if (issue.executionState?.status === "changes_requested" && issue.executionState.currentStageType) { return issue.executionState.currentStageType; } if (issue.executionState) return null; if (reviewerValues.length > 0) return "review"; if (approverValues.length > 0) return "approval"; return null; })(); const runExecutionButton = (stageType: "review" | "approval") => ( ); const currentExecutionLabel = (() => { if (!issue.executionState?.currentStageType) return null; const stageLabel = issue.executionState.currentStageType === "review" ? "Review" : "Approval"; const participant = issue.executionState.currentParticipant; const participantLabel = participant ? (participant.type === "agent" ? agentName(participant.agentId ?? null) : userLabel(participant.userId ?? null)) : null; if (issue.executionState.status === "changes_requested") { return `${stageLabel} requested changes${participantLabel ? ` by ${participantLabel}` : ""}`; } return `${stageLabel} pending${participantLabel ? ` with ${participantLabel}` : ""}`; })(); useEffect(() => { setMonitorAtInput(toDateTimeLocalValue(executionPolicy?.monitor?.nextCheckAt)); setMonitorNotesInput(executionPolicy?.monitor?.notes ?? ""); setMonitorServiceInput(executionPolicy?.monitor?.serviceName ?? ""); }, [ executionPolicy?.monitor?.nextCheckAt, executionPolicy?.monitor?.notes, executionPolicy?.monitor?.serviceName, ]); // Re-sync watchdog editor inputs when the persisted watchdog changes (and reset on close). useEffect(() => { if (watchdogOpen) return; setWatchdogAgentInput(issue.watchdog?.watchdogAgentId ?? ""); setWatchdogInstructionsInput(issue.watchdog?.instructions ?? ""); }, [issue.watchdog?.watchdogAgentId, issue.watchdog?.instructions, watchdogOpen]); const watchdogAgentOptions = useMemo( () => (agents ?? []) .filter(isAgentTaskTarget) .map((agent) => ({ id: agent.id, label: agent.name, searchText: `${agent.name} ${agent.role} ${agent.title ?? ""}`, })), [agents], ); const upsertWatchdog = useMutation({ mutationFn: (data: { agentId: string; instructions: string | null }) => issuesApi.upsertWatchdog(issue.id, data), onSuccess: (watchdog) => { queryClient.setQueryData(queryKeys.issues.detail(issue.id), (current) => current ? { ...current, watchdog } : current, ); void queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(issue.id) }); setWatchdogOpen(false); }, }); const deleteWatchdog = useMutation({ mutationFn: () => issuesApi.deleteWatchdog(issue.id), onSuccess: () => { queryClient.setQueryData(queryKeys.issues.detail(issue.id), (current) => current ? { ...current, watchdog: null } : current, ); void queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(issue.id) }); setWatchdogOpen(false); }, }); const saveWatchdog = () => { if (!watchdogAgentInput) return; upsertWatchdog.mutate({ agentId: watchdogAgentInput, instructions: watchdogInstructionsInput.trim() || null, }); }; const removeWatchdog = () => { if (issue.watchdog) { deleteWatchdog.mutate(); } else { setWatchdogOpen(false); } setWatchdogAgentInput(""); setWatchdogInstructionsInput(""); }; const watchdogMutationError = upsertWatchdog.error instanceof Error ? upsertWatchdog.error.message : deleteWatchdog.error instanceof Error ? deleteWatchdog.error.message : null; const watchdogIssueRef = (childIssues ?? []).find( (child) => child.id === issue.watchdog?.watchdogIssueId, ); const watchdogTrigger = issue.watchdog ? ( {(() => { const agent = (agents ?? []).find((candidate) => candidate.id === issue.watchdog?.watchdogAgentId); return agent ? : null; })()} {agentName(issue.watchdog.watchdogAgentId)} {issue.watchdog.instructions?.trim() ? ( · {issue.watchdog.instructions.trim()} ) : null} {issue.watchdog.status === "disabled" ? ( (disabled) ) : null} ) : ( None ); const labelsExtra = !streamlinedPropertiesEnabled && (issue.labelIds ?? []).length > 0 ? ( ) : undefined; const watchdogContent = (
Watchdog agent
{ if (!option) return Select agent; const agent = (agents ?? []).find((candidate) => candidate.id === option.id); return ( <> {agent ? : null} {option.label} ); }} renderOption={(option) => { const agent = (agents ?? []).find((candidate) => candidate.id === option.id); return ( <> {agent ? : null} {option.label} ); }} />
Instructions (optional)