feat(webapp): UX improvements for TaskRun page and TaskRun table (#2760)

##  Checklist

- [x] I have followed every step in the [contributing
guide](https://github.com/triggerdotdev/trigger.dev/blob/main/CONTRIBUTING.md)
- [x] The PR title follows the convention.
- [x] I ran and tested the code works

---

## Testing

Manual testing of the task run pages

---

## Changelog

- Add previous/next run navigation buttons to run detail page header
- Support [ and ] keyboard shortcuts to jump between adjacent runs
- Preserve runs table state (filters, pagination) when navigating
- Preload adjacent page runs at boundaries for seamless navigation
- Add actions prop to PageTitle component
- Document shortcut in keyboard shortcuts panel

- Store current filter state from runs table as `tableState` search
param when navigating to individual run pages
- Restore filters when navigating back from run detail view to runs list
- Update `v3RunPath` and `v3RunSpanPath` helpers to accept optional
searchParams
- Use `useOptimisticLocation` to capture current search params in
TaskRunsTable
- Parse `tableState` param in run detail route and pass filters to back
button

- This improves UX by remembering filter selections (task, status, date
range, etc.) when users click into a run and then navigate back to the
runs list

- Add new text-below variant that shows "Click to copy" tooltip on hover
and "Copied" on click. Also add controlled open/onOpenChange props to
SimpleTooltip for managing tooltip visibility.

---

## Screenshots


https://github.com/user-attachments/assets/5067bbe0-1bcd-4e75-80a7-f56dabd5ed69
This commit is contained in:
Oskar Otwinowski
2025-12-09 13:10:11 +00:00
committed by GitHub
parent b71bf89444
commit 2c3cb4a43a
8 changed files with 360 additions and 64 deletions
+4
View File
@@ -134,6 +134,10 @@ function ShortcutContent() {
<ShortcutKey shortcut={{ key: "arrowleft" }} variant="medium/bright" />
<ShortcutKey shortcut={{ key: "arrowright" }} variant="medium/bright" />
</Shortcut>
<Shortcut name="Jump to adjacent">
<ShortcutKey shortcut={{ key: "[" }} variant="medium/bright" />
<ShortcutKey shortcut={{ key: "]" }} variant="medium/bright" />
</Shortcut>
<Shortcut name="Expand all">
<ShortcutKey shortcut={{ key: "e" }} variant="medium/bright" />
</Shortcut>
@@ -372,7 +372,7 @@ export const LinkButton = ({
<ExtLink
href={to.toString()}
ref={innerRef}
className={cn("group/button focus-custom", props.fullWidth ? "w-full" : "")}
className={cn("group/button block focus-custom", props.fullWidth ? "w-full" : "")}
onClick={onClick}
onMouseDown={onMouseDown}
onMouseEnter={onMouseEnter}
@@ -387,7 +387,7 @@ export const LinkButton = ({
<Link
to={to}
ref={innerRef}
className={cn("group/button focus-custom", props.fullWidth ? "w-full" : "")}
className={cn("group/button block focus-custom", props.fullWidth ? "w-full" : "")}
onClick={onClick}
onMouseDown={onMouseDown}
onMouseEnter={onMouseEnter}
@@ -408,7 +408,7 @@ export const NavLinkButton = ({ to, className, target, ...props }: NavLinkPropsT
return (
<NavLink
to={to}
className={cn("group/button outline-none", props.fullWidth ? "w-full" : "")}
className={cn("group/button outline-none block", props.fullWidth ? "w-full" : "")}
target={target}
>
{({ isActive, isPending }) => (
@@ -3,59 +3,95 @@ import { useState } from "react";
import { SimpleTooltip } from "~/components/primitives/Tooltip";
import { useCopy } from "~/hooks/useCopy";
import { cn } from "~/utils/cn";
import { Button } from "./Buttons";
export function CopyableText({
value,
copyValue,
className,
asChild,
variant,
}: {
value: string;
copyValue?: string;
className?: string;
asChild?: boolean;
variant?: "icon-right" | "text-below";
}) {
const [isHovered, setIsHovered] = useState(false);
const { copy, copied } = useCopy(copyValue ?? value);
return (
<span
className={cn("group relative inline-flex h-6 items-center", className)}
onMouseLeave={() => setIsHovered(false)}
>
<span onMouseEnter={() => setIsHovered(true)}>{value}</span>
const resolvedVariant = variant ?? "icon-right";
if (resolvedVariant === "icon-right") {
return (
<span
onClick={copy}
onMouseDown={(e) => e.stopPropagation()}
className={cn(
"absolute -right-6 top-0 z-10 size-6 font-sans",
isHovered ? "flex" : "hidden"
)}
className={cn("group relative inline-flex h-6 items-center", className)}
onMouseLeave={() => setIsHovered(false)}
>
<SimpleTooltip
button={
<span
className={cn(
"ml-1 flex size-6 items-center justify-center rounded border border-charcoal-650 bg-charcoal-750",
asChild && "p-1",
copied
? "text-green-500"
: "text-text-dimmed hover:border-charcoal-600 hover:bg-charcoal-700 hover:text-text-bright"
)}
>
{copied ? (
<ClipboardCheckIcon className="size-3.5" />
) : (
<ClipboardIcon className="size-3.5" />
)}
</span>
}
content={copied ? "Copied!" : "Copy"}
className="font-sans"
disableHoverableContent
asChild={asChild}
/>
<span onMouseEnter={() => setIsHovered(true)}>{value}</span>
<span
onClick={copy}
onMouseDown={(e) => e.stopPropagation()}
className={cn(
"absolute -right-6 top-0 z-10 size-6 font-sans",
isHovered ? "flex" : "hidden"
)}
>
<SimpleTooltip
button={
<span
className={cn(
"ml-1 flex size-6 items-center justify-center rounded border border-charcoal-650 bg-charcoal-750",
asChild && "p-1",
copied
? "text-green-500"
: "text-text-dimmed hover:border-charcoal-600 hover:bg-charcoal-700 hover:text-text-bright"
)}
>
{copied ? (
<ClipboardCheckIcon className="size-3.5" />
) : (
<ClipboardIcon className="size-3.5" />
)}
</span>
}
content={copied ? "Copied!" : "Copy"}
className="font-sans"
disableHoverableContent
asChild={asChild}
/>
</span>
</span>
</span>
);
);
}
if (resolvedVariant === "text-below") {
return (
<SimpleTooltip
button={
<Button
variant="minimal/small"
onClick={(e) => {
e.stopPropagation();
copy();
}}
className={cn(
"cursor-pointer bg-transparent py-0 px-1 text-left text-text-bright transition-colors hover:text-white hover:bg-transparent",
className
)}
>
<span>{value}</span>
</Button>
}
content={copied ? "Copied" : "Click to copy"}
className="font-sans px-2 py-1"
disableHoverableContent
open={isHovered || copied}
onOpenChange={setIsHovered}
/>
);
}
return null;
}
@@ -9,11 +9,11 @@ import { useOperatingSystem } from "./OperatingSystemProvider";
import { KeyboardEnterIcon } from "~/assets/icons/KeyboardEnterIcon";
const medium =
"text-[0.75rem] font-medium min-w-[17px] rounded-[2px] tabular-nums px-1 ml-1 -mr-0.5 flex items-center gap-x-1.5 border border-dimmed/40 text-text-dimmed group-hover:text-text-bright/80 group-hover:border-dimmed/60 transition uppercase";
"justify-center min-w-[1.25rem] min-h-[1.25rem] text-[0.65rem] font-mono font-medium rounded-[2px] tabular-nums px-1 ml-1 -mr-0.5 flex items-center gap-x-1.5 border border-dimmed/40 text-text-dimmed group-hover:text-text-bright/80 group-hover:border-dimmed/60 transition uppercase";
export const variants = {
small:
"text-[0.6rem] font-medium min-w-[17px] rounded-[2px] tabular-nums px-1 ml-1 -mr-0.5 flex items-center gap-x-1 border border-text-dimmed/40 text-text-dimmed group-hover:text-text-bright/80 group-hover:border-text-dimmed/60 transition uppercase",
"justify-center text-[0.6rem] font-mono font-medium min-w-[1rem] min-h-[1rem] rounded-[2px] tabular-nums px-1 ml-1 -mr-0.5 flex items-center gap-x-1 border border-text-dimmed/40 text-text-dimmed group-hover:text-text-bright/80 group-hover:border-text-dimmed/60 transition uppercase",
medium: cn(medium, "group-hover:border-charcoal-550"),
"medium/bright": cn(medium, "bg-charcoal-750 text-text-bright border-charcoal-650"),
};
@@ -57,7 +57,7 @@ export function ShortcutKey({ shortcut, variant, className }: ShortcutKeyProps)
function keyString(key: string, isMac: boolean, variant: "small" | "medium" | "medium/bright") {
key = key.toLowerCase();
const className = variant === "small" ? "w-2.5 h-4" : "w-3 h-5";
const className = variant === "small" ? "w-2.5 h-4" : "w-2.5 h-4.5";
switch (key) {
case "enter":
@@ -6,7 +6,7 @@ import { cn } from "~/utils/cn";
const variantClasses = {
basic:
"bg-background-bright border border-grid-bright rounded px-3 py-2 text-sm text-text-bright shadow-md fade-in-50",
dark: "bg-background-dimmed border border-grid-bright rounded px-3 py-2 text-sm text-text-bright shadow-md fade-in-50",
dark: "bg-background-dimmed border border-grid-bright rounded px-3 py-2 text-sm text-text-bright shadow-md fade-in-50"
};
type Variant = keyof typeof variantClasses;
@@ -64,6 +64,8 @@ function SimpleTooltip({
buttonStyle,
asChild = false,
sideOffset,
open,
onOpenChange,
}: {
button: React.ReactNode;
content: React.ReactNode;
@@ -76,10 +78,12 @@ function SimpleTooltip({
buttonStyle?: React.CSSProperties;
asChild?: boolean;
sideOffset?: number;
open?: boolean;
onOpenChange?: (open: boolean) => void;
}) {
return (
<TooltipProvider disableHoverableContent={disableHoverableContent}>
<Tooltip>
<Tooltip open={open} onOpenChange={onOpenChange}>
<TooltipTrigger
tabIndex={-1}
className={cn("h-fit", buttonClassName)}
@@ -55,6 +55,7 @@ import {
filterableTaskRunStatuses,
TaskRunStatusCombo,
} from "./TaskRunStatus";
import { useOptimisticLocation } from "~/hooks/useOptimisticLocation";
type RunsTableProps = {
total: number;
@@ -81,6 +82,8 @@ export function TaskRunsTable({
const checkboxes = useRef<(HTMLInputElement | null)[]>([]);
const { has, hasAll, select, deselect, toggle } = useSelectedItems(allowSelection);
const { isManagedCloud } = useFeatures();
const location = useOptimisticLocation();
const tableStateParam = encodeURIComponent(location.search ? `${location.search}&rt=1` : "rt=1");
const showCompute = isManagedCloud;
@@ -293,16 +296,20 @@ export function TaskRunsTable({
<BlankState isLoading={isLoading} filters={filters} />
) : (
runs.map((run, index) => {
const searchParams = new URLSearchParams();
if (tableStateParam) {
searchParams.set("tableState", tableStateParam);
}
const path = v3RunSpanPath(organization, project, run.environment, run, {
spanId: run.spanId,
});
}, searchParams);
return (
<TableRow key={run.id}>
{allowSelection && (
<TableCell className="pl-3 pr-0">
<Checkbox
checked={has(run.friendlyId)}
onChange={(element) => {
onChange={() => {
toggle(run.friendlyId);
}}
ref={(r) => {
@@ -2,6 +2,7 @@ import {
ArrowUturnLeftIcon,
BoltSlashIcon,
BookOpenIcon,
ChevronUpIcon,
ChevronDownIcon,
ChevronRightIcon,
InformationCircleIcon,
@@ -22,7 +23,7 @@ import {
} from "@trigger.dev/core/v3";
import type { RuntimeEnvironmentType } from "@trigger.dev/database";
import { motion } from "framer-motion";
import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useHotkeys } from "react-hotkeys-hook";
import { redirect } from "remix-typedjson";
import { MoveToTopIcon } from "~/assets/icons/MoveToTopIcon";
@@ -68,7 +69,6 @@ import {
eventBorderClassName,
} from "~/components/runs/v3/SpanTitle";
import { TaskRunStatusIcon, runStatusClassNameColor } from "~/components/runs/v3/TaskRunStatus";
import { env } from "~/env.server";
import { useDebounce } from "~/hooks/useDebounce";
import { useEnvironment } from "~/hooks/useEnvironment";
import { useEventSource } from "~/hooks/useEventSource";
@@ -88,6 +88,7 @@ import {
docsPath,
v3BillingPath,
v3RunParamsSchema,
v3RunPath,
v3RunRedirectPath,
v3RunSpanPath,
v3RunStreamingPath,
@@ -98,6 +99,13 @@ import { SpanView } from "../resources.orgs.$organizationSlug.projects.$projectP
import { useSearchParams } from "~/hooks/useSearchParam";
import { CopyableText } from "~/components/primitives/CopyableText";
import type { SpanOverride } from "~/v3/eventRepository/eventRepository.types";
import { getRunFiltersFromSearchParams } from "~/components/runs/v3/RunFilters";
import { NextRunListPresenter } from "~/presenters/v3/NextRunListPresenter.server";
import { $replica } from "~/db.server";
import { clickhouseClient } from "~/services/clickhouseInstance.server";
import { findProjectBySlug } from "~/models/project.server";
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
import { logger } from "~/services/logger.server";
const resizableSettings = {
parent: {
@@ -131,6 +139,101 @@ const resizableSettings = {
type TraceEvent = NonNullable<SerializeFrom<typeof loader>["trace"]>["events"][0];
type RunsListNavigation = {
runs: Array<{ friendlyId: string }>;
pagination: { next?: string; previous?: string };
prevPageLastRun?: { friendlyId: string; cursor: string };
nextPageFirstRun?: { friendlyId: string; cursor: string };
};
async function getRunsListFromTableState({
tableStateParam,
organizationSlug,
projectParam,
envParam,
runParam,
userId,
}: {
tableStateParam: string | null;
organizationSlug: string;
projectParam: string;
envParam: string;
runParam: string;
userId: string;
}): Promise<RunsListNavigation | null> {
if (!tableStateParam) {
return null;
}
try {
const tableStateSearchParams = new URLSearchParams(decodeURIComponent(tableStateParam));
const filters = getRunFiltersFromSearchParams(tableStateSearchParams);
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
const environment = await findEnvironmentBySlug(project?.id ?? "", envParam, userId);
if (!project || !environment) {
return null;
}
const runsListPresenter = new NextRunListPresenter($replica, clickhouseClient);
const currentPageResult = await runsListPresenter.call(project.organizationId, environment.id, {
userId,
projectId: project.id,
...filters,
pageSize: 25, // Load enough runs to provide navigation context
});
const runsList: RunsListNavigation = {
runs: currentPageResult.runs,
pagination: currentPageResult.pagination,
};
const currentRunIndex = currentPageResult.runs.findIndex((r) => r.friendlyId === runParam);
if (currentRunIndex === 0 && currentPageResult.pagination.previous) {
const prevPageResult = await runsListPresenter.call(project.organizationId, environment.id, {
userId,
projectId: project.id,
...filters,
cursor: currentPageResult.pagination.previous,
direction: "backward",
pageSize: 1, // We only need the last run from the previous page
});
if (prevPageResult.runs.length > 0) {
runsList.prevPageLastRun = {
friendlyId: prevPageResult.runs[0].friendlyId,
cursor: currentPageResult.pagination.previous,
};
}
}
if (currentRunIndex === currentPageResult.runs.length - 1 && currentPageResult.pagination.next) {
const nextPageResult = await runsListPresenter.call(project.organizationId, environment.id, {
userId,
projectId: project.id,
...filters,
cursor: currentPageResult.pagination.next,
direction: "forward",
pageSize: 1, // We only need the first run from the next page
});
if (nextPageResult.runs.length > 0) {
runsList.nextPageFirstRun = {
friendlyId: nextPageResult.runs[0].friendlyId,
cursor: currentPageResult.pagination.next,
};
}
}
return runsList;
} catch (error) {
logger.error("Error loading runs list from tableState:", { error });
return null;
}
}
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const userId = await requireUserId(request);
const impersonationId = await getImpersonationId(request);
@@ -169,6 +272,15 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const parent = await getResizableSnapshot(request, resizableSettings.parent.autosaveId);
const tree = await getResizableSnapshot(request, resizableSettings.tree.autosaveId);
const runsList = await getRunsListFromTableState({
tableStateParam: url.searchParams.get("tableState"),
organizationSlug,
projectParam,
envParam,
runParam,
userId,
});
return json({
run: result.run,
trace: result.trace,
@@ -177,13 +289,14 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
parent,
tree,
},
runsList,
});
};
type LoaderData = SerializeFrom<typeof loader>;
export default function Page() {
const { run, trace, resizable, maximumLiveReloadingSetting } = useLoaderData<typeof loader>();
const { run, trace, resizable, maximumLiveReloadingSetting, runsList } = useLoaderData<typeof loader>();
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
@@ -191,16 +304,28 @@ export default function Page() {
logCount: trace?.events.length ?? 0,
isCompleted: run.completedAt !== null,
});
const { value } = useSearchParams();
const tableState = decodeURIComponent(value("tableState") ?? "");
const tableStateSearchParams = new URLSearchParams(tableState);
const filters = getRunFiltersFromSearchParams(tableStateSearchParams);
const [previousRunPath, nextRunPath] = useAdjacentRunPaths({organization, project, environment, tableState, run, runsList});
return (
<>
<NavBar>
<PageTitle
backButton={{
to: v3RunsPath(organization, project, environment),
to: v3RunsPath(organization, project, environment, filters),
text: "Runs",
}}
title={<CopyableText value={run.friendlyId} />}
title={<>
<CopyableText value={run.friendlyId} variant="text-below" className="font-mono"/>
{tableState && (<div className="flex">
<PreviousRunButton to={previousRunPath} />
<NextRunButton to={nextRunPath} />
</div>)}
</>}
/>
{environment.type === "DEVELOPMENT" && <DevDisconnectedBanner isConnected={isConnected} />}
<PageAccessories>
@@ -276,14 +401,10 @@ export default function Page() {
run={run}
trace={trace}
maximumLiveReloadingSetting={maximumLiveReloadingSetting}
resizable={resizable}
/>
) : (
<NoLogsView
run={run}
trace={trace}
maximumLiveReloadingSetting={maximumLiveReloadingSetting}
resizable={resizable}
/>
)}
</PageBody>
@@ -291,7 +412,7 @@ export default function Page() {
);
}
function TraceView({ run, trace, maximumLiveReloadingSetting, resizable }: LoaderData) {
function TraceView({ run, trace, maximumLiveReloadingSetting }: Pick<LoaderData, "run" | "trace" | "maximumLiveReloadingSetting">) {
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
@@ -385,7 +506,7 @@ function TraceView({ run, trace, maximumLiveReloadingSetting, resizable }: Loade
);
}
function NoLogsView({ run, resizable }: LoaderData) {
function NoLogsView({ run }: Pick<LoaderData, "run">) {
const plan = useCurrentPlan();
const organization = useOrganization();
@@ -1432,6 +1553,7 @@ function KeyboardShortcuts({
return (
<>
<ArrowKeyShortcuts />
<AdjacentRunsShortcuts />
<ShortcutWithAction
shortcut={{ key: "e" }}
action={() => expandAllBelowDepth(0)}
@@ -1448,6 +1570,16 @@ function KeyboardShortcuts({
);
}
function AdjacentRunsShortcuts() {
return (<div className="flex items-center gap-0.5">
<ShortcutKey shortcut={{ key: "[" }} variant="medium" className="ml-0 mr-0 px-1" />
<ShortcutKey shortcut={{ key: "]" }} variant="medium" className="ml-0 mr-0 px-1" />
<Paragraph variant="extra-small" className="ml-1.5 whitespace-nowrap">
Adjacent runs
</Paragraph>
</div>);
}
function ArrowKeyShortcuts() {
return (
<div className="flex items-center gap-0.5">
@@ -1494,7 +1626,7 @@ function NumberShortcuts({ toggleLevel }: { toggleLevel: (depth: number) => void
return (
<div className="flex items-center gap-0.5">
<span className={cn(variants.medium, "ml-0 mr-0")}>0</span>
<span className="text-[0.75rem] text-text-dimmed"></span>
<span className="text-[0.65rem] text-text-dimmed"></span>
<span className={cn(variants.medium, "ml-0 mr-0")}>9</span>
<Paragraph variant="extra-small" className="ml-1.5 whitespace-nowrap">
Toggle level
@@ -1526,3 +1658,111 @@ function SearchField({ onChange }: { onChange: (value: string) => void }) {
/>
);
}
function useAdjacentRunPaths({
organization,
project,
environment,
tableState,
run,
runsList,
}: {
organization: { slug: string };
project: { slug: string };
environment: { slug: string };
tableState: string;
run: { friendlyId: string };
runsList: RunsListNavigation | null;
}): [string | null, string | null] {
return useMemo(() => {
if (!runsList || runsList.runs.length === 0) {
return [null, null];
}
const currentIndex = runsList.runs.findIndex((r) => r.friendlyId === run.friendlyId);
if (currentIndex === -1) {
return [null, null];
}
// Determine previous run: use prevPageLastRun if at first position, otherwise use previous run in list
let previousRun: { friendlyId: string } | null = null;
const previousRunTableState = new URLSearchParams(tableState);
if (currentIndex > 0) {
previousRun = runsList.runs[currentIndex - 1];
} else if (runsList.prevPageLastRun) {
previousRun = runsList.prevPageLastRun;
// Update tableState with the new cursor for the previous page
previousRunTableState.set("cursor", runsList.prevPageLastRun.cursor);
previousRunTableState.set("direction", "backward");
}
// Determine next run: use nextPageFirstRun if at last position, otherwise use next run in list
let nextRun: { friendlyId: string } | null = null;
const nextRunTableState = new URLSearchParams(tableState);
if (currentIndex < runsList.runs.length - 1) {
nextRun = runsList.runs[currentIndex + 1];
} else if (runsList.nextPageFirstRun) {
nextRun = runsList.nextPageFirstRun;
// Update tableState with the new cursor for the next page
nextRunTableState.set("cursor", runsList.nextPageFirstRun.cursor);
nextRunTableState.set("direction", "forward");
}
const previousURLSearchParams = new URLSearchParams();
previousURLSearchParams.set("tableState", previousRunTableState.toString());
const previousRunPath = previousRun
? v3RunPath(organization, project, environment, previousRun, previousURLSearchParams)
: null;
const nextURLSearchParams = new URLSearchParams();
nextURLSearchParams.set("tableState", nextRunTableState.toString());
const nextRunPath = nextRun
? v3RunPath(organization, project, environment, nextRun, nextURLSearchParams)
: null;
return [previousRunPath, nextRunPath];
}, [organization, project, environment, tableState, run.friendlyId, runsList]);
}
function PreviousRunButton({ to }: { to: string | null }) {
return (
<div className={cn("peer/prev order-1", !to && "pointer-events-none")}>
<LinkButton
to={to ? to : '#'}
variant={"minimal/small"}
LeadingIcon={ChevronUpIcon}
className={cn(
"flex items-center rounded-r-none border-r-0 pl-2 pr-[0.5625rem]",
!to && "cursor-not-allowed opacity-50"
)}
onClick={(e) => !to && e.preventDefault()}
shortcut={{ key: "[" }}
tooltip="Previous Run"
disabled={!to}
/>
</div>
);
}
function NextRunButton({ to }: { to: string | null }) {
return (
<div className={cn("peer/next order-3", !to && "pointer-events-none")}>
<LinkButton
to={to ? to : '#'}
variant={"minimal/small"}
TrailingIcon={ChevronDownIcon}
className={cn(
"flex items-center rounded-l-none border-l-0 pl-[0.5625rem] pr-2",
!to && "cursor-not-allowed opacity-50"
)}
onClick={(e) => !to && e.preventDefault()}
shortcut={{ key: "]" }}
tooltip="Next Run"
disabled={!to}
/>
</div>
);
}
+10 -5
View File
@@ -288,15 +288,17 @@ export function v3RunPath(
organization: OrgForPath,
project: ProjectForPath,
environment: EnvironmentForPath,
run: v3RunForPath
run: v3RunForPath,
searchParams?: URLSearchParams
) {
return `${v3RunsPath(organization, project, environment)}/${run.friendlyId}`;
const query = searchParams ? `?${searchParams.toString()}` : "";
return `${v3RunsPath(organization, project, environment)}/${run.friendlyId}${query}`;
}
export function v3RunRedirectPath(
organization: OrgForPath,
project: ProjectForPath,
run: v3RunForPath
run: v3RunForPath,
) {
return `${v3ProjectPath(organization, project)}/runs/${run.friendlyId}`;
}
@@ -310,9 +312,12 @@ export function v3RunSpanPath(
project: ProjectForPath,
environment: EnvironmentForPath,
run: v3RunForPath,
span: v3SpanForPath
span: v3SpanForPath,
searchParams?: URLSearchParams
) {
return `${v3RunPath(organization, project, environment, run)}?span=${span.spanId}`;
searchParams = searchParams ?? new URLSearchParams();
searchParams.set("span", span.spanId);
return `${v3RunPath(organization, project, environment, run, searchParams)}`;
}
export function v3RunStreamingPath(