Files
paperclip/ui/src/components/IssueProperties.tsx
Dotta 0c121b856f Merge remote-tracking branch 'public-gh/master' into paperclip-subissues
* 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
2026-03-16 16:02:37 -05:00

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>
);
}