diff --git a/ui/src/components/IssueRow.tsx b/ui/src/components/IssueRow.tsx
index d3bcfcff..f23736fa 100644
--- a/ui/src/components/IssueRow.tsx
+++ b/ui/src/components/IssueRow.tsx
@@ -2,6 +2,7 @@ import type { ReactNode } from "react";
import type { Issue } from "@paperclipai/shared";
import { Link } from "@/lib/router";
import { X } from "lucide-react";
+import { createIssueDetailPath } from "../lib/issueDetailBreadcrumb";
import { cn } from "../lib/utils";
import { StatusIcon } from "./StatusIcon";
@@ -45,7 +46,7 @@ export function IssueRow({
return (
{
+ it("prefers the full breadcrumb from route state", () => {
+ const state = createIssueDetailLocationState("Inbox", "/inbox/mine", "inbox");
+
+ expect(readIssueDetailBreadcrumb(state, "?from=issues")).toEqual({
+ label: "Inbox",
+ href: "/inbox/mine",
+ });
+ });
+
+ it("falls back to the source query param when route state is unavailable", () => {
+ expect(readIssueDetailBreadcrumb(null, "?from=inbox")).toEqual({
+ label: "Inbox",
+ href: "/inbox",
+ });
+ });
+
+ it("adds the source query param when building an issue detail path", () => {
+ const state = createIssueDetailLocationState("Inbox", "/inbox/mine", "inbox");
+
+ expect(createIssueDetailPath("PAP-465", state)).toBe("/issues/PAP-465?from=inbox");
+ });
+
+ it("reuses the current source query param when state has been dropped", () => {
+ expect(createIssueDetailPath("PAP-465", null, "?from=issues")).toBe("/issues/PAP-465?from=issues");
+ });
+});
diff --git a/ui/src/lib/issueDetailBreadcrumb.ts b/ui/src/lib/issueDetailBreadcrumb.ts
index ba330eb3..1f940ef8 100644
--- a/ui/src/lib/issueDetailBreadcrumb.ts
+++ b/ui/src/lib/issueDetailBreadcrumb.ts
@@ -1,3 +1,5 @@
+type IssueDetailSource = "issues" | "inbox";
+
type IssueDetailBreadcrumb = {
label: string;
href: string;
@@ -5,20 +7,64 @@ type IssueDetailBreadcrumb = {
type IssueDetailLocationState = {
issueDetailBreadcrumb?: IssueDetailBreadcrumb;
+ issueDetailSource?: IssueDetailSource;
};
+const ISSUE_DETAIL_SOURCE_QUERY_PARAM = "from";
+
function isIssueDetailBreadcrumb(value: unknown): value is IssueDetailBreadcrumb {
if (typeof value !== "object" || value === null) return false;
const candidate = value as Partial;
return typeof candidate.label === "string" && typeof candidate.href === "string";
}
-export function createIssueDetailLocationState(label: string, href: string): IssueDetailLocationState {
- return { issueDetailBreadcrumb: { label, href } };
+function isIssueDetailSource(value: unknown): value is IssueDetailSource {
+ return value === "issues" || value === "inbox";
}
-export function readIssueDetailBreadcrumb(state: unknown): IssueDetailBreadcrumb | null {
+function readIssueDetailSource(state: unknown): IssueDetailSource | null {
if (typeof state !== "object" || state === null) return null;
- const candidate = (state as IssueDetailLocationState).issueDetailBreadcrumb;
- return isIssueDetailBreadcrumb(candidate) ? candidate : null;
+ const source = (state as IssueDetailLocationState).issueDetailSource;
+ return isIssueDetailSource(source) ? source : null;
+}
+
+function readIssueDetailSourceFromSearch(search?: string): IssueDetailSource | null {
+ if (!search) return null;
+ const params = new URLSearchParams(search);
+ const source = params.get(ISSUE_DETAIL_SOURCE_QUERY_PARAM);
+ return isIssueDetailSource(source) ? source : null;
+}
+
+function breadcrumbForSource(source: IssueDetailSource): IssueDetailBreadcrumb {
+ if (source === "inbox") return { label: "Inbox", href: "/inbox" };
+ return { label: "Issues", href: "/issues" };
+}
+
+export function createIssueDetailLocationState(
+ label: string,
+ href: string,
+ source?: IssueDetailSource,
+): IssueDetailLocationState {
+ return {
+ issueDetailBreadcrumb: { label, href },
+ issueDetailSource: source,
+ };
+}
+
+export function createIssueDetailPath(issuePathId: string, state?: unknown, search?: string): string {
+ const source = readIssueDetailSource(state) ?? readIssueDetailSourceFromSearch(search);
+ if (!source) return `/issues/${issuePathId}`;
+ const params = new URLSearchParams();
+ params.set(ISSUE_DETAIL_SOURCE_QUERY_PARAM, source);
+ return `/issues/${issuePathId}?${params.toString()}`;
+}
+
+export function readIssueDetailBreadcrumb(state: unknown, search?: string): IssueDetailBreadcrumb | null {
+ if (typeof state === "object" && state !== null) {
+ const candidate = (state as IssueDetailLocationState).issueDetailBreadcrumb;
+ if (isIssueDetailBreadcrumb(candidate)) return candidate;
+ }
+
+ const source = readIssueDetailSourceFromSearch(search);
+ return source ? breadcrumbForSource(source) : null;
}
diff --git a/ui/src/pages/Inbox.tsx b/ui/src/pages/Inbox.tsx
index e5e50928..810e4bd4 100644
--- a/ui/src/pages/Inbox.tsx
+++ b/ui/src/pages/Inbox.tsx
@@ -12,7 +12,7 @@ import { heartbeatsApi } from "../api/heartbeats";
import { useCompany } from "../context/CompanyContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { queryKeys } from "../lib/queryKeys";
-import { createIssueDetailLocationState } from "../lib/issueDetailBreadcrumb";
+import { createIssueDetailLocationState, createIssueDetailPath } from "../lib/issueDetailBreadcrumb";
import { EmptyState } from "../components/EmptyState";
import { PageSkeleton } from "../components/PageSkeleton";
import { IssueRow } from "../components/IssueRow";
@@ -526,6 +526,7 @@ export function Inbox() {
createIssueDetailLocationState(
"Inbox",
`${location.pathname}${location.search}${location.hash}`,
+ "inbox",
),
[location.pathname, location.search, location.hash],
);
@@ -1019,7 +1020,7 @@ export function Inbox() {
const item = workItemsToRender[selectedIndex];
if (item.kind === "issue") {
const pathId = item.issue.identifier ?? item.issue.id;
- navigate(`/issues/${pathId}`, { state: issueLinkState });
+ navigate(createIssueDetailPath(pathId, issueLinkState), { state: issueLinkState });
} else if (item.kind === "approval") {
navigate(`/approvals/${item.approval.id}`);
} else if (item.kind === "failed_run") {
diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx
index 1aaa0b87..7bb616ce 100644
--- a/ui/src/pages/IssueDetail.tsx
+++ b/ui/src/pages/IssueDetail.tsx
@@ -14,7 +14,7 @@ import { useToast } from "../context/ToastContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { assigneeValueFromSelection, suggestedCommentAssigneeValue } from "../lib/assignees";
import { queryKeys } from "../lib/queryKeys";
-import { readIssueDetailBreadcrumb } from "../lib/issueDetailBreadcrumb";
+import { createIssueDetailPath, readIssueDetailBreadcrumb } from "../lib/issueDetailBreadcrumb";
import { useProjectOrder } from "../hooks/useProjectOrder";
import { relativeTime, cn, formatTokens, visibleRunCostUsd } from "../lib/utils";
import { InlineEditor } from "../components/InlineEditor";
@@ -270,8 +270,8 @@ export function IssueDetail() {
const hasLiveRuns = (liveRuns ?? []).length > 0 || !!activeRun;
const sourceBreadcrumb = useMemo(
- () => readIssueDetailBreadcrumb(location.state) ?? { label: "Issues", href: "/issues" },
- [location.state],
+ () => readIssueDetailBreadcrumb(location.state, location.search) ?? { label: "Issues", href: "/issues" },
+ [location.state, location.search],
);
// Filter out runs already shown by the live widget to avoid duplication
@@ -581,9 +581,12 @@ export function IssueDetail() {
// Redirect to identifier-based URL if navigated via UUID
useEffect(() => {
if (issue?.identifier && issueId !== issue.identifier) {
- navigate(`/issues/${issue.identifier}`, { replace: true, state: location.state });
+ navigate(createIssueDetailPath(issue.identifier, location.state, location.search), {
+ replace: true,
+ state: location.state,
+ });
}
- }, [issue, issueId, navigate, location.state]);
+ }, [issue, issueId, navigate, location.state, location.search]);
useEffect(() => {
if (!issue?.id) return;
@@ -695,7 +698,7 @@ export function IssueDetail() {
{i > 0 && }
(
diff --git a/ui/src/pages/Issues.tsx b/ui/src/pages/Issues.tsx
index 2b6e48b0..60719785 100644
--- a/ui/src/pages/Issues.tsx
+++ b/ui/src/pages/Issues.tsx
@@ -70,6 +70,7 @@ export function Issues() {
createIssueDetailLocationState(
"Issues",
`${location.pathname}${location.search}${location.hash}`,
+ "issues",
),
[location.pathname, location.search, location.hash],
);