Refactor onboarding wizard with ASCII art animation and expanded adapter support. Enhance markdown editor with code block, table, and CodeMirror plugins. Improve comment thread layout. Add activity charts to agent detail page. Polish metric cards, issue detail reassignment, and new issue dialog. Simplify agent detail page structure. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
875 lines
34 KiB
TypeScript
875 lines
34 KiB
TypeScript
import { useEffect, useMemo, useRef, useState, type ChangeEvent } from "react";
|
|
import { useParams, Link, useNavigate } from "react-router-dom";
|
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
|
import { issuesApi } from "../api/issues";
|
|
import { activityApi } from "../api/activity";
|
|
import { heartbeatsApi } from "../api/heartbeats";
|
|
import { agentsApi } from "../api/agents";
|
|
import { authApi } from "../api/auth";
|
|
import { projectsApi } from "../api/projects";
|
|
import { useCompany } from "../context/CompanyContext";
|
|
import { useToast } from "../context/ToastContext";
|
|
import { usePanel } from "../context/PanelContext";
|
|
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
|
import { queryKeys } from "../lib/queryKeys";
|
|
import { relativeTime, cn, formatTokens } from "../lib/utils";
|
|
import { InlineEditor } from "../components/InlineEditor";
|
|
import { CommentThread } from "../components/CommentThread";
|
|
import { IssueProperties } from "../components/IssueProperties";
|
|
import { LiveRunWidget } from "../components/LiveRunWidget";
|
|
import { StatusIcon } from "../components/StatusIcon";
|
|
import { PriorityIcon } from "../components/PriorityIcon";
|
|
import { StatusBadge } from "../components/StatusBadge";
|
|
import { Identity } from "../components/Identity";
|
|
import { Separator } from "@/components/ui/separator";
|
|
import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
|
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet";
|
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|
import {
|
|
Activity as ActivityIcon,
|
|
ChevronDown,
|
|
ChevronRight,
|
|
EyeOff,
|
|
Hexagon,
|
|
ListTree,
|
|
MessageSquare,
|
|
MoreHorizontal,
|
|
Paperclip,
|
|
SlidersHorizontal,
|
|
Trash2,
|
|
} from "lucide-react";
|
|
import type { ActivityEvent } from "@paperclip/shared";
|
|
import type { Agent, IssueAttachment } from "@paperclip/shared";
|
|
|
|
type CommentReassignment = {
|
|
assigneeAgentId: string | null;
|
|
assigneeUserId: string | null;
|
|
};
|
|
|
|
const ACTION_LABELS: Record<string, string> = {
|
|
"issue.created": "created the issue",
|
|
"issue.updated": "updated the issue",
|
|
"issue.checked_out": "checked out the issue",
|
|
"issue.released": "released the issue",
|
|
"issue.comment_added": "added a comment",
|
|
"issue.attachment_added": "added an attachment",
|
|
"issue.attachment_removed": "removed an attachment",
|
|
"issue.deleted": "deleted the issue",
|
|
"agent.created": "created an agent",
|
|
"agent.updated": "updated the agent",
|
|
"agent.paused": "paused the agent",
|
|
"agent.resumed": "resumed the agent",
|
|
"agent.terminated": "terminated the agent",
|
|
"heartbeat.invoked": "invoked a heartbeat",
|
|
"heartbeat.cancelled": "cancelled a heartbeat",
|
|
"approval.created": "requested approval",
|
|
"approval.approved": "approved",
|
|
"approval.rejected": "rejected",
|
|
};
|
|
|
|
function humanizeValue(value: unknown): string {
|
|
if (typeof value !== "string") return String(value ?? "none");
|
|
return value.replace(/_/g, " ");
|
|
}
|
|
|
|
function asRecord(value: unknown): Record<string, unknown> | null {
|
|
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
|
|
return value as Record<string, unknown>;
|
|
}
|
|
|
|
function usageNumber(usage: Record<string, unknown> | null, ...keys: string[]) {
|
|
if (!usage) return 0;
|
|
for (const key of keys) {
|
|
const value = usage[key];
|
|
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
function truncate(text: string, max: number): string {
|
|
if (text.length <= max) return text;
|
|
return text.slice(0, max - 1) + "\u2026";
|
|
}
|
|
|
|
function formatAction(action: string, details?: Record<string, unknown> | null): string {
|
|
if (action === "issue.updated" && details) {
|
|
const previous = (details._previous ?? {}) as Record<string, unknown>;
|
|
const parts: string[] = [];
|
|
|
|
if (details.status !== undefined) {
|
|
const from = previous.status;
|
|
parts.push(
|
|
from
|
|
? `changed the status from ${humanizeValue(from)} to ${humanizeValue(details.status)}`
|
|
: `changed the status to ${humanizeValue(details.status)}`
|
|
);
|
|
}
|
|
if (details.priority !== undefined) {
|
|
const from = previous.priority;
|
|
parts.push(
|
|
from
|
|
? `changed the priority from ${humanizeValue(from)} to ${humanizeValue(details.priority)}`
|
|
: `changed the priority to ${humanizeValue(details.priority)}`
|
|
);
|
|
}
|
|
if (details.assigneeAgentId !== undefined || details.assigneeUserId !== undefined) {
|
|
parts.push(
|
|
details.assigneeAgentId || details.assigneeUserId
|
|
? "assigned the issue"
|
|
: "unassigned the issue",
|
|
);
|
|
}
|
|
if (details.title !== undefined) parts.push("updated the title");
|
|
if (details.description !== undefined) parts.push("updated the description");
|
|
|
|
if (parts.length > 0) return parts.join(", ");
|
|
}
|
|
return ACTION_LABELS[action] ?? action.replace(/[._]/g, " ");
|
|
}
|
|
|
|
function ActorIdentity({ evt, agentMap }: { evt: ActivityEvent; agentMap: Map<string, Agent> }) {
|
|
const id = evt.actorId;
|
|
if (evt.actorType === "agent") {
|
|
const agent = agentMap.get(id);
|
|
return <Identity name={agent?.name ?? id.slice(0, 8)} size="sm" />;
|
|
}
|
|
if (evt.actorType === "system") return <Identity name="System" size="sm" />;
|
|
if (evt.actorType === "user") return <Identity name="Board" size="sm" />;
|
|
return <Identity name={id || "Unknown"} size="sm" />;
|
|
}
|
|
|
|
export function IssueDetail() {
|
|
const { issueId } = useParams<{ issueId: string }>();
|
|
const { selectedCompanyId } = useCompany();
|
|
const { pushToast } = useToast();
|
|
const { openPanel, closePanel } = usePanel();
|
|
const { setBreadcrumbs } = useBreadcrumbs();
|
|
const queryClient = useQueryClient();
|
|
const navigate = useNavigate();
|
|
const [moreOpen, setMoreOpen] = useState(false);
|
|
const [mobilePropsOpen, setMobilePropsOpen] = useState(false);
|
|
const [detailTab, setDetailTab] = useState("comments");
|
|
const [secondaryOpen, setSecondaryOpen] = useState({
|
|
approvals: false,
|
|
cost: false,
|
|
});
|
|
const [attachmentError, setAttachmentError] = useState<string | null>(null);
|
|
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
|
|
|
const { data: issue, isLoading, error } = useQuery({
|
|
queryKey: queryKeys.issues.detail(issueId!),
|
|
queryFn: () => issuesApi.get(issueId!),
|
|
enabled: !!issueId,
|
|
});
|
|
|
|
const { data: comments } = useQuery({
|
|
queryKey: queryKeys.issues.comments(issueId!),
|
|
queryFn: () => issuesApi.listComments(issueId!),
|
|
enabled: !!issueId,
|
|
});
|
|
|
|
const { data: activity } = useQuery({
|
|
queryKey: queryKeys.issues.activity(issueId!),
|
|
queryFn: () => activityApi.forIssue(issueId!),
|
|
enabled: !!issueId,
|
|
});
|
|
|
|
const { data: linkedRuns } = useQuery({
|
|
queryKey: queryKeys.issues.runs(issueId!),
|
|
queryFn: () => activityApi.runsForIssue(issueId!),
|
|
enabled: !!issueId,
|
|
refetchInterval: 5000,
|
|
});
|
|
|
|
const { data: linkedApprovals } = useQuery({
|
|
queryKey: queryKeys.issues.approvals(issueId!),
|
|
queryFn: () => issuesApi.listApprovals(issueId!),
|
|
enabled: !!issueId,
|
|
});
|
|
|
|
const { data: attachments } = useQuery({
|
|
queryKey: queryKeys.issues.attachments(issueId!),
|
|
queryFn: () => issuesApi.listAttachments(issueId!),
|
|
enabled: !!issueId,
|
|
});
|
|
|
|
const { data: liveRuns } = useQuery({
|
|
queryKey: queryKeys.issues.liveRuns(issueId!),
|
|
queryFn: () => heartbeatsApi.liveRunsForIssue(issueId!),
|
|
enabled: !!issueId && !!selectedCompanyId,
|
|
refetchInterval: 3000,
|
|
});
|
|
|
|
const hasLiveRuns = (liveRuns ?? []).length > 0;
|
|
|
|
const { data: allIssues } = useQuery({
|
|
queryKey: queryKeys.issues.list(selectedCompanyId!),
|
|
queryFn: () => issuesApi.list(selectedCompanyId!),
|
|
enabled: !!selectedCompanyId,
|
|
});
|
|
|
|
const { data: agents } = useQuery({
|
|
queryKey: queryKeys.agents.list(selectedCompanyId!),
|
|
queryFn: () => agentsApi.list(selectedCompanyId!),
|
|
enabled: !!selectedCompanyId,
|
|
});
|
|
|
|
const { data: session } = useQuery({
|
|
queryKey: queryKeys.auth.session,
|
|
queryFn: () => authApi.getSession(),
|
|
});
|
|
|
|
const { data: projects } = useQuery({
|
|
queryKey: queryKeys.projects.list(selectedCompanyId!),
|
|
queryFn: () => projectsApi.list(selectedCompanyId!),
|
|
enabled: !!selectedCompanyId,
|
|
});
|
|
|
|
const agentMap = useMemo(() => {
|
|
const map = new Map<string, Agent>();
|
|
for (const a of agents ?? []) map.set(a.id, a);
|
|
return map;
|
|
}, [agents]);
|
|
|
|
const childIssues = useMemo(() => {
|
|
if (!allIssues || !issue) return [];
|
|
return allIssues
|
|
.filter((i) => i.parentId === issue.id)
|
|
.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
|
|
}, [allIssues, issue]);
|
|
|
|
const currentUserId = session?.user?.id ?? session?.session?.userId ?? null;
|
|
|
|
const canReassignFromComment = Boolean(
|
|
issue?.assigneeUserId &&
|
|
(issue.assigneeUserId === "local-board" || (currentUserId && issue.assigneeUserId === currentUserId)),
|
|
);
|
|
|
|
const commentReassignOptions = useMemo(() => {
|
|
const options: Array<{ value: string; label: string }> = [{ value: "__none__", label: "No assignee" }];
|
|
const activeAgents = [...(agents ?? [])]
|
|
.filter((agent) => agent.status !== "terminated")
|
|
.sort((a, b) => a.name.localeCompare(b.name));
|
|
for (const agent of activeAgents) {
|
|
options.push({ value: `agent:${agent.id}`, label: agent.name });
|
|
}
|
|
if (issue?.createdByUserId && issue.createdByUserId !== issue.assigneeUserId) {
|
|
const requesterLabel =
|
|
issue.createdByUserId === "local-board"
|
|
? "Board"
|
|
: currentUserId && issue.createdByUserId === currentUserId
|
|
? "Me"
|
|
: issue.createdByUserId.slice(0, 8);
|
|
options.push({ value: `user:${issue.createdByUserId}`, label: `Requester (${requesterLabel})` });
|
|
}
|
|
return options;
|
|
}, [agents, currentUserId, issue?.assigneeUserId, issue?.createdByUserId]);
|
|
|
|
const commentsWithRunMeta = useMemo(() => {
|
|
const runMetaByCommentId = new Map<string, { runId: string; runAgentId: string | null }>();
|
|
const agentIdByRunId = new Map<string, string>();
|
|
for (const run of linkedRuns ?? []) {
|
|
agentIdByRunId.set(run.runId, run.agentId);
|
|
}
|
|
for (const evt of activity ?? []) {
|
|
if (evt.action !== "issue.comment_added" || !evt.runId) continue;
|
|
const details = evt.details ?? {};
|
|
const commentId = typeof details["commentId"] === "string" ? details["commentId"] : null;
|
|
if (!commentId || runMetaByCommentId.has(commentId)) continue;
|
|
runMetaByCommentId.set(commentId, {
|
|
runId: evt.runId,
|
|
runAgentId: evt.agentId ?? agentIdByRunId.get(evt.runId) ?? null,
|
|
});
|
|
}
|
|
return (comments ?? []).map((comment) => {
|
|
const meta = runMetaByCommentId.get(comment.id);
|
|
return meta ? { ...comment, ...meta } : comment;
|
|
});
|
|
}, [activity, comments, linkedRuns]);
|
|
|
|
const issueCostSummary = useMemo(() => {
|
|
let input = 0;
|
|
let output = 0;
|
|
let cached = 0;
|
|
let cost = 0;
|
|
let hasCost = false;
|
|
let hasTokens = false;
|
|
|
|
for (const run of linkedRuns ?? []) {
|
|
const usage = asRecord(run.usageJson);
|
|
const result = asRecord(run.resultJson);
|
|
const runInput = usageNumber(usage, "inputTokens", "input_tokens");
|
|
const runOutput = usageNumber(usage, "outputTokens", "output_tokens");
|
|
const runCached = usageNumber(
|
|
usage,
|
|
"cachedInputTokens",
|
|
"cached_input_tokens",
|
|
"cache_read_input_tokens",
|
|
);
|
|
const runCost =
|
|
usageNumber(usage, "costUsd", "cost_usd", "total_cost_usd") ||
|
|
usageNumber(result, "total_cost_usd", "cost_usd", "costUsd");
|
|
if (runCost > 0) hasCost = true;
|
|
if (runInput + runOutput + runCached > 0) hasTokens = true;
|
|
input += runInput;
|
|
output += runOutput;
|
|
cached += runCached;
|
|
cost += runCost;
|
|
}
|
|
|
|
return {
|
|
input,
|
|
output,
|
|
cached,
|
|
cost,
|
|
totalTokens: input + output,
|
|
hasCost,
|
|
hasTokens,
|
|
};
|
|
}, [linkedRuns]);
|
|
|
|
const invalidateIssue = () => {
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(issueId!) });
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.activity(issueId!) });
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.runs(issueId!) });
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.approvals(issueId!) });
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.attachments(issueId!) });
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.liveRuns(issueId!) });
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.activeRun(issueId!) });
|
|
if (selectedCompanyId) {
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(selectedCompanyId) });
|
|
}
|
|
};
|
|
|
|
const updateIssue = useMutation({
|
|
mutationFn: (data: Record<string, unknown>) => issuesApi.update(issueId!, data),
|
|
onSuccess: (updated) => {
|
|
invalidateIssue();
|
|
const issueRef = updated.identifier ?? `Issue ${updated.id.slice(0, 8)}`;
|
|
pushToast({
|
|
dedupeKey: `activity:issue.updated:${updated.id}`,
|
|
title: `${issueRef} updated`,
|
|
body: truncate(updated.title, 96),
|
|
tone: "success",
|
|
action: { label: `View ${issueRef}`, href: `/issues/${updated.identifier ?? updated.id}` },
|
|
});
|
|
},
|
|
});
|
|
|
|
const addComment = useMutation({
|
|
mutationFn: ({ body, reopen }: { body: string; reopen?: boolean }) =>
|
|
issuesApi.addComment(issueId!, body, reopen),
|
|
onSuccess: (comment) => {
|
|
invalidateIssue();
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.comments(issueId!) });
|
|
const issueRef = issue?.identifier ?? (issueId ? `Issue ${issueId.slice(0, 8)}` : "Issue");
|
|
pushToast({
|
|
dedupeKey: `activity:issue.comment_added:${issueId}:${comment.id}`,
|
|
title: `Comment posted on ${issueRef}`,
|
|
body: issue?.title ? truncate(issue.title, 96) : undefined,
|
|
tone: "success",
|
|
action: issueId ? { label: `View ${issueRef}`, href: `/issues/${issue?.identifier ?? issueId}` } : undefined,
|
|
});
|
|
},
|
|
});
|
|
|
|
const addCommentAndReassign = useMutation({
|
|
mutationFn: ({
|
|
body,
|
|
reopen,
|
|
reassignment,
|
|
}: {
|
|
body: string;
|
|
reopen?: boolean;
|
|
reassignment: CommentReassignment;
|
|
}) =>
|
|
issuesApi.update(issueId!, {
|
|
comment: body,
|
|
assigneeAgentId: reassignment.assigneeAgentId,
|
|
assigneeUserId: reassignment.assigneeUserId,
|
|
...(reopen ? { status: "todo" } : {}),
|
|
}),
|
|
onSuccess: (updated) => {
|
|
invalidateIssue();
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.comments(issueId!) });
|
|
const issueRef = updated.identifier ?? (issueId ? `Issue ${issueId.slice(0, 8)}` : "Issue");
|
|
pushToast({
|
|
dedupeKey: `activity:issue.reassigned:${updated.id}`,
|
|
title: `${issueRef} reassigned`,
|
|
body: issue?.title ? truncate(issue.title, 96) : undefined,
|
|
tone: "success",
|
|
action: issueId ? { label: `View ${issueRef}`, href: `/issues/${issue?.identifier ?? issueId}` } : undefined,
|
|
});
|
|
},
|
|
});
|
|
|
|
const uploadAttachment = useMutation({
|
|
mutationFn: async (file: File) => {
|
|
if (!selectedCompanyId) throw new Error("No company selected");
|
|
return issuesApi.uploadAttachment(selectedCompanyId, issueId!, file);
|
|
},
|
|
onSuccess: () => {
|
|
setAttachmentError(null);
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.attachments(issueId!) });
|
|
invalidateIssue();
|
|
},
|
|
onError: (err) => {
|
|
setAttachmentError(err instanceof Error ? err.message : "Upload failed");
|
|
},
|
|
});
|
|
|
|
const deleteAttachment = useMutation({
|
|
mutationFn: (attachmentId: string) => issuesApi.deleteAttachment(attachmentId),
|
|
onSuccess: () => {
|
|
setAttachmentError(null);
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.attachments(issueId!) });
|
|
invalidateIssue();
|
|
},
|
|
onError: (err) => {
|
|
setAttachmentError(err instanceof Error ? err.message : "Delete failed");
|
|
},
|
|
});
|
|
|
|
useEffect(() => {
|
|
setBreadcrumbs([
|
|
{ label: "Issues", href: "/issues" },
|
|
{ label: issue?.title ?? issueId ?? "Issue" },
|
|
]);
|
|
}, [setBreadcrumbs, issue, issueId]);
|
|
|
|
// Redirect to identifier-based URL if navigated via UUID
|
|
useEffect(() => {
|
|
if (issue?.identifier && issueId !== issue.identifier) {
|
|
navigate(`/issues/${issue.identifier}`, { replace: true });
|
|
}
|
|
}, [issue, issueId, navigate]);
|
|
|
|
useEffect(() => {
|
|
if (issue) {
|
|
openPanel(
|
|
<IssueProperties issue={issue} onUpdate={(data) => updateIssue.mutate(data)} />
|
|
);
|
|
}
|
|
return () => closePanel();
|
|
}, [issue]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
|
|
if (isLoading) return <p className="text-sm text-muted-foreground">Loading...</p>;
|
|
if (error) return <p className="text-sm text-destructive">{error.message}</p>;
|
|
if (!issue) return null;
|
|
|
|
// Ancestors are returned oldest-first from the server (root at end, immediate parent at start)
|
|
const ancestors = issue.ancestors ?? [];
|
|
|
|
const handleFilePicked = async (evt: ChangeEvent<HTMLInputElement>) => {
|
|
const file = evt.target.files?.[0];
|
|
if (!file) return;
|
|
await uploadAttachment.mutateAsync(file);
|
|
if (fileInputRef.current) {
|
|
fileInputRef.current.value = "";
|
|
}
|
|
};
|
|
|
|
const isImageAttachment = (attachment: IssueAttachment) => attachment.contentType.startsWith("image/");
|
|
|
|
return (
|
|
<div className="max-w-2xl space-y-6">
|
|
{/* Parent chain breadcrumb */}
|
|
{ancestors.length > 0 && (
|
|
<nav className="flex items-center gap-1 text-xs text-muted-foreground flex-wrap">
|
|
{[...ancestors].reverse().map((ancestor, i) => (
|
|
<span key={ancestor.id} className="flex items-center gap-1">
|
|
{i > 0 && <ChevronRight className="h-3 w-3 shrink-0" />}
|
|
<Link
|
|
to={`/issues/${ancestor.identifier ?? ancestor.id}`}
|
|
className="hover:text-foreground transition-colors truncate max-w-[200px]"
|
|
title={ancestor.title}
|
|
>
|
|
{ancestor.title}
|
|
</Link>
|
|
</span>
|
|
))}
|
|
<ChevronRight className="h-3 w-3 shrink-0" />
|
|
<span className="text-foreground/60 truncate max-w-[200px]">{issue.title}</span>
|
|
</nav>
|
|
)}
|
|
|
|
{issue.hiddenAt && (
|
|
<div className="flex items-center gap-2 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
|
<EyeOff className="h-4 w-4 shrink-0" />
|
|
This issue is hidden
|
|
</div>
|
|
)}
|
|
|
|
<div className="space-y-3">
|
|
<div className="flex items-center gap-2 min-w-0 flex-wrap">
|
|
<StatusIcon
|
|
status={issue.status}
|
|
onChange={(status) => updateIssue.mutate({ status })}
|
|
/>
|
|
<PriorityIcon
|
|
priority={issue.priority}
|
|
onChange={(priority) => updateIssue.mutate({ priority })}
|
|
/>
|
|
<span className="text-sm font-mono text-muted-foreground shrink-0">{issue.identifier ?? issue.id.slice(0, 8)}</span>
|
|
|
|
{hasLiveRuns && (
|
|
<span className="inline-flex items-center gap-1.5 rounded-full bg-cyan-500/10 border border-cyan-500/30 px-2 py-0.5 text-[10px] font-medium text-cyan-600 dark:text-cyan-400 shrink-0">
|
|
<span className="relative flex h-1.5 w-1.5">
|
|
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-cyan-400 opacity-75" />
|
|
<span className="relative inline-flex rounded-full h-1.5 w-1.5 bg-cyan-400" />
|
|
</span>
|
|
Live
|
|
</span>
|
|
)}
|
|
|
|
{issue.projectId ? (
|
|
<Link
|
|
to={`/projects/${issue.projectId}`}
|
|
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors rounded px-1 -mx-1 py-0.5 min-w-0"
|
|
>
|
|
<Hexagon className="h-3 w-3 shrink-0" />
|
|
<span className="truncate">{(projects ?? []).find((p) => p.id === issue.projectId)?.name ?? issue.projectId.slice(0, 8)}</span>
|
|
</Link>
|
|
) : (
|
|
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground opacity-50 px-1 -mx-1 py-0.5">
|
|
<Hexagon className="h-3 w-3 shrink-0" />
|
|
No project
|
|
</span>
|
|
)}
|
|
|
|
{(issue.labels ?? []).length > 0 && (
|
|
<div className="hidden sm:flex items-center gap-1">
|
|
{(issue.labels ?? []).slice(0, 4).map((label) => (
|
|
<span
|
|
key={label.id}
|
|
className="inline-flex items-center rounded-full border px-2 py-0.5 text-[10px] font-medium"
|
|
style={{
|
|
borderColor: label.color,
|
|
color: label.color,
|
|
backgroundColor: `${label.color}1f`,
|
|
}}
|
|
>
|
|
{label.name}
|
|
</span>
|
|
))}
|
|
{(issue.labels ?? []).length > 4 && (
|
|
<span className="text-[10px] text-muted-foreground">+{(issue.labels ?? []).length - 4}</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
<Button
|
|
variant="ghost"
|
|
size="icon-xs"
|
|
className="ml-auto md:hidden shrink-0"
|
|
onClick={() => setMobilePropsOpen(true)}
|
|
title="Properties"
|
|
>
|
|
<SlidersHorizontal className="h-4 w-4" />
|
|
</Button>
|
|
|
|
<Popover open={moreOpen} onOpenChange={setMoreOpen}>
|
|
<PopoverTrigger asChild>
|
|
<Button variant="ghost" size="icon-xs" className="md:ml-auto shrink-0">
|
|
<MoreHorizontal className="h-4 w-4" />
|
|
</Button>
|
|
</PopoverTrigger>
|
|
<PopoverContent className="w-44 p-1" align="end">
|
|
<button
|
|
className="flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50 text-destructive"
|
|
onClick={() => {
|
|
updateIssue.mutate(
|
|
{ hiddenAt: new Date().toISOString() },
|
|
{ onSuccess: () => navigate("/issues/all") },
|
|
);
|
|
setMoreOpen(false);
|
|
}}
|
|
>
|
|
<EyeOff className="h-3 w-3" />
|
|
Hide this Issue
|
|
</button>
|
|
</PopoverContent>
|
|
</Popover>
|
|
</div>
|
|
|
|
<InlineEditor
|
|
value={issue.title}
|
|
onSave={(title) => updateIssue.mutate({ title })}
|
|
as="h2"
|
|
className="text-xl font-bold"
|
|
/>
|
|
|
|
<InlineEditor
|
|
value={issue.description ?? ""}
|
|
onSave={(description) => updateIssue.mutate({ description })}
|
|
as="p"
|
|
className="text-sm text-muted-foreground"
|
|
placeholder="Add a description..."
|
|
multiline
|
|
imageUploadHandler={async (file) => {
|
|
const attachment = await uploadAttachment.mutateAsync(file);
|
|
return attachment.contentPath;
|
|
}}
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-3">
|
|
<div className="flex items-center justify-between gap-2">
|
|
<h3 className="text-sm font-medium text-muted-foreground">Attachments</h3>
|
|
<div className="flex items-center gap-2">
|
|
<input
|
|
ref={fileInputRef}
|
|
type="file"
|
|
accept="image/png,image/jpeg,image/webp,image/gif"
|
|
className="hidden"
|
|
onChange={handleFilePicked}
|
|
/>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => fileInputRef.current?.click()}
|
|
disabled={uploadAttachment.isPending}
|
|
>
|
|
<Paperclip className="h-3.5 w-3.5 mr-1.5" />
|
|
{uploadAttachment.isPending ? "Uploading..." : "Upload image"}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{attachmentError && (
|
|
<p className="text-xs text-destructive">{attachmentError}</p>
|
|
)}
|
|
|
|
{(!attachments || attachments.length === 0) ? (
|
|
<p className="text-xs text-muted-foreground">No attachments yet.</p>
|
|
) : (
|
|
<div className="space-y-2">
|
|
{attachments.map((attachment) => (
|
|
<div key={attachment.id} className="border border-border rounded-md p-2">
|
|
<div className="flex items-center justify-between gap-2">
|
|
<a
|
|
href={attachment.contentPath}
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
className="text-xs hover:underline truncate"
|
|
title={attachment.originalFilename ?? attachment.id}
|
|
>
|
|
{attachment.originalFilename ?? attachment.id}
|
|
</a>
|
|
<button
|
|
type="button"
|
|
className="text-muted-foreground hover:text-destructive"
|
|
onClick={() => deleteAttachment.mutate(attachment.id)}
|
|
disabled={deleteAttachment.isPending}
|
|
title="Delete attachment"
|
|
>
|
|
<Trash2 className="h-3.5 w-3.5" />
|
|
</button>
|
|
</div>
|
|
<p className="text-[11px] text-muted-foreground">
|
|
{attachment.contentType} · {(attachment.byteSize / 1024).toFixed(1)} KB
|
|
</p>
|
|
{isImageAttachment(attachment) && (
|
|
<a href={attachment.contentPath} target="_blank" rel="noreferrer">
|
|
<img
|
|
src={attachment.contentPath}
|
|
alt={attachment.originalFilename ?? "attachment"}
|
|
className="mt-2 max-h-56 rounded border border-border object-contain bg-accent/10"
|
|
loading="lazy"
|
|
/>
|
|
</a>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<Separator />
|
|
|
|
<Tabs value={detailTab} onValueChange={setDetailTab} className="space-y-3">
|
|
<TabsList variant="line" className="w-full justify-start gap-1">
|
|
<TabsTrigger value="comments" className="gap-1.5">
|
|
<MessageSquare className="h-3.5 w-3.5" />
|
|
Comments
|
|
</TabsTrigger>
|
|
<TabsTrigger value="subissues" className="gap-1.5">
|
|
<ListTree className="h-3.5 w-3.5" />
|
|
Sub-issues
|
|
</TabsTrigger>
|
|
<TabsTrigger value="activity" className="gap-1.5">
|
|
<ActivityIcon className="h-3.5 w-3.5" />
|
|
Activity
|
|
</TabsTrigger>
|
|
</TabsList>
|
|
|
|
<TabsContent value="comments">
|
|
<CommentThread
|
|
comments={commentsWithRunMeta}
|
|
linkedRuns={linkedRuns ?? []}
|
|
issueStatus={issue.status}
|
|
agentMap={agentMap}
|
|
draftKey={`paperclip:issue-comment-draft:${issue.id}`}
|
|
enableReassign={canReassignFromComment}
|
|
reassignOptions={commentReassignOptions}
|
|
onAdd={async (body, reopen, reassignment) => {
|
|
if (reassignment) {
|
|
await addCommentAndReassign.mutateAsync({ body, reopen, reassignment });
|
|
return;
|
|
}
|
|
await addComment.mutateAsync({ body, reopen });
|
|
}}
|
|
imageUploadHandler={async (file) => {
|
|
const attachment = await uploadAttachment.mutateAsync(file);
|
|
return attachment.contentPath;
|
|
}}
|
|
onAttachImage={async (file) => {
|
|
await uploadAttachment.mutateAsync(file);
|
|
}}
|
|
liveRunSlot={<LiveRunWidget issueId={issueId!} companyId={selectedCompanyId} />}
|
|
/>
|
|
</TabsContent>
|
|
|
|
<TabsContent value="subissues">
|
|
{childIssues.length === 0 ? (
|
|
<p className="text-xs text-muted-foreground">No sub-issues.</p>
|
|
) : (
|
|
<div className="border border-border rounded-lg divide-y divide-border">
|
|
{childIssues.map((child) => (
|
|
<Link
|
|
key={child.id}
|
|
to={`/issues/${child.identifier ?? child.id}`}
|
|
className="flex items-center justify-between px-3 py-2 text-sm hover:bg-accent/20 transition-colors"
|
|
>
|
|
<div className="flex items-center gap-2 min-w-0">
|
|
<StatusIcon status={child.status} />
|
|
<PriorityIcon priority={child.priority} />
|
|
<span className="font-mono text-muted-foreground shrink-0">
|
|
{child.identifier ?? child.id.slice(0, 8)}
|
|
</span>
|
|
<span className="truncate">{child.title}</span>
|
|
</div>
|
|
{child.assigneeAgentId && (() => {
|
|
const name = agentMap.get(child.assigneeAgentId)?.name;
|
|
return name
|
|
? <Identity name={name} size="sm" />
|
|
: <span className="text-muted-foreground font-mono">{child.assigneeAgentId.slice(0, 8)}</span>;
|
|
})()}
|
|
</Link>
|
|
))}
|
|
</div>
|
|
)}
|
|
</TabsContent>
|
|
|
|
<TabsContent value="activity">
|
|
{!activity || activity.length === 0 ? (
|
|
<p className="text-xs text-muted-foreground">No activity yet.</p>
|
|
) : (
|
|
<div className="space-y-1.5">
|
|
{activity.slice(0, 20).map((evt) => (
|
|
<div key={evt.id} className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
|
<ActorIdentity evt={evt} agentMap={agentMap} />
|
|
<span>{formatAction(evt.action, evt.details)}</span>
|
|
<span className="ml-auto shrink-0">{relativeTime(evt.createdAt)}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</TabsContent>
|
|
</Tabs>
|
|
|
|
{linkedApprovals && linkedApprovals.length > 0 && (
|
|
<Collapsible
|
|
open={secondaryOpen.approvals}
|
|
onOpenChange={(open) => setSecondaryOpen((prev) => ({ ...prev, approvals: open }))}
|
|
className="rounded-lg border border-border"
|
|
>
|
|
<CollapsibleTrigger className="flex w-full items-center justify-between px-3 py-2 text-left">
|
|
<span className="text-sm font-medium text-muted-foreground">
|
|
Linked Approvals ({linkedApprovals.length})
|
|
</span>
|
|
<ChevronDown
|
|
className={cn("h-4 w-4 text-muted-foreground transition-transform", secondaryOpen.approvals && "rotate-180")}
|
|
/>
|
|
</CollapsibleTrigger>
|
|
<CollapsibleContent>
|
|
<div className="border-t border-border divide-y divide-border">
|
|
{linkedApprovals.map((approval) => (
|
|
<Link
|
|
key={approval.id}
|
|
to={`/approvals/${approval.id}`}
|
|
className="flex items-center justify-between px-3 py-2 text-xs hover:bg-accent/20 transition-colors"
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
<StatusBadge status={approval.status} />
|
|
<span className="font-medium">
|
|
{approval.type.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase())}
|
|
</span>
|
|
<span className="font-mono text-muted-foreground">{approval.id.slice(0, 8)}</span>
|
|
</div>
|
|
<span className="text-muted-foreground">{relativeTime(approval.createdAt)}</span>
|
|
</Link>
|
|
))}
|
|
</div>
|
|
</CollapsibleContent>
|
|
</Collapsible>
|
|
)}
|
|
|
|
{linkedRuns && linkedRuns.length > 0 && (
|
|
<Collapsible
|
|
open={secondaryOpen.cost}
|
|
onOpenChange={(open) => setSecondaryOpen((prev) => ({ ...prev, cost: open }))}
|
|
className="rounded-lg border border-border"
|
|
>
|
|
<CollapsibleTrigger className="flex w-full items-center justify-between px-3 py-2 text-left">
|
|
<span className="text-sm font-medium text-muted-foreground">Cost Summary</span>
|
|
<ChevronDown
|
|
className={cn("h-4 w-4 text-muted-foreground transition-transform", secondaryOpen.cost && "rotate-180")}
|
|
/>
|
|
</CollapsibleTrigger>
|
|
<CollapsibleContent>
|
|
<div className="border-t border-border px-3 py-2">
|
|
{!issueCostSummary.hasCost && !issueCostSummary.hasTokens ? (
|
|
<div className="text-xs text-muted-foreground">No cost data yet.</div>
|
|
) : (
|
|
<div className="flex flex-wrap gap-3 text-xs text-muted-foreground">
|
|
{issueCostSummary.hasCost && (
|
|
<span className="font-medium text-foreground">
|
|
${issueCostSummary.cost.toFixed(4)}
|
|
</span>
|
|
)}
|
|
{issueCostSummary.hasTokens && (
|
|
<span>
|
|
Tokens {formatTokens(issueCostSummary.totalTokens)}
|
|
{issueCostSummary.cached > 0
|
|
? ` (in ${formatTokens(issueCostSummary.input)}, out ${formatTokens(issueCostSummary.output)}, cached ${formatTokens(issueCostSummary.cached)})`
|
|
: ` (in ${formatTokens(issueCostSummary.input)}, out ${formatTokens(issueCostSummary.output)})`}
|
|
</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</CollapsibleContent>
|
|
</Collapsible>
|
|
)}
|
|
|
|
{/* Mobile properties drawer */}
|
|
<Sheet open={mobilePropsOpen} onOpenChange={setMobilePropsOpen}>
|
|
<SheetContent side="bottom" className="max-h-[85dvh] pb-[env(safe-area-inset-bottom)]">
|
|
<SheetHeader>
|
|
<SheetTitle className="text-sm">Properties</SheetTitle>
|
|
</SheetHeader>
|
|
<ScrollArea className="flex-1 overflow-y-auto">
|
|
<div className="px-4 pb-4">
|
|
<IssueProperties issue={issue} onUpdate={(data) => updateIssue.mutate(data)} inline />
|
|
</div>
|
|
</ScrollArea>
|
|
</SheetContent>
|
|
</Sheet>
|
|
</div>
|
|
);
|
|
}
|