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], );