import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { pickTextColorForPillBg } from "@/lib/color-contrast"; import { Link } from "@/lib/router"; import type { Issue, IssueLabel, Project, WorkspaceRuntimeService } from "@paperclipai/shared"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import type { AdapterModel } from "../api/agents"; import { accessApi } from "../api/access"; import { agentsApi } from "../api/agents"; import { authApi } from "../api/auth"; import { issuesApi } from "../api/issues"; import { projectsApi } from "../api/projects"; import { useCompany } from "../context/CompanyContext"; import { queryKeys } from "../lib/queryKeys"; import { buildCompanyUserInlineOptions, buildCompanyUserLabelMap } from "../lib/company-members"; import { ISSUE_OVERRIDE_ADAPTER_TYPES, type IssueModelLane } from "../lib/issue-assignee-overrides"; import { useProjectOrder } from "../hooks/useProjectOrder"; import { getRecentAssigneeIds, getRecentAssigneeSelectionIds, sortAgentsByRecency, trackRecentAssignee, trackRecentAssigneeUser, } from "../lib/recent-assignees"; import { getRecentProjectIds, trackRecentProject } from "../lib/recent-projects"; import { orderItemsBySelectedAndRecent } from "../lib/recent-selections"; import { formatAssigneeUserLabel } from "../lib/assignees"; import { buildExecutionPolicy, stageParticipantValues } from "../lib/issue-execution-policy"; import { formatMonitorOffset } from "../lib/issue-monitor"; import { formatRetryReason } from "../lib/runRetryState"; import { useRetryNowMutation } from "../hooks/useRetryNowMutation"; import { RetryErrorBand } from "./IssueScheduledRetryCard"; import { extractProviderIdWithFallback } from "../lib/model-utils"; import { StatusIcon } from "./StatusIcon"; import { PriorityIcon } from "./PriorityIcon"; import { Identity } from "./Identity"; import { IssueReferencePill } from "./IssueReferencePill"; import { formatDate, formatDateTime, cn, projectUrl } from "../lib/utils"; import { timeAgo } from "../lib/timeAgo"; import { Button } from "@/components/ui/button"; import { ToggleSwitch } from "@/components/ui/toggle-switch"; import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { Separator } from "@/components/ui/separator"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { User, Hexagon, ArrowUpRight, Tag, Plus, GitBranch, FolderOpen, Check, ExternalLink, X, Clock, RotateCcw, Loader2, CheckCircle2 } from "lucide-react"; import { AgentIcon } from "./AgentIconPicker"; import { InlineEntitySelector, type InlineEntityOption } from "./InlineEntitySelector"; function TruncatedCopyable({ value, icon: Icon }: { value: string; icon: React.ComponentType<{ className?: string }> }) { const [copied, setCopied] = useState(false); const timerRef = useRef>(undefined); useEffect(() => () => clearTimeout(timerRef.current), []); const handleCopy = useCallback(async () => { try { await navigator.clipboard.writeText(value); setCopied(true); clearTimeout(timerRef.current); timerRef.current = setTimeout(() => setCopied(false), 1500); } catch { /* noop */ } }, [value]); return (
{copied && }
); } function defaultProjectWorkspaceIdForProject(project: { workspaces?: Array<{ id: string; isPrimary: boolean }>; executionWorkspacePolicy?: { defaultProjectWorkspaceId?: string | null } | null; } | null | undefined) { if (!project) return null; return project.executionWorkspacePolicy?.defaultProjectWorkspaceId ?? project.workspaces?.find((workspace) => workspace.isPrimary)?.id ?? project.workspaces?.[0]?.id ?? null; } function defaultExecutionWorkspaceModeForProject(project: { executionWorkspacePolicy?: { enabled?: boolean; defaultMode?: string | null } | null } | null | undefined) { const defaultMode = project?.executionWorkspacePolicy?.enabled ? project.executionWorkspacePolicy.defaultMode : null; if (defaultMode === "isolated_workspace" || defaultMode === "operator_branch") return defaultMode; if (defaultMode === "adapter_default") return "agent_default"; return "shared_workspace"; } function primaryWorkspaceIdForProject(project: Pick | null | undefined) { return project?.primaryWorkspace?.id ?? project?.workspaces.find((workspace) => workspace.isPrimary)?.id ?? project?.workspaces[0]?.id ?? null; } function isMainIssueWorkspace(input: { issue: Pick; project: Pick | null | undefined; }) { const workspace = input.issue.currentExecutionWorkspace ?? null; const primaryWorkspaceId = primaryWorkspaceIdForProject(input.project); const linkedProjectWorkspaceId = workspace?.projectWorkspaceId ?? input.issue.projectWorkspaceId ?? null; if (workspace) { if (workspace.mode !== "shared_workspace") return false; if (!linkedProjectWorkspaceId || !primaryWorkspaceId) return true; return workspace.mode === "shared_workspace" && linkedProjectWorkspaceId === primaryWorkspaceId; } if (!linkedProjectWorkspaceId || !primaryWorkspaceId) return true; return linkedProjectWorkspaceId === primaryWorkspaceId; } function runningRuntimeServiceWithUrl( runtimeServices: WorkspaceRuntimeService[] | null | undefined, ) { return runtimeServices?.find((service) => service.status === "running" && service.url?.trim()) ?? null; } function toDateTimeLocalValue(value: string | null | undefined) { if (!value) return ""; const date = new Date(value); if (Number.isNaN(date.getTime())) return ""; const offsetMs = date.getTimezoneOffset() * 60_000; return new Date(date.getTime() - offsetMs).toISOString().slice(0, 16); } interface IssuePropertiesProps { issue: Issue; childIssues?: Issue[]; onAddSubIssue?: () => void; onUpdate: (data: Record) => void; inline?: boolean; } const ISSUE_BLOCKER_SEARCH_LIMIT = 50; function PropertyRow({ label, children }: { label: string; children: React.ReactNode }) { return (
{label}
{children}
); } const ISSUE_THINKING_EFFORT_OPTIONS = { claude_local: [ { value: "", label: "Default" }, { value: "low", label: "Low" }, { value: "medium", label: "Medium" }, { value: "high", label: "High" }, ], codex_local: [ { value: "", label: "Default" }, { value: "minimal", label: "Minimal" }, { value: "low", label: "Low" }, { value: "medium", label: "Medium" }, { value: "high", label: "High" }, { value: "xhigh", label: "X-High" }, ], opencode_local: [ { value: "", label: "Default" }, { value: "minimal", label: "Minimal" }, { value: "low", label: "Low" }, { value: "medium", label: "Medium" }, { value: "high", label: "High" }, { value: "xhigh", label: "X-High" }, { value: "max", label: "Max" }, ], } as const; function asRecord(value: unknown): Record { return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record : {}; } function compactRecord(record: Record) { return Object.fromEntries( Object.entries(record).filter(([, value]) => value !== undefined), ); } function thinkingEffortOptionsFor(adapterType: string | null | undefined) { if (adapterType === "codex_local") return ISSUE_THINKING_EFFORT_OPTIONS.codex_local; if (adapterType === "opencode_local") return ISSUE_THINKING_EFFORT_OPTIONS.opencode_local; return ISSUE_THINKING_EFFORT_OPTIONS.claude_local; } function thinkingEffortKeyFor(adapterType: string | null | undefined) { if (adapterType === "codex_local") return "modelReasoningEffort"; if (adapterType === "opencode_local") return "variant"; return "effort"; } function thinkingEffortValueFor(adapterType: string | null | undefined, adapterConfig: Record) { if (adapterType === "codex_local") { return String(adapterConfig.modelReasoningEffort ?? adapterConfig.reasoningEffort ?? adapterConfig.effort ?? ""); } if (adapterType === "opencode_local") { return String(adapterConfig.variant ?? ""); } return String(adapterConfig.effort ?? ""); } function overrideLane(overrides: Issue["assigneeAdapterOverrides"]): IssueModelLane { if (overrides?.modelProfile === "cheap") return "cheap"; if (overrides?.adapterConfig) return "custom"; return "primary"; } function sortAdapterModels(models: AdapterModel[]) { return [...models].sort((a, b) => { const providerA = extractProviderIdWithFallback(a.id); const providerB = extractProviderIdWithFallback(b.id); const byProvider = providerA.localeCompare(providerB); if (byProvider !== 0) return byProvider; return a.id.localeCompare(b.id); }); } function RemovableIssueReferencePill({ issue, onRemove, }: { issue: NonNullable[number]; onRemove: (issueId: string) => void; }) { const [isConfirmOpen, setIsConfirmOpen] = useState(false); const issueLabel = issue.identifier ?? issue.title; const confirmLabel = issue.identifier ? `${issue.identifier}: ${issue.title}` : issue.title; const content = ( <> {issueLabel} ); const removeLabel = `Remove ${issueLabel} as blocker`; const handleRemove = (event: React.MouseEvent) => { event.preventDefault(); event.stopPropagation(); setIsConfirmOpen(true); }; const confirmRemove = () => { onRemove(issue.id); setIsConfirmOpen(false); }; return ( <> {issue.identifier ? ( {content} ) : ( {content} )} Remove blocker? Remove {confirmLabel} as a blocker for this issue. ); } /** Renders a Popover on desktop, or an inline collapsible section on mobile (inline mode). */ function PropertyPicker({ inline, label, open, onOpenChange, triggerContent, triggerClassName, popoverClassName, popoverAlign = "end", extra, children, }: { inline?: boolean; label: string; open: boolean; onOpenChange: (open: boolean) => void; triggerContent: React.ReactNode; triggerClassName?: string; popoverClassName?: string; popoverAlign?: "start" | "center" | "end"; extra?: React.ReactNode; children: React.ReactNode; }) { const btnCn = cn( "inline-flex items-start gap-1.5 cursor-pointer hover:bg-accent/50 rounded px-1 -mx-1 py-0.5 transition-colors min-w-0 max-w-full text-left", triggerClassName, ); if (inline) { return (
{extra} {open && (
{children}
)}
); } return ( {children} {extra} ); } export function IssueProperties({ issue, childIssues = [], onAddSubIssue, onUpdate, inline, }: IssuePropertiesProps) { const { selectedCompanyId } = useCompany(); const queryClient = useQueryClient(); const companyId = issue.companyId ?? selectedCompanyId; const [assigneeOpen, setAssigneeOpen] = useState(false); const [assigneeSearch, setAssigneeSearch] = useState(""); const [projectOpen, setProjectOpen] = useState(false); const [projectSearch, setProjectSearch] = useState(""); const [blockedByOpen, setBlockedByOpen] = useState(false); const [blockedBySearch, setBlockedBySearch] = useState(""); 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 [scheduledRetryOpen, setScheduledRetryOpen] = useState(false); const [labelsOpen, setLabelsOpen] = useState(false); const [assigneeOptionsOpen, setAssigneeOptionsOpen] = useState(false); const [labelSearch, setLabelSearch] = useState(""); const [newLabelName, setNewLabelName] = useState(""); const [newLabelColor, setNewLabelColor] = useState("#6366f1"); const [monitorAtInput, setMonitorAtInput] = useState(() => toDateTimeLocalValue(issue.executionPolicy?.monitor?.nextCheckAt)); const [monitorNotesInput, setMonitorNotesInput] = useState(issue.executionPolicy?.monitor?.notes ?? ""); const [monitorServiceInput, setMonitorServiceInput] = useState(issue.executionPolicy?.monitor?.serviceName ?? ""); const normalizedBlockedBySearch = blockedBySearch.trim(); 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!), queryFn: () => projectsApi.list(companyId!), 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 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 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 issueUsesMainWorkspace = useMemo( () => isMainIssueWorkspace({ issue, project: issueProject }), [issue, issueProject], ); const showWorkspaceDetailLink = Boolean(issue.executionWorkspaceId) && !issueUsesMainWorkspace; const liveWorkspaceService = useMemo(() => { if (issueUsesMainWorkspace) return null; return runningRuntimeServiceWithUrl(issue.currentExecutionWorkspace?.runtimeServices); }, [issue.currentExecutionWorkspace?.runtimeServices, issueUsesMainWorkspace]); 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 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 recentAssigneeSelectionIds = useMemo(() => getRecentAssigneeSelectionIds(), [assigneeOpen]); const sortedAgents = useMemo( () => sortAgentsByRecency((agents ?? []).filter((a) => a.status !== "terminated"), recentAssigneeIds), [agents, recentAssigneeIds], ); const recentAssigneeValues = useMemo( () => recentAssigneeSelectionIds, [recentAssigneeSelectionIds], ); const recentProjectIds = useMemo(() => getRecentProjectIds(), [projectOpen]); const userLabelMap = useMemo( () => buildCompanyUserLabelMap(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 assigneeAdapterType = assignee?.adapterType ?? null; const assigneeAdapterOverrides = issue.assigneeAdapterOverrides ?? null; const showAssigneeAdapterOptions = assigneeAdapterOverrides !== null; const supportsAssigneeOverrides = Boolean( assigneeAdapterType && ISSUE_OVERRIDE_ADAPTER_TYPES.has(assigneeAdapterType), ); const assigneeSupportsCheapLane = Boolean( supportsAssigneeOverrides && (assigneeAdapterType === "claude_local" || assigneeAdapterType === "codex_local" || assigneeAdapterType === "opencode_local"), ); const assigneeOverrideLane = overrideLane(assigneeAdapterOverrides); const assigneeOverrideAdapterConfig = asRecord(assigneeAdapterOverrides?.adapterConfig); const assigneeOverrideModel = typeof assigneeOverrideAdapterConfig.model === "string" ? assigneeOverrideAdapterConfig.model : ""; const assigneeOverrideThinkingEffort = thinkingEffortValueFor( assigneeAdapterType, assigneeOverrideAdapterConfig, ); const assigneeOverrideChrome = assigneeAdapterType === "claude_local" && assigneeOverrideAdapterConfig.chrome === true; const { data: assigneeAdapterModels } = useQuery({ queryKey: companyId && assigneeAdapterType ? queryKeys.agents.adapterModels(companyId, assigneeAdapterType) : ["agents", "none", "adapter-models", assigneeAdapterType ?? "none"], queryFn: () => agentsApi.adapterModels(companyId!, assigneeAdapterType!), enabled: Boolean(companyId) && showAssigneeAdapterOptions && supportsAssigneeOverrides, }); const { data: assigneeCheapProfiles } = useQuery({ queryKey: companyId && assigneeAdapterType ? queryKeys.agents.adapterModelProfiles(companyId, assigneeAdapterType) : ["agents", "none", "adapter-model-profiles", assigneeAdapterType ?? "none"], queryFn: () => agentsApi.adapterModelProfiles(companyId!, assigneeAdapterType!), enabled: Boolean(companyId) && showAssigneeAdapterOptions && assigneeSupportsCheapLane, }); const assigneeCheapProfile = useMemo( () => (assigneeCheapProfiles ?? []).find((profile) => profile.key === "cheap") ?? null, [assigneeCheapProfiles], ); 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 setAssigneeOverrideLane = (lane: IssueModelLane) => { if (lane === "primary") { updateAssigneeAdapterOverrides(null); return; } if (lane === "cheap") { updateAssigneeAdapterOverrides( compactRecord({ useProjectWorkspace: assigneeAdapterOverrides?.useProjectWorkspace, modelProfile: "cheap", }), ); return; } updateAssigneeAdapterOverrides(buildAssigneeOverrideWithConfig(assigneeOverrideAdapterConfig) ?? { adapterConfig: {} }); }; const assigneeOptionsTrigger = (() => { if (assigneeOverrideLane === "cheap") { return Cheap model; } if (assigneeOverrideLane === "custom") { const details = [ assigneeOverrideModel, assigneeOverrideThinkingEffort, assigneeOverrideChrome ? "Chrome" : "", ].filter(Boolean); return ( Custom{details.length > 0 ? ` · ${details.join(" · ")}` : " adapter options"} ); } return Primary model; })(); const assigneeOptionsContent = supportsAssigneeOverrides ? (
Model lane
{(["primary", ...(assigneeSupportsCheapLane ? (["cheap"] as const) : ([] as const)), "custom"] as const).map((lane) => ( ))}
{assigneeOverrideLane === "cheap" ? (

Sends modelProfile: "cheap"{" "} {assigneeCheapProfile?.adapterConfig && typeof (assigneeCheapProfile.adapterConfig as Record).model === "string" ? <>· adapter default {String((assigneeCheapProfile.adapterConfig as Record).model)} : assigneeCheapProfile ? <>· uses the agent's configured cheap profile : <>· falls back to the primary model if no cheap profile is configured}

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

{assignee ? "This assignee's adapter does not expose editable issue overrides." : "Select a compatible agent assignee 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 assigneeUserLabel = userLabel(issue.assigneeUserId); const creatorUserLabel = userLabel(issue.createdByUserId); const selectedAssigneeValue = issue.assigneeAgentId ? `agent:${issue.assigneeAgentId}` : issue.assigneeUserId ? `user:${issue.assigneeUserId}` : ""; const updateExecutionPolicy = (nextReviewers: string[], nextApprovers: string[]) => { 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 reviewerTrigger = reviewerValues.length > 0 ? {reviewerValues.map((value) => executionParticipantLabel(value)).join(", ")} : None; const approverTrigger = approverValues.length > 0 ? {approverValues.map((value) => executionParticipantLabel(value)).join(", ")} : None; 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(issue.executionPolicy?.monitor?.nextCheckAt)); setMonitorNotesInput(issue.executionPolicy?.monitor?.notes ?? ""); setMonitorServiceInput(issue.executionPolicy?.monitor?.serviceName ?? ""); }, [ issue.executionPolicy?.monitor?.nextCheckAt, issue.executionPolicy?.monitor?.notes, issue.executionPolicy?.monitor?.serviceName, ]); const updateMonitor = (nextMonitor: Issue["executionPolicy"] extends infer T ? T extends { monitor?: infer M | null } | null | undefined ? M | null : never : never) => { const basePolicy = buildExecutionPolicy({ existingPolicy: issue.executionPolicy ?? null, reviewerValues, approverValues, }); if (!basePolicy && !nextMonitor) { onUpdate({ executionPolicy: null }); return; } onUpdate({ executionPolicy: { mode: basePolicy?.mode ?? issue.executionPolicy?.mode ?? "normal", commentRequired: true, stages: basePolicy?.stages ?? [], ...(nextMonitor ? { monitor: nextMonitor } : {}), }, }); }; const saveMonitor = () => { if (!monitorAtInput) return; const nextCheckAt = new Date(monitorAtInput); if (Number.isNaN(nextCheckAt.getTime())) return; const serviceName = monitorServiceInput.trim() || null; updateMonitor({ nextCheckAt: nextCheckAt.toISOString(), notes: monitorNotesInput.trim() || null, scheduledBy: "board", kind: serviceName ? "external_service" : null, serviceName, externalRef: null, }); setMonitorOpen(false); }; const clearMonitor = () => { updateMonitor(null); setMonitorOpen(false); }; const currentMonitorLabel = (() => { if (issue.executionPolicy?.monitor?.nextCheckAt) { return `Next check ${formatDate(new Date(issue.executionPolicy.monitor.nextCheckAt))}`; } if (issue.executionState?.monitor?.status === "cleared") { return "Cleared"; } if (issue.monitorLastTriggeredAt) { return `Last triggered ${timeAgo(issue.monitorLastTriggeredAt)}`; } return "Not scheduled"; })(); const monitorNextCheckAt = issue.executionPolicy?.monitor?.nextCheckAt ?? null; const monitorTrigger = ( {monitorNextCheckAt ? ( ); const monitorAttemptBadge = issue.monitorAttemptCount && issue.monitorAttemptCount > 0 ? ( Attempt {issue.monitorAttemptCount} ) : null; const scheduledRetry = issue.scheduledRetry ?? null; const retryNow = useRetryNowMutation(issue.id); const showScheduledRetryRow = scheduledRetry && scheduledRetry.status === "scheduled_retry"; const scheduledRetryDueAtIso = scheduledRetry?.scheduledRetryAt ? new Date(scheduledRetry.scheduledRetryAt).toISOString() : null; const scheduledRetryRelative = scheduledRetryDueAtIso ? formatMonitorOffset(scheduledRetryDueAtIso) : null; const scheduledRetryAbsolute = scheduledRetry?.scheduledRetryAt ? formatDateTime(scheduledRetry.scheduledRetryAt) : null; const scheduledRetryShortDate = scheduledRetry?.scheduledRetryAt ? formatDate(new Date(scheduledRetry.scheduledRetryAt)) : null; const scheduledRetryReasonLabel = formatRetryReason(scheduledRetry?.scheduledRetryReason); const scheduledRetryAttempt = typeof scheduledRetry?.scheduledRetryAttempt === "number" && Number.isFinite(scheduledRetry.scheduledRetryAttempt) && scheduledRetry.scheduledRetryAttempt > 0 ? scheduledRetry.scheduledRetryAttempt : null; const scheduledRetryIsContinuation = scheduledRetry?.scheduledRetryReason === "max_turns_continuation"; const scheduledRetryRelativeLabel = (() => { if (!scheduledRetryRelative) return "Pending schedule"; const action = scheduledRetryIsContinuation ? "Continuation" : "Retry"; if (scheduledRetryRelative === "now") return `${action} due now`; return `${action} ${scheduledRetryRelative}`; })(); const scheduledRetryRetryNowSuccess = retryNow.isSuccess && (retryNow.data?.outcome === "promoted" || retryNow.data?.outcome === "already_promoted"); const scheduledRetryAttemptBadge = scheduledRetryAttempt !== null ? ( Attempt {scheduledRetryAttempt} ) : null; const scheduledRetryTrigger = ( ); const scheduledRetryContent = scheduledRetry ? (
{scheduledRetryIsContinuation ? "Scheduled continuation" : "Scheduled retry"} {scheduledRetryAttempt !== null ? ( Attempt {scheduledRetryAttempt} ) : null}
{scheduledRetryReasonLabel ? ( <>
Reason
{scheduledRetryReasonLabel}
) : null} {scheduledRetryAbsolute ? ( <>
Next attempt
{scheduledRetryAbsolute} {scheduledRetryRelative ? ( · {scheduledRetryRelative} ) : null}
) : null} {scheduledRetry.retryOfRunId ? ( <>
Replaces run
{scheduledRetry.retryOfRunId.slice(0, 8)}
) : null} {scheduledRetry.agentName ? ( <>
Agent
{scheduledRetry.agentName}
) : null} {scheduledRetry.error ? ( <>
Last error
{scheduledRetry.error}
) : null}
{ retryNow.reset(); retryNow.mutate(); }} />
{retryNow.isPending ? "Promoting scheduled retry" : scheduledRetryRetryNowSuccess ? retryNow.data?.outcome === "already_promoted" ? "Already promoted — run starting" : "Promoted — run starting" : scheduledRetryIsContinuation ? "Pulls continuation forward immediately" : "Pulls retry forward immediately"}
) : null; const monitorContent = (
setMonitorAtInput(e.target.value)} /> setMonitorNotesInput(e.target.value)} />
setMonitorServiceInput(e.target.value)} />
{issue.executionPolicy?.monitor ? ( ) : null}
); const selectedIssueLabels = useMemo(() => { const selectedIds = issue.labelIds ?? []; if (selectedIds.length === 0) return issue.labels ?? []; const labelById = new Map(); for (const label of labels ?? []) labelById.set(label.id, label); for (const label of issue.labels ?? []) labelById.set(label.id, label); return selectedIds .map((id) => labelById.get(id)) .filter((label): label is IssueLabel => Boolean(label)); }, [issue.labelIds, issue.labels, labels]); const labelsTrigger = selectedIssueLabels.length > 0 ? (
{selectedIssueLabels.slice(0, 3).map((label) => ( {label.name} ))} {selectedIssueLabels.length > 3 && ( +{selectedIssueLabels.length - 3} )}
) : ( <> No labels ); const labelsExtra = (issue.labelIds ?? []).length > 0 ? ( ) : undefined; const labelsContent = ( <> setLabelSearch(e.target.value)} autoFocus={!inline} />
{(labels ?? []) .filter((label) => { if (!labelSearch.trim()) return true; return label.name.toLowerCase().includes(labelSearch.toLowerCase()); }) .map((label) => { const selected = (issue.labelIds ?? []).includes(label.id); return ( ); })}
setNewLabelColor(e.target.value)} /> setNewLabelName(e.target.value)} />
); const assigneeTrigger = assignee ? ( ) : assigneeUserLabel ? ( <> {assigneeUserLabel} ) : ( <> Unassigned ); const assigneePickerOptions = orderItemsBySelectedAndRecent( [ { id: "", kind: "none" as const, label: "No assignee", searchText: "" }, ...(currentUserId ? [{ id: `user:${currentUserId}`, kind: "user" as const, userId: currentUserId, label: "Assign to me", searchText: userLabel(currentUserId) ?? "", }] : []), ...(issue.createdByUserId && issue.createdByUserId !== currentUserId ? [{ id: `user:${issue.createdByUserId}`, kind: "user" as const, userId: issue.createdByUserId, label: creatorUserLabel ? `Assign to ${creatorUserLabel}` : "Assign to requester", searchText: creatorUserLabel ?? "requester", }] : []), ...otherUserOptions.map((option) => ({ id: option.id, kind: "user" as const, userId: option.id.slice("user:".length), label: option.label, searchText: option.searchText ?? "", })), ...sortedAgents.map((agent) => ({ id: `agent:${agent.id}`, kind: "agent" as const, agent, label: agent.name, searchText: `${agent.name} ${agent.role} ${agent.title ?? ""}`, })), ], selectedAssigneeValue, recentAssigneeValues, ); const assigneeContent = ( <> setAssigneeSearch(e.target.value)} autoFocus={!inline} />
{assigneePickerOptions .filter((option) => { if (!assigneeSearch.trim()) return true; const q = assigneeSearch.toLowerCase(); return `${option.label} ${option.searchText}`.toLowerCase().includes(q); }) .map((option) => ( ))}
); const executionParticipantsContent = ( stageType: "review" | "approval", values: string[], search: string, setSearch: (value: string) => void, onClear: () => void, ) => ( <> setSearch(e.target.value)} autoFocus={!inline} />
{currentUserId && ( )} {issue.createdByUserId && issue.createdByUserId !== currentUserId && ( )} {otherUserOptions .filter((option) => { if (!search.trim()) return true; return `${option.label} ${option.searchText ?? ""}`.toLowerCase().includes(search.toLowerCase()); }) .map((option) => ( ))} {sortedAgents .filter((agent) => { if (!search.trim()) return true; return agent.name.toLowerCase().includes(search.toLowerCase()); }) .map((agent) => { const encoded = `agent:${agent.id}`; return ( ); })}
); const projectTrigger = issue.projectId ? ( <> p.id === issue.projectId)?.color ?? "#6366f1" }} /> {projectName(issue.projectId)} ) : ( <> No project ); const projectPickerOptions = orderItemsBySelectedAndRecent( [ { id: "", kind: "none" as const, name: "No project", color: null as string | null }, ...orderedProjects.map((project) => ({ id: project.id, kind: "project" as const, project, name: project.name, color: project.color ?? null, })), ], issue.projectId ?? "", recentProjectIds, ); const projectContent = ( <> setProjectSearch(e.target.value)} autoFocus={!inline} />
{projectPickerOptions .filter((option) => { if (!projectSearch.trim()) return true; const q = projectSearch.toLowerCase(); return option.name.toLowerCase().includes(q); }) .map((option) => ( ))}
); const blockedByIds = issue.blockedBy?.map((relation) => relation.id) ?? []; const descendantIssueIds = useMemo(() => { if (!allIssues?.length) return new Set(); const childrenByParentId = new Map(); for (const candidate of allIssues) { if (!candidate.parentId) continue; const children = childrenByParentId.get(candidate.parentId) ?? []; children.push(candidate.id); childrenByParentId.set(candidate.parentId, children); } const descendants = new Set(); const stack = [...(childrenByParentId.get(issue.id) ?? [])]; while (stack.length > 0) { const candidateId = stack.pop(); if (!candidateId || descendants.has(candidateId)) continue; descendants.add(candidateId); stack.push(...(childrenByParentId.get(candidateId) ?? [])); } return descendants; }, [allIssues, issue.id]); const currentParentIssue = useMemo(() => { if (!issue.parentId) return null; return allIssues?.find((candidate) => candidate.id === issue.parentId) ?? null; }, [allIssues, issue.parentId]); const parentIdentifier = issue.ancestors?.[0]?.identifier ?? currentParentIssue?.identifier; const parentTitle = issue.ancestors?.[0]?.title ?? currentParentIssue?.title ?? issue.parentId?.slice(0, 8); const parentTrigger = issue.parentId ? ( {parentIdentifier ? `${parentIdentifier} ` : ""} {parentTitle} ) : ( No parent ); const parentLink = issue.parentId ? ( e.stopPropagation()} > ) : undefined; const parentOptions = (allIssues ?? []) .filter((candidate) => candidate.id !== issue.id) .filter((candidate) => !descendantIssueIds.has(candidate.id)) .filter((candidate) => { if (!parentSearch.trim()) return true; const query = parentSearch.toLowerCase(); return ( (candidate.identifier ?? "").toLowerCase().includes(query) || candidate.title.toLowerCase().includes(query) ); }) .sort((a, b) => { const aLabel = `${a.identifier ?? ""} ${a.title}`.trim(); const bLabel = `${b.identifier ?? ""} ${b.title}`.trim(); return aLabel.localeCompare(bLabel); }); const parentContent = ( <> setParentSearch(e.target.value)} autoFocus={!inline} />
{parentOptions.map((candidate) => ( ))}
); const blockingIssues = issue.blocks ?? []; const blockerSearchActive = normalizedBlockedBySearch.length > 0; const blockerSourceIssues = blockerSearchActive ? searchedBlockedByIssues : allIssues; const blockerOptions = (blockerSourceIssues ?? []) .filter((candidate) => candidate.id !== issue.id); if (!blockerSearchActive) { blockerOptions.sort((a, b) => { const aLabel = `${a.identifier ?? ""} ${a.title}`.trim(); const bLabel = `${b.identifier ?? ""} ${b.title}`.trim(); return aLabel.localeCompare(bLabel); }); } const blockerOptionsLoading = blockedByOpen && ( blockerSearchActive ? isFetchingSearchedBlockedByIssues : isFetchingIssuePickerIssues ); const toggleBlockedBy = (blockedByIssueId: string) => { const nextBlockedByIds = blockedByIds.includes(blockedByIssueId) ? blockedByIds.filter((candidate) => candidate !== blockedByIssueId) : [...blockedByIds, blockedByIssueId]; onUpdate({ blockedByIssueIds: nextBlockedByIds }); setBlockedByOpen(false); setBlockedBySearch(""); }; const removeBlockedBy = (blockedByIssueId: string) => { onUpdate({ blockedByIssueIds: blockedByIds.filter((candidate) => candidate !== blockedByIssueId) }); }; const blockedByContent = ( <> setBlockedBySearch(e.target.value)} autoFocus={!inline} aria-label="Search issues to add as blockers" />
{blockerOptions.map((candidate) => { const selected = blockedByIds.includes(candidate.id); return ( ); })} {blockerOptionsLoading ? (
Searching issues...
) : blockerOptions.length === 0 ? (
No matching issues.
) : null}
); const renderAddBlockedByButton = (onClick?: () => void) => ( ); return (
onUpdate({ status })} showLabel /> onUpdate({ priority })} showLabel /> { setLabelsOpen(open); if (!open) setLabelSearch(""); }} triggerContent={labelsTrigger} triggerClassName="min-w-0 max-w-full" popoverClassName="w-64" extra={labelsExtra} > {labelsContent} { setAssigneeOpen(open); if (!open) setAssigneeSearch(""); }} triggerContent={assigneeTrigger} popoverClassName="w-52" extra={issue.assigneeAgentId ? ( e.stopPropagation()} > ) : undefined} > {assigneeContent} {showAssigneeAdapterOptions ? ( updateAssigneeAdapterOverrides(null)} aria-label="Clear adapter options" title="Clear adapter options" > } > {assigneeOptionsContent} ) : null} { setProjectOpen(open); if (!open) setProjectSearch(""); }} triggerContent={projectTrigger} triggerClassName="min-w-0 max-w-full" popoverClassName="w-fit min-w-[11rem]" extra={issue.projectId ? ( e.stopPropagation()} > ) : undefined} > {projectContent} { setParentOpen(open); if (!open) setParentSearch(""); }} triggerContent={parentTrigger} triggerClassName="min-w-0 max-w-full" popoverClassName="w-72" extra={parentLink} > {parentContent} {inline ? (
{(issue.blockedBy ?? []).map((relation) => ( ))} {renderAddBlockedByButton(() => setBlockedByOpen((open) => !open))} {blockedByOpen && (
{blockedByContent}
)}
) : ( {(issue.blockedBy ?? []).map((relation) => ( ))} { setBlockedByOpen(open); if (!open) setBlockedBySearch(""); }} > {renderAddBlockedByButton()} {blockedByContent} )} {blockingIssues.length > 0 ? (
{blockingIssues.map((relation) => ( ))}
) : null}
{childIssues.length > 0 ? childIssues.map((child) => ( )) : null} {onAddSubIssue ? ( ) : null}
{relatedTasks.length > 0 ? (
{relatedTasks.map((related) => ( ))}
) : null} { setReviewersOpen(open); if (!open) setReviewerSearch(""); }} triggerContent={reviewerTrigger} triggerClassName="min-w-0 max-w-full" popoverClassName="w-56" > {executionParticipantsContent( "review", reviewerValues, reviewerSearch, setReviewerSearch, () => updateExecutionPolicy([], approverValues), )} {nextRunnableExecutionStage === "review" && reviewerValues.length > 0 ? runExecutionButton("review") : null} { setApproversOpen(open); if (!open) setApproverSearch(""); }} triggerContent={approverTrigger} triggerClassName="min-w-0 max-w-full" popoverClassName="w-56" > {executionParticipantsContent( "approval", approverValues, approverSearch, setApproverSearch, () => updateExecutionPolicy(reviewerValues, []), )} {nextRunnableExecutionStage === "approval" && approverValues.length > 0 ? runExecutionButton("approval") : null} {currentExecutionLabel && ( {currentExecutionLabel} )} {showScheduledRetryRow && scheduledRetryContent ? ( {scheduledRetryContent} ) : null} {monitorContent} {issue.requestDepth > 0 && ( {issue.requestDepth} )}
{liveWorkspaceService || issue.currentExecutionWorkspace?.branchName || issue.currentExecutionWorkspace?.cwd || issue.executionWorkspaceId ? ( <>
{liveWorkspaceService?.url && ( {liveWorkspaceService.url} )} {showWorkspaceDetailLink && issue.executionWorkspaceId && ( View workspace )} {issue.currentExecutionWorkspace?.branchName && ( )} {issue.currentExecutionWorkspace?.cwd && ( )}
) : null}
{(issue.createdByAgentId || issue.createdByUserId) && ( {issue.createdByAgentId ? ( ) : ( <> {creatorUserLabel ?? "User"} )} )} {issue.startedAt && ( {formatDateTime(issue.startedAt)} )} {issue.completedAt && ( {formatDateTime(issue.completedAt)} )} {formatDateTime(issue.createdAt)} {timeAgo(issue.updatedAt)}
); }