diff --git a/packages/shared/src/types/issue.ts b/packages/shared/src/types/issue.ts
index 0aed8b4b..409a27e1 100644
--- a/packages/shared/src/types/issue.ts
+++ b/packages/shared/src/types/issue.ts
@@ -18,6 +18,7 @@ export interface IssueAncestorGoal {
export interface IssueAncestor {
id: string;
+ identifier: string | null;
title: string;
description: string | null;
status: string;
diff --git a/server/src/routes/activity.ts b/server/src/routes/activity.ts
index 6933a446..1ff1f6d9 100644
--- a/server/src/routes/activity.ts
+++ b/server/src/routes/activity.ts
@@ -47,6 +47,21 @@ export function activityRoutes(db: Db) {
res.status(201).json(event);
});
+ // Resolve issue identifiers (e.g. "PAP-39") to UUIDs
+ router.param("id", async (req, res, next, rawId) => {
+ try {
+ if (/^[A-Z]+-\d+$/i.test(rawId)) {
+ const issue = await issueSvc.getByIdentifier(rawId);
+ if (issue) {
+ req.params.id = issue.id;
+ }
+ }
+ next();
+ } catch (err) {
+ next(err);
+ }
+ });
+
router.get("/issues/:id/activity", async (req, res) => {
const id = req.params.id as string;
const issue = await issueSvc.getById(id);
diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts
index 396d633a..21aef860 100644
--- a/server/src/routes/agents.ts
+++ b/server/src/routes/agents.ts
@@ -1017,9 +1017,10 @@ export function agentRoutes(db: Db) {
});
router.get("/issues/:id/live-runs", async (req, res) => {
- const id = req.params.id as string;
+ const rawId = req.params.id as string;
const issueSvc = issueService(db);
- const issue = await issueSvc.getById(id);
+ const isIdentifier = /^[A-Z]+-\d+$/i.test(rawId);
+ const issue = isIdentifier ? await issueSvc.getByIdentifier(rawId) : await issueSvc.getById(rawId);
if (!issue) {
res.status(404).json({ error: "Issue not found" });
return;
@@ -1045,7 +1046,7 @@ export function agentRoutes(db: Db) {
and(
eq(heartbeatRuns.companyId, issue.companyId),
inArray(heartbeatRuns.status, ["queued", "running"]),
- sql`${heartbeatRuns.contextSnapshot} ->> 'issueId' = ${id}`,
+ sql`${heartbeatRuns.contextSnapshot} ->> 'issueId' = ${issue.id}`,
),
)
.orderBy(desc(heartbeatRuns.createdAt));
@@ -1054,9 +1055,10 @@ export function agentRoutes(db: Db) {
});
router.get("/issues/:id/active-run", async (req, res) => {
- const id = req.params.id as string;
+ const rawId = req.params.id as string;
const issueSvc = issueService(db);
- const issue = await issueSvc.getById(id);
+ const isIdentifier = /^[A-Z]+-\d+$/i.test(rawId);
+ const issue = isIdentifier ? await issueSvc.getByIdentifier(rawId) : await issueSvc.getById(rawId);
if (!issue) {
res.status(404).json({ error: "Issue not found" });
return;
diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts
index 6577e574..6e99db11 100644
--- a/server/src/routes/issues.ts
+++ b/server/src/routes/issues.ts
@@ -106,6 +106,21 @@ export function issueRoutes(db: Db, storage: StorageService) {
return true;
}
+ // Resolve issue identifiers (e.g. "PAP-39") to UUIDs for all /issues/:id routes
+ router.param("id", async (req, res, next, rawId) => {
+ try {
+ if (/^[A-Z]+-\d+$/i.test(rawId)) {
+ const issue = await svc.getByIdentifier(rawId);
+ if (issue) {
+ req.params.id = issue.id;
+ }
+ }
+ next();
+ } catch (err) {
+ next(err);
+ }
+ });
+
router.get("/companies/:companyId/issues", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
@@ -119,8 +134,7 @@ export function issueRoutes(db: Db, storage: StorageService) {
router.get("/issues/:id", async (req, res) => {
const id = req.params.id as string;
- const isIdentifier = /^[A-Z]+-\d+$/i.test(id);
- const issue = isIdentifier ? await svc.getByIdentifier(id) : await svc.getById(id);
+ const issue = await svc.getById(id);
if (!issue) {
res.status(404).json({ error: "Issue not found" });
return;
diff --git a/server/src/services/activity.ts b/server/src/services/activity.ts
index 1fd6e3ff..ef34a07d 100644
--- a/server/src/services/activity.ts
+++ b/server/src/services/activity.ts
@@ -88,6 +88,7 @@ export function activityService(db: Db) {
db
.selectDistinctOn([issueIdAsText], {
issueId: issues.id,
+ identifier: issues.identifier,
title: issues.title,
status: issues.status,
priority: issues.priority,
diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts
index ea97c210..47bdd9cb 100644
--- a/server/src/services/issues.ts
+++ b/server/src/services/issues.ts
@@ -574,7 +574,7 @@ export function issueService(db: Db) {
getAncestors: async (issueId: string) => {
const raw: Array<{
- id: string; title: string; description: string | null;
+ id: string; identifier: string | null; title: string; description: string | null;
status: string; priority: string;
assigneeAgentId: string | null; projectId: string | null; goalId: string | null;
}> = [];
@@ -584,14 +584,14 @@ export function issueService(db: Db) {
while (currentId && !visited.has(currentId) && raw.length < 50) {
visited.add(currentId);
const parent = await db.select({
- id: issues.id, title: issues.title, description: issues.description,
+ id: issues.id, identifier: issues.identifier, title: issues.title, description: issues.description,
status: issues.status, priority: issues.priority,
assigneeAgentId: issues.assigneeAgentId, projectId: issues.projectId,
goalId: issues.goalId, parentId: issues.parentId,
}).from(issues).where(eq(issues.id, currentId)).then(r => r[0] ?? null);
if (!parent) break;
raw.push({
- id: parent.id, title: parent.title, description: parent.description ?? null,
+ id: parent.id, identifier: parent.identifier ?? null, title: parent.title, description: parent.description ?? null,
status: parent.status, priority: parent.priority,
assigneeAgentId: parent.assigneeAgentId ?? null,
projectId: parent.projectId ?? null, goalId: parent.goalId ?? null,
diff --git a/ui/src/api/activity.ts b/ui/src/api/activity.ts
index 98ef9190..711133ae 100644
--- a/ui/src/api/activity.ts
+++ b/ui/src/api/activity.ts
@@ -15,6 +15,7 @@ export interface RunForIssue {
export interface IssueForRun {
issueId: string;
+ identifier: string | null;
title: string;
status: string;
priority: string;
diff --git a/ui/src/components/ActiveAgentsPanel.tsx b/ui/src/components/ActiveAgentsPanel.tsx
index 36f74fc7..1cef5722 100644
--- a/ui/src/components/ActiveAgentsPanel.tsx
+++ b/ui/src/components/ActiveAgentsPanel.tsx
@@ -359,7 +359,7 @@ function AgentRunCard({
Working on:
diff --git a/ui/src/components/CommandPalette.tsx b/ui/src/components/CommandPalette.tsx
index 59709af3..12953143 100644
--- a/ui/src/components/CommandPalette.tsx
+++ b/ui/src/components/CommandPalette.tsx
@@ -149,7 +149,7 @@ export function CommandPalette() {
{issues.slice(0, 10).map((issue) => (
- go(`/issues/${issue.id}`)}>
+ go(`/issues/${issue.identifier ?? issue.id}`)}>
{issue.identifier ?? issue.id.slice(0, 8)}
diff --git a/ui/src/components/IssueProperties.tsx b/ui/src/components/IssueProperties.tsx
index 66ff4e9e..4f5e931a 100644
--- a/ui/src/components/IssueProperties.tsx
+++ b/ui/src/components/IssueProperties.tsx
@@ -211,7 +211,7 @@ export function IssueProperties({ issue, onUpdate }: IssuePropertiesProps) {
{issue.parentId && (
{issue.ancestors?.[0]?.title ?? issue.parentId.slice(0, 8)}
diff --git a/ui/src/components/NewIssueDialog.tsx b/ui/src/components/NewIssueDialog.tsx
index 4a378928..255d97bc 100644
--- a/ui/src/components/NewIssueDialog.tsx
+++ b/ui/src/components/NewIssueDialog.tsx
@@ -130,7 +130,7 @@ export function NewIssueDialog() {
title: `${issue.identifier ?? "Issue"} created`,
body: issue.title,
tone: "success",
- action: { label: `View ${issue.identifier ?? "issue"}`, href: `/issues/${issue.id}` },
+ action: { label: `View ${issue.identifier ?? "issue"}`, href: `/issues/${issue.identifier ?? issue.id}` },
});
},
});
diff --git a/ui/src/context/LiveUpdatesProvider.tsx b/ui/src/context/LiveUpdatesProvider.tsx
index ae7c4842..9c8fa1fc 100644
--- a/ui/src/context/LiveUpdatesProvider.tsx
+++ b/ui/src/context/LiveUpdatesProvider.tsx
@@ -100,7 +100,7 @@ function resolveIssueToastContext(
ref,
title,
label: title ? `${ref} - ${truncate(title, 72)}` : ref,
- href: `/issues/${issueId}`,
+ href: `/issues/${cachedIssue?.identifier ?? issueId}`,
};
}
diff --git a/ui/src/lib/utils.ts b/ui/src/lib/utils.ts
index d61e475b..51813350 100644
--- a/ui/src/lib/utils.ts
+++ b/ui/src/lib/utils.ts
@@ -36,3 +36,8 @@ export function formatTokens(n: number): string {
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`;
return String(n);
}
+
+/** Build an issue URL using the human-readable identifier when available. */
+export function issueUrl(issue: { id: string; identifier?: string | null }): string {
+ return `/issues/${issue.identifier ?? issue.id}`;
+}
diff --git a/ui/src/pages/AgentDetail.tsx b/ui/src/pages/AgentDetail.tsx
index 2e28d6f2..fd803e9e 100644
--- a/ui/src/pages/AgentDetail.tsx
+++ b/ui/src/pages/AgentDetail.tsx
@@ -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={}
/>
))}
@@ -1210,13 +1210,13 @@ function RunDetail({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
))}
diff --git a/ui/src/pages/ApprovalDetail.tsx b/ui/src/pages/ApprovalDetail.tsx
index fb09bd8a..c6149429 100644
--- a/ui/src/pages/ApprovalDetail.tsx
+++ b/ui/src/pages/ApprovalDetail.tsx
@@ -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) => (
diff --git a/ui/src/pages/Dashboard.tsx b/ui/src/pages/Dashboard.tsx
index 4cd6b7d6..82e6b882 100644
--- a/ui/src/pages/Dashboard.tsx
+++ b/ui/src/pages/Dashboard.tsx
@@ -266,7 +266,7 @@ export function Dashboard() {
navigate(`/issues/${issue.id}`)}
+ onClick={() => navigate(`/issues/${issue.identifier ?? issue.id}`)}
>
diff --git a/ui/src/pages/Inbox.tsx b/ui/src/pages/Inbox.tsx
index 35cb9259..c4a5a333 100644
--- a/ui/src/pages/Inbox.tsx
+++ b/ui/src/pages/Inbox.tsx
@@ -299,7 +299,7 @@ export function Inbox() {
@@ -372,7 +372,7 @@ export function Inbox() {
navigate(`/issues/${issue.id}`)}
+ onClick={() => navigate(`/issues/${issue.identifier ?? issue.id}`)}
>
diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx
index 57f057ed..2fac5e89 100644
--- a/ui/src/pages/IssueDetail.tsx
+++ b/ui/src/pages/IssueDetail.tsx
@@ -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
();
@@ -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() {
{i > 0 && }
@@ -595,7 +602,7 @@ export function IssueDetail() {
{childIssues.map((child) => (
diff --git a/ui/src/pages/Issues.tsx b/ui/src/pages/Issues.tsx
index c9340466..195718f2 100644
--- a/ui/src/pages/Issues.tsx
+++ b/ui/src/pages/Issues.tsx
@@ -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
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
e.stopPropagation()}>
diff --git a/ui/src/pages/MyIssues.tsx b/ui/src/pages/MyIssues.tsx
index 17c9a862..0d69c410 100644
--- a/ui/src/pages/MyIssues.tsx
+++ b/ui/src/pages/MyIssues.tsx
@@ -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={
<>
diff --git a/ui/src/pages/ProjectDetail.tsx b/ui/src/pages/ProjectDetail.tsx
index 27330c1e..d7cbbfd0 100644
--- a/ui/src/pages/ProjectDetail.tsx
+++ b/ui/src/pages/ProjectDetail.tsx
@@ -132,7 +132,7 @@ export function ProjectDetail() {
identifier={issue.identifier ?? issue.id.slice(0, 8)}
title={issue.title}
trailing={
}
- onClick={() => navigate(`/issues/${issue.id}`)}
+ onClick={() => navigate(`/issues/${issue.identifier ?? issue.id}`)}
/>
))}