shared: - Add IssueAncestor type and ancestors?: IssueAncestor[] to Issue interface IssueDetail: - Show clickable parent chain breadcrumb above issue title when issue has parents - Ancestors rendered root-first (reversed from API order) with chevron separators IssueProperties: - Project field now shows project name with a link instead of raw UUID - Assignee field now links to agent detail page - Parent field shows immediate parent title (from ancestors[0]) with link - Show request depth when > 0 Approvals: - Pending/All filter tabs with pending count badge - HireAgentPayload renders name, role, title, capabilities, adapter type - CeoStrategyPayload renders plan/description text in a monospace block - Decision note shown when present - Requester agent name shown in header - Approve button uses green styling; empty state with icon Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
139 lines
4.8 KiB
TypeScript
139 lines
4.8 KiB
TypeScript
import { useEffect } from "react";
|
|
import { useParams, Link } from "react-router-dom";
|
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
|
import { issuesApi } from "../api/issues";
|
|
import { useCompany } from "../context/CompanyContext";
|
|
import { usePanel } from "../context/PanelContext";
|
|
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
|
import { queryKeys } from "../lib/queryKeys";
|
|
import { InlineEditor } from "../components/InlineEditor";
|
|
import { CommentThread } from "../components/CommentThread";
|
|
import { IssueProperties } from "../components/IssueProperties";
|
|
import { StatusIcon } from "../components/StatusIcon";
|
|
import { PriorityIcon } from "../components/PriorityIcon";
|
|
import { Separator } from "@/components/ui/separator";
|
|
import { ChevronRight } from "lucide-react";
|
|
|
|
export function IssueDetail() {
|
|
const { issueId } = useParams<{ issueId: string }>();
|
|
const { selectedCompanyId } = useCompany();
|
|
const { openPanel, closePanel } = usePanel();
|
|
const { setBreadcrumbs } = useBreadcrumbs();
|
|
const queryClient = useQueryClient();
|
|
|
|
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 updateIssue = useMutation({
|
|
mutationFn: (data: Record<string, unknown>) => issuesApi.update(issueId!, data),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(issueId!) });
|
|
if (selectedCompanyId) {
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(selectedCompanyId) });
|
|
}
|
|
},
|
|
});
|
|
|
|
const addComment = useMutation({
|
|
mutationFn: (body: string) => issuesApi.addComment(issueId!, body),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.comments(issueId!) });
|
|
},
|
|
});
|
|
|
|
useEffect(() => {
|
|
setBreadcrumbs([
|
|
{ label: "Issues", href: "/issues" },
|
|
{ label: issue?.title ?? issueId ?? "Issue" },
|
|
]);
|
|
}, [setBreadcrumbs, issue, issueId]);
|
|
|
|
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 ?? [];
|
|
|
|
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.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>
|
|
)}
|
|
|
|
<div className="space-y-3">
|
|
<div className="flex items-center gap-2">
|
|
<StatusIcon
|
|
status={issue.status}
|
|
onChange={(status) => updateIssue.mutate({ status })}
|
|
/>
|
|
<PriorityIcon
|
|
priority={issue.priority}
|
|
onChange={(priority) => updateIssue.mutate({ priority })}
|
|
/>
|
|
<span className="text-xs font-mono text-muted-foreground">{issue.id.slice(0, 8)}</span>
|
|
</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
|
|
/>
|
|
</div>
|
|
|
|
<Separator />
|
|
|
|
<CommentThread
|
|
comments={comments ?? []}
|
|
onAdd={async (body) => {
|
|
await addComment.mutateAsync(body);
|
|
}}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|