Support issue identifiers (PAP-39) in URLs and prefer them throughout
Backend: - Add router.param middleware in issues, activity, and agents routes to resolve identifiers (e.g. PAP-39) to UUIDs before handlers run - Simplify GET /issues/:id now that param middleware handles resolution - Include identifier in getAncestors response and issuesForRun query - Add identifier field to IssueAncestor shared type Frontend: - Update all issue navigation links across 15+ files to use issue.identifier ?? issue.id instead of bare UUIDs - Add URL redirect in IssueDetail: navigating via UUID automatically replaces the URL with the human-readable identifier - Fix childIssues filter to use issue.id (UUID) instead of URL param so it works correctly with identifier-based URLs - Add issueUrl() utility in lib/utils.ts Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -670,7 +670,7 @@ export function AgentDetail() {
|
||||
key={issue.id}
|
||||
identifier={issue.identifier ?? issue.id.slice(0, 8)}
|
||||
title={issue.title}
|
||||
onClick={() => navigate(`/issues/${issue.id}`)}
|
||||
onClick={() => navigate(`/issues/${issue.identifier ?? issue.id}`)}
|
||||
trailing={<StatusBadge status={issue.status} />}
|
||||
/>
|
||||
))}
|
||||
@@ -1210,13 +1210,13 @@ function RunDetail({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
|
||||
<button
|
||||
key={issue.issueId}
|
||||
className="flex items-center justify-between w-full px-3 py-2 text-xs hover:bg-accent/20 transition-colors text-left"
|
||||
onClick={() => navigate(`/issues/${issue.issueId}`)}
|
||||
onClick={() => navigate(`/issues/${issue.identifier ?? issue.issueId}`)}
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<StatusBadge status={issue.status} />
|
||||
<span className="truncate">{issue.title}</span>
|
||||
</div>
|
||||
<span className="font-mono text-muted-foreground shrink-0 ml-2">{issue.issueId.slice(0, 8)}</span>
|
||||
<span className="font-mono text-muted-foreground shrink-0 ml-2">{issue.identifier ?? issue.issueId.slice(0, 8)}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -149,7 +149,7 @@ export function ApprovalDetail() {
|
||||
(linkedIssues?.length ?? 0) > 1
|
||||
? "Review linked issues"
|
||||
: "Review linked issue",
|
||||
to: `/issues/${primaryLinkedIssue.id}`,
|
||||
to: `/issues/${primaryLinkedIssue.identifier ?? primaryLinkedIssue.id}`,
|
||||
}
|
||||
: linkedAgentId
|
||||
? {
|
||||
@@ -236,7 +236,7 @@ export function ApprovalDetail() {
|
||||
{linkedIssues.map((issue) => (
|
||||
<Link
|
||||
key={issue.id}
|
||||
to={`/issues/${issue.id}`}
|
||||
to={`/issues/${issue.identifier ?? issue.id}`}
|
||||
className="block text-xs rounded border border-border/70 px-2 py-1.5 hover:bg-accent/20"
|
||||
>
|
||||
<span className="font-mono text-muted-foreground mr-2">
|
||||
|
||||
@@ -266,7 +266,7 @@ export function Dashboard() {
|
||||
<div
|
||||
key={issue.id}
|
||||
className="px-4 py-2 text-sm cursor-pointer hover:bg-accent/50 transition-colors"
|
||||
onClick={() => navigate(`/issues/${issue.id}`)}
|
||||
onClick={() => navigate(`/issues/${issue.identifier ?? issue.id}`)}
|
||||
>
|
||||
<div className="flex gap-3">
|
||||
<div className="flex items-start gap-2 min-w-0 flex-1">
|
||||
|
||||
@@ -299,7 +299,7 @@ export function Inbox() {
|
||||
<button
|
||||
type="button"
|
||||
className="truncate text-muted-foreground transition-colors hover:text-foreground"
|
||||
onClick={() => navigate(`/issues/${issue.id}`)}
|
||||
onClick={() => navigate(`/issues/${issue.identifier ?? issue.id}`)}
|
||||
>
|
||||
{issue.identifier ?? issue.id.slice(0, 8)} · {issue.title}
|
||||
</button>
|
||||
@@ -372,7 +372,7 @@ export function Inbox() {
|
||||
<div
|
||||
key={issue.id}
|
||||
className="flex cursor-pointer items-center gap-3 px-4 py-3 transition-colors hover:bg-accent/50"
|
||||
onClick={() => navigate(`/issues/${issue.id}`)}
|
||||
onClick={() => navigate(`/issues/${issue.identifier ?? issue.id}`)}
|
||||
>
|
||||
<Clock className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<PriorityIcon priority={issue.priority} />
|
||||
|
||||
@@ -189,11 +189,11 @@ export function IssueDetail() {
|
||||
}, [agents]);
|
||||
|
||||
const childIssues = useMemo(() => {
|
||||
if (!allIssues || !issueId) return [];
|
||||
if (!allIssues || !issue) return [];
|
||||
return allIssues
|
||||
.filter((i) => i.parentId === issueId)
|
||||
.filter((i) => i.parentId === issue.id)
|
||||
.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
|
||||
}, [allIssues, issueId]);
|
||||
}, [allIssues, issue]);
|
||||
|
||||
const commentsWithRunMeta = useMemo(() => {
|
||||
const runMetaByCommentId = new Map<string, { runId: string; runAgentId: string | null }>();
|
||||
@@ -281,7 +281,7 @@ export function IssueDetail() {
|
||||
title: `${issueRef} updated`,
|
||||
body: truncate(updated.title, 96),
|
||||
tone: "success",
|
||||
action: { label: `View ${issueRef}`, href: `/issues/${updated.id}` },
|
||||
action: { label: `View ${issueRef}`, href: `/issues/${updated.identifier ?? updated.id}` },
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -298,7 +298,7 @@ export function IssueDetail() {
|
||||
title: `Comment posted on ${issueRef}`,
|
||||
body: issue?.title ? truncate(issue.title, 96) : undefined,
|
||||
tone: "success",
|
||||
action: issueId ? { label: `View ${issueRef}`, href: `/issues/${issueId}` } : undefined,
|
||||
action: issueId ? { label: `View ${issueRef}`, href: `/issues/${issue?.identifier ?? issueId}` } : undefined,
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -337,6 +337,13 @@ export function IssueDetail() {
|
||||
]);
|
||||
}, [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(
|
||||
@@ -373,7 +380,7 @@ export function IssueDetail() {
|
||||
<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}`}
|
||||
to={`/issues/${ancestor.identifier ?? ancestor.id}`}
|
||||
className="hover:text-foreground transition-colors truncate max-w-[200px]"
|
||||
title={ancestor.title}
|
||||
>
|
||||
@@ -595,7 +602,7 @@ export function IssueDetail() {
|
||||
{childIssues.map((child) => (
|
||||
<Link
|
||||
key={child.id}
|
||||
to={`/issues/${child.id}`}
|
||||
to={`/issues/${child.identifier ?? child.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 min-w-0">
|
||||
|
||||
@@ -157,7 +157,7 @@ export function Issues() {
|
||||
key={issue.id}
|
||||
identifier={issue.identifier ?? issue.id.slice(0, 8)}
|
||||
title={issue.title}
|
||||
onClick={() => navigate(`/issues/${issue.id}`)}
|
||||
onClick={() => navigate(`/issues/${issue.identifier ?? issue.id}`)}
|
||||
leading={
|
||||
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
|
||||
<div className="flex items-center gap-2" onClick={(e) => e.stopPropagation()}>
|
||||
@@ -220,7 +220,7 @@ export function Issues() {
|
||||
key={issue.id}
|
||||
identifier={issue.identifier ?? issue.id.slice(0, 8)}
|
||||
title={issue.title}
|
||||
onClick={() => navigate(`/issues/${issue.id}`)}
|
||||
onClick={() => navigate(`/issues/${issue.identifier ?? issue.id}`)}
|
||||
leading={
|
||||
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
|
||||
<div className="flex items-center gap-2" onClick={(e) => e.stopPropagation()}>
|
||||
|
||||
@@ -52,7 +52,7 @@ export function MyIssues() {
|
||||
key={issue.id}
|
||||
identifier={issue.identifier ?? issue.id.slice(0, 8)}
|
||||
title={issue.title}
|
||||
onClick={() => navigate(`/issues/${issue.id}`)}
|
||||
onClick={() => navigate(`/issues/${issue.identifier ?? issue.id}`)}
|
||||
leading={
|
||||
<>
|
||||
<PriorityIcon priority={issue.priority} />
|
||||
|
||||
@@ -132,7 +132,7 @@ export function ProjectDetail() {
|
||||
identifier={issue.identifier ?? issue.id.slice(0, 8)}
|
||||
title={issue.title}
|
||||
trailing={<StatusBadge status={issue.status} />}
|
||||
onClick={() => navigate(`/issues/${issue.id}`)}
|
||||
onClick={() => navigate(`/issues/${issue.identifier ?? issue.id}`)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user