* public-gh/master: (51 commits) Use attachment-size limit for company logos Address Greptile company logo feedback Drop lockfile from PR branch Use asset-backed company logos fix: use appType "custom" for Vite dev server so worktree branding is applied docs: fix documentation drift — adapters, plugins, tech stack docs: update documentation for accuracy after plugin system launch chore: ignore superset artifacts Dark theme for CodeMirror code blocks in MDXEditor Remove duplicate @paperclipai/adapter-openclaw-gateway in server/package.json Fix code block styles with robust prose overrides Add Docker setup for untrusted PR review in isolated containers Fix org chart canvas height to fit viewport without scrolling Add doc-maintenance skill for periodic documentation accuracy audits Fix sidebar scrollbar: hide track background when not hovering Restyle markdown code blocks: dark background, smaller font, compact padding Add archive project button and filter archived projects from selectors fix: address review feedback — subscription cleanup, filter nullability, stale diagram fix: wire plugin event subscriptions from worker to host fix(ui): hide scrollbar track background when sidebar is not hovered ... # Conflicts: # packages/db/src/migrations/meta/0030_snapshot.json # packages/db/src/migrations/meta/_journal.json
755 lines
28 KiB
TypeScript
755 lines
28 KiB
TypeScript
import { useMemo, 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 { 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 { useExperimentalWorkspacesEnabled } from "../lib/experimentalSettings";
|
|
import { Separator } from "@/components/ui/separator";
|
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
|
import { User, Hexagon, ArrowUpRight, Tag, Plus, Trash2 } 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" },
|
|
{ value: "operator_branch", label: "Operator branch" },
|
|
{ value: "agent_default", label: "Agent default" },
|
|
] 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";
|
|
}
|
|
|
|
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>
|
|
);
|
|
}
|
|
|
|
export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProps) {
|
|
const { selectedCompanyId } = useCompany();
|
|
const { enabled: showExperimentalWorkspaceUi } = useExperimentalWorkspacesEnabled();
|
|
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 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 = showExperimentalWorkspaceUi
|
|
? currentProject?.executionWorkspacePolicy ?? null
|
|
: null;
|
|
const currentProjectSupportsExecutionWorkspace = Boolean(currentProjectExecutionWorkspacePolicy?.enabled);
|
|
const currentProjectWorkspaces = currentProject?.workspaces ?? [];
|
|
const currentExecutionWorkspaceSelection =
|
|
issue.executionWorkspacePreference
|
|
?? issue.executionWorkspaceSettings?.mode
|
|
?? defaultExecutionWorkspaceModeForProject(currentProject);
|
|
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) && showExperimentalWorkspaceUi && Boolean(issue.projectId),
|
|
});
|
|
const selectedReusableExecutionWorkspace = (reusableExecutionWorkspaces ?? []).find(
|
|
(workspace) => workspace.id === issue.executionWorkspaceId,
|
|
);
|
|
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: showExperimentalWorkspaceUi ? defaultProjectWorkspaceIdForProject(p) : null,
|
|
executionWorkspaceId: null,
|
|
executionWorkspacePreference: showExperimentalWorkspaceUi ? defaultMode : null,
|
|
executionWorkspaceSettings: showExperimentalWorkspaceUi && 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>
|
|
|
|
{showExperimentalWorkspaceUi && currentProjectWorkspaces.length > 0 && (
|
|
<PropertyRow label="Codebase">
|
|
<select
|
|
className="w-full rounded border border-border bg-transparent px-2 py-1.5 text-xs outline-none"
|
|
value={issue.projectWorkspaceId ?? ""}
|
|
onChange={(e) =>
|
|
onUpdate({
|
|
projectWorkspaceId: e.target.value || null,
|
|
executionWorkspaceId: null,
|
|
})}
|
|
>
|
|
{currentProjectWorkspaces.map((workspace) => (
|
|
<option key={workspace.id} value={workspace.id}>
|
|
{workspace.name}
|
|
{workspace.isPrimary ? " (default)" : ""}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</PropertyRow>
|
|
)}
|
|
|
|
{showExperimentalWorkspaceUi && 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.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 = (reusableExecutionWorkspaces ?? []).find(
|
|
(workspace) => workspace.id === nextExecutionWorkspaceId,
|
|
);
|
|
onUpdate({
|
|
executionWorkspacePreference: "reuse_existing",
|
|
executionWorkspaceId: nextExecutionWorkspaceId,
|
|
executionWorkspaceSettings: {
|
|
mode: issueModeForExistingWorkspace(nextExecutionWorkspace?.mode),
|
|
},
|
|
});
|
|
}}
|
|
>
|
|
<option value="">Choose an existing workspace</option>
|
|
{(reusableExecutionWorkspaces ?? []).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">
|
|
Current:{" "}
|
|
<Link
|
|
to={`/execution-workspaces/${issue.currentExecutionWorkspace.id}`}
|
|
className="hover:text-foreground hover:underline"
|
|
>
|
|
{issue.currentExecutionWorkspace.name}
|
|
</Link>
|
|
{" · "}
|
|
{issue.currentExecutionWorkspace.status}
|
|
</div>
|
|
)}
|
|
</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>
|
|
);
|
|
}
|