forked from farhoodlabs/paperclip
5a1e17f27f
Persist realized isolated/operator workspaces back onto the issue as reusable workspaces so later runs stay on the same workspace, and update the issue workspace picker to present realized isolated workspaces as existing workspaces. Co-Authored-By: Paperclip <noreply@paperclip.ing>
821 lines
31 KiB
TypeScript
821 lines
31 KiB
TypeScript
import { useCallback, useMemo, useRef, useState } from "react";
|
|
import { Link } from "@/lib/router";
|
|
import type { Issue } from "@paperclipai/shared";
|
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
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 { projectsApi } from "../api/projects";
|
|
import { useCompany } from "../context/CompanyContext";
|
|
import { queryKeys } from "../lib/queryKeys";
|
|
import { useProjectOrder } from "../hooks/useProjectOrder";
|
|
import { getRecentAssigneeIds, sortAgentsByRecency, trackRecentAssignee } from "../lib/recent-assignees";
|
|
import { formatAssigneeUserLabel } from "../lib/assignees";
|
|
import { StatusIcon } from "./StatusIcon";
|
|
import { PriorityIcon } from "./PriorityIcon";
|
|
import { Identity } from "./Identity";
|
|
import { formatDate, cn, projectUrl } from "../lib/utils";
|
|
import { timeAgo } from "../lib/timeAgo";
|
|
import { Separator } from "@/components/ui/separator";
|
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
|
import { User, Hexagon, ArrowUpRight, Tag, Plus, Trash2, Copy, Check } from "lucide-react";
|
|
import { AgentIcon } from "./AgentIconPicker";
|
|
|
|
const EXECUTION_WORKSPACE_OPTIONS = [
|
|
{ value: "shared_workspace", label: "Project default" },
|
|
{ value: "isolated_workspace", label: "New isolated workspace" },
|
|
{ value: "reuse_existing", label: "Reuse existing workspace" },
|
|
] as const;
|
|
|
|
function defaultProjectWorkspaceIdForProject(project: {
|
|
workspaces?: Array<{ id: string; isPrimary: boolean }>;
|
|
executionWorkspacePolicy?: { defaultProjectWorkspaceId?: string | null } | null;
|
|
} | null | undefined) {
|
|
if (!project) return 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 issueModeForExistingWorkspace(mode: string | null | undefined) {
|
|
if (mode === "isolated_workspace" || mode === "operator_branch" || mode === "shared_workspace") return mode;
|
|
if (mode === "adapter_managed" || mode === "cloud_sandbox") return "agent_default";
|
|
return "shared_workspace";
|
|
}
|
|
|
|
function shouldPresentExistingWorkspaceSelection(issue: Issue) {
|
|
const persistedMode =
|
|
issue.currentExecutionWorkspace?.mode
|
|
?? issue.executionWorkspaceSettings?.mode
|
|
?? issue.executionWorkspacePreference;
|
|
return Boolean(
|
|
issue.executionWorkspaceId &&
|
|
(persistedMode === "isolated_workspace" || persistedMode === "operator_branch"),
|
|
);
|
|
}
|
|
|
|
interface IssuePropertiesProps {
|
|
issue: Issue;
|
|
onUpdate: (data: Record<string, unknown>) => void;
|
|
inline?: boolean;
|
|
}
|
|
|
|
function PropertyRow({ label, children }: { label: string; children: React.ReactNode }) {
|
|
return (
|
|
<div className="flex items-center gap-3 py-1.5">
|
|
<span className="text-xs text-muted-foreground shrink-0 w-20">{label}</span>
|
|
<div className="flex items-center gap-1.5 min-w-0 flex-1">{children}</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** 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>
|
|
);
|
|
}
|
|
|
|
/** Splits a string at `/` and `-` boundaries, inserting <wbr> for natural line breaks. */
|
|
function BreakablePath({ text }: { text: string }) {
|
|
const parts: React.ReactNode[] = [];
|
|
// Split on path separators and hyphens, keeping them in the output
|
|
const segments = text.split(/(?<=[\/-])/);
|
|
for (let i = 0; i < segments.length; i++) {
|
|
if (i > 0) parts.push(<wbr key={i} />);
|
|
parts.push(segments[i]);
|
|
}
|
|
return <>{parts}</>;
|
|
}
|
|
|
|
/** Displays a value with a copy-to-clipboard icon and "Copied!" feedback. */
|
|
function CopyableValue({ value, label, mono, className }: { value: string; label?: string; mono?: boolean; className?: string }) {
|
|
const [copied, setCopied] = useState(false);
|
|
const timerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
|
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 (
|
|
<div className={cn("flex items-start gap-1 group", className)}>
|
|
<span className="min-w-0" style={{ overflowWrap: "anywhere" }}>
|
|
{label && <span className="text-muted-foreground">{label} </span>}
|
|
<span className={mono ? "font-mono" : undefined}><BreakablePath text={value} /></span>
|
|
</span>
|
|
<button
|
|
type="button"
|
|
className="shrink-0 mt-0.5 p-0.5 rounded hover:bg-accent/50 transition-colors text-muted-foreground hover:text-foreground opacity-0 group-hover:opacity-100 focus:opacity-100"
|
|
onClick={handleCopy}
|
|
title={copied ? "Copied!" : "Copy to clipboard"}
|
|
>
|
|
{copied ? <Check className="h-3 w-3 text-green-500" /> : <Copy className="h-3 w-3" />}
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function IssueProperties({ issue, 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 [labelsOpen, setLabelsOpen] = useState(false);
|
|
const [labelSearch, setLabelSearch] = useState("");
|
|
const [newLabelName, setNewLabelName] = useState("");
|
|
const [newLabelColor, setNewLabelColor] = useState("#6366f1");
|
|
|
|
const { data: session } = useQuery({
|
|
queryKey: queryKeys.auth.session,
|
|
queryFn: () => authApi.getSession(),
|
|
});
|
|
const { data: experimentalSettings } = useQuery({
|
|
queryKey: queryKeys.instance.experimentalSettings,
|
|
queryFn: () => instanceSettingsApi.getExperimental(),
|
|
});
|
|
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: 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 createLabel = useMutation({
|
|
mutationFn: (data: { name: string; color: string }) => issuesApi.createLabel(companyId!, data),
|
|
onSuccess: async (created) => {
|
|
await queryClient.invalidateQueries({ queryKey: queryKeys.issues.labels(companyId!) });
|
|
onUpdate({ labelIds: [...(issue.labelIds ?? []), created.id] });
|
|
setNewLabelName("");
|
|
},
|
|
});
|
|
|
|
const deleteLabel = useMutation({
|
|
mutationFn: (labelId: string) => issuesApi.deleteLabel(labelId),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.labels(companyId!) });
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(companyId!) });
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(issue.id) });
|
|
},
|
|
});
|
|
|
|
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 currentProjectExecutionWorkspacePolicy =
|
|
experimentalSettings?.enableIsolatedWorkspaces === true
|
|
? currentProject?.executionWorkspacePolicy ?? null
|
|
: null;
|
|
const currentProjectSupportsExecutionWorkspace = Boolean(currentProjectExecutionWorkspacePolicy?.enabled);
|
|
const { data: reusableExecutionWorkspaces } = 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),
|
|
});
|
|
const deduplicatedReusableWorkspaces = useMemo(() => {
|
|
const workspaces = reusableExecutionWorkspaces ?? [];
|
|
const seen = new Map<string, typeof workspaces[number]>();
|
|
for (const ws of workspaces) {
|
|
const key = ws.cwd ?? ws.id;
|
|
const existing = seen.get(key);
|
|
if (!existing || new Date(ws.lastUsedAt) > new Date(existing.lastUsedAt)) {
|
|
seen.set(key, ws);
|
|
}
|
|
}
|
|
return Array.from(seen.values());
|
|
}, [reusableExecutionWorkspaces]);
|
|
const selectedReusableExecutionWorkspace =
|
|
deduplicatedReusableWorkspaces.find((workspace) => workspace.id === issue.executionWorkspaceId)
|
|
?? issue.currentExecutionWorkspace
|
|
?? null;
|
|
const currentExecutionWorkspaceSelection = shouldPresentExistingWorkspaceSelection(issue)
|
|
? "reuse_existing"
|
|
: (
|
|
issue.executionWorkspacePreference
|
|
?? issue.executionWorkspaceSettings?.mode
|
|
?? defaultExecutionWorkspaceModeForProject(currentProject)
|
|
);
|
|
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((a) => a.status !== "terminated"), recentAssigneeIds),
|
|
[agents, recentAssigneeIds],
|
|
);
|
|
|
|
const assignee = issue.assigneeAgentId
|
|
? agents?.find((a) => a.id === issue.assigneeAgentId)
|
|
: null;
|
|
const userLabel = (userId: string | null | undefined) => formatAssigneeUserLabel(userId, currentUserId);
|
|
const assigneeUserLabel = userLabel(issue.assigneeUserId);
|
|
const creatorUserLabel = userLabel(issue.createdByUserId);
|
|
|
|
const labelsTrigger = (issue.labels ?? []).length > 0 ? (
|
|
<div className="flex items-center gap-1 flex-wrap">
|
|
{(issue.labels ?? []).slice(0, 3).map((label) => (
|
|
<span
|
|
key={label.id}
|
|
className="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium border"
|
|
style={{
|
|
borderColor: label.color,
|
|
backgroundColor: `${label.color}22`,
|
|
color: label.color,
|
|
}}
|
|
>
|
|
{label.name}
|
|
</span>
|
|
))}
|
|
{(issue.labels ?? []).length > 3 && (
|
|
<span className="text-xs text-muted-foreground">+{(issue.labels ?? []).length - 3}</span>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<>
|
|
<Tag className="h-3.5 w-3.5 text-muted-foreground" />
|
|
<span className="text-sm text-muted-foreground">No labels</span>
|
|
</>
|
|
);
|
|
|
|
const labelsContent = (
|
|
<>
|
|
<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 labels..."
|
|
value={labelSearch}
|
|
onChange={(e) => setLabelSearch(e.target.value)}
|
|
autoFocus={!inline}
|
|
/>
|
|
<div className="max-h-44 overflow-y-auto overscroll-contain space-y-0.5">
|
|
{(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 (
|
|
<div key={label.id} className="flex items-center gap-1">
|
|
<button
|
|
className={cn(
|
|
"flex items-center gap-2 flex-1 px-2 py-1.5 text-xs rounded hover:bg-accent/50 text-left",
|
|
selected && "bg-accent"
|
|
)}
|
|
onClick={() => toggleLabel(label.id)}
|
|
>
|
|
<span className="h-2.5 w-2.5 rounded-full shrink-0" style={{ backgroundColor: label.color }} />
|
|
<span className="truncate">{label.name}</span>
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="p-1 text-muted-foreground hover:text-destructive rounded"
|
|
onClick={() => deleteLabel.mutate(label.id)}
|
|
title={`Delete ${label.name}`}
|
|
>
|
|
<Trash2 className="h-3 w-3" />
|
|
</button>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
<div className="mt-2 border-t border-border pt-2 space-y-1">
|
|
<div className="flex items-center gap-1">
|
|
<input
|
|
className="h-7 w-7 p-0 rounded bg-transparent"
|
|
type="color"
|
|
value={newLabelColor}
|
|
onChange={(e) => setNewLabelColor(e.target.value)}
|
|
/>
|
|
<input
|
|
className="flex-1 px-2 py-1.5 text-xs bg-transparent outline-none rounded placeholder:text-muted-foreground/50"
|
|
placeholder="New label"
|
|
value={newLabelName}
|
|
onChange={(e) => setNewLabelName(e.target.value)}
|
|
/>
|
|
</div>
|
|
<button
|
|
className="flex items-center justify-center gap-1.5 w-full px-2 py-1.5 text-xs rounded border border-border hover:bg-accent/50 disabled:opacity-50"
|
|
disabled={!newLabelName.trim() || createLabel.isPending}
|
|
onClick={() =>
|
|
createLabel.mutate({
|
|
name: newLabelName.trim(),
|
|
color: newLabelColor,
|
|
})
|
|
}
|
|
>
|
|
<Plus className="h-3 w-3" />
|
|
{createLabel.isPending ? "Creating…" : "Create label"}
|
|
</button>
|
|
</div>
|
|
</>
|
|
);
|
|
|
|
const assigneeTrigger = assignee ? (
|
|
<Identity name={assignee.name} size="sm" />
|
|
) : assigneeUserLabel ? (
|
|
<>
|
|
<User className="h-3.5 w-3.5 text-muted-foreground" />
|
|
<span className="text-sm">{assigneeUserLabel}</span>
|
|
</>
|
|
) : (
|
|
<>
|
|
<User className="h-3.5 w-3.5 text-muted-foreground" />
|
|
<span className="text-sm text-muted-foreground">Unassigned</span>
|
|
</>
|
|
);
|
|
|
|
const assigneeContent = (
|
|
<>
|
|
<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 assignees..."
|
|
value={assigneeSearch}
|
|
onChange={(e) => setAssigneeSearch(e.target.value)}
|
|
autoFocus={!inline}
|
|
/>
|
|
<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",
|
|
!issue.assigneeAgentId && !issue.assigneeUserId && "bg-accent"
|
|
)}
|
|
onClick={() => { onUpdate({ assigneeAgentId: null, assigneeUserId: null }); setAssigneeOpen(false); }}
|
|
>
|
|
No assignee
|
|
</button>
|
|
{currentUserId && (
|
|
<button
|
|
className={cn(
|
|
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
|
|
issue.assigneeUserId === currentUserId && "bg-accent",
|
|
)}
|
|
onClick={() => {
|
|
onUpdate({ assigneeAgentId: null, assigneeUserId: currentUserId });
|
|
setAssigneeOpen(false);
|
|
}}
|
|
>
|
|
<User className="h-3 w-3 shrink-0 text-muted-foreground" />
|
|
Assign to me
|
|
</button>
|
|
)}
|
|
{issue.createdByUserId && issue.createdByUserId !== currentUserId && (
|
|
<button
|
|
className={cn(
|
|
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
|
|
issue.assigneeUserId === issue.createdByUserId && "bg-accent",
|
|
)}
|
|
onClick={() => {
|
|
onUpdate({ assigneeAgentId: null, assigneeUserId: issue.createdByUserId });
|
|
setAssigneeOpen(false);
|
|
}}
|
|
>
|
|
<User className="h-3 w-3 shrink-0 text-muted-foreground" />
|
|
{creatorUserLabel ? `Assign to ${creatorUserLabel}` : "Assign to requester"}
|
|
</button>
|
|
)}
|
|
{sortedAgents
|
|
.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 === issue.assigneeAgentId && "bg-accent"
|
|
)}
|
|
onClick={() => { trackRecentAssignee(a.id); onUpdate({ assigneeAgentId: a.id, assigneeUserId: null }); setAssigneeOpen(false); }}
|
|
>
|
|
<AgentIcon icon={a.icon} className="shrink-0 h-3 w-3 text-muted-foreground" />
|
|
{a.name}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</>
|
|
);
|
|
|
|
const projectTrigger = issue.projectId ? (
|
|
<>
|
|
<span
|
|
className="shrink-0 h-3 w-3 rounded-sm"
|
|
style={{ backgroundColor: orderedProjects.find((p) => p.id === issue.projectId)?.color ?? "#6366f1" }}
|
|
/>
|
|
<span className="text-sm truncate">{projectName(issue.projectId)}</span>
|
|
</>
|
|
) : (
|
|
<>
|
|
<Hexagon className="h-3.5 w-3.5 text-muted-foreground" />
|
|
<span className="text-sm text-muted-foreground">No project</span>
|
|
</>
|
|
);
|
|
|
|
const projectContent = (
|
|
<>
|
|
<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 projects..."
|
|
value={projectSearch}
|
|
onChange={(e) => setProjectSearch(e.target.value)}
|
|
autoFocus={!inline}
|
|
/>
|
|
<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",
|
|
!issue.projectId && "bg-accent"
|
|
)}
|
|
onClick={() => {
|
|
onUpdate({
|
|
projectId: null,
|
|
projectWorkspaceId: null,
|
|
executionWorkspaceId: null,
|
|
executionWorkspacePreference: null,
|
|
executionWorkspaceSettings: null,
|
|
});
|
|
setProjectOpen(false);
|
|
}}
|
|
>
|
|
No project
|
|
</button>
|
|
{orderedProjects
|
|
.filter((p) => {
|
|
if (!projectSearch.trim()) return true;
|
|
const q = projectSearch.toLowerCase();
|
|
return p.name.toLowerCase().includes(q);
|
|
})
|
|
.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 === issue.projectId && "bg-accent"
|
|
)}
|
|
onClick={() => {
|
|
const defaultMode = defaultExecutionWorkspaceModeForProject(p);
|
|
onUpdate({
|
|
projectId: p.id,
|
|
projectWorkspaceId: defaultProjectWorkspaceIdForProject(p),
|
|
executionWorkspaceId: null,
|
|
executionWorkspacePreference: defaultMode,
|
|
executionWorkspaceSettings: p.executionWorkspacePolicy?.enabled
|
|
? { mode: defaultMode }
|
|
: null,
|
|
});
|
|
setProjectOpen(false);
|
|
}}
|
|
>
|
|
<span
|
|
className="shrink-0 h-3 w-3 rounded-sm"
|
|
style={{ backgroundColor: p.color ?? "#6366f1" }}
|
|
/>
|
|
{p.name}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</>
|
|
);
|
|
|
|
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
|
|
to={projectLink(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"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<ArrowUpRight className="h-3 w-3" />
|
|
</Link>
|
|
) : undefined}
|
|
>
|
|
{projectContent}
|
|
</PropertyPicker>
|
|
|
|
{currentProjectSupportsExecutionWorkspace && (
|
|
<PropertyRow label="Workspace">
|
|
<div className="w-full space-y-2">
|
|
<select
|
|
className="w-full rounded border border-border bg-transparent px-2 py-1.5 text-xs outline-none"
|
|
value={currentExecutionWorkspaceSelection}
|
|
onChange={(e) => {
|
|
const nextMode = e.target.value;
|
|
onUpdate({
|
|
executionWorkspacePreference: nextMode,
|
|
executionWorkspaceId: nextMode === "reuse_existing" ? issue.executionWorkspaceId : null,
|
|
executionWorkspaceSettings: {
|
|
mode:
|
|
nextMode === "reuse_existing"
|
|
? issueModeForExistingWorkspace(selectedReusableExecutionWorkspace?.mode)
|
|
: nextMode,
|
|
},
|
|
});
|
|
}}
|
|
>
|
|
{EXECUTION_WORKSPACE_OPTIONS.map((option) => (
|
|
<option key={option.value} value={option.value}>
|
|
{option.value === "reuse_existing" && selectedReusableExecutionWorkspace?.mode === "isolated_workspace"
|
|
? "Existing isolated workspace"
|
|
: option.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
|
|
{currentExecutionWorkspaceSelection === "reuse_existing" && (
|
|
<select
|
|
className="w-full rounded border border-border bg-transparent px-2 py-1.5 text-xs outline-none"
|
|
value={issue.executionWorkspaceId ?? ""}
|
|
onChange={(e) => {
|
|
const nextExecutionWorkspaceId = e.target.value || null;
|
|
const nextExecutionWorkspace = deduplicatedReusableWorkspaces.find(
|
|
(workspace) => workspace.id === nextExecutionWorkspaceId,
|
|
);
|
|
onUpdate({
|
|
executionWorkspacePreference: "reuse_existing",
|
|
executionWorkspaceId: nextExecutionWorkspaceId,
|
|
executionWorkspaceSettings: {
|
|
mode: issueModeForExistingWorkspace(nextExecutionWorkspace?.mode),
|
|
},
|
|
});
|
|
}}
|
|
>
|
|
<option value="">Choose an existing workspace</option>
|
|
{deduplicatedReusableWorkspaces.map((workspace) => (
|
|
<option key={workspace.id} value={workspace.id}>
|
|
{workspace.name} · {workspace.status} · {workspace.branchName ?? workspace.cwd ?? workspace.id.slice(0, 8)}
|
|
</option>
|
|
))}
|
|
</select>
|
|
)}
|
|
|
|
{issue.currentExecutionWorkspace && (
|
|
<div className="text-[11px] text-muted-foreground space-y-0.5">
|
|
<div style={{ overflowWrap: "anywhere" }}>
|
|
Current:{" "}
|
|
<Link
|
|
to={`/execution-workspaces/${issue.currentExecutionWorkspace.id}`}
|
|
className="hover:text-foreground hover:underline"
|
|
>
|
|
<BreakablePath text={issue.currentExecutionWorkspace.name} />
|
|
</Link>
|
|
{" · "}
|
|
{issue.currentExecutionWorkspace.status}
|
|
</div>
|
|
{issue.currentExecutionWorkspace.cwd && (
|
|
<CopyableValue value={issue.currentExecutionWorkspace.cwd} mono className="text-[11px]" />
|
|
)}
|
|
{issue.currentExecutionWorkspace.branchName && (
|
|
<CopyableValue value={issue.currentExecutionWorkspace.branchName} label="Branch:" className="text-[11px]" />
|
|
)}
|
|
{issue.currentExecutionWorkspace.repoUrl && (
|
|
<CopyableValue value={issue.currentExecutionWorkspace.repoUrl} label="Repo:" mono className="text-[11px]" />
|
|
)}
|
|
</div>
|
|
)}
|
|
{!issue.currentExecutionWorkspace && currentProject?.primaryWorkspace?.cwd && (
|
|
<CopyableValue value={currentProject.primaryWorkspace.cwd} mono className="text-[11px] text-muted-foreground" />
|
|
)}
|
|
</div>
|
|
</PropertyRow>
|
|
)}
|
|
|
|
{issue.parentId && (
|
|
<PropertyRow label="Parent">
|
|
<Link
|
|
to={`/issues/${issue.ancestors?.[0]?.identifier ?? issue.parentId}`}
|
|
className="text-sm hover:underline"
|
|
>
|
|
{issue.ancestors?.[0]?.title ?? issue.parentId.slice(0, 8)}
|
|
</Link>
|
|
</PropertyRow>
|
|
)}
|
|
|
|
{issue.requestDepth > 0 && (
|
|
<PropertyRow label="Depth">
|
|
<span className="text-sm font-mono">{issue.requestDepth}</span>
|
|
</PropertyRow>
|
|
)}
|
|
</div>
|
|
|
|
<Separator />
|
|
|
|
<div className="space-y-1">
|
|
{(issue.createdByAgentId || issue.createdByUserId) && (
|
|
<PropertyRow label="Created by">
|
|
{issue.createdByAgentId ? (
|
|
<Link
|
|
to={`/agents/${issue.createdByAgentId}`}
|
|
className="hover:underline"
|
|
>
|
|
<Identity name={agentName(issue.createdByAgentId) ?? issue.createdByAgentId.slice(0, 8)} size="sm" />
|
|
</Link>
|
|
) : (
|
|
<>
|
|
<User className="h-3.5 w-3.5 text-muted-foreground" />
|
|
<span className="text-sm">{creatorUserLabel ?? "User"}</span>
|
|
</>
|
|
)}
|
|
</PropertyRow>
|
|
)}
|
|
{issue.startedAt && (
|
|
<PropertyRow label="Started">
|
|
<span className="text-sm">{formatDate(issue.startedAt)}</span>
|
|
</PropertyRow>
|
|
)}
|
|
{issue.completedAt && (
|
|
<PropertyRow label="Completed">
|
|
<span className="text-sm">{formatDate(issue.completedAt)}</span>
|
|
</PropertyRow>
|
|
)}
|
|
<PropertyRow label="Created">
|
|
<span className="text-sm">{formatDate(issue.createdAt)}</span>
|
|
</PropertyRow>
|
|
<PropertyRow label="Updated">
|
|
<span className="text-sm">{timeAgo(issue.updatedAt)}</span>
|
|
</PropertyRow>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|