commit
This commit is contained in:
182
ui/src/components/InlineEntitySelector.tsx
Normal file
182
ui/src/components/InlineEntitySelector.tsx
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
import { forwardRef, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||||
|
import { Check } from "lucide-react";
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
|
import { cn } from "../lib/utils";
|
||||||
|
|
||||||
|
export interface InlineEntityOption {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
searchText?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface InlineEntitySelectorProps {
|
||||||
|
value: string;
|
||||||
|
options: InlineEntityOption[];
|
||||||
|
placeholder: string;
|
||||||
|
noneLabel: string;
|
||||||
|
searchPlaceholder: string;
|
||||||
|
emptyMessage: string;
|
||||||
|
onChange: (id: string) => void;
|
||||||
|
onConfirm?: () => void;
|
||||||
|
className?: string;
|
||||||
|
renderTriggerValue?: (option: InlineEntityOption | null) => ReactNode;
|
||||||
|
renderOption?: (option: InlineEntityOption, isSelected: boolean) => ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const InlineEntitySelector = forwardRef<HTMLButtonElement, InlineEntitySelectorProps>(
|
||||||
|
function InlineEntitySelector(
|
||||||
|
{
|
||||||
|
value,
|
||||||
|
options,
|
||||||
|
placeholder,
|
||||||
|
noneLabel,
|
||||||
|
searchPlaceholder,
|
||||||
|
emptyMessage,
|
||||||
|
onChange,
|
||||||
|
onConfirm,
|
||||||
|
className,
|
||||||
|
renderTriggerValue,
|
||||||
|
renderOption,
|
||||||
|
},
|
||||||
|
ref,
|
||||||
|
) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [highlightedIndex, setHighlightedIndex] = useState(0);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const allOptions = useMemo<InlineEntityOption[]>(
|
||||||
|
() => [{ id: "", label: noneLabel, searchText: noneLabel }, ...options],
|
||||||
|
[noneLabel, options],
|
||||||
|
);
|
||||||
|
|
||||||
|
const filteredOptions = useMemo(() => {
|
||||||
|
const term = query.trim().toLowerCase();
|
||||||
|
if (!term) return allOptions;
|
||||||
|
return allOptions.filter((option) => {
|
||||||
|
const haystack = `${option.label} ${option.searchText ?? ""}`.toLowerCase();
|
||||||
|
return haystack.includes(term);
|
||||||
|
});
|
||||||
|
}, [allOptions, query]);
|
||||||
|
|
||||||
|
const currentOption = options.find((option) => option.id === value) ?? null;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const selectedIndex = filteredOptions.findIndex((option) => option.id === value);
|
||||||
|
setHighlightedIndex(selectedIndex >= 0 ? selectedIndex : 0);
|
||||||
|
}, [filteredOptions, open, value]);
|
||||||
|
|
||||||
|
const commitSelection = (index: number, moveNext: boolean) => {
|
||||||
|
const option = filteredOptions[index] ?? filteredOptions[0];
|
||||||
|
if (option) onChange(option.id);
|
||||||
|
setOpen(false);
|
||||||
|
setQuery("");
|
||||||
|
if (moveNext && onConfirm) {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
onConfirm();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(next) => {
|
||||||
|
setOpen(next);
|
||||||
|
if (!next) setQuery("");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<button
|
||||||
|
ref={ref}
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
"inline-flex min-w-0 items-center gap-1 rounded-md border border-border bg-muted/40 px-2 py-1 text-sm font-medium text-foreground transition-colors hover:bg-accent/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
onFocus={() => setOpen(true)}
|
||||||
|
>
|
||||||
|
{renderTriggerValue
|
||||||
|
? renderTriggerValue(currentOption)
|
||||||
|
: (currentOption?.label ?? <span className="text-muted-foreground">{placeholder}</span>)}
|
||||||
|
</button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent
|
||||||
|
align="start"
|
||||||
|
className="w-[min(20rem,calc(100vw-2rem))] p-1"
|
||||||
|
onOpenAutoFocus={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
inputRef.current?.focus();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
className="w-full border-b border-border bg-transparent px-2 py-1.5 text-sm outline-none placeholder:text-muted-foreground/60"
|
||||||
|
placeholder={searchPlaceholder}
|
||||||
|
value={query}
|
||||||
|
onChange={(event) => {
|
||||||
|
setQuery(event.target.value);
|
||||||
|
}}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === "ArrowDown") {
|
||||||
|
event.preventDefault();
|
||||||
|
setHighlightedIndex((current) =>
|
||||||
|
filteredOptions.length === 0 ? 0 : (current + 1) % filteredOptions.length,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.key === "ArrowUp") {
|
||||||
|
event.preventDefault();
|
||||||
|
setHighlightedIndex((current) => {
|
||||||
|
if (filteredOptions.length === 0) return 0;
|
||||||
|
return current <= 0 ? filteredOptions.length - 1 : current - 1;
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.key === "Enter") {
|
||||||
|
event.preventDefault();
|
||||||
|
commitSelection(highlightedIndex, true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.key === "Tab" && !event.shiftKey) {
|
||||||
|
event.preventDefault();
|
||||||
|
commitSelection(highlightedIndex, true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
event.preventDefault();
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="max-h-56 overflow-y-auto overscroll-contain py-1">
|
||||||
|
{filteredOptions.length === 0 ? (
|
||||||
|
<p className="px-2 py-2 text-xs text-muted-foreground">{emptyMessage}</p>
|
||||||
|
) : (
|
||||||
|
filteredOptions.map((option, index) => {
|
||||||
|
const isSelected = option.id === value;
|
||||||
|
const isHighlighted = index === highlightedIndex;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={option.id || "__none__"}
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
"flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-sm",
|
||||||
|
isHighlighted && "bg-accent",
|
||||||
|
)}
|
||||||
|
onMouseEnter={() => setHighlightedIndex(index)}
|
||||||
|
onClick={() => commitSelection(index, true)}
|
||||||
|
>
|
||||||
|
{renderOption ? renderOption(option, isSelected) : <span className="truncate">{option.label}</span>}
|
||||||
|
<Check className={cn("ml-auto h-3.5 w-3.5 text-muted-foreground", isSelected ? "opacity-100" : "opacity-0")} />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -20,6 +20,7 @@ import { AgentIcon } from "./AgentIconPicker";
|
|||||||
interface IssuePropertiesProps {
|
interface IssuePropertiesProps {
|
||||||
issue: Issue;
|
issue: Issue;
|
||||||
onUpdate: (data: Record<string, unknown>) => void;
|
onUpdate: (data: Record<string, unknown>) => void;
|
||||||
|
inline?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function PropertyRow({ label, children }: { label: string; children: React.ReactNode }) {
|
function PropertyRow({ label, children }: { label: string; children: React.ReactNode }) {
|
||||||
@@ -31,7 +32,69 @@ function PropertyRow({ label, children }: { label: string; children: React.React
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function IssueProperties({ issue, onUpdate }: IssuePropertiesProps) {
|
/** 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-center gap-1.5 cursor-pointer hover:bg-accent/50 rounded px-1 -mx-1 py-0.5 transition-colors",
|
||||||
|
triggerClassName,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (inline) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<PropertyRow label={label}>
|
||||||
|
<button className={btnCn} onClick={() => onOpenChange(!open)}>
|
||||||
|
{triggerContent}
|
||||||
|
</button>
|
||||||
|
{extra}
|
||||||
|
</PropertyRow>
|
||||||
|
{open && (
|
||||||
|
<div className={cn("rounded-md border border-border bg-popover p-1 mb-2", popoverClassName)}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PropertyRow label={label}>
|
||||||
|
<Popover open={open} onOpenChange={onOpenChange}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<button className={btnCn}>{triggerContent}</button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className={cn("p-1", popoverClassName)} align={popoverAlign} collisionPadding={16}>
|
||||||
|
{children}
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
{extra}
|
||||||
|
</PropertyRow>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProps) {
|
||||||
const { selectedCompanyId } = useCompany();
|
const { selectedCompanyId } = useCompany();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const companyId = issue.companyId ?? selectedCompanyId;
|
const companyId = issue.companyId ?? selectedCompanyId;
|
||||||
@@ -104,30 +167,7 @@ export function IssueProperties({ issue, onUpdate }: IssuePropertiesProps) {
|
|||||||
? agents?.find((a) => a.id === issue.assigneeAgentId)
|
? agents?.find((a) => a.id === issue.assigneeAgentId)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
return (
|
const labelsTrigger = (issue.labels ?? []).length > 0 ? (
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="space-y-1">
|
|
||||||
<PropertyRow label="Status">
|
|
||||||
<StatusIcon
|
|
||||||
status={issue.status}
|
|
||||||
onChange={(status) => onUpdate({ status })}
|
|
||||||
showLabel
|
|
||||||
/>
|
|
||||||
</PropertyRow>
|
|
||||||
|
|
||||||
<PropertyRow label="Priority">
|
|
||||||
<PriorityIcon
|
|
||||||
priority={issue.priority}
|
|
||||||
onChange={(priority) => onUpdate({ priority })}
|
|
||||||
showLabel
|
|
||||||
/>
|
|
||||||
</PropertyRow>
|
|
||||||
|
|
||||||
<PropertyRow label="Labels">
|
|
||||||
<Popover open={labelsOpen} onOpenChange={(open) => { setLabelsOpen(open); if (!open) setLabelSearch(""); }}>
|
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<button className="inline-flex items-center 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">
|
|
||||||
{(issue.labels ?? []).length > 0 ? (
|
|
||||||
<div className="flex items-center gap-1 flex-wrap">
|
<div className="flex items-center gap-1 flex-wrap">
|
||||||
{(issue.labels ?? []).slice(0, 3).map((label) => (
|
{(issue.labels ?? []).slice(0, 3).map((label) => (
|
||||||
<span
|
<span
|
||||||
@@ -151,16 +191,16 @@ export function IssueProperties({ issue, onUpdate }: IssuePropertiesProps) {
|
|||||||
<Tag className="h-3.5 w-3.5 text-muted-foreground" />
|
<Tag className="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
<span className="text-sm text-muted-foreground">No labels</span>
|
<span className="text-sm text-muted-foreground">No labels</span>
|
||||||
</>
|
</>
|
||||||
)}
|
);
|
||||||
</button>
|
|
||||||
</PopoverTrigger>
|
const labelsContent = (
|
||||||
<PopoverContent className="w-64 p-1" align="end" collisionPadding={16}>
|
<>
|
||||||
<input
|
<input
|
||||||
className="w-full px-2 py-1.5 text-xs bg-transparent outline-none border-b border-border mb-1 placeholder:text-muted-foreground/50"
|
className="w-full px-2 py-1.5 text-xs bg-transparent outline-none border-b border-border mb-1 placeholder:text-muted-foreground/50"
|
||||||
placeholder="Search labels..."
|
placeholder="Search labels..."
|
||||||
value={labelSearch}
|
value={labelSearch}
|
||||||
onChange={(e) => setLabelSearch(e.target.value)}
|
onChange={(e) => setLabelSearch(e.target.value)}
|
||||||
autoFocus
|
autoFocus={!inline}
|
||||||
/>
|
/>
|
||||||
<div className="max-h-44 overflow-y-auto overscroll-contain space-y-0.5">
|
<div className="max-h-44 overflow-y-auto overscroll-contain space-y-0.5">
|
||||||
{(labels ?? [])
|
{(labels ?? [])
|
||||||
@@ -223,31 +263,26 @@ export function IssueProperties({ issue, onUpdate }: IssuePropertiesProps) {
|
|||||||
{createLabel.isPending ? "Creating..." : "Create label"}
|
{createLabel.isPending ? "Creating..." : "Create label"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</PopoverContent>
|
</>
|
||||||
</Popover>
|
);
|
||||||
</PropertyRow>
|
|
||||||
|
|
||||||
<PropertyRow label="Assignee">
|
const assigneeTrigger = assignee ? (
|
||||||
<Popover open={assigneeOpen} onOpenChange={(open) => { setAssigneeOpen(open); if (!open) setAssigneeSearch(""); }}>
|
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<button className="inline-flex items-center gap-1.5 cursor-pointer hover:bg-accent/50 rounded px-1 -mx-1 py-0.5 transition-colors">
|
|
||||||
{assignee ? (
|
|
||||||
<Identity name={assignee.name} size="sm" />
|
<Identity name={assignee.name} size="sm" />
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<User className="h-3.5 w-3.5 text-muted-foreground" />
|
<User className="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
<span className="text-sm text-muted-foreground">Unassigned</span>
|
<span className="text-sm text-muted-foreground">Unassigned</span>
|
||||||
</>
|
</>
|
||||||
)}
|
);
|
||||||
</button>
|
|
||||||
</PopoverTrigger>
|
const assigneeContent = (
|
||||||
<PopoverContent className="w-52 p-1" align="end" collisionPadding={16}>
|
<>
|
||||||
<input
|
<input
|
||||||
className="w-full px-2 py-1.5 text-xs bg-transparent outline-none border-b border-border mb-1 placeholder:text-muted-foreground/50"
|
className="w-full px-2 py-1.5 text-xs bg-transparent outline-none border-b border-border mb-1 placeholder:text-muted-foreground/50"
|
||||||
placeholder="Search agents..."
|
placeholder="Search agents..."
|
||||||
value={assigneeSearch}
|
value={assigneeSearch}
|
||||||
onChange={(e) => setAssigneeSearch(e.target.value)}
|
onChange={(e) => setAssigneeSearch(e.target.value)}
|
||||||
autoFocus
|
autoFocus={!inline}
|
||||||
/>
|
/>
|
||||||
<div className="max-h-48 overflow-y-auto overscroll-contain">
|
<div className="max-h-48 overflow-y-auto overscroll-contain">
|
||||||
<button
|
<button
|
||||||
@@ -280,24 +315,10 @@ export function IssueProperties({ issue, onUpdate }: IssuePropertiesProps) {
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</PopoverContent>
|
</>
|
||||||
</Popover>
|
);
|
||||||
{issue.assigneeAgentId && (
|
|
||||||
<Link
|
|
||||||
to={`/agents/${issue.assigneeAgentId}`}
|
|
||||||
className="inline-flex items-center justify-center h-5 w-5 rounded hover:bg-accent/50 transition-colors text-muted-foreground hover:text-foreground"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<ArrowUpRight className="h-3 w-3" />
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
</PropertyRow>
|
|
||||||
|
|
||||||
<PropertyRow label="Project">
|
const projectTrigger = issue.projectId ? (
|
||||||
<Popover open={projectOpen} onOpenChange={(open) => { setProjectOpen(open); if (!open) setProjectSearch(""); }}>
|
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<button className="inline-flex items-center 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">
|
|
||||||
{issue.projectId ? (
|
|
||||||
<>
|
<>
|
||||||
<span
|
<span
|
||||||
className="shrink-0 h-3 w-3 rounded-sm"
|
className="shrink-0 h-3 w-3 rounded-sm"
|
||||||
@@ -310,16 +331,16 @@ export function IssueProperties({ issue, onUpdate }: IssuePropertiesProps) {
|
|||||||
<Hexagon className="h-3.5 w-3.5 text-muted-foreground" />
|
<Hexagon className="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
<span className="text-sm text-muted-foreground">No project</span>
|
<span className="text-sm text-muted-foreground">No project</span>
|
||||||
</>
|
</>
|
||||||
)}
|
);
|
||||||
</button>
|
|
||||||
</PopoverTrigger>
|
const projectContent = (
|
||||||
<PopoverContent className="w-fit min-w-[11rem] p-1" align="end" collisionPadding={16}>
|
<>
|
||||||
<input
|
<input
|
||||||
className="w-full px-2 py-1.5 text-xs bg-transparent outline-none border-b border-border mb-1 placeholder:text-muted-foreground/50"
|
className="w-full px-2 py-1.5 text-xs bg-transparent outline-none border-b border-border mb-1 placeholder:text-muted-foreground/50"
|
||||||
placeholder="Search projects..."
|
placeholder="Search projects..."
|
||||||
value={projectSearch}
|
value={projectSearch}
|
||||||
onChange={(e) => setProjectSearch(e.target.value)}
|
onChange={(e) => setProjectSearch(e.target.value)}
|
||||||
autoFocus
|
autoFocus={!inline}
|
||||||
/>
|
/>
|
||||||
<div className="max-h-48 overflow-y-auto overscroll-contain">
|
<div className="max-h-48 overflow-y-auto overscroll-contain">
|
||||||
<button
|
<button
|
||||||
@@ -354,9 +375,69 @@ export function IssueProperties({ issue, onUpdate }: IssuePropertiesProps) {
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</PopoverContent>
|
</>
|
||||||
</Popover>
|
);
|
||||||
{issue.projectId && (
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<PropertyRow label="Status">
|
||||||
|
<StatusIcon
|
||||||
|
status={issue.status}
|
||||||
|
onChange={(status) => onUpdate({ status })}
|
||||||
|
showLabel
|
||||||
|
/>
|
||||||
|
</PropertyRow>
|
||||||
|
|
||||||
|
<PropertyRow label="Priority">
|
||||||
|
<PriorityIcon
|
||||||
|
priority={issue.priority}
|
||||||
|
onChange={(priority) => onUpdate({ priority })}
|
||||||
|
showLabel
|
||||||
|
/>
|
||||||
|
</PropertyRow>
|
||||||
|
|
||||||
|
<PropertyPicker
|
||||||
|
inline={inline}
|
||||||
|
label="Labels"
|
||||||
|
open={labelsOpen}
|
||||||
|
onOpenChange={(open) => { setLabelsOpen(open); if (!open) setLabelSearch(""); }}
|
||||||
|
triggerContent={labelsTrigger}
|
||||||
|
triggerClassName="min-w-0 max-w-full"
|
||||||
|
popoverClassName="w-64"
|
||||||
|
>
|
||||||
|
{labelsContent}
|
||||||
|
</PropertyPicker>
|
||||||
|
|
||||||
|
<PropertyPicker
|
||||||
|
inline={inline}
|
||||||
|
label="Assignee"
|
||||||
|
open={assigneeOpen}
|
||||||
|
onOpenChange={(open) => { setAssigneeOpen(open); if (!open) setAssigneeSearch(""); }}
|
||||||
|
triggerContent={assigneeTrigger}
|
||||||
|
popoverClassName="w-52"
|
||||||
|
extra={issue.assigneeAgentId ? (
|
||||||
|
<Link
|
||||||
|
to={`/agents/${issue.assigneeAgentId}`}
|
||||||
|
className="inline-flex items-center justify-center h-5 w-5 rounded hover:bg-accent/50 transition-colors text-muted-foreground hover:text-foreground"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<ArrowUpRight className="h-3 w-3" />
|
||||||
|
</Link>
|
||||||
|
) : undefined}
|
||||||
|
>
|
||||||
|
{assigneeContent}
|
||||||
|
</PropertyPicker>
|
||||||
|
|
||||||
|
<PropertyPicker
|
||||||
|
inline={inline}
|
||||||
|
label="Project"
|
||||||
|
open={projectOpen}
|
||||||
|
onOpenChange={(open) => { setProjectOpen(open); if (!open) setProjectSearch(""); }}
|
||||||
|
triggerContent={projectTrigger}
|
||||||
|
triggerClassName="min-w-0 max-w-full"
|
||||||
|
popoverClassName="w-fit min-w-[11rem]"
|
||||||
|
extra={issue.projectId ? (
|
||||||
<Link
|
<Link
|
||||||
to={`/projects/${issue.projectId}`}
|
to={`/projects/${issue.projectId}`}
|
||||||
className="inline-flex items-center justify-center h-5 w-5 rounded hover:bg-accent/50 transition-colors text-muted-foreground hover:text-foreground"
|
className="inline-flex items-center justify-center h-5 w-5 rounded hover:bg-accent/50 transition-colors text-muted-foreground hover:text-foreground"
|
||||||
@@ -364,8 +445,10 @@ export function IssueProperties({ issue, onUpdate }: IssuePropertiesProps) {
|
|||||||
>
|
>
|
||||||
<ArrowUpRight className="h-3 w-3" />
|
<ArrowUpRight className="h-3 w-3" />
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
) : undefined}
|
||||||
</PropertyRow>
|
>
|
||||||
|
{projectContent}
|
||||||
|
</PropertyPicker>
|
||||||
|
|
||||||
{issue.parentId && (
|
{issue.parentId && (
|
||||||
<PropertyRow label="Parent">
|
<PropertyRow label="Parent">
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect, useRef, useCallback, type ChangeEvent } from "react";
|
import { useState, useEffect, useRef, useCallback, useMemo, type ChangeEvent } from "react";
|
||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useDialog } from "../context/DialogContext";
|
import { useDialog } from "../context/DialogContext";
|
||||||
import { useCompany } from "../context/CompanyContext";
|
import { useCompany } from "../context/CompanyContext";
|
||||||
@@ -27,8 +27,6 @@ import {
|
|||||||
ArrowUp,
|
ArrowUp,
|
||||||
ArrowDown,
|
ArrowDown,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
User,
|
|
||||||
Hexagon,
|
|
||||||
Tag,
|
Tag,
|
||||||
Calendar,
|
Calendar,
|
||||||
Paperclip,
|
Paperclip,
|
||||||
@@ -37,7 +35,7 @@ import { cn } from "../lib/utils";
|
|||||||
import { issueStatusText, issueStatusTextDefault, priorityColor, priorityColorDefault } from "../lib/status-colors";
|
import { issueStatusText, issueStatusTextDefault, priorityColor, priorityColorDefault } from "../lib/status-colors";
|
||||||
import { MarkdownEditor, type MarkdownEditorRef } from "./MarkdownEditor";
|
import { MarkdownEditor, type MarkdownEditorRef } from "./MarkdownEditor";
|
||||||
import { AgentIcon } from "./AgentIconPicker";
|
import { AgentIcon } from "./AgentIconPicker";
|
||||||
import type { Project, Agent } from "@paperclip/shared";
|
import { InlineEntitySelector, type InlineEntityOption } from "./InlineEntitySelector";
|
||||||
|
|
||||||
const DRAFT_KEY = "paperclip:issue-draft";
|
const DRAFT_KEY = "paperclip:issue-draft";
|
||||||
const DEBOUNCE_MS = 800;
|
const DEBOUNCE_MS = 800;
|
||||||
@@ -101,12 +99,11 @@ export function NewIssueDialog() {
|
|||||||
// Popover states
|
// Popover states
|
||||||
const [statusOpen, setStatusOpen] = useState(false);
|
const [statusOpen, setStatusOpen] = useState(false);
|
||||||
const [priorityOpen, setPriorityOpen] = useState(false);
|
const [priorityOpen, setPriorityOpen] = useState(false);
|
||||||
const [assigneeOpen, setAssigneeOpen] = useState(false);
|
|
||||||
const [assigneeSearch, setAssigneeSearch] = useState("");
|
|
||||||
const [projectOpen, setProjectOpen] = useState(false);
|
|
||||||
const [moreOpen, setMoreOpen] = useState(false);
|
const [moreOpen, setMoreOpen] = useState(false);
|
||||||
const descriptionEditorRef = useRef<MarkdownEditorRef>(null);
|
const descriptionEditorRef = useRef<MarkdownEditorRef>(null);
|
||||||
const attachInputRef = useRef<HTMLInputElement | null>(null);
|
const attachInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
const assigneeSelectorRef = useRef<HTMLButtonElement | null>(null);
|
||||||
|
const projectSelectorRef = useRef<HTMLButtonElement | null>(null);
|
||||||
|
|
||||||
const { data: agents } = useQuery({
|
const { data: agents } = useQuery({
|
||||||
queryKey: queryKeys.agents.list(selectedCompanyId!),
|
queryKey: queryKeys.agents.list(selectedCompanyId!),
|
||||||
@@ -245,6 +242,26 @@ export function NewIssueDialog() {
|
|||||||
const currentPriority = priorities.find((p) => p.value === priority);
|
const currentPriority = priorities.find((p) => p.value === priority);
|
||||||
const currentAssignee = (agents ?? []).find((a) => a.id === assigneeId);
|
const currentAssignee = (agents ?? []).find((a) => a.id === assigneeId);
|
||||||
const currentProject = (projects ?? []).find((p) => p.id === projectId);
|
const currentProject = (projects ?? []).find((p) => p.id === projectId);
|
||||||
|
const assigneeOptions = useMemo<InlineEntityOption[]>(
|
||||||
|
() =>
|
||||||
|
(agents ?? [])
|
||||||
|
.filter((agent) => agent.status !== "terminated")
|
||||||
|
.map((agent) => ({
|
||||||
|
id: agent.id,
|
||||||
|
label: agent.name,
|
||||||
|
searchText: `${agent.name} ${agent.role} ${agent.title ?? ""}`,
|
||||||
|
})),
|
||||||
|
[agents],
|
||||||
|
);
|
||||||
|
const projectOptions = useMemo<InlineEntityOption[]>(
|
||||||
|
() =>
|
||||||
|
(projects ?? []).map((project) => ({
|
||||||
|
id: project.id,
|
||||||
|
label: project.name,
|
||||||
|
searchText: project.description ?? "",
|
||||||
|
})),
|
||||||
|
[projects],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog
|
||||||
@@ -257,9 +274,9 @@ export function NewIssueDialog() {
|
|||||||
showCloseButton={false}
|
showCloseButton={false}
|
||||||
aria-describedby={undefined}
|
aria-describedby={undefined}
|
||||||
className={cn(
|
className={cn(
|
||||||
"p-0 gap-0 flex flex-col max-h-[calc(100vh-6rem)]",
|
"p-0 gap-0 flex flex-col max-h-[calc(100dvh-2rem)]",
|
||||||
expanded
|
expanded
|
||||||
? "sm:max-w-2xl h-[calc(100vh-6rem)]"
|
? "sm:max-w-2xl h-[calc(100dvh-2rem)]"
|
||||||
: "sm:max-w-lg"
|
: "sm:max-w-lg"
|
||||||
)}
|
)}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
@@ -297,23 +314,113 @@ export function NewIssueDialog() {
|
|||||||
|
|
||||||
{/* Title */}
|
{/* Title */}
|
||||||
<div className="px-4 pt-4 pb-2 shrink-0">
|
<div className="px-4 pt-4 pb-2 shrink-0">
|
||||||
<input
|
<textarea
|
||||||
className="w-full text-lg font-semibold bg-transparent outline-none placeholder:text-muted-foreground/50"
|
className="w-full text-lg font-semibold bg-transparent outline-none resize-none overflow-hidden placeholder:text-muted-foreground/50"
|
||||||
placeholder="Issue title"
|
placeholder="Issue title"
|
||||||
|
rows={1}
|
||||||
value={title}
|
value={title}
|
||||||
onChange={(e) => setTitle(e.target.value)}
|
onChange={(e) => {
|
||||||
|
setTitle(e.target.value);
|
||||||
|
e.target.style.height = "auto";
|
||||||
|
e.target.style.height = `${e.target.scrollHeight}px`;
|
||||||
|
}}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Tab" && !e.shiftKey) {
|
if (e.key === "Enter" && !e.metaKey && !e.ctrlKey) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
descriptionEditorRef.current?.focus();
|
descriptionEditorRef.current?.focus();
|
||||||
}
|
}
|
||||||
|
if (e.key === "Tab" && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
assigneeSelectorRef.current?.focus();
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="px-4 pb-2 shrink-0">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<div className="inline-flex min-w-max items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<span>For</span>
|
||||||
|
<InlineEntitySelector
|
||||||
|
ref={assigneeSelectorRef}
|
||||||
|
value={assigneeId}
|
||||||
|
options={assigneeOptions}
|
||||||
|
placeholder="Assignee"
|
||||||
|
noneLabel="No assignee"
|
||||||
|
searchPlaceholder="Search assignees..."
|
||||||
|
emptyMessage="No assignees found."
|
||||||
|
onChange={setAssigneeId}
|
||||||
|
onConfirm={() => {
|
||||||
|
projectSelectorRef.current?.focus();
|
||||||
|
}}
|
||||||
|
renderTriggerValue={(option) =>
|
||||||
|
option && currentAssignee ? (
|
||||||
|
<>
|
||||||
|
<AgentIcon icon={currentAssignee.icon} className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||||
|
<span className="truncate">{option.label}</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">Assignee</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
renderOption={(option) => {
|
||||||
|
if (!option.id) return <span className="truncate">{option.label}</span>;
|
||||||
|
const assignee = (agents ?? []).find((agent) => agent.id === option.id);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<AgentIcon icon={assignee?.icon} className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||||
|
<span className="truncate">{option.label}</span>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span>in</span>
|
||||||
|
<InlineEntitySelector
|
||||||
|
ref={projectSelectorRef}
|
||||||
|
value={projectId}
|
||||||
|
options={projectOptions}
|
||||||
|
placeholder="Project"
|
||||||
|
noneLabel="No project"
|
||||||
|
searchPlaceholder="Search projects..."
|
||||||
|
emptyMessage="No projects found."
|
||||||
|
onChange={setProjectId}
|
||||||
|
onConfirm={() => {
|
||||||
|
descriptionEditorRef.current?.focus();
|
||||||
|
}}
|
||||||
|
renderTriggerValue={(option) =>
|
||||||
|
option && currentProject ? (
|
||||||
|
<>
|
||||||
|
<span
|
||||||
|
className="h-3.5 w-3.5 shrink-0 rounded-sm"
|
||||||
|
style={{ backgroundColor: currentProject.color ?? "#6366f1" }}
|
||||||
|
/>
|
||||||
|
<span className="truncate">{option.label}</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">Project</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
renderOption={(option) => {
|
||||||
|
if (!option.id) return <span className="truncate">{option.label}</span>;
|
||||||
|
const project = (projects ?? []).find((item) => item.id === option.id);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<span
|
||||||
|
className="h-3.5 w-3.5 shrink-0 rounded-sm"
|
||||||
|
style={{ backgroundColor: project?.color ?? "#6366f1" }}
|
||||||
|
/>
|
||||||
|
<span className="truncate">{option.label}</span>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Description */}
|
{/* Description */}
|
||||||
<div className={cn("px-4 pb-2 overflow-y-auto min-h-0", expanded ? "flex-1" : "")}>
|
<div className={cn("px-4 pb-2 overflow-y-auto min-h-0 border-t border-border/60 pt-3", expanded ? "flex-1" : "")}>
|
||||||
<MarkdownEditor
|
<MarkdownEditor
|
||||||
ref={descriptionEditorRef}
|
ref={descriptionEditorRef}
|
||||||
value={description}
|
value={description}
|
||||||
@@ -389,116 +496,6 @@ export function NewIssueDialog() {
|
|||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
|
|
||||||
{/* Assignee chip */}
|
|
||||||
<Popover open={assigneeOpen} onOpenChange={(open) => { setAssigneeOpen(open); if (!open) setAssigneeSearch(""); }}>
|
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<button className="inline-flex items-center gap-1.5 rounded-md border border-border px-2 py-1 text-xs hover:bg-accent/50 transition-colors">
|
|
||||||
{currentAssignee ? (
|
|
||||||
<>
|
|
||||||
<AgentIcon icon={currentAssignee.icon} className="h-3 w-3 text-muted-foreground" />
|
|
||||||
{currentAssignee.name}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<User className="h-3 w-3 text-muted-foreground" />
|
|
||||||
Assignee
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent className="w-52 p-1" align="start">
|
|
||||||
<input
|
|
||||||
className="w-full px-2 py-1.5 text-xs bg-transparent outline-none border-b border-border mb-1 placeholder:text-muted-foreground/50"
|
|
||||||
placeholder="Search agents..."
|
|
||||||
value={assigneeSearch}
|
|
||||||
onChange={(e) => setAssigneeSearch(e.target.value)}
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
<div className="max-h-48 overflow-y-auto overscroll-contain">
|
|
||||||
<button
|
|
||||||
className={cn(
|
|
||||||
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
|
|
||||||
!assigneeId && "bg-accent"
|
|
||||||
)}
|
|
||||||
onClick={() => { setAssigneeId(""); setAssigneeOpen(false); }}
|
|
||||||
>
|
|
||||||
No assignee
|
|
||||||
</button>
|
|
||||||
{(agents ?? [])
|
|
||||||
.filter((a) => a.status !== "terminated")
|
|
||||||
.filter((a) => {
|
|
||||||
if (!assigneeSearch.trim()) return true;
|
|
||||||
const q = assigneeSearch.toLowerCase();
|
|
||||||
return a.name.toLowerCase().includes(q);
|
|
||||||
})
|
|
||||||
.map((a) => (
|
|
||||||
<button
|
|
||||||
key={a.id}
|
|
||||||
className={cn(
|
|
||||||
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
|
|
||||||
a.id === assigneeId && "bg-accent"
|
|
||||||
)}
|
|
||||||
onClick={() => { setAssigneeId(a.id); setAssigneeOpen(false); }}
|
|
||||||
>
|
|
||||||
<AgentIcon icon={a.icon} className="shrink-0 h-3 w-3 text-muted-foreground" />
|
|
||||||
{a.name}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
|
|
||||||
{/* Project chip */}
|
|
||||||
<Popover open={projectOpen} onOpenChange={setProjectOpen}>
|
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<button className="inline-flex items-center gap-1.5 rounded-md border border-border px-2 py-1 text-xs hover:bg-accent/50 transition-colors">
|
|
||||||
{currentProject ? (
|
|
||||||
<>
|
|
||||||
<span
|
|
||||||
className="shrink-0 h-3 w-3 rounded-sm"
|
|
||||||
style={{ backgroundColor: currentProject.color ?? "#6366f1" }}
|
|
||||||
/>
|
|
||||||
{currentProject.name}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Hexagon className="h-3 w-3 text-muted-foreground" />
|
|
||||||
Project
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent className="w-fit min-w-[11rem] p-1" align="start">
|
|
||||||
<div className="max-h-48 overflow-y-auto overscroll-contain">
|
|
||||||
<button
|
|
||||||
className={cn(
|
|
||||||
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50 whitespace-nowrap",
|
|
||||||
!projectId && "bg-accent"
|
|
||||||
)}
|
|
||||||
onClick={() => { setProjectId(""); setProjectOpen(false); }}
|
|
||||||
>
|
|
||||||
No project
|
|
||||||
</button>
|
|
||||||
{(projects ?? []).map((p) => (
|
|
||||||
<button
|
|
||||||
key={p.id}
|
|
||||||
className={cn(
|
|
||||||
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50 whitespace-nowrap",
|
|
||||||
p.id === projectId && "bg-accent"
|
|
||||||
)}
|
|
||||||
onClick={() => { setProjectId(p.id); setProjectOpen(false); }}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className="shrink-0 h-3 w-3 rounded-sm"
|
|
||||||
style={{ backgroundColor: p.color ?? "#6366f1" }}
|
|
||||||
/>
|
|
||||||
{p.name}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
|
|
||||||
{/* Labels chip (placeholder) */}
|
{/* Labels chip (placeholder) */}
|
||||||
<button className="inline-flex items-center gap-1.5 rounded-md border border-border px-2 py-1 text-xs hover:bg-accent/50 transition-colors text-muted-foreground">
|
<button className="inline-flex items-center gap-1.5 rounded-md border border-border px-2 py-1 text-xs hover:bg-accent/50 transition-colors text-muted-foreground">
|
||||||
<Tag className="h-3 w-3" />
|
<Tag className="h-3 w-3" />
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ function DialogContent({
|
|||||||
<DialogPrimitive.Content
|
<DialogPrimitive.Content
|
||||||
data-slot="dialog-content"
|
data-slot="dialog-content"
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-[0.97] data-[state=open]:zoom-in-[0.97] data-[state=closed]:slide-out-to-top-[1%] data-[state=open]:slide-in-from-top-[1%] fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-150 ease-[cubic-bezier(0.16,1,0.3,1)] outline-none sm:max-w-lg",
|
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-[0.97] data-[state=open]:zoom-in-[0.97] data-[state=closed]:slide-out-to-top-[1%] data-[state=open]:slide-in-from-top-[1%] fixed top-[max(1rem,env(safe-area-inset-top))] md:top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-0 md:translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-150 ease-[cubic-bezier(0.16,1,0.3,1)] outline-none sm:max-w-lg",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -11,12 +11,12 @@ function ScrollArea({
|
|||||||
return (
|
return (
|
||||||
<ScrollAreaPrimitive.Root
|
<ScrollAreaPrimitive.Root
|
||||||
data-slot="scroll-area"
|
data-slot="scroll-area"
|
||||||
className={cn("relative", className)}
|
className={cn("relative flex flex-col", className)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<ScrollAreaPrimitive.Viewport
|
<ScrollAreaPrimitive.Viewport
|
||||||
data-slot="scroll-area-viewport"
|
data-slot="scroll-area-viewport"
|
||||||
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
|
className="flex-1 min-h-0 w-full focus-visible:ring-ring/50 rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</ScrollAreaPrimitive.Viewport>
|
</ScrollAreaPrimitive.Viewport>
|
||||||
|
|||||||
@@ -819,7 +819,7 @@ export function IssueDetail() {
|
|||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
<ScrollArea className="flex-1 overflow-y-auto">
|
<ScrollArea className="flex-1 overflow-y-auto">
|
||||||
<div className="px-4 pb-4">
|
<div className="px-4 pb-4">
|
||||||
<IssueProperties issue={issue} onUpdate={(data) => updateIssue.mutate(data)} />
|
<IssueProperties issue={issue} onUpdate={(data) => updateIssue.mutate(data)} inline />
|
||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
</SheetContent>
|
</SheetContent>
|
||||||
|
|||||||
Reference in New Issue
Block a user